diff --git a/.gitignore b/.gitignore
index 4a43175..2cd97ae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,5 +6,8 @@
/node_modules/
/dist/
+# Coverage
+/coverage/
+
# Claude Code
/.claude/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 706f546..609f487 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,11 @@
+## Unreleased
+
+- Added support for managing existing (already-partitioned) tables: bounds-anchored extension that recognizes legacy-named partitions and extends contiguously without renaming, gaps, or overlaps
+- Added a weekly (ISO-week, Monday-aligned) period
+- Added composite / parent-owned primary key support to `add_partitions` (skips the redundant per-partition key when the parent owns one)
+- Added grant inheritance on new partitions, on by default (`--no-inherit-grants` to disable)
+- Added UTC-pinned, type-correct bounds so `timestamptz`-keyed tables extend deterministically regardless of session timezone
+
## 0.7.1 (2025-07-27)
- Fixed `analyze` analyzing partitions twice with declarative partitioning
diff --git a/README.md b/README.md
index 5bb432c..4640897 100644
--- a/README.md
+++ b/README.md
@@ -45,7 +45,7 @@ export PGSLICE_URL=postgres://localhost/myapp_development
pgslice prep
```
-The column should be a `timestamp`, `timestamptz`, or `date` column and period can be `day`, `month`, or `year`.
+The column should be a `timestamp`, `timestamptz`, or `date` column and period can be `day`, `week` (ISO-week, Monday-aligned), `month`, or `year`.
This creates a partitioned table named `_intermediate` using range partitioning.
@@ -280,12 +280,15 @@ To add partitions, use:
pgslice add_partitions --future 3
```
-Add this as a cron job to create a new partition each day, month, or year.
+Add this as a cron job to create a new partition each day, week, month, or year.
```sh
# day
0 0 * * * pgslice add_partitions --future 3 --url ...
+# week (Monday-aligned ISO weeks)
+0 0 * * 1 pgslice add_partitions --future 3 --url ...
+
# month
0 0 1 * * pgslice add_partitions --future 3 --url ...
@@ -304,10 +307,43 @@ WHERE
c.relkind = 'r' AND
n.nspname = 'public' AND
c.relname = '_' || to_char(NOW() + INTERVAL '3 days', 'YYYYMMDD')
+ -- for weeks, use to_char(NOW() + INTERVAL '3 weeks', 'IYYY"w"IW')
-- for months, use to_char(NOW() + INTERVAL '3 months', 'YYYYMM')
-- for years, use to_char(NOW() + INTERVAL '3 years', 'YYYY')
```
+## Managing Existing Tables
+
+pgslice can manage tables that were already partitioned outside of it (for example by an application migration), so you can converge future-partition creation onto a single tool without recreating or renaming anything.
+
+Mark a table as managed by giving it the pgslice settings comment:
+
+```sql
+COMMENT ON TABLE IS 'column:created_at,period:week,cast:date,version:3';
+```
+
+Then extend it like any other table:
+
+```sh
+pgslice add_partitions --future 3
+```
+
+When a table already has partitions, pgslice extends it **by partition bounds**: it anchors on the maximum existing upper bound and chains contiguous, period-sized ranges from there. This means it:
+
+- never renames or collides with existing (legacy-named) partitions,
+- introduces no gap or overlap at the boundary, and
+- continues whatever scheme the table already uses — ISO weeks, calendar months, or a year-resetting weekly scheme — rather than snapping to an absolute calendar.
+
+Because extension is keyed on bounds rather than names, newly-created partitions use pgslice's own naming (`_`), which may differ cosmetically from a legacy naming convention. Partition names are not functional, so mixed naming is expected and harmless. `DEFAULT` and `MINVALUE`/`MAXVALUE` partitions are recognized and ignored when choosing the extension anchor.
+
+### Primary keys
+
+If the partitioned parent owns a primary key (including a composite key), Postgres propagates it — and any partitioned indexes — to each new partition, so pgslice does not add a per-partition key. Tables in the classic pgslice model (no key on the parent) still get a per-partition key.
+
+### Grants
+
+By default, pgslice re-issues the parent table's grants on each new partition, because Postgres does not cascade a parent's grants to its partitions. This keeps a replication/CDC role's access intact as new partitions appear. Disable it with `--no-inherit-grants`.
+
## Archiving Partitions
Back up and drop older partitions each day, month, or year.
diff --git a/package-lock.json b/package-lock.json
index d13102d..b800a0f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
},
"devDependencies": {
"@types/node": "^22.10.7",
+ "@vitest/coverage-v8": "^4.0.18",
"dotenv": "^16.4.7",
"prettier": "^3.4.2",
"typescript": "^5.7.3",
@@ -27,6 +28,66 @@
"node": ">=20"
}
},
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
@@ -443,12 +504,33 @@
"node": ">=18"
}
},
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true
},
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -913,11 +995,41 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
"dev": true,
- "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
+ "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.0.18",
+ "ast-v8-to-istanbul": "^0.3.10",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.1",
+ "obug": "^2.1.1",
+ "std-env": "^3.10.0",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.0.18",
+ "vitest": "4.0.18"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vitest/expect": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
@@ -1031,6 +1143,18 @@
"node": ">=12"
}
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "0.3.12",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz",
+ "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -1189,11 +1313,74 @@
"node": ">=18.0"
}
},
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/iso8601-duration": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/iso8601-duration/-/iso8601-duration-1.3.0.tgz",
"integrity": "sha512-K4CiUBzo3YeWk76FuET/dQPH03WE04R94feo5TSKQCXpoXQt9E4yx2CnY737QZnSAI3PI4WlKo/zfqizGx52QQ=="
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1203,6 +1390,34 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magicast": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
+ "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@babel/types": "^7.29.0",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -1254,7 +1469,6 @@
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.17.1.tgz",
"integrity": "sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ==",
- "peer": true,
"dependencies": {
"pg-connection-string": "^2.10.0",
"pg-pool": "^3.11.0",
@@ -1430,7 +1644,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -1600,6 +1813,19 @@
"node": ">=10"
}
},
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/semver-compare": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
@@ -1629,7 +1855,6 @@
"version": "46.8.0",
"resolved": "https://registry.npmjs.org/slonik/-/slonik-46.8.0.tgz",
"integrity": "sha512-1sBzz4k5eowrzaGvP0gdZ41p62K1FxC7tDpA/IWvyGpf7A51eGNoAVfrg/mL9+OifWnKuru9opHPrdE15UHpfg==",
- "peer": true,
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@slonik/driver": "^46.8.0",
@@ -1720,6 +1945,19 @@
"resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz",
"integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA=="
},
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -1803,7 +2041,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
- "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
diff --git a/package.json b/package.json
index f1b06e9..b02cb88 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest",
+ "coverage": "vitest run --coverage",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
@@ -35,6 +36,7 @@
},
"devDependencies": {
"@types/node": "^22.10.7",
+ "@vitest/coverage-v8": "^4.0.18",
"dotenv": "^16.4.7",
"prettier": "^3.4.2",
"typescript": "^5.7.3",
diff --git a/src/add-partitions-retrofit.test.ts b/src/add-partitions-retrofit.test.ts
new file mode 100644
index 0000000..a432f9a
--- /dev/null
+++ b/src/add-partitions-retrofit.test.ts
@@ -0,0 +1,563 @@
+import { afterEach, beforeEach, describe, expect, vi } from "vitest";
+import { sql, type DatabaseTransactionConnection } from "slonik";
+import { z } from "zod";
+
+import { pgsliceTest as test } from "./testing/index.js";
+import { assertContiguous } from "./testing/shapes.js";
+
+/**
+ * These tests cover retrofitting pgslice management onto tables that were
+ * created outside of pgslice: a partitioned parent that already owns a
+ * (composite) primary key, weekly partitioning, and copying the parent's
+ * grants onto new partitions.
+ */
+describe("Pgslice.addPartitions (retrofit)", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ // 2026-01-15 is a Thursday; its ISO week is 2026-W03 (Monday 2026-01-12).
+ vi.setSystemTime(new Date(Date.UTC(2026, 0, 15)));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ async function createCompositePkParent(
+ transaction: DatabaseTransactionConnection,
+ period: "month" | "week",
+ ) {
+ await transaction.query(sql.unsafe`
+ CREATE TABLE posts (
+ id varchar NOT NULL,
+ author_id varchar NOT NULL,
+ created_at timestamp without time zone NOT NULL,
+ PRIMARY KEY (id, created_at)
+ ) PARTITION BY RANGE (created_at)
+ `);
+ await transaction.query(sql.unsafe`
+ CREATE INDEX ON posts (author_id)
+ `);
+ await transaction.query(
+ sql.unsafe`
+ COMMENT ON TABLE posts IS ${sql.literalValue(
+ `column:created_at,period:${period},cast:date,version:3`,
+ )}
+ `,
+ );
+ }
+
+ describe("parent owns a composite primary key", () => {
+ test.beforeEach(async ({ transaction }) => {
+ await createCompositePkParent(transaction, "month");
+ });
+
+ test("creates future partitions without colliding on the inherited primary key", async ({
+ pgslice,
+ transaction,
+ }) => {
+ await pgslice.addPartitions(transaction, {
+ table: "posts",
+ future: 2,
+ });
+
+ const partitions = await transaction.any(
+ sql.type(z.object({ tablename: z.string() }))`
+ SELECT tablename FROM pg_tables
+ WHERE schemaname = 'public' AND tablename LIKE 'posts_2%'
+ ORDER BY tablename
+ `,
+ );
+
+ expect(partitions.map((p) => p.tablename)).toEqual([
+ "posts_202601",
+ "posts_202602",
+ "posts_202603",
+ ]);
+ });
+
+ test("new partitions inherit the parent composite key and gain no duplicate PK", async ({
+ pgslice,
+ transaction,
+ }) => {
+ await pgslice.addPartitions(transaction, { table: "posts", future: 1 });
+
+ const constraints = await transaction.any(
+ sql.type(z.object({ def: z.string() }))`
+ SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint
+ WHERE conrelid = 'public.posts_202602'::regclass AND contype = 'p'
+ `,
+ );
+
+ expect(constraints.map((c) => c.def)).toEqual([
+ "PRIMARY KEY (id, created_at)",
+ ]);
+ });
+ });
+
+ describe("grant inheritance", () => {
+ test.beforeEach(async ({ transaction }) => {
+ await createCompositePkParent(transaction, "month");
+ await transaction.query(sql.unsafe`
+ DO $$ BEGIN
+ IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'pgslice_grant_test') THEN
+ CREATE ROLE pgslice_grant_test;
+ END IF;
+ END $$;
+ `);
+ await transaction.query(sql.unsafe`
+ GRANT SELECT ON posts TO pgslice_grant_test
+ `);
+ });
+
+ test("copies the parent's grants onto new partitions by default", async ({
+ pgslice,
+ transaction,
+ }) => {
+ await pgslice.addPartitions(transaction, { table: "posts", future: 1 });
+
+ const { granted } = await transaction.one(
+ sql.type(z.object({ granted: z.boolean() }))`
+ SELECT has_table_privilege('pgslice_grant_test', 'public.posts_202602', 'SELECT') AS granted
+ `,
+ );
+
+ expect(granted).toBe(true);
+ });
+
+ test("skips grant inheritance when inheritGrants is false", async ({
+ pgslice,
+ transaction,
+ }) => {
+ await pgslice.addPartitions(transaction, {
+ table: "posts",
+ future: 1,
+ inheritGrants: false,
+ });
+
+ const { granted } = await transaction.one(
+ sql.type(z.object({ granted: z.boolean() }))`
+ SELECT has_table_privilege('pgslice_grant_test', 'public.posts_202602', 'SELECT') AS granted
+ `,
+ );
+
+ expect(granted).toBe(false);
+ });
+
+ test("inherits the original table's grants during the intermediate flow", async ({
+ pgslice,
+ transaction,
+ }) => {
+ // Simulate prep: an intermediate partitioned table carrying the settings
+ // but no grants of its own. Inheritance must read grants from the
+ // original `posts` (which has the SELECT grant), not the intermediate.
+ await transaction.query(sql.unsafe`
+ CREATE TABLE posts_intermediate (
+ id varchar NOT NULL,
+ author_id varchar NOT NULL,
+ created_at timestamp without time zone NOT NULL,
+ PRIMARY KEY (id, created_at)
+ ) PARTITION BY RANGE (created_at)
+ `);
+ await transaction.query(sql.unsafe`
+ COMMENT ON TABLE posts_intermediate IS 'column:created_at,period:month,cast:date,version:3'
+ `);
+
+ await pgslice.addPartitions(transaction, {
+ table: "posts",
+ intermediate: true,
+ future: 1,
+ });
+
+ const { granted } = await transaction.one(
+ sql.type(z.object({ granted: z.boolean() }))`
+ SELECT has_table_privilege('pgslice_grant_test', 'public.posts_202601', 'SELECT') AS granted
+ `,
+ );
+ expect(granted).toBe(true);
+ });
+ });
+
+ describe("weekly period", () => {
+ test.beforeEach(async ({ transaction }) => {
+ await createCompositePkParent(transaction, "week");
+ });
+
+ test("creates ISO-week partitions with seven-day, Monday-aligned bounds", async ({
+ pgslice,
+ transaction,
+ }) => {
+ await pgslice.addPartitions(transaction, { table: "posts", future: 2 });
+
+ const partitions = await transaction.any(
+ sql.type(z.object({ name: z.string(), bound: z.string() }))`
+ SELECT c.relname AS name, pg_get_expr(c.relpartbound, c.oid) AS bound
+ FROM pg_inherits i
+ JOIN pg_class c ON c.oid = i.inhrelid
+ WHERE i.inhparent = 'public.posts'::regclass
+ ORDER BY c.relname
+ `,
+ );
+
+ expect(partitions).toEqual([
+ {
+ name: "posts_2026w03",
+ bound:
+ "FOR VALUES FROM ('2026-01-12 00:00:00') TO ('2026-01-19 00:00:00')",
+ },
+ {
+ name: "posts_2026w04",
+ bound:
+ "FOR VALUES FROM ('2026-01-19 00:00:00') TO ('2026-01-26 00:00:00')",
+ },
+ {
+ name: "posts_2026w05",
+ bound:
+ "FOR VALUES FROM ('2026-01-26 00:00:00') TO ('2026-02-02 00:00:00')",
+ },
+ ]);
+ });
+ });
+
+ describe("extending an existing partitioned table (by bounds, no rename)", () => {
+ async function boundsByName(transaction: DatabaseTransactionConnection) {
+ await transaction.query(sql.unsafe`SET LOCAL TIME ZONE 'UTC'`);
+ return transaction.any(
+ sql.type(z.object({ name: z.string(), bound: z.string() }))`
+ SELECT c.relname AS name, pg_get_expr(c.relpartbound, c.oid) AS bound
+ FROM pg_inherits i
+ JOIN pg_class c ON c.oid = i.inhrelid
+ WHERE i.inhparent = 'public.evt'::regclass
+ ORDER BY c.relname
+ `,
+ );
+ }
+
+ test("extends a legacy-named weekly table in place, never renaming existing partitions", async ({
+ pgslice,
+ transaction,
+ }) => {
+ vi.setSystemTime(new Date(Date.UTC(2026, 5, 15)));
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt (
+ id varchar NOT NULL,
+ created_at timestamp without time zone NOT NULL,
+ PRIMARY KEY (id, created_at)
+ ) PARTITION BY RANGE (created_at)
+ `);
+ await transaction.query(sql.unsafe`
+ COMMENT ON TABLE evt IS 'column:created_at,period:week,cast:date,version:3'
+ `);
+ // Legacy ISO-weekly partitions with a non-pgslice "p" name prefix.
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt_p2026w24 PARTITION OF evt
+ FOR VALUES FROM ('2026-06-08') TO ('2026-06-15')
+ `);
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt_p2026w25 PARTITION OF evt
+ FOR VALUES FROM ('2026-06-15') TO ('2026-06-22')
+ `);
+
+ await pgslice.addPartitions(transaction, { table: "evt", future: 2 });
+
+ // Legacy names are untouched; new partitions pick up at the bound.
+ expect(await boundsByName(transaction)).toEqual([
+ {
+ name: "evt_2026w26",
+ bound:
+ "FOR VALUES FROM ('2026-06-22 00:00:00') TO ('2026-06-29 00:00:00')",
+ },
+ {
+ name: "evt_2026w27",
+ bound:
+ "FOR VALUES FROM ('2026-06-29 00:00:00') TO ('2026-07-06 00:00:00')",
+ },
+ {
+ name: "evt_p2026w24",
+ bound:
+ "FOR VALUES FROM ('2026-06-08 00:00:00') TO ('2026-06-15 00:00:00')",
+ },
+ {
+ name: "evt_p2026w25",
+ bound:
+ "FOR VALUES FROM ('2026-06-15 00:00:00') TO ('2026-06-22 00:00:00')",
+ },
+ ]);
+ });
+
+ test("extends a year-resetting weekly scheme across the year boundary with no gap or overlap", async ({
+ pgslice,
+ transaction,
+ }) => {
+ vi.setSystemTime(new Date(Date.UTC(2026, 11, 15)));
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt (
+ id varchar NOT NULL,
+ occurred_at timestamp without time zone NOT NULL,
+ PRIMARY KEY (id, occurred_at)
+ ) PARTITION BY RANGE (occurred_at)
+ `);
+ await transaction.query(sql.unsafe`
+ COMMENT ON TABLE evt IS 'column:occurred_at,period:week,cast:date,version:3'
+ `);
+ // Year-resetting weeks: w53 is a one-day stub ending exactly at the year
+ // boundary. ISO-week generation would overlap it; bounds-anchored
+ // extension continues contiguously from 2027-01-01.
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt_y2026w52 PARTITION OF evt
+ FOR VALUES FROM ('2026-12-24') TO ('2026-12-31')
+ `);
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt_y2026w53 PARTITION OF evt
+ FOR VALUES FROM ('2026-12-31') TO ('2027-01-01')
+ `);
+
+ await pgslice.addPartitions(transaction, { table: "evt", future: 4 });
+
+ const rows = await boundsByName(transaction);
+ const firstGenerated = rows.find((r) => !r.name.startsWith("evt_y"));
+ expect(firstGenerated?.bound).toBe(
+ "FOR VALUES FROM ('2027-01-01 00:00:00') TO ('2027-01-08 00:00:00')",
+ );
+
+ // No two partitions overlap, and coverage is contiguous. (boundsByName
+ // already pinned the session to UTC before reading the bounds.)
+ assertContiguous(rows);
+ });
+
+ test("ignores a MINVALUE (historic) partition when choosing the extension anchor", async ({
+ pgslice,
+ transaction,
+ }) => {
+ vi.setSystemTime(new Date(Date.UTC(2026, 5, 10)));
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt (
+ id varchar NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ PRIMARY KEY (id, created_at)
+ ) PARTITION BY RANGE (created_at)
+ `);
+ await transaction.query(sql.unsafe`
+ COMMENT ON TABLE evt IS 'column:created_at,period:week,cast:timestamptz,version:3'
+ `);
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt_historic PARTITION OF evt
+ FOR VALUES FROM (MINVALUE) TO ('2026-04-20 00:00:00+00')
+ `);
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt_p2026w24 PARTITION OF evt
+ FOR VALUES FROM ('2026-06-08 00:00:00+00') TO ('2026-06-15 00:00:00+00')
+ `);
+
+ await pgslice.addPartitions(transaction, { table: "evt", future: 1 });
+
+ // Nothing is created below the historic partition; extension anchors on
+ // the latest finite upper bound (2026-06-15), not the MINVALUE partition.
+ const generated = (await boundsByName(transaction)).filter(
+ (r) => r.name !== "evt_historic" && r.name !== "evt_p2026w24",
+ );
+ expect(generated).toEqual([
+ {
+ name: "evt_2026w25",
+ bound:
+ "FOR VALUES FROM ('2026-06-15 00:00:00+00') TO ('2026-06-22 00:00:00+00')",
+ },
+ ]);
+ });
+
+ test("tolerates an existing DEFAULT partition and leaves it in place", async ({
+ pgslice,
+ transaction,
+ }) => {
+ vi.setSystemTime(new Date(Date.UTC(2026, 5, 15)));
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt (
+ id varchar NOT NULL,
+ created_at timestamp without time zone NOT NULL,
+ PRIMARY KEY (id, created_at)
+ ) PARTITION BY RANGE (created_at)
+ `);
+ await transaction.query(sql.unsafe`
+ COMMENT ON TABLE evt IS 'column:created_at,period:week,cast:date,version:3'
+ `);
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt_p2026w24 PARTITION OF evt
+ FOR VALUES FROM ('2026-06-08') TO ('2026-06-15')
+ `);
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt_default PARTITION OF evt DEFAULT
+ `);
+
+ await pgslice.addPartitions(transaction, { table: "evt", future: 1 });
+
+ const rows = await boundsByName(transaction);
+ expect(rows.find((r) => r.name === "evt_default")?.bound).toBe("DEFAULT");
+ expect(rows.some((r) => r.name === "evt_2026w25")).toBe(true);
+ });
+
+ test("is idempotent: a second run creates no further partitions", async ({
+ pgslice,
+ transaction,
+ }) => {
+ vi.setSystemTime(new Date(Date.UTC(2026, 5, 15)));
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt (
+ id varchar NOT NULL,
+ created_at timestamp without time zone NOT NULL,
+ PRIMARY KEY (id, created_at)
+ ) PARTITION BY RANGE (created_at)
+ `);
+ await transaction.query(sql.unsafe`
+ COMMENT ON TABLE evt IS 'column:created_at,period:month,cast:date,version:3'
+ `);
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt_202606 PARTITION OF evt
+ FOR VALUES FROM ('2026-06-01') TO ('2026-07-01')
+ `);
+
+ await pgslice.addPartitions(transaction, { table: "evt", future: 3 });
+ const afterFirst = (await boundsByName(transaction)).length;
+ await pgslice.addPartitions(transaction, { table: "evt", future: 3 });
+ const afterSecond = (await boundsByName(transaction)).length;
+
+ expect(afterFirst).toBe(4);
+ expect(afterSecond).toBe(afterFirst);
+ });
+
+ test("aligns new partitions to UTC regardless of the session timezone", async ({
+ pgslice,
+ transaction,
+ }) => {
+ vi.setSystemTime(new Date(Date.UTC(2026, 5, 15)));
+ // Deliberately operate under a non-UTC session timezone.
+ await transaction.query(
+ sql.unsafe`SET LOCAL TIME ZONE 'America/New_York'`,
+ );
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt (
+ id varchar NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ PRIMARY KEY (id, created_at)
+ ) PARTITION BY RANGE (created_at)
+ `);
+ await transaction.query(sql.unsafe`
+ COMMENT ON TABLE evt IS 'column:created_at,period:week,cast:timestamptz,version:3'
+ `);
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt_p2026w25 PARTITION OF evt
+ FOR VALUES FROM ('2026-06-15 00:00:00+00') TO ('2026-06-22 00:00:00+00')
+ `);
+
+ await pgslice.addPartitions(transaction, { table: "evt", future: 1 });
+
+ // Boundary is the UTC midnight, contiguous with the existing partition —
+ // not shifted by the session's -04:00 offset.
+ const generated = (await boundsByName(transaction)).find(
+ (r) => r.name === "evt_2026w26",
+ );
+ expect(generated?.bound).toBe(
+ "FOR VALUES FROM ('2026-06-22 00:00:00+00') TO ('2026-06-29 00:00:00+00')",
+ );
+ });
+
+ test("creates the current period at --future 0 when it is not yet covered", async ({
+ pgslice,
+ transaction,
+ }) => {
+ // today is June 2026; the latest existing partition is May, so today's
+ // period is uncovered. --future 0 must still create June, matching the
+ // fresh-table path which always includes the current period.
+ vi.setSystemTime(new Date(Date.UTC(2026, 5, 15)));
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt (
+ id varchar NOT NULL,
+ created_at timestamp without time zone NOT NULL,
+ PRIMARY KEY (id, created_at)
+ ) PARTITION BY RANGE (created_at)
+ `);
+ await transaction.query(sql.unsafe`
+ COMMENT ON TABLE evt IS 'column:created_at,period:month,cast:date,version:3'
+ `);
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt_202605 PARTITION OF evt
+ FOR VALUES FROM ('2026-05-01') TO ('2026-06-01')
+ `);
+
+ await pgslice.addPartitions(transaction, { table: "evt", future: 0 });
+
+ const names = (await boundsByName(transaction)).map((r) => r.name);
+ expect(names).toContain("evt_202606");
+ });
+
+ test("does not extend forward past a MAXVALUE catch-all partition", async ({
+ pgslice,
+ transaction,
+ }) => {
+ vi.setSystemTime(new Date(Date.UTC(2026, 5, 15)));
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt (
+ id varchar NOT NULL,
+ created_at timestamp without time zone NOT NULL,
+ PRIMARY KEY (id, created_at)
+ ) PARTITION BY RANGE (created_at)
+ `);
+ await transaction.query(sql.unsafe`
+ COMMENT ON TABLE evt IS 'column:created_at,period:month,cast:date,version:3'
+ `);
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt_202606 PARTITION OF evt
+ FOR VALUES FROM ('2026-06-01') TO ('2026-07-01')
+ `);
+ // An open-ended catch-all above already covers every future row.
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt_future PARTITION OF evt
+ FOR VALUES FROM ('2026-07-01') TO (MAXVALUE)
+ `);
+
+ await pgslice.addPartitions(transaction, { table: "evt", future: 3 });
+
+ // Forward extension is disabled when a partition is unbounded above —
+ // there is nothing to add, and no error is raised.
+ expect(
+ (await boundsByName(transaction)).map((r) => r.name).sort(),
+ ).toEqual(["evt_202606", "evt_future"]);
+ });
+
+ test("extends a classic (parent has no PK) table with the inherited composite key", async ({
+ pgslice,
+ transaction,
+ }) => {
+ vi.setSystemTime(new Date(Date.UTC(2026, 5, 15)));
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt (
+ id varchar NOT NULL,
+ tenant_id varchar NOT NULL,
+ created_at timestamp without time zone NOT NULL
+ ) PARTITION BY RANGE (created_at)
+ `);
+ await transaction.query(sql.unsafe`
+ COMMENT ON TABLE evt IS 'column:created_at,period:month,cast:date,version:3'
+ `);
+ // Classic pgslice model: the parent has no key; each partition owns a
+ // (here three-column) composite key of its own.
+ await transaction.query(sql.unsafe`
+ CREATE TABLE evt_202606 PARTITION OF evt
+ FOR VALUES FROM ('2026-06-01') TO ('2026-07-01')
+ `);
+ await transaction.query(sql.unsafe`
+ ALTER TABLE evt_202606 ADD PRIMARY KEY (tenant_id, id, created_at)
+ `);
+
+ await pgslice.addPartitions(transaction, { table: "evt", future: 1 });
+
+ const pk = await transaction.any(
+ sql.type(z.object({ def: z.string() }))`
+ SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint
+ WHERE conrelid = 'public.evt_202607'::regclass AND contype = 'p'
+ `,
+ );
+ expect(pk.map((c) => c.def)).toEqual([
+ "PRIMARY KEY (tenant_id, id, created_at)",
+ ]);
+ });
+ });
+});
diff --git a/src/commands/add-partitions.test.ts b/src/commands/add-partitions.test.ts
index 81e065b..0631441 100644
--- a/src/commands/add-partitions.test.ts
+++ b/src/commands/add-partitions.test.ts
@@ -1,4 +1,5 @@
import { describe, expect } from "vitest";
+import { sql } from "slonik";
import { commandTest as test } from "../testing/index.js";
import { AddPartitionsCommand } from "./add-partitions.js";
@@ -6,6 +7,31 @@ import { AddPartitionsCommand } from "./add-partitions.js";
describe("AddPartitionsCommand", () => {
test.scoped({ commandClass: ({}, use) => use(AddPartitionsCommand) });
+ test("reports the partitions it created", async ({
+ cli,
+ commandContext,
+ transaction,
+ }) => {
+ await transaction.query(sql.unsafe`
+ CREATE TABLE posts (
+ id bigint NOT NULL,
+ created_at timestamp without time zone NOT NULL,
+ PRIMARY KEY (id, created_at)
+ ) PARTITION BY RANGE (created_at)
+ `);
+ await transaction.query(sql.unsafe`
+ COMMENT ON TABLE posts IS 'column:created_at,period:month,cast:date,version:3'
+ `);
+
+ const exitCode = await cli.run(
+ ["add_partitions", "posts", "--future=1"],
+ commandContext,
+ );
+
+ expect(exitCode).toBe(0);
+ expect(commandContext.stdout.read()?.toString()).toContain("posts: +");
+ });
+
test("returns error when --past is negative", async ({
cli,
commandContext,
diff --git a/src/commands/add-partitions.ts b/src/commands/add-partitions.ts
index ef997ef..aa9e430 100644
--- a/src/commands/add-partitions.ts
+++ b/src/commands/add-partitions.ts
@@ -46,16 +46,27 @@ export class AddPartitionsCommand extends BaseCommand {
tablespace = Option.String("--tablespace", {
description: "Tablespace to use for new partitions",
});
+ inheritGrants = Option.Boolean("--inherit-grants", true, {
+ description:
+ "Copy the parent table's privileges onto each new partition (default: true; disable with --no-inherit-grants)",
+ });
override async perform(pgslice: Pgslice): Promise {
- await pgslice.start(async (tx) =>
+ const created = await pgslice.start(async (tx) =>
pgslice.addPartitions(tx, {
table: this.table,
intermediate: this.intermediate,
past: this.past,
future: this.future,
tablespace: this.tablespace,
+ inheritGrants: this.inheritGrants,
}),
);
+
+ this.context.stdout.write(
+ created.length > 0
+ ? `${this.table}: +${created.length} partition(s): ${created.join(", ")}\n`
+ : `${this.table}: no new partitions needed\n`,
+ );
}
}
diff --git a/src/date-ranges.test.ts b/src/date-ranges.test.ts
index 257725f..77d846a 100644
--- a/src/date-ranges.test.ts
+++ b/src/date-ranges.test.ts
@@ -2,10 +2,42 @@ import { describe, expect, it } from "vitest";
import {
DateRanges,
advanceDate,
+ extendRanges,
+ extendRangesBackward,
formatDateSuffix,
+ maxUpperBound,
+ minLowerBound,
+ parsePartitionDate,
+ parseRangeBound,
+ rangeOverlaps,
roundDate,
+ type ExistingRange,
} from "./date-ranges.js";
+const finite = (lo: string, hi: string): ExistingRange => ({
+ lower: new Date(`${lo}T00:00:00Z`),
+ upper: new Date(`${hi}T00:00:00Z`),
+ lowerUnbounded: false,
+ upperUnbounded: false,
+ isDefault: false,
+});
+
+const DEFAULT_RANGE: ExistingRange = {
+ lower: null,
+ upper: null,
+ lowerUnbounded: true,
+ upperUnbounded: true,
+ isDefault: true,
+};
+
+const HISTORIC_RANGE: ExistingRange = {
+ lower: null,
+ upper: new Date("2025-01-01T00:00:00Z"),
+ lowerUnbounded: true,
+ upperUnbounded: false,
+ isDefault: false,
+};
+
describe("roundDate", () => {
it("rounds to start of day", () => {
const date = new Date(Date.UTC(2026, 0, 21, 15, 30, 45));
@@ -13,6 +45,20 @@ describe("roundDate", () => {
expect(result).toEqual(new Date(Date.UTC(2026, 0, 21)));
});
+ it("rounds to start of ISO week (Monday)", () => {
+ // 2026-08-15 is a Saturday; its ISO week starts Monday 2026-08-10.
+ const date = new Date(Date.UTC(2026, 7, 15, 15, 30, 45));
+ const result = roundDate(date, "week");
+ expect(result).toEqual(new Date(Date.UTC(2026, 7, 10)));
+ });
+
+ it("rounds to start of ISO week across a year boundary", () => {
+ // 2026-01-01 (Thu) belongs to ISO week 2026-W01, starting 2025-12-29.
+ const date = new Date(Date.UTC(2026, 0, 1, 9, 0, 0));
+ const result = roundDate(date, "week");
+ expect(result).toEqual(new Date(Date.UTC(2025, 11, 29)));
+ });
+
it("rounds to start of month", () => {
const date = new Date(Date.UTC(2026, 0, 21, 15, 30, 45));
const result = roundDate(date, "month");
@@ -47,6 +93,20 @@ describe("advanceDate", () => {
});
});
+ describe("week period", () => {
+ it("advances by positive count", () => {
+ const date = new Date(Date.UTC(2026, 7, 10));
+ const result = advanceDate(date, "week", 2);
+ expect(result).toEqual(new Date(Date.UTC(2026, 7, 24)));
+ });
+
+ it("advances by negative count across a month boundary", () => {
+ const date = new Date(Date.UTC(2026, 7, 10));
+ const result = advanceDate(date, "week", -2);
+ expect(result).toEqual(new Date(Date.UTC(2026, 6, 27)));
+ });
+ });
+
describe("month period", () => {
it("advances by positive count", () => {
const date = new Date(Date.UTC(2026, 0, 1));
@@ -88,6 +148,29 @@ describe("formatDateSuffix", () => {
expect(formatDateSuffix(date, "day")).toBe("20260121");
});
+ it("formats ISO week suffix", () => {
+ // Confirmed against Postgres to_char(date, 'IYYY"w"IW').
+ expect(formatDateSuffix(new Date(Date.UTC(2023, 0, 9)), "week")).toBe(
+ "2023w02",
+ );
+ expect(formatDateSuffix(new Date(Date.UTC(2026, 7, 10)), "week")).toBe(
+ "2026w33",
+ );
+ });
+
+ it("formats ISO week suffix using the ISO week-year at boundaries", () => {
+ // 2025-12-29 belongs to ISO week 2026-W01; 2024-12-30 to 2025-W01.
+ expect(formatDateSuffix(new Date(Date.UTC(2025, 11, 29)), "week")).toBe(
+ "2026w01",
+ );
+ expect(formatDateSuffix(new Date(Date.UTC(2024, 11, 30)), "week")).toBe(
+ "2025w01",
+ );
+ expect(formatDateSuffix(new Date(Date.UTC(2026, 11, 28)), "week")).toBe(
+ "2026w53",
+ );
+ });
+
it("formats month suffix", () => {
const date = new Date(Date.UTC(2026, 0, 1));
expect(formatDateSuffix(date, "month")).toBe("202601");
@@ -201,6 +284,36 @@ describe("DateRanges", () => {
});
});
+ describe("week period", () => {
+ it("generates Monday-aligned ISO-week ranges with past and future", () => {
+ const ranges = new DateRanges({
+ today: new Date(Date.UTC(2026, 7, 15)),
+ period: "week",
+ past: 1,
+ future: 1,
+ });
+
+ const result = [...ranges];
+ expect(result).toHaveLength(3);
+
+ expect(result[0]).toEqual({
+ start: new Date(Date.UTC(2026, 7, 3)),
+ end: new Date(Date.UTC(2026, 7, 10)),
+ suffix: "2026w32",
+ });
+ expect(result[1]).toEqual({
+ start: new Date(Date.UTC(2026, 7, 10)),
+ end: new Date(Date.UTC(2026, 7, 17)),
+ suffix: "2026w33",
+ });
+ expect(result[2]).toEqual({
+ start: new Date(Date.UTC(2026, 7, 17)),
+ end: new Date(Date.UTC(2026, 7, 24)),
+ suffix: "2026w34",
+ });
+ });
+ });
+
describe("edge cases", () => {
it("handles zero past and future (single partition)", () => {
const ranges = new DateRanges({
@@ -252,3 +365,327 @@ describe("DateRanges", () => {
});
});
});
+
+describe("parsePartitionDate", () => {
+ it("parses an ISO-week suffix back to its Monday", () => {
+ expect(parsePartitionDate("posts_2026w33", "week")).toEqual(
+ new Date(Date.UTC(2026, 7, 10)),
+ );
+ });
+
+ it("parses an ISO-week suffix across a year boundary", () => {
+ // 2026-W01 starts on 2025-12-29.
+ expect(parsePartitionDate("visits_2026w01", "week")).toEqual(
+ new Date(Date.UTC(2025, 11, 29)),
+ );
+ });
+
+ it("round-trips week suffixes through formatDateSuffix", () => {
+ for (const iso of [
+ "2023-01-09",
+ "2026-08-10",
+ "2024-12-30",
+ "2026-12-28",
+ ]) {
+ const monday = new Date(`${iso}T00:00:00Z`);
+ const suffix = formatDateSuffix(monday, "week");
+ expect(parsePartitionDate(`t_${suffix}`, "week")).toEqual(monday);
+ }
+ });
+
+ it("parses month and day suffixes", () => {
+ expect(parsePartitionDate("posts_202601", "month")).toEqual(
+ new Date(Date.UTC(2026, 0, 1)),
+ );
+ expect(parsePartitionDate("posts_20260121", "day")).toEqual(
+ new Date(Date.UTC(2026, 0, 21)),
+ );
+ });
+
+ it("throws on a legacy-prefixed or out-of-range week suffix it can't parse", () => {
+ expect(() => parsePartitionDate("evt_y2026w03", "week")).toThrow(
+ /Unrecognized week partition suffix/,
+ );
+ // Out-of-range ISO week numbers are rejected rather than silently parsed.
+ expect(() => parsePartitionDate("evt_2026w00", "week")).toThrow(
+ /Unrecognized week partition suffix/,
+ );
+ expect(() => parsePartitionDate("evt_2026w54", "week")).toThrow(
+ /Unrecognized week partition suffix/,
+ );
+ });
+});
+
+describe("custom partition formats", () => {
+ const monday = new Date(Date.UTC(2026, 7, 10)); // ISO 2026-W33 Monday
+
+ it("renders a weekly suffix with a configurable prefix", () => {
+ expect(formatDateSuffix(monday, "week", "p{YYYY}w{WW}")).toBe("p2026w33");
+ expect(formatDateSuffix(monday, "week", "y{YYYY}w{WW}")).toBe("y2026w33");
+ // No format => the period default, unchanged.
+ expect(formatDateSuffix(monday, "week")).toBe("2026w33");
+ });
+
+ it("renders a monthly suffix with a prefix and separator", () => {
+ const may = new Date(Date.UTC(2027, 4, 1));
+ expect(formatDateSuffix(may, "month", "y{YYYY}m{MM}")).toBe("y2027m05");
+ expect(formatDateSuffix(may, "month")).toBe("202705");
+ });
+
+ it("round-trips each custom format through parsePartitionDate", () => {
+ const cases = [
+ { format: "p{YYYY}w{WW}", period: "week", iso: "2026-08-10" },
+ { format: "y{YYYY}w{WW}", period: "week", iso: "2025-12-29" },
+ { format: "y{YYYY}m{MM}", period: "month", iso: "2027-05-01" },
+ ] as const;
+ for (const { format, period, iso } of cases) {
+ const start = new Date(`${iso}T00:00:00Z`);
+ const suffix = formatDateSuffix(start, period, format);
+ expect(parsePartitionDate(`t_${suffix}`, period, format)).toEqual(start);
+ }
+ });
+
+ it("throws when a partition name doesn't match its format", () => {
+ expect(() =>
+ parsePartitionDate("t_p2026w33", "week", "y{YYYY}w{WW}"),
+ ).toThrow(/Unrecognized week partition suffix/);
+ expect(() =>
+ parsePartitionDate("t_2026w33", "week", "p{YYYY}w{WW}"),
+ ).toThrow(/Unrecognized week partition suffix/);
+ });
+
+ it("rejects malformed format templates", () => {
+ const date = new Date(Date.UTC(2027, 0, 4));
+ // {MM} is not a week placeholder.
+ expect(() => formatDateSuffix(date, "week", "p{YYYY}m{MM}")).toThrow(
+ /Unknown placeholder/,
+ );
+ // Missing the required {WW}.
+ expect(() => formatDateSuffix(date, "week", "p{YYYY}")).toThrow(
+ /missing required placeholder/,
+ );
+ // Unsafe literal characters (uppercase, hyphen).
+ expect(() => formatDateSuffix(date, "week", "P-{YYYY}w{WW}")).toThrow(
+ /Malformed week partition format/,
+ );
+ // Underscore is reserved: the suffix is the text after the table name's
+ // last "_", so a literal "_" would render an unparseable name.
+ expect(() => formatDateSuffix(date, "week", "p{YYYY}_w{WW}")).toThrow(
+ /Malformed week partition format/,
+ );
+ });
+});
+
+describe("parseRangeBound", () => {
+ it("parses a DEFAULT bound", () => {
+ expect(parseRangeBound("DEFAULT")).toEqual({
+ lower: null,
+ upper: null,
+ lowerUnbounded: true,
+ upperUnbounded: true,
+ isDefault: true,
+ });
+ });
+
+ it("parses date, timestamp, and timestamptz bounds identically (by day token)", () => {
+ const expected = {
+ lower: new Date(Date.UTC(2026, 5, 1)),
+ upper: new Date(Date.UTC(2026, 6, 1)),
+ lowerUnbounded: false,
+ upperUnbounded: false,
+ isDefault: false,
+ };
+ expect(
+ parseRangeBound("FOR VALUES FROM ('2026-06-01') TO ('2026-07-01')"),
+ ).toEqual(expected);
+ expect(
+ parseRangeBound(
+ "FOR VALUES FROM ('2026-06-01 00:00:00') TO ('2026-07-01 00:00:00')",
+ ),
+ ).toEqual(expected);
+ expect(
+ parseRangeBound(
+ "FOR VALUES FROM ('2026-06-01 00:00:00+00') TO ('2026-07-01 00:00:00+00')",
+ ),
+ ).toEqual(expected);
+ });
+
+ it("parses a non-UTC-midnight timestamptz bound at its exact instant", () => {
+ expect(
+ parseRangeBound(
+ "FOR VALUES FROM ('2026-06-08 05:00:00+00') TO ('2026-06-15 05:00:00+00')",
+ ),
+ ).toEqual({
+ lower: new Date("2026-06-08T05:00:00Z"),
+ upper: new Date("2026-06-15T05:00:00Z"),
+ lowerUnbounded: false,
+ upperUnbounded: false,
+ isDefault: false,
+ });
+ });
+
+ it("treats MINVALUE / MAXVALUE as unbounded", () => {
+ expect(
+ parseRangeBound("FOR VALUES FROM (MINVALUE) TO ('2026-07-01')"),
+ ).toMatchObject({
+ lower: null,
+ lowerUnbounded: true,
+ upper: new Date(Date.UTC(2026, 6, 1)),
+ upperUnbounded: false,
+ });
+ expect(
+ parseRangeBound("FOR VALUES FROM ('2026-06-01') TO (MAXVALUE)"),
+ ).toMatchObject({
+ lower: new Date(Date.UTC(2026, 5, 1)),
+ lowerUnbounded: false,
+ upper: null,
+ upperUnbounded: true,
+ });
+ });
+
+ it("returns null for a non-RANGE bound it doesn't manage", () => {
+ expect(parseRangeBound("FOR VALUES IN (1, 2, 3)")).toBeNull();
+ expect(
+ parseRangeBound("FOR VALUES WITH (modulus 4, remainder 0)"),
+ ).toBeNull();
+ });
+
+ it("returns null for a RANGE bound whose values aren't temporal", () => {
+ // An integer range; a non-date bound must not read as an infinite span.
+ expect(parseRangeBound("FOR VALUES FROM ('100') TO ('200')")).toBeNull();
+ });
+
+ it("returns null for a multi-column RANGE bound", () => {
+ // A compound key (timestamp, int): we manage only single-column temporal
+ // ranges, so this must not be misread as a single date bound.
+ expect(
+ parseRangeBound(
+ "FOR VALUES FROM ('2026-01-01 00:00:00+00', 5) TO ('2026-04-01 00:00:00+00', 10)",
+ ),
+ ).toBeNull();
+ });
+});
+
+describe("maxUpperBound / minLowerBound", () => {
+ it("returns the greatest finite upper bound, ignoring DEFAULT and MINVALUE", () => {
+ expect(
+ maxUpperBound([
+ finite("2026-01-01", "2026-02-01"),
+ finite("2026-03-01", "2026-04-01"),
+ DEFAULT_RANGE,
+ HISTORIC_RANGE,
+ ]),
+ ).toEqual(new Date("2026-04-01T00:00:00Z"));
+ });
+
+ it("returns the least finite lower bound, ignoring DEFAULT and unbounded-below", () => {
+ expect(
+ minLowerBound([
+ finite("2026-03-01", "2026-04-01"),
+ finite("2026-01-01", "2026-02-01"),
+ DEFAULT_RANGE,
+ HISTORIC_RANGE,
+ ]),
+ ).toEqual(new Date("2026-01-01T00:00:00Z"));
+ });
+
+ it("returns null when there is no finite bound", () => {
+ expect(maxUpperBound([DEFAULT_RANGE])).toBeNull();
+ expect(minLowerBound([DEFAULT_RANGE])).toBeNull();
+ });
+});
+
+describe("rangeOverlaps", () => {
+ it("treats ranges as half-open: adjacent does not overlap, interior does", () => {
+ const existing = finite("2026-01-01", "2026-02-01");
+ expect(
+ rangeOverlaps(
+ new Date("2026-02-01T00:00:00Z"),
+ new Date("2026-03-01T00:00:00Z"),
+ existing,
+ ),
+ ).toBe(false);
+ expect(
+ rangeOverlaps(
+ new Date("2026-01-15T00:00:00Z"),
+ new Date("2026-02-15T00:00:00Z"),
+ existing,
+ ),
+ ).toBe(true);
+ });
+
+ it("treats a DEFAULT partition as never overlapping", () => {
+ expect(
+ rangeOverlaps(
+ new Date("2026-01-01T00:00:00Z"),
+ new Date("2026-02-01T00:00:00Z"),
+ DEFAULT_RANGE,
+ ),
+ ).toBe(false);
+ });
+
+ it("treats MINVALUE/MAXVALUE as infinities", () => {
+ expect(
+ rangeOverlaps(
+ new Date("2020-01-01T00:00:00Z"),
+ new Date("2020-02-01T00:00:00Z"),
+ HISTORIC_RANGE,
+ ),
+ ).toBe(true);
+ expect(
+ rangeOverlaps(
+ new Date("2026-01-01T00:00:00Z"),
+ new Date("2026-02-01T00:00:00Z"),
+ HISTORIC_RANGE,
+ ),
+ ).toBe(false);
+ });
+});
+
+describe("extendRanges", () => {
+ it("chains contiguous month ranges from the anchor through the horizon", () => {
+ const ranges = [
+ ...extendRanges({
+ anchorStart: new Date(Date.UTC(2026, 1, 1)),
+ period: "month",
+ horizon: new Date(Date.UTC(2026, 3, 1)),
+ }),
+ ];
+ expect(ranges.map((r) => r.suffix)).toEqual(["202602", "202603", "202604"]);
+ for (let i = 1; i < ranges.length; i++) {
+ expect(ranges[i].start).toEqual(ranges[i - 1].end);
+ }
+ });
+
+ it("is scheme-agnostic: chains 7-day weeks off a non-Monday anchor without re-rounding", () => {
+ const wednesday = new Date(Date.UTC(2025, 10, 12)); // 2025-11-12
+ const ranges = [
+ ...extendRanges({
+ anchorStart: wednesday,
+ period: "week",
+ horizon: new Date(Date.UTC(2025, 10, 19)),
+ }),
+ ];
+ expect(ranges).toHaveLength(2);
+ expect(ranges[0].start).toEqual(wednesday);
+ expect(ranges[0].end).toEqual(new Date(Date.UTC(2025, 10, 19)));
+ // Drift preserved: still a Wednesday (day 3), not snapped to Monday.
+ expect(ranges[0].start.getUTCDay()).toBe(3);
+ });
+});
+
+describe("extendRangesBackward", () => {
+ it("chains contiguous month ranges backward from the anchor to the horizon", () => {
+ const ranges = [
+ ...extendRangesBackward({
+ anchorEnd: new Date(Date.UTC(2026, 1, 1)),
+ period: "month",
+ horizon: new Date(Date.UTC(2025, 11, 1)),
+ }),
+ ];
+ expect(ranges.map((r) => r.suffix).sort()).toEqual(["202512", "202601"]);
+ for (const range of ranges) {
+ expect(range.end.getTime()).toBeGreaterThan(range.start.getTime());
+ }
+ });
+});
diff --git a/src/date-ranges.ts b/src/date-ranges.ts
index b793666..047f5ff 100644
--- a/src/date-ranges.ts
+++ b/src/date-ranges.ts
@@ -1,5 +1,65 @@
import type { Period } from "./types.js";
+const MS_PER_DAY = 86_400_000;
+
+/**
+ * Returns the ISO weekday (Mon=1 .. Sun=7) for a date in UTC.
+ */
+function isoWeekday(date: Date): number {
+ const day = date.getUTCDay();
+ return day === 0 ? 7 : day;
+}
+
+/**
+ * Returns the UTC Monday that starts the ISO week containing the given date.
+ */
+function startOfIsoWeek(date: Date): Date {
+ const monday = new Date(
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
+ );
+ monday.setUTCDate(monday.getUTCDate() - (isoWeekday(date) - 1));
+ return monday;
+}
+
+/**
+ * Computes the ISO week-numbering year and week number for a date.
+ * Per ISO 8601, week 1 is the week containing the year's first Thursday.
+ */
+function isoWeekInfo(date: Date): { isoYear: number; isoWeek: number } {
+ // The Thursday of the ISO week containing `date` determines the ISO year and
+ // anchors the week count (ISO week 1 is the week containing the year's first
+ // Thursday).
+ const weekMonday = startOfIsoWeek(date);
+ const thursday = new Date(weekMonday);
+ thursday.setUTCDate(weekMonday.getUTCDate() + 3);
+
+ const isoYear = thursday.getUTCFullYear();
+ // The Thursday of ISO week 1: Jan 4 is always in week 1, so take the Monday
+ // of its week and advance to Thursday.
+ const jan4 = new Date(Date.UTC(isoYear, 0, 4));
+ const week1Thursday = new Date(jan4);
+ week1Thursday.setUTCDate(jan4.getUTCDate() - (isoWeekday(jan4) - 1) + 3);
+
+ const isoWeek =
+ 1 +
+ Math.round(
+ (thursday.getTime() - week1Thursday.getTime()) / (7 * MS_PER_DAY),
+ );
+
+ return { isoYear, isoWeek };
+}
+
+/**
+ * Returns the UTC Monday that starts the given ISO week-numbering year/week.
+ */
+function isoWeekToMonday(isoYear: number, isoWeek: number): Date {
+ const jan4 = new Date(Date.UTC(isoYear, 0, 4));
+ const week1Monday = new Date(jan4);
+ week1Monday.setUTCDate(jan4.getUTCDate() - (isoWeekday(jan4) - 1));
+ week1Monday.setUTCDate(week1Monday.getUTCDate() + (isoWeek - 1) * 7);
+ return week1Monday;
+}
+
/**
* Represents a date range for a single partition.
*/
@@ -21,6 +81,8 @@ export interface DateRangesOptions {
past: number;
/** Number of future partitions to include */
future: number;
+ /** Partition-name format template (defaults to the period's standard suffix). */
+ format?: string;
}
/**
@@ -34,6 +96,8 @@ export function roundDate(date: Date, period: Period): Date {
switch (period) {
case "day":
return new Date(Date.UTC(year, month, day));
+ case "week":
+ return startOfIsoWeek(date);
case "month":
return new Date(Date.UTC(year, month, 1));
case "year":
@@ -52,6 +116,8 @@ export function advanceDate(date: Date, period: Period, count: number): Date {
switch (period) {
case "day":
return new Date(Date.UTC(year, month, day + count));
+ case "week":
+ return new Date(Date.UTC(year, month, day + count * 7));
case "month":
return new Date(Date.UTC(year, month + count, 1));
case "year":
@@ -60,56 +126,246 @@ export function advanceDate(date: Date, period: Period, count: number): Date {
}
/**
- * Formats a date as a partition suffix based on the period.
+ * A partition-name suffix is a tiny template of literal characters and
+ * `{PLACEHOLDER}` tokens. The placeholders available depend on the period, and
+ * each resolves to the *same* date component pgslice already uses — so a custom
+ * format only changes the rendered string, never which dates a partition
+ * covers. For weeks the year/week are ISO (week-year + ISO week); for
+ * day/month/year they are the calendar fields.
+ *
+ * week: {YYYY} = ISO week-year, {WW} = ISO week (01–53) default "{YYYY}w{WW}"
+ * month: {YYYY} = year, {MM} = month (01–12) default "{YYYY}{MM}"
+ * day: {YYYY}, {MM}, {DD} default "{YYYY}{MM}{DD}"
+ * year: {YYYY} default "{YYYY}"
+ *
+ * Examples: "p{YYYY}w{WW}" → "p2027w01", "y{YYYY}m{MM}" → "y2027m05".
*/
-export function formatDateSuffix(date: Date, period: Period): string {
- const year = date.getUTCFullYear().toString();
- const month = (date.getUTCMonth() + 1).toString().padStart(2, "0");
- const day = date.getUTCDate().toString().padStart(2, "0");
+interface PlaceholderSpec {
+ /** Renders the placeholder's value from a period-start date. */
+ render: (date: Date) => string;
+ /** Regex (without a capturing group) matching the rendered value. */
+ pattern: string;
+}
+
+interface PeriodFormat {
+ placeholders: Record;
+ defaultFormat: string;
+ /** Rebuilds the period-start date from parsed placeholder values. */
+ reconstruct: (values: Record) => Date;
+}
+
+const pad = (value: number, width: number): string =>
+ value.toString().padStart(width, "0");
+
+function periodFormat(period: Period): PeriodFormat {
+ const calendarYear: PlaceholderSpec = {
+ render: (date) => pad(date.getUTCFullYear(), 4),
+ pattern: "\\d{4}",
+ };
+ const calendarMonth: PlaceholderSpec = {
+ render: (date) => pad(date.getUTCMonth() + 1, 2),
+ pattern: "(?:0[1-9]|1[0-2])",
+ };
switch (period) {
case "day":
- return `${year}${month}${day}`;
+ return {
+ placeholders: {
+ YYYY: calendarYear,
+ MM: calendarMonth,
+ DD: {
+ render: (date) => pad(date.getUTCDate(), 2),
+ pattern: "(?:0[1-9]|[12]\\d|3[01])",
+ },
+ },
+ defaultFormat: "{YYYY}{MM}{DD}",
+ reconstruct: (v) => new Date(Date.UTC(v.YYYY, v.MM - 1, v.DD)),
+ };
+ case "week": {
+ // Both placeholders derive from the same ISO-week computation; memoize it
+ // so a single suffix render does the week arithmetic once, not twice.
+ let memoTime = Number.NaN;
+ let memoInfo: { isoYear: number; isoWeek: number } | undefined;
+ const weekInfo = (date: Date): { isoYear: number; isoWeek: number } => {
+ const time = date.getTime();
+ if (memoInfo !== undefined && time === memoTime) {
+ return memoInfo;
+ }
+ const info = isoWeekInfo(date);
+ memoTime = time;
+ memoInfo = info;
+ return info;
+ };
+ return {
+ placeholders: {
+ YYYY: {
+ render: (date) => pad(weekInfo(date).isoYear, 4),
+ pattern: "\\d{4}",
+ },
+ WW: {
+ render: (date) => pad(weekInfo(date).isoWeek, 2),
+ pattern: "(?:0[1-9]|[1-4]\\d|5[0-3])",
+ },
+ },
+ defaultFormat: "{YYYY}w{WW}",
+ reconstruct: (v) => isoWeekToMonday(v.YYYY, v.WW),
+ };
+ }
case "month":
- return `${year}${month}`;
+ return {
+ placeholders: { YYYY: calendarYear, MM: calendarMonth },
+ defaultFormat: "{YYYY}{MM}",
+ reconstruct: (v) => new Date(Date.UTC(v.YYYY, v.MM - 1, 1)),
+ };
case "year":
- return year;
+ return {
+ placeholders: { YYYY: calendarYear },
+ defaultFormat: "{YYYY}",
+ reconstruct: (v) => new Date(Date.UTC(v.YYYY, 0, 1)),
+ };
+ }
+}
+
+type FormatSegment = { placeholder: string } | { literal: string };
+
+const FORMAT_TOKEN = /\{([A-Z]+)\}|([^{}]+)/g;
+
+const compiledFormatCache = new Map<
+ string,
+ { segments: FormatSegment[]; order: string[] }
+>();
+
+/**
+ * Splits a format template into ordered literal/placeholder segments,
+ * validating that every placeholder is known for the period, every literal is
+ * a safe identifier fragment, and every required placeholder appears exactly
+ * once. Throws on a malformed template so a mistyped settings comment fails
+ * loudly rather than producing an unusable partition name.
+ */
+function compileFormat(
+ period: Period,
+ template: string,
+): { segments: FormatSegment[]; order: string[] } {
+ // Compiling a template is a regex scan + allocation; cache by (period,
+ // template) so a large back-fill rendering thousands of suffixes doesn't
+ // recompile the same spec on every call.
+ const cacheKey = `${period} ${template}`;
+ const cached = compiledFormatCache.get(cacheKey);
+ if (cached) {
+ return cached;
+ }
+
+ // Literals are restricted to [a-z0-9] (no "_"): a partition is named
+ // `_` and the suffix is recovered by splitting on the last
+ // "_" (see parsePartitionDate), so an underscore inside the suffix template
+ // would make the rendered name unparseable.
+ if (!/^(?:[a-z0-9]+|\{[A-Z]+\})+$/.test(template)) {
+ throw new Error(
+ `Malformed ${period} partition format "${template}"; use [a-z0-9] literals and {PLACEHOLDER} tokens`,
+ );
+ }
+
+ const { placeholders } = periodFormat(period);
+ const segments: FormatSegment[] = [];
+ const order: string[] = [];
+
+ for (const match of template.matchAll(FORMAT_TOKEN)) {
+ const [, placeholder, literal] = match;
+ if (placeholder !== undefined) {
+ if (!(placeholder in placeholders)) {
+ throw new Error(
+ `Unknown placeholder "{${placeholder}}" in ${period} partition format "${template}"`,
+ );
+ }
+ if (order.includes(placeholder)) {
+ throw new Error(
+ `Duplicate placeholder "{${placeholder}}" in ${period} partition format "${template}"`,
+ );
+ }
+ segments.push({ placeholder });
+ order.push(placeholder);
+ } else if (literal !== undefined) {
+ segments.push({ literal });
+ }
+ }
+
+ const missing = Object.keys(placeholders).filter(
+ (name) => !order.includes(name),
+ );
+ if (missing.length > 0) {
+ throw new Error(
+ `${period} partition format "${template}" is missing required placeholder(s): ${missing
+ .map((name) => `{${name}}`)
+ .join(", ")}`,
+ );
}
+
+ const compiled = { segments, order };
+ compiledFormatCache.set(cacheKey, compiled);
+ return compiled;
+}
+
+/**
+ * Renders a partition-name suffix for the period-start `date`, using the given
+ * format template (or the period's default suffix when omitted).
+ */
+export function formatDateSuffix(
+ date: Date,
+ period: Period,
+ format?: string,
+): string {
+ const { placeholders, defaultFormat } = periodFormat(period);
+ const { segments } = compileFormat(period, format ?? defaultFormat);
+ return segments
+ .map((segment) =>
+ "literal" in segment
+ ? segment.literal
+ : placeholders[segment.placeholder].render(date),
+ )
+ .join("");
}
/**
- * Parses a partition table name to extract the date from its suffix.
- * The suffix is expected to be the last underscore-separated component.
+ * Parses a partition table name back to its period-start date, inverting
+ * {@link formatDateSuffix} for the same period + format. The suffix is the last
+ * underscore-separated component. Throws if the suffix doesn't match the format
+ * (e.g. a legacy-named or out-of-range partition), so misuse on a
+ * differently-named table is immediately visible.
*/
export function parsePartitionDate(
partitionName: string,
period: Period,
+ format?: string,
): Date {
const suffix = partitionName.split("_").pop();
if (!suffix) {
throw new Error(`Invalid partition name: ${partitionName}`);
}
- switch (period) {
- case "day": {
- // Format: YYYYMMDD
- const year = parseInt(suffix.slice(0, 4), 10);
- const month = parseInt(suffix.slice(4, 6), 10) - 1;
- const day = parseInt(suffix.slice(6, 8), 10);
- return new Date(Date.UTC(year, month, day));
- }
- case "month": {
- // Format: YYYYMM
- const year = parseInt(suffix.slice(0, 4), 10);
- const month = parseInt(suffix.slice(4, 6), 10) - 1;
- return new Date(Date.UTC(year, month, 1));
- }
- case "year": {
- // Format: YYYY
- const year = parseInt(suffix, 10);
- return new Date(Date.UTC(year, 0, 1));
- }
+ const { placeholders, defaultFormat, reconstruct } = periodFormat(period);
+ const { segments, order } = compileFormat(period, format ?? defaultFormat);
+ const regex = new RegExp(
+ `^${segments
+ .map((segment) =>
+ "literal" in segment
+ ? segment.literal
+ : `(${placeholders[segment.placeholder].pattern})`,
+ )
+ .join("")}$`,
+ );
+
+ const match = suffix.match(regex);
+ if (!match) {
+ throw new Error(
+ `Unrecognized ${period} partition suffix "${suffix}" in "${partitionName}"`,
+ );
}
+
+ const values: Record = {};
+ order.forEach((placeholder, index) => {
+ values[placeholder] = parseInt(match[index + 1], 10);
+ });
+ return reconstruct(values);
}
/**
@@ -120,6 +376,7 @@ export class DateRanges implements Iterable {
readonly #period: Period;
readonly #past: number;
readonly #future: number;
+ readonly #format?: string;
constructor(options: DateRangesOptions) {
this.#today = options.today
@@ -128,15 +385,240 @@ export class DateRanges implements Iterable {
this.#period = options.period;
this.#past = options.past;
this.#future = options.future;
+ this.#format = options.format;
}
*[Symbol.iterator](): Generator {
for (let n = -this.#past; n <= this.#future; n++) {
const start = advanceDate(this.#today, this.#period, n);
const end = advanceDate(start, this.#period, 1);
- const suffix = formatDateSuffix(start, this.#period);
+ const suffix = formatDateSuffix(start, this.#period, this.#format);
yield { start, end, suffix };
}
}
}
+
+/**
+ * The bounds of a single existing range partition, read from the catalog.
+ *
+ * A `null` lower means the partition is unbounded below (`MINVALUE`); a `null`
+ * upper means unbounded above (`MAXVALUE`). The DEFAULT partition has no bounds.
+ */
+export interface ExistingRange {
+ lower: Date | null;
+ upper: Date | null;
+ lowerUnbounded: boolean;
+ upperUnbounded: boolean;
+ isDefault: boolean;
+ /** The partition's catalog name, when read from the catalog. */
+ name?: string;
+}
+
+/** Backstop so a malformed anchor/horizon can never spin forever. */
+const MAX_GENERATED_PARTITIONS = 100_000;
+
+/**
+ * Parses a single RANGE bound value (the text inside `FROM (...)` / `TO (...)`)
+ * into a UTC instant, or `null` for a value we can't read as temporal (e.g. an
+ * integer range). `MINVALUE`/`MAXVALUE` are reported as unbounded.
+ *
+ * Boundaries are read under a UTC-pinned session, so a `timestamptz` renders
+ * with a `+00` offset and a `date`/`timestamp` without one. We parse the date
+ * and any time component as UTC (the `+00` is redundant), so a boundary that
+ * isn't UTC-midnight anchors at its exact instant rather than being truncated
+ * to the day.
+ */
+function parseBoundValue(value: string): {
+ date: Date | null;
+ unbounded: boolean;
+} | null {
+ const token = value.trim();
+ if (token === "MINVALUE" || token === "MAXVALUE") {
+ return { date: null, unbounded: true };
+ }
+ // A single-column RANGE bound is exactly one quoted literal; anything else
+ // (e.g. a multi-column key like `'2026-01-01', 5`) is a shape we don't manage.
+ const literal = token.match(/^'([^']*)'$/);
+ if (!literal) {
+ return null;
+ }
+ // Anchored: a date, an optional time, and an optional (UTC, redundant)
+ // offset — and nothing else. Unexpected trailing content yields null rather
+ // than a silently-truncated parse.
+ const match = literal[1].match(
+ /^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2}:\d{2}(?:\.\d+)?)(?:[+-]\d{2}(?::?\d{2})?)?)?$/,
+ );
+ if (!match) {
+ return null;
+ }
+ const parsed = new Date(`${match[1]}T${match[2] ?? "00:00:00"}Z`);
+ if (Number.isNaN(parsed.getTime())) {
+ return null;
+ }
+ return { date: parsed, unbounded: false };
+}
+
+/**
+ * Parses a partition's `pg_get_expr(relpartbound, ...)` text into an
+ * {@link ExistingRange}. Returns `null` for non-RANGE bounds we don't manage.
+ */
+export function parseRangeBound(bound: string): ExistingRange | null {
+ if (bound.trim() === "DEFAULT") {
+ return {
+ lower: null,
+ upper: null,
+ lowerUnbounded: true,
+ upperUnbounded: true,
+ isDefault: true,
+ };
+ }
+
+ const match = bound.match(/FOR VALUES FROM \((.+)\) TO \((.+)\)/s);
+ if (!match) {
+ return null;
+ }
+
+ const lower = parseBoundValue(match[1]);
+ const upper = parseBoundValue(match[2]);
+ if (lower === null || upper === null) {
+ // A RANGE bound we can't read as temporal (e.g. an integer range): treat it
+ // as unmanaged rather than letting a null bound read as ±infinity in
+ // rangeOverlaps, which would block every new partition for the table.
+ return null;
+ }
+ return {
+ lower: lower.date,
+ upper: upper.date,
+ lowerUnbounded: lower.unbounded,
+ upperUnbounded: upper.unbounded,
+ isDefault: false,
+ };
+}
+
+/** The greatest finite upper bound among non-DEFAULT partitions, or null. */
+export function maxUpperBound(ranges: readonly ExistingRange[]): Date | null {
+ let max: Date | null = null;
+ for (const range of ranges) {
+ if (range.isDefault || range.upperUnbounded || range.upper === null) {
+ continue;
+ }
+ if (max === null || range.upper.getTime() > max.getTime()) {
+ max = range.upper;
+ }
+ }
+ return max;
+}
+
+/** The least finite lower bound among non-DEFAULT partitions, or null. */
+export function minLowerBound(ranges: readonly ExistingRange[]): Date | null {
+ let min: Date | null = null;
+ for (const range of ranges) {
+ if (range.isDefault || range.lowerUnbounded || range.lower === null) {
+ continue;
+ }
+ if (min === null || range.lower.getTime() < min.getTime()) {
+ min = range.lower;
+ }
+ }
+ return min;
+}
+
+/**
+ * Whether a Date sits exactly on a UTC-midnight boundary. Bounds-anchored
+ * extension emits new boundaries at UTC midnight (see formatDateForSql), so it
+ * can only abut existing partitions whose own boundaries are UTC-midnight.
+ */
+export function isUtcMidnight(date: Date): boolean {
+ return (
+ date.getUTCHours() === 0 &&
+ date.getUTCMinutes() === 0 &&
+ date.getUTCSeconds() === 0 &&
+ date.getUTCMilliseconds() === 0
+ );
+}
+
+/**
+ * Whether a candidate `[start, end)` overlaps an existing partition's range,
+ * treating `MINVALUE`/`MAXVALUE` as -/+ infinity. The DEFAULT partition never
+ * overlaps (it only absorbs rows no explicit range claims).
+ */
+export function rangeOverlaps(
+ start: Date,
+ end: Date,
+ range: ExistingRange,
+): boolean {
+ if (range.isDefault) {
+ return false;
+ }
+ const lower =
+ range.lowerUnbounded || range.lower === null
+ ? -Infinity
+ : range.lower.getTime();
+ const upper =
+ range.upperUnbounded || range.upper === null
+ ? Infinity
+ : range.upper.getTime();
+ return start.getTime() < upper && end.getTime() > lower;
+}
+
+/**
+ * Generates contiguous period-aligned ranges starting at `anchorStart` and
+ * continuing while the start is at or before `horizon`.
+ *
+ * Unlike {@link DateRanges} (which aligns to an absolute calendar around
+ * "today"), this chains each range off the previous one's end. That makes
+ * extension scheme-agnostic: it continues an existing table's partitioning
+ * contiguously whether the table uses ISO weeks, year-resetting weeks, or
+ * calendar months — never introducing a gap or an overlap at the boundary.
+ */
+export function* extendRanges(options: {
+ anchorStart: Date;
+ period: Period;
+ horizon: Date;
+ format?: string;
+}): Generator {
+ let start = options.anchorStart;
+ for (
+ let count = 0;
+ start.getTime() <= options.horizon.getTime() &&
+ count < MAX_GENERATED_PARTITIONS;
+ count++
+ ) {
+ const end = advanceDate(start, options.period, 1);
+ yield {
+ start,
+ end,
+ suffix: formatDateSuffix(start, options.period, options.format),
+ };
+ start = end;
+ }
+}
+
+/**
+ * Generates contiguous period-aligned ranges ending at `anchorEnd` and
+ * continuing backward while the start is at or after `horizon`. Mirror of
+ * {@link extendRanges} for the `--past` direction.
+ */
+export function* extendRangesBackward(options: {
+ anchorEnd: Date;
+ period: Period;
+ horizon: Date;
+ format?: string;
+}): Generator {
+ let end = options.anchorEnd;
+ for (let count = 0; count < MAX_GENERATED_PARTITIONS; count++) {
+ const start = advanceDate(end, options.period, -1);
+ // Inclusive of the horizon period (start >= horizon), mirroring the
+ // start <= horizon guard in extendRanges for the opposite direction.
+ if (start.getTime() < options.horizon.getTime()) {
+ break;
+ }
+ yield {
+ start,
+ end,
+ suffix: formatDateSuffix(start, options.period, options.format),
+ };
+ end = start;
+ }
+}
diff --git a/src/legacy-shapes.test.ts b/src/legacy-shapes.test.ts
new file mode 100644
index 0000000..f90b4d9
--- /dev/null
+++ b/src/legacy-shapes.test.ts
@@ -0,0 +1,481 @@
+import { afterEach, beforeEach, describe, expect, vi } from "vitest";
+import { sql } from "slonik";
+import { z } from "zod";
+
+import { pgsliceTest as test } from "./testing/index.js";
+import {
+ addChild,
+ addDefault,
+ addMinvalueChild,
+ assertContiguous,
+ childBounds,
+ childNames,
+ createCdcRole,
+ DATE,
+ nativeParent,
+ TS,
+ TSTZ,
+} from "./testing/shapes.js";
+
+/**
+ * End-to-end coverage of the retrofit engine against the full range of
+ * real-world partitioned-table shapes, on PostgreSQL 13.20 (the production
+ * version). Each shape is exercised through `add_partitions`. The shapes use
+ * generic names; they stand in for the heterogeneous schemes a long-lived
+ * application accumulates (monthly, ISO-week, and year-resetting-weekly periods;
+ * `timestamp`/`timestamptz`/`date` keys; composite parent-owned keys; DEFAULT
+ * and MINVALUE catch-alls; non-public schemas; replication grants).
+ */
+describe("legacy partitioned-table shapes", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(Date.UTC(2026, 0, 15)));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ // The retrofit features exist because stock Postgres behavior on these shapes
+ // is wrong or fatal. These tests pin that current behavior so the features
+ // are justified rather than assumed.
+ describe("stock Postgres behavior the retrofit must compensate for", () => {
+ test("does not propagate a parent's grants to a newly-created partition", async ({
+ transaction,
+ }) => {
+ await createCdcRole(transaction, "cdc_stock_grants");
+ await nativeParent(
+ transaction,
+ "metrics",
+ "created_at",
+ TS,
+ "month",
+ "date",
+ );
+ await transaction.query(
+ sql.unsafe`GRANT SELECT ON TABLE metrics TO cdc_stock_grants`,
+ );
+
+ // Create the partition directly, so pgslice's grant inheritance never runs.
+ await addChild(
+ transaction,
+ "metrics",
+ "metrics_202602",
+ "2026-02-01",
+ "2026-03-01",
+ );
+
+ const { granted } = await transaction.one(
+ sql.type(z.object({ granted: z.boolean() }))`
+ SELECT has_table_privilege('cdc_stock_grants', 'public.metrics_202602', 'SELECT') AS granted
+ `,
+ );
+ // Postgres does NOT cascade the parent's SELECT — this is the recurring
+ // "a replication role loses access to a new partition" failure this fixes.
+ expect(granted).toBe(false);
+ });
+
+ test("rejects a per-child primary key when the parent already owns one", async ({
+ transaction,
+ }) => {
+ await nativeParent(
+ transaction,
+ "metrics",
+ "created_at",
+ TS,
+ "month",
+ "date",
+ );
+ await addChild(
+ transaction,
+ "metrics",
+ "metrics_202602",
+ "2026-02-01",
+ "2026-03-01",
+ );
+
+ // The child already carries the parent's propagated PK; adding another is
+ // fatal — which is why the engine skips the per-child ADD PRIMARY KEY for
+ // parent-owned-PK tables.
+ await expect(
+ transaction.query(sql.unsafe`
+ ALTER TABLE metrics_202602 ADD PRIMARY KEY (id, created_at)
+ `),
+ ).rejects.toThrow(/multiple primary keys/i);
+ });
+ });
+
+ describe("non-midnight partition boundaries (unsupported)", () => {
+ test("rejects loudly instead of silently creating nothing", async ({
+ pgslice,
+ transaction,
+ }) => {
+ await nativeParent(
+ transaction,
+ "skewed",
+ "created_at",
+ TSTZ,
+ "month",
+ "timestamptz",
+ );
+ // An existing partition whose bounds sit at 06:00 UTC, not midnight.
+ await addChild(
+ transaction,
+ "skewed",
+ "skewed_202602",
+ "2026-02-01 06:00:00+00",
+ "2026-03-01 06:00:00+00",
+ );
+
+ // New bounds are emitted at UTC midnight, so this shape can't be extended
+ // contiguously; it must fail loudly rather than return [] ("no new
+ // partitions needed") while forward coverage silently stalls.
+ await expect(
+ pgslice.addPartitions(transaction, { table: "skewed", future: 2 }),
+ ).rejects.toThrow(/midnight/i);
+ });
+ });
+
+ describe("monthly, date key, with a parent UNIQUE + CHECK", () => {
+ test("extends and new partitions inherit the composite PK, UNIQUE, and CHECK", async ({
+ pgslice,
+ transaction,
+ }) => {
+ await nativeParent(
+ transaction,
+ "rollups",
+ "bucket_date",
+ DATE,
+ "month",
+ "date",
+ {
+ extraColumns: sql.fragment`,
+ session_id bigint NOT NULL,
+ kind text NOT NULL`,
+ unique: ["session_id", "kind", "bucket_date"],
+ check: sql.fragment`kind <> ''`,
+ },
+ );
+ await addChild(
+ transaction,
+ "rollups",
+ "rollups_202601",
+ "2026-01-01",
+ "2026-02-01",
+ );
+
+ const created = await pgslice.addPartitions(transaction, {
+ table: "rollups",
+ future: 2,
+ });
+ expect([...created].sort()).toEqual(["rollups_202602", "rollups_202603"]);
+
+ // The new leaf inherits the composite PK and the CHECK, and the parent's
+ // UNIQUE + CHECK don't block extension. (The PK and CHECK propagate as
+ // child constraints; the UNIQUE propagates as an attached index.)
+ const constraints = await transaction.any(
+ sql.type(z.object({ def: z.string() }))`
+ SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint
+ WHERE conrelid = 'public.rollups_202602'::regclass
+ ORDER BY def
+ `,
+ );
+ const defs = constraints.map((c) => c.def);
+ expect(defs).toContain("PRIMARY KEY (id, bucket_date)");
+ expect(defs.some((d) => /CHECK .*kind/.test(d))).toBe(true);
+
+ const uniqueIndexes = await transaction.any(
+ sql.type(z.object({ indexdef: z.string() }))`
+ SELECT indexdef FROM pg_indexes
+ WHERE schemaname = 'public' AND tablename = 'rollups_202602'
+ AND indexdef ILIKE '%UNIQUE%'
+ AND indexdef ILIKE '%session_id%'
+ `,
+ );
+ expect(uniqueIndexes.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe("schema-qualified ISO-week table with a DEFAULT and parent indexes", () => {
+ test("new partitions inherit the full parent index set, incl. a partial index, and the empty DEFAULT is left in place", async ({
+ pgslice,
+ transaction,
+ }) => {
+ vi.setSystemTime(new Date(Date.UTC(2026, 9, 20)));
+ await nativeParent(
+ transaction,
+ "archive.events_weekly",
+ "created_at",
+ TS,
+ "week",
+ "date",
+ );
+ await addChild(
+ transaction,
+ "archive.events_weekly",
+ "events_weekly_p2026w43",
+ "2026-10-19",
+ "2026-10-26",
+ );
+ await addDefault(
+ transaction,
+ "archive.events_weekly",
+ "events_weekly_default",
+ );
+ // A secondary index and a partial index, declared on the parent so they
+ // propagate to every existing and future leaf.
+ await transaction.query(sql.unsafe`
+ CREATE INDEX events_weekly_payload_idx ON archive.events_weekly (payload)
+ `);
+ await transaction.query(sql.unsafe`
+ CREATE INDEX events_weekly_active_idx ON archive.events_weekly (id)
+ WHERE payload IS NOT NULL
+ `);
+
+ await pgslice.addPartitions(transaction, {
+ table: "archive.events_weekly",
+ future: 1,
+ });
+
+ // The new leaf must carry the same index set (count + the partial) as a
+ // pre-existing leaf — a missing-index regression would show up here.
+ const indexCount = async (leaf: string) => {
+ const { count } = await transaction.one(
+ sql.type(z.object({ count: z.coerce.number() }))`
+ SELECT count(*) FROM pg_indexes
+ WHERE schemaname = 'archive' AND tablename = ${leaf}
+ `,
+ );
+ return count;
+ };
+ const legacy = await indexCount("events_weekly_p2026w43");
+ const fresh = await indexCount("events_weekly_2026w44");
+ expect(fresh).toBe(legacy);
+
+ const partials = await transaction.any(
+ sql.type(z.object({ indexdef: z.string() }))`
+ SELECT indexdef FROM pg_indexes
+ WHERE schemaname = 'archive' AND tablename = 'events_weekly_2026w44'
+ AND indexdef ILIKE '%WHERE%'
+ `,
+ );
+ expect(partials.length).toBeGreaterThan(0);
+
+ // The empty DEFAULT is tolerated and left untouched.
+ expect(await childNames(transaction, "archive.events_weekly")).toContain(
+ "events_weekly_default",
+ );
+ });
+ });
+
+ describe("timestamptz weekly across the ISO week-53 year boundary", () => {
+ test("extends contiguously into the new ISO year with UTC-aligned bounds, ignoring the MINVALUE historic", async ({
+ pgslice,
+ transaction,
+ }) => {
+ vi.setSystemTime(new Date(Date.UTC(2026, 11, 28)));
+ await nativeParent(
+ transaction,
+ "outbox",
+ "created_at",
+ TSTZ,
+ "week",
+ "timestamptz",
+ );
+ await addMinvalueChild(
+ transaction,
+ "outbox",
+ "outbox_historic",
+ "2025-01-01 00:00:00+00",
+ );
+ // ISO week 53 of 2026 is the Monday-aligned 2026-12-28..2027-01-04.
+ await addChild(
+ transaction,
+ "outbox",
+ "outbox_p2026w53",
+ "2026-12-28 00:00:00+00",
+ "2027-01-04 00:00:00+00",
+ );
+
+ await pgslice.addPartitions(transaction, { table: "outbox", future: 2 });
+
+ const generated = (await childBounds(transaction, "outbox")).filter(
+ (r) => r.name !== "outbox_historic" && r.name !== "outbox_p2026w53",
+ );
+ // The first new partition is ISO 2027-W01, chained off the w53 upper
+ // bound, UTC-aligned (+00), with no overlap onto the historic partition.
+ expect(generated[0]).toEqual({
+ name: "outbox_2027w01",
+ bound:
+ "FOR VALUES FROM ('2027-01-04 00:00:00+00') TO ('2027-01-11 00:00:00+00')",
+ });
+ assertContiguous(
+ (await childBounds(transaction, "outbox")).filter(
+ (r) => r.name !== "outbox_historic",
+ ),
+ );
+ });
+ });
+
+ // The hard case: weeks numbered from Jan 1, so the weekday drifts year to year
+ // (Wed in 2025) and there is a one-day stub at each year boundary. The engine
+ // extends by bounds, so it continues the drifted scheme contiguously rather
+ // than snapping to an absolute Monday-aligned ISO calendar (which would
+ // overlap). The generated names are ISO `w` and may differ cosmetically
+ // from the legacy `yw` names — names are non-functional.
+ describe("year-resetting weekly with a drifted (non-Monday) weekday", () => {
+ test("continues the drifted weekday by bounds; first new partition has the expected name and bounds", async ({
+ pgslice,
+ transaction,
+ }) => {
+ vi.setSystemTime(new Date(Date.UTC(2025, 10, 5)));
+ await nativeParent(
+ transaction,
+ "ledger",
+ "occurred_at",
+ TS,
+ "week",
+ "date",
+ );
+ // Wednesday-aligned 2025 weeks, as a Jan-1-anchored scheme produces.
+ await addChild(
+ transaction,
+ "ledger",
+ "ledger_y2025w44",
+ "2025-10-29",
+ "2025-11-05",
+ );
+ await addChild(
+ transaction,
+ "ledger",
+ "ledger_y2025w45",
+ "2025-11-05",
+ "2025-11-12",
+ );
+
+ await pgslice.addPartitions(transaction, { table: "ledger", future: 2 });
+
+ const generated = (await childBounds(transaction, "ledger")).filter(
+ (r) => !r.name.startsWith("ledger_y"),
+ );
+ // Chained off the 2025-11-12 (Wednesday) upper bound — still Wednesday,
+ // NOT snapped to Monday. The ISO suffix names the week containing it.
+ expect(generated[0]).toEqual({
+ name: "ledger_2025w46",
+ bound:
+ "FOR VALUES FROM ('2025-11-12 00:00:00') TO ('2025-11-19 00:00:00')",
+ });
+ // Drift is preserved: the new partition starts on a Wednesday (day 3).
+ expect(new Date("2025-11-12T00:00:00Z").getUTCDay()).toBe(3);
+ // Legacy partitions are untouched and coverage stays contiguous.
+ const names = await childNames(transaction, "ledger");
+ expect(names).toContain("ledger_y2025w44");
+ expect(names).toContain("ledger_y2025w45");
+ assertContiguous(await childBounds(transaction, "ledger"));
+ });
+
+ test("crosses the year boundary off the one-day stub, with no rename and no name collision", async ({
+ pgslice,
+ transaction,
+ }) => {
+ vi.setSystemTime(new Date(Date.UTC(2026, 11, 28)));
+ await nativeParent(
+ transaction,
+ "ledger",
+ "occurred_at",
+ TS,
+ "week",
+ "date",
+ );
+ // The year-end one-day stub the year-resetting scheme leaves at 2026-12-31.
+ await addChild(
+ transaction,
+ "ledger",
+ "ledger_y2026w52",
+ "2026-12-24",
+ "2026-12-31",
+ );
+ await addChild(
+ transaction,
+ "ledger",
+ "ledger_y2026w53",
+ "2026-12-31",
+ "2027-01-01",
+ );
+
+ await pgslice.addPartitions(transaction, { table: "ledger", future: 1 });
+
+ const generated = (await childBounds(transaction, "ledger")).filter(
+ (r) => !r.name.startsWith("ledger_y"),
+ );
+ // Anchored on 2027-01-01 (a Friday); ISO 8601 places it in week 53 of
+ // 2026, so the suffix reads `2026w53` even though it covers a 2027 week.
+ // Cosmetically odd, but create-safe (distinct from the legacy
+ // `ledger_y2026w53`) and contiguous.
+ expect(generated[0]).toEqual({
+ name: "ledger_2026w53",
+ bound:
+ "FOR VALUES FROM ('2027-01-01 00:00:00') TO ('2027-01-08 00:00:00')",
+ });
+ expect(await childNames(transaction, "ledger")).toContain(
+ "ledger_y2026w53",
+ );
+ assertContiguous(await childBounds(transaction, "ledger"));
+ });
+ });
+
+ describe("grant inheritance breadth", () => {
+ test("re-issues multiple privileges, preserving WITH GRANT OPTION", async ({
+ pgslice,
+ transaction,
+ }) => {
+ await createCdcRole(transaction, "cdc_multi_priv");
+ await nativeParent(
+ transaction,
+ "metrics",
+ "created_at",
+ TS,
+ "month",
+ "date",
+ );
+ await addChild(
+ transaction,
+ "metrics",
+ "metrics_202601",
+ "2026-01-01",
+ "2026-02-01",
+ );
+ await transaction.query(sql.unsafe`
+ GRANT SELECT ON TABLE metrics TO cdc_multi_priv WITH GRANT OPTION
+ `);
+ await transaction.query(sql.unsafe`
+ GRANT INSERT, UPDATE ON TABLE metrics TO cdc_multi_priv
+ `);
+
+ await pgslice.addPartitions(transaction, { table: "metrics", future: 1 });
+
+ const privs = await transaction.one(
+ sql.type(
+ z.object({
+ sel: z.boolean(),
+ sel_grant: z.boolean(),
+ ins: z.boolean(),
+ upd: z.boolean(),
+ }),
+ )`
+ SELECT
+ has_table_privilege('cdc_multi_priv', 'public.metrics_202602', 'SELECT') AS sel,
+ has_table_privilege('cdc_multi_priv', 'public.metrics_202602', 'SELECT WITH GRANT OPTION') AS sel_grant,
+ has_table_privilege('cdc_multi_priv', 'public.metrics_202602', 'INSERT') AS ins,
+ has_table_privilege('cdc_multi_priv', 'public.metrics_202602', 'UPDATE') AS upd
+ `,
+ );
+ expect(privs).toEqual({
+ sel: true,
+ sel_grant: true,
+ ins: true,
+ upd: true,
+ });
+ });
+ });
+});
diff --git a/src/partition-format.test.ts b/src/partition-format.test.ts
new file mode 100644
index 0000000..4047f37
--- /dev/null
+++ b/src/partition-format.test.ts
@@ -0,0 +1,149 @@
+import { afterEach, beforeEach, describe, expect, vi } from "vitest";
+
+import { pgsliceTest as test } from "./testing/index.js";
+import {
+ addChild,
+ childNames,
+ nativeParent,
+ setSettings,
+ TS,
+} from "./testing/shapes.js";
+
+/**
+ * The optional per-table `format` setting threads through `add_partitions` so a
+ * retrofitted table keeps its existing naming convention. The week math is
+ * unchanged (ISO) — only the rendered name differs — so these use generic names
+ * that stand in for the real conventions a long-lived schema accumulates.
+ */
+describe("custom partition naming format", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(Date.UTC(2026, 0, 15))); // Thursday, ISO 2026-W03
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ test("applies a p{YYYY}w{WW} convention to a weekly table", async ({
+ pgslice,
+ transaction,
+ }) => {
+ await nativeParent(
+ transaction,
+ "events_fmt",
+ "created_at",
+ TS,
+ "week",
+ "date",
+ );
+ await setSettings(
+ transaction,
+ "events_fmt",
+ "created_at",
+ "week",
+ "date",
+ "p{YYYY}w{WW}",
+ );
+ // Monday-aligned legacy partition (ISO 2026-W02) to anchor the extension.
+ await addChild(
+ transaction,
+ "events_fmt",
+ "events_fmt_p2026w02",
+ "2026-01-05",
+ "2026-01-12",
+ );
+
+ const created = await pgslice.addPartitions(transaction, {
+ table: "events_fmt",
+ future: 2,
+ });
+
+ expect(created).toEqual([
+ "events_fmt_p2026w03",
+ "events_fmt_p2026w04",
+ "events_fmt_p2026w05",
+ ]);
+ });
+
+ test("applies a y{YYYY}m{MM} convention to a monthly table", async ({
+ pgslice,
+ transaction,
+ }) => {
+ await nativeParent(
+ transaction,
+ "metrics_fmt",
+ "created_at",
+ TS,
+ "month",
+ "date",
+ );
+ await setSettings(
+ transaction,
+ "metrics_fmt",
+ "created_at",
+ "month",
+ "date",
+ "y{YYYY}m{MM}",
+ );
+ await addChild(
+ transaction,
+ "metrics_fmt",
+ "metrics_fmt_y2025m12",
+ "2025-12-01",
+ "2026-01-01",
+ );
+
+ const created = await pgslice.addPartitions(transaction, {
+ table: "metrics_fmt",
+ future: 1,
+ });
+
+ expect(created).toEqual(["metrics_fmt_y2026m01", "metrics_fmt_y2026m02"]);
+ });
+
+ test("is idempotent under a custom format", async ({
+ pgslice,
+ transaction,
+ }) => {
+ await nativeParent(
+ transaction,
+ "events_fmt",
+ "created_at",
+ TS,
+ "week",
+ "date",
+ );
+ await setSettings(
+ transaction,
+ "events_fmt",
+ "created_at",
+ "week",
+ "date",
+ "p{YYYY}w{WW}",
+ );
+ await addChild(
+ transaction,
+ "events_fmt",
+ "events_fmt_p2026w02",
+ "2026-01-05",
+ "2026-01-12",
+ );
+
+ await pgslice.addPartitions(transaction, {
+ table: "events_fmt",
+ future: 2,
+ });
+ const second = await pgslice.addPartitions(transaction, {
+ table: "events_fmt",
+ future: 2,
+ });
+
+ expect(second).toEqual([]);
+ expect(await childNames(transaction, "events_fmt")).toEqual([
+ "events_fmt_p2026w02",
+ "events_fmt_p2026w03",
+ "events_fmt_p2026w04",
+ "events_fmt_p2026w05",
+ ]);
+ });
+});
diff --git a/src/pgslice.ts b/src/pgslice.ts
index eb3ba51..0006731 100644
--- a/src/pgslice.ts
+++ b/src/pgslice.ts
@@ -26,8 +26,20 @@ import type {
UnswapOptions,
} from "./types.js";
import { isPeriod } from "./types.js";
-import { Table, getServerVersionNum } from "./table.js";
-import { DateRanges } from "./date-ranges.js";
+import { TableSettings } from "./table-settings.js";
+import { Table, getServerVersionNum, type TableGrant } from "./table.js";
+import {
+ DateRanges,
+ advanceDate,
+ extendRanges,
+ extendRangesBackward,
+ isUtcMidnight,
+ maxUpperBound,
+ minLowerBound,
+ rangeOverlaps,
+ roundDate,
+ type DateRange,
+} from "./date-ranges.js";
import { formatDateForSql, rawSql, sql } from "./sql-utils.js";
import { Mirroring } from "./mirroring.js";
import { Filler } from "./filler.js";
@@ -35,6 +47,21 @@ import { Synchronizer } from "./synchronizer.js";
import { Swapper } from "./swapper.js";
import { AdvisoryLock } from "./advisory-lock.js";
+/**
+ * Table privileges pgslice knows how to re-issue on new partitions, mapped to
+ * their SQL keyword. Privilege names come from `aclexplode`; anything not in
+ * this allow-list is skipped so we never emit an unrecognized keyword.
+ */
+const GRANTABLE_PRIVILEGES: Record> = {
+ SELECT: sql.fragment`SELECT`,
+ INSERT: sql.fragment`INSERT`,
+ UPDATE: sql.fragment`UPDATE`,
+ DELETE: sql.fragment`DELETE`,
+ TRUNCATE: sql.fragment`TRUNCATE`,
+ REFERENCES: sql.fragment`REFERENCES`,
+ TRIGGER: sql.fragment`TRIGGER`,
+};
+
interface PgsliceOptions {
/**
* Whether to use Postgres advisory locks to prevent concurrent operations
@@ -266,15 +293,26 @@ export class Pgslice {
/**
* Adds partitions to a partitioned table.
+ *
+ * Returns the names of the partitions that were created (empty when every
+ * target period is already covered, which keeps re-runs idempotent).
*/
async addPartitions(
connection: DatabasePoolConnection,
options: AddPartitionsOptions,
- ): Promise {
+ ): Promise {
const originalTable = Table.parse(options.table);
return connection.transaction(async (tx) =>
this.#withLock(tx, originalTable, "add_partitions", async () => {
+ // Pin the transaction to UTC. Partition boundaries are UTC calendar
+ // dates; without this, reading an existing timestamptz bound via
+ // pg_get_expr renders it in the session timezone (so the parsed
+ // boundary day drifts) and emitting a date/timestamptz literal coerces
+ // it through the session timezone — either of which misaligns the new
+ // partitions against the existing ones under a non-UTC session.
+ await tx.query(sql.typeAlias("void")`SET LOCAL TIME ZONE 'UTC'`);
+
const targetTable = options.intermediate
? originalTable.intermediate
: originalTable;
@@ -295,29 +333,123 @@ export class Pgslice {
const past = options.past ?? 0;
const future = options.future ?? 0;
- // Determine which table to get the primary key from.
- // For intermediate tables, use the original table.
- // For swapped tables, use the last existing partition (if any) or the original.
- let schemaTable: Table;
- if (options.intermediate) {
- schemaTable = originalTable;
- } else {
- const existingPartitions = await targetTable.partitions(tx);
- schemaTable =
- existingPartitions.length > 0
- ? existingPartitions[existingPartitions.length - 1]
- : originalTable;
+ // If the partitioned parent owns a primary key, Postgres propagates it
+ // (and any partitioned indexes) to each new partition automatically, so
+ // we must not add a per-partition primary key. Otherwise we follow the
+ // classic pgslice model and add the key to each partition ourselves,
+ // supporting composite keys.
+ const parentPrimaryKey = await targetTable.primaryKeyColumns(tx);
+ let partitionPrimaryKey: string[] = [];
+ if (parentPrimaryKey.length === 0) {
+ // Classic model only: read the key to replicate onto each new
+ // partition from the original table (intermediate flow) or the last
+ // existing partition. Native (parent-owned-PK) tables skip this query.
+ const schemaTable = options.intermediate
+ ? originalTable
+ : ((await targetTable.partitions(tx)).at(-1) ?? originalTable);
+ partitionPrimaryKey = await this.#partitionPrimaryKeyColumns(
+ tx,
+ schemaTable,
+ );
}
- const primaryKeyColumn = await schemaTable.primaryKey(tx);
+ // Read grants from the original table, the source of truth. In the
+ // intermediate flow targetTable is the freshly-prepped intermediate
+ // (which carries no grants), so reading it would silently inherit
+ // nothing; in the retrofit flow originalTable === targetTable.
+ const grants =
+ (options.inheritGrants ?? true) ? await originalTable.grants(tx) : [];
+
+ // Read existing partition bounds (empty for the intermediate/prep
+ // flow, which operates on a freshly-created intermediate table).
+ const existingRanges = options.intermediate
+ ? []
+ : await targetTable.rangePartitionBounds(tx);
+ const finiteRanges = existingRanges.filter((r) => !r.isDefault);
+
+ let ranges: Iterable;
+ if (finiteRanges.length === 0) {
+ // Fresh table (or the prep/intermediate flow): generate calendar-
+ // aligned ranges centered on today, the classic pgslice behavior.
+ ranges = new DateRanges({
+ today: options.now,
+ period: settings.period,
+ past,
+ future,
+ format: settings.format,
+ });
+ } else {
+ // Existing partitioned table: extend contiguously from the current
+ // coverage by partition *bounds*, independent of the legacy naming
+ // or week-alignment scheme. This recognizes partitions created
+ // outside pgslice (so it never renames or collides with them) and
+ // continues whatever scheme they use without a gap or overlap at the
+ // boundary.
+ const today = roundDate(options.now ?? new Date(), settings.period);
+ const candidates: DateRange[] = [];
+
+ // Forward extension fills every period from the last existing bound
+ // to the horizon (today + future periods). With future = 0 the
+ // horizon is today, so the current period is still created if not yet
+ // covered — matching the fresh-table path, which always includes
+ // today. If a table lapsed, this back-fills the whole gap in one run
+ // — intended, and bounded by MAX_GENERATED_PARTITIONS.
+ const maxUpper = maxUpperBound(existingRanges);
+ const unboundedAbove = finiteRanges.some((r) => r.upperUnbounded);
+ if (maxUpper && !unboundedAbove) {
+ // Bounds-anchored extension emits new boundaries at UTC midnight
+ // (see formatDateForSql), so it can only continue a table whose
+ // existing boundaries are UTC-midnight too. A non-midnight anchor
+ // otherwise yields a silent no-op (the midnight-rounded horizon can
+ // fall short of it) or a CREATE-time overlap — reject it loudly.
+ if (!isUtcMidnight(maxUpper)) {
+ throw new Error(
+ `${targetTable.name}: existing partition boundary ${maxUpper.toISOString()} is not UTC-midnight aligned; bounds-anchored extension only supports midnight-aligned boundaries`,
+ );
+ }
+ const horizon = advanceDate(today, settings.period, future);
+ for (const range of extendRanges({
+ anchorStart: maxUpper,
+ period: settings.period,
+ horizon,
+ format: settings.format,
+ })) {
+ candidates.push(range);
+ }
+ }
- const dateRanges = new DateRanges({
- period: settings.period,
- past,
- future,
- });
+ const minLower = minLowerBound(existingRanges);
+ const unboundedBelow = finiteRanges.some((r) => r.lowerUnbounded);
+ if (minLower && !unboundedBelow && past > 0) {
+ // Same UTC-midnight requirement as the forward anchor above.
+ if (!isUtcMidnight(minLower)) {
+ throw new Error(
+ `${targetTable.name}: existing partition boundary ${minLower.toISOString()} is not UTC-midnight aligned; bounds-anchored extension only supports midnight-aligned boundaries`,
+ );
+ }
+ const horizon = advanceDate(today, settings.period, -past);
+ for (const range of extendRangesBackward({
+ anchorEnd: minLower,
+ period: settings.period,
+ horizon,
+ format: settings.format,
+ })) {
+ candidates.push(range);
+ }
+ }
+
+ // Defensive: drop any candidate that would overlap existing coverage.
+ // Anchored generation shouldn't produce one, but this keeps re-runs
+ // idempotent and guards against an unexpected existing layout.
+ ranges = candidates.filter(
+ (c) =>
+ !existingRanges.some((r) => rangeOverlaps(c.start, c.end, r)),
+ );
+ }
+
+ const created: string[] = [];
- for (const range of dateRanges) {
+ for (const range of ranges) {
const partitionTable = originalTable.partition(range.suffix);
if (await partitionTable.exists(tx)) {
@@ -340,17 +472,76 @@ export class Pgslice {
await tx.query(sql.typeAlias("void")`${createSql}`);
- await tx.query(
- sql.typeAlias("void")`
- ALTER TABLE ${partitionTable.sqlIdentifier}
- ADD PRIMARY KEY (${sql.identifier([primaryKeyColumn])})
- `,
- );
+ if (partitionPrimaryKey.length > 0) {
+ await tx.query(
+ sql.typeAlias("void")`
+ ALTER TABLE ${partitionTable.sqlIdentifier}
+ ADD PRIMARY KEY (${sql.join(
+ partitionPrimaryKey.map((col) => sql.identifier([col])),
+ sql.fragment`, `,
+ )})
+ `,
+ );
+ }
+
+ for (const grant of grants) {
+ await this.#applyGrant(tx, partitionTable, grant);
+ }
+
+ created.push(partitionTable.name);
}
+
+ return created;
}),
);
}
+ /**
+ * Resolves the primary key columns to place on each new partition in the
+ * classic pgslice model (where the parent has no primary key of its own).
+ * Returns the explicit (possibly composite) key when one exists; otherwise
+ * falls back to the implicit single-column `id` of {@link Table.primaryKey}.
+ */
+ async #partitionPrimaryKeyColumns(
+ tx: DatabaseTransactionConnection,
+ schemaTable: Table,
+ ): Promise {
+ const columns = await schemaTable.primaryKeyColumns(tx);
+ if (columns.length > 0) {
+ return columns;
+ }
+ return [await schemaTable.primaryKey(tx)];
+ }
+
+ /**
+ * Re-issues a single grant from the parent table onto a new partition.
+ * Unrecognized privileges are skipped rather than emitted unsafely.
+ */
+ async #applyGrant(
+ tx: DatabaseTransactionConnection,
+ table: Table,
+ grant: TableGrant,
+ ): Promise {
+ const privilege = GRANTABLE_PRIVILEGES[grant.privilege];
+ if (!privilege) {
+ return;
+ }
+
+ const grantee =
+ grant.grantee === null
+ ? sql.fragment`PUBLIC`
+ : sql.fragment`${sql.identifier([grant.grantee])}`;
+ const grantOption = grant.grantable
+ ? sql.fragment` WITH GRANT OPTION`
+ : sql.fragment``;
+
+ await tx.query(
+ sql.typeAlias("void")`
+ GRANT ${privilege} ON TABLE ${table.sqlIdentifier} TO ${grantee}${grantOption}
+ `,
+ );
+ }
+
/**
* Enables mirroring triggers from a table to its intermediate or retired table.
* This ensures that INSERT, UPDATE, and DELETE operations on the source
diff --git a/src/sql-utils.ts b/src/sql-utils.ts
index 4e12b99..08040ff 100644
--- a/src/sql-utils.ts
+++ b/src/sql-utils.ts
@@ -26,7 +26,10 @@ export function rawSql(query: string) {
/**
* Formats a Date as a SQL literal value for use in partition constraints.
- * Uses UTC values to ensure consistent behavior across timezones.
+ * Uses UTC values to ensure consistent behavior across timezones. Bounds are
+ * emitted at UTC midnight (calendar-aligned); a table whose existing partitions
+ * sit on non-midnight boundaries is therefore not supported by bounds-anchored
+ * extension, since the first new bound would not abut the existing one.
*/
export function formatDateForSql(date: Date, cast: Cast) {
const year = date.getUTCFullYear();
diff --git a/src/table-settings.test.ts b/src/table-settings.test.ts
index 9e2fa21..b26fa15 100644
--- a/src/table-settings.test.ts
+++ b/src/table-settings.test.ts
@@ -35,6 +35,20 @@ describe("TableSettings", () => {
expect(settings?.cast).toBe("timestamptz");
});
+ it("parses an optional format template", () => {
+ const settings = TableSettings.parseFromComment(
+ "column:occurred_at,period:week,cast:date,format:p{YYYY}w{WW}",
+ );
+ expect(settings?.format).toBe("p{YYYY}w{WW}");
+ });
+
+ it("leaves format undefined when the key is absent", () => {
+ const settings = TableSettings.parseFromComment(
+ "column:occurred_at,period:week,cast:date",
+ );
+ expect(settings?.format).toBeUndefined();
+ });
+
describe("periods", () => {
it("parses day period", () => {
const settings = TableSettings.parseFromComment(
diff --git a/src/table-settings.ts b/src/table-settings.ts
index 582e5b5..5eed67b 100644
--- a/src/table-settings.ts
+++ b/src/table-settings.ts
@@ -9,6 +9,12 @@ export class TableSettings {
readonly column: string,
readonly period: Period,
readonly cast: Cast,
+ /**
+ * Optional partition-name format template (e.g. `p{YYYY}w{WW}`). When
+ * absent, the period's standard suffix is used. Lets a retrofitted table
+ * keep its existing naming convention; see {@link formatDateSuffix}.
+ */
+ readonly format?: string,
) {}
/**
@@ -20,20 +26,30 @@ export class TableSettings {
let column: string | undefined;
let period: Period | undefined;
let cast: Cast | undefined;
+ let format: string | undefined;
for (const part of parts) {
- const [key, value] = part.split(":");
+ // Split on the first ":" only, so a value that itself contains a colon
+ // (e.g. a mistyped format template) is preserved and validated downstream
+ // rather than silently truncated at the second colon.
+ const sep = part.indexOf(":");
+ const key = sep === -1 ? part : part.slice(0, sep);
+ const value = sep === -1 ? "" : part.slice(sep + 1);
if (key === "column") {
column = value;
} else if (key === "period" && isValidPeriod(value)) {
period = value;
} else if (key === "cast" && isValidCast(value)) {
cast = value;
+ } else if (key === "format" && value) {
+ // Stored verbatim; validated when rendered/parsed (compileFormat), so a
+ // mistyped template fails loudly at add_partitions time rather than here.
+ format = value;
}
}
if (column && period && cast) {
- return new TableSettings(column, period, cast);
+ return new TableSettings(column, period, cast, format);
}
return null;
diff --git a/src/table.test.ts b/src/table.test.ts
index ee683a1..36cd7b6 100644
--- a/src/table.test.ts
+++ b/src/table.test.ts
@@ -3,6 +3,15 @@ import { sql } from "slonik";
import { pgsliceTest as test } from "./testing/index.js";
import { Table } from "./table.js";
+import {
+ addChild,
+ addDefault,
+ addMinvalueChild,
+ createCdcRole,
+ nativeParent,
+ TS,
+ TSTZ,
+} from "./testing/shapes.js";
describe("Table.isPartitioned", () => {
test("returns true for partitioned table", async ({ transaction }) => {
@@ -570,3 +579,162 @@ describe("Table.primaryKey", () => {
expect(primaryKey).toEqual("Id");
});
});
+
+describe("Table.primaryKeyColumns", () => {
+ test("returns a composite primary key in key order", async ({
+ transaction,
+ }) => {
+ await transaction.query(sql.unsafe`
+ CREATE TABLE composite_pk (
+ id bigint NOT NULL,
+ created_at timestamp NOT NULL,
+ PRIMARY KEY (id, created_at)
+ )
+ `);
+ expect(
+ await Table.parse("composite_pk").primaryKeyColumns(transaction),
+ ).toEqual(["id", "created_at"]);
+ });
+
+ test("returns a three-column composite key in declared order", async ({
+ transaction,
+ }) => {
+ await transaction.query(sql.unsafe`
+ CREATE TABLE three_pk (
+ tenant_id bigint NOT NULL,
+ id bigint NOT NULL,
+ created_at timestamp NOT NULL,
+ PRIMARY KEY (tenant_id, id, created_at)
+ )
+ `);
+ expect(
+ await Table.parse("three_pk").primaryKeyColumns(transaction),
+ ).toEqual(["tenant_id", "id", "created_at"]);
+ });
+
+ test("returns an empty array when there is no primary key", async ({
+ transaction,
+ }) => {
+ await transaction.query(
+ sql.unsafe`CREATE TABLE no_pk (id bigint, payload text)`,
+ );
+ expect(await Table.parse("no_pk").primaryKeyColumns(transaction)).toEqual(
+ [],
+ );
+ });
+});
+
+describe("Table.grants", () => {
+ test("returns each grantee's privileges with grantable flags, excluding the owner", async ({
+ transaction,
+ }) => {
+ await createCdcRole(transaction, "cdc_table_grants");
+ await transaction.query(
+ sql.unsafe`CREATE TABLE granted (id bigint, created_at timestamptz)`,
+ );
+ await transaction.query(sql.unsafe`
+ GRANT SELECT ON granted TO cdc_table_grants WITH GRANT OPTION
+ `);
+ await transaction.query(
+ sql.unsafe`GRANT INSERT ON granted TO cdc_table_grants`,
+ );
+
+ const grants = await Table.parse("granted").grants(transaction);
+
+ expect(grants).toEqual(
+ expect.arrayContaining([
+ { grantee: "cdc_table_grants", privilege: "SELECT", grantable: true },
+ { grantee: "cdc_table_grants", privilege: "INSERT", grantable: false },
+ ]),
+ );
+ // The owner's implicit privileges are excluded.
+ expect(grants.every((g) => g.grantee !== "postgres")).toBe(true);
+ });
+
+ test("represents a PUBLIC grant with a null grantee", async ({
+ transaction,
+ }) => {
+ await transaction.query(sql.unsafe`CREATE TABLE public_grant (id bigint)`);
+ await transaction.query(sql.unsafe`GRANT SELECT ON public_grant TO PUBLIC`);
+
+ const grants = await Table.parse("public_grant").grants(transaction);
+
+ expect(grants).toContainEqual({
+ grantee: null,
+ privilege: "SELECT",
+ grantable: false,
+ });
+ });
+});
+
+describe("Table.rangePartitionBounds", () => {
+ test("parses a mixed set of finite, MINVALUE, and DEFAULT bounds", async ({
+ transaction,
+ }) => {
+ await nativeParent(
+ transaction,
+ "bounds",
+ "created_at",
+ TS,
+ "month",
+ "date",
+ );
+ await addChild(
+ transaction,
+ "bounds",
+ "bounds_202601",
+ "2026-01-01",
+ "2026-02-01",
+ );
+ await addMinvalueChild(
+ transaction,
+ "bounds",
+ "bounds_historic",
+ "2025-01-01",
+ );
+ await addDefault(transaction, "bounds", "bounds_default");
+
+ const ranges =
+ await Table.parse("bounds").rangePartitionBounds(transaction);
+
+ expect(ranges).toHaveLength(3);
+ expect(ranges.filter((r) => r.isDefault)).toHaveLength(1);
+
+ const historic = ranges.find((r) => r.lowerUnbounded && !r.isDefault);
+ expect(historic?.upper).toEqual(new Date("2025-01-01T00:00:00Z"));
+
+ const finiteRange = ranges.find(
+ (r) => !r.isDefault && !r.lowerUnbounded && !r.upperUnbounded,
+ );
+ expect(finiteRange?.lower).toEqual(new Date("2026-01-01T00:00:00Z"));
+ expect(finiteRange?.upper).toEqual(new Date("2026-02-01T00:00:00Z"));
+ });
+
+ test("parses timestamptz bounds as UTC under a UTC-pinned session", async ({
+ transaction,
+ }) => {
+ // The documented contract: read bounds in a UTC-pinned transaction.
+ await transaction.query(sql.unsafe`SET LOCAL TIME ZONE 'UTC'`);
+ await nativeParent(
+ transaction,
+ "tz_bounds",
+ "created_at",
+ TSTZ,
+ "week",
+ "timestamptz",
+ );
+ await addChild(
+ transaction,
+ "tz_bounds",
+ "tz_bounds_2026w03",
+ "2026-01-12 00:00:00+00",
+ "2026-01-19 00:00:00+00",
+ );
+
+ const ranges =
+ await Table.parse("tz_bounds").rangePartitionBounds(transaction);
+ const finiteRange = ranges.find((r) => !r.isDefault);
+ expect(finiteRange?.lower).toEqual(new Date("2026-01-12T00:00:00Z"));
+ expect(finiteRange?.upper).toEqual(new Date("2026-01-19T00:00:00Z"));
+ });
+});
diff --git a/src/table.ts b/src/table.ts
index e7eb98a..6999647 100644
--- a/src/table.ts
+++ b/src/table.ts
@@ -5,7 +5,12 @@ import {
IdentifierSqlToken,
} from "slonik";
import { z } from "zod";
-import { advanceDate, parsePartitionDate } from "./date-ranges.js";
+import {
+ advanceDate,
+ parsePartitionDate,
+ parseRangeBound,
+ type ExistingRange,
+} from "./date-ranges.js";
import type {
Cast,
ColumnInfo,
@@ -66,6 +71,14 @@ function dataTypeToCast(dataType: string): Cast | null {
}
}
+/**
+ * Derives a time filter from the first/last partition *names* via
+ * {@link parsePartitionDate}, so it assumes pgslice's `_` naming.
+ * It is invalid for a retrofitted table whose partitions use a legacy scheme
+ * (e.g. `..._y2026w45`): the suffix won't parse. `fill`/`synchronize` depend on
+ * it, so don't run them against such a table — `add_partitions` only needs the
+ * partition bounds, not the names, so it is unaffected.
+ */
function derivePartitionTimeFilter(
settings: TableSettings,
partitions: Table[],
@@ -76,10 +89,15 @@ function derivePartitionTimeFilter(
const firstPartition = partitions[0];
const lastPartition = partitions[partitions.length - 1];
- const startingTime = parsePartitionDate(firstPartition.name, settings.period);
+ const startingTime = parsePartitionDate(
+ firstPartition.name,
+ settings.period,
+ settings.format,
+ );
const lastPartitionDate = parsePartitionDate(
lastPartition.name,
settings.period,
+ settings.format,
);
const endingTime = advanceDate(lastPartitionDate, settings.period, 1);
@@ -111,6 +129,15 @@ export async function getServerVersionNum(
return result.server_version_num;
}
+/**
+ * A privilege grant on a table. A null grantee represents PUBLIC.
+ */
+export interface TableGrant {
+ grantee: string | null;
+ privilege: string;
+ grantable: boolean;
+}
+
/**
* Represents a database table with schema and name.
*/
@@ -346,6 +373,70 @@ export class Table {
throw new Error(`Primary key not found in "${this.toString()}".`);
}
+ /**
+ * Gets the explicit primary key column names for this table, in key order.
+ *
+ * Unlike {@link primaryKey}, this supports composite primary keys and returns
+ * an empty array (rather than throwing) when no explicit primary key exists.
+ */
+ async primaryKeyColumns(
+ tx: DatabaseTransactionConnection,
+ ): Promise {
+ const result = await tx.any(
+ sql.type(z.object({ attname: z.string(), ord: z.coerce.number() }))`
+ SELECT a.attname, k.ord
+ FROM pg_index i
+ JOIN pg_class c ON c.oid = i.indrelid
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = k.attnum
+ WHERE n.nspname = ${this.schema}
+ AND c.relname = ${this.name}
+ AND i.indisprimary
+ ORDER BY k.ord
+ `,
+ );
+
+ return result.map((row) => row.attname);
+ }
+
+ /**
+ * Gets the privilege grants present on this table, excluding the table
+ * owner's implicit privileges. A null grantee represents PUBLIC.
+ */
+ async grants(tx: DatabaseTransactionConnection): Promise {
+ const result = await tx.any(
+ sql.type(
+ z.object({
+ grantee: z.string().nullable(),
+ privilege_type: z.string(),
+ is_grantable: z.boolean(),
+ }),
+ )`
+ SELECT DISTINCT ON (grantee_role.rolname, acl.privilege_type)
+ grantee_role.rolname AS grantee,
+ acl.privilege_type,
+ acl.is_grantable
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ CROSS JOIN LATERAL aclexplode(c.relacl) AS acl
+ LEFT JOIN pg_roles grantee_role ON grantee_role.oid = acl.grantee
+ WHERE n.nspname = ${this.schema}
+ AND c.relname = ${this.name}
+ AND acl.grantee <> c.relowner
+ -- A (grantee, privilege) pair can appear once per grantor; collapse to
+ -- one row, preferring the grantable variant so the result is stable.
+ ORDER BY grantee_role.rolname, acl.privilege_type, acl.is_grantable DESC
+ `,
+ );
+
+ return result.map((row) => ({
+ grantee: row.grantee,
+ privilege: row.privilege_type,
+ grantable: row.is_grantable,
+ }));
+ }
+
/**
* Gets all child partitions of this table.
*/
@@ -370,6 +461,42 @@ export class Table {
return result.map((r) => new Table(r.schema, r.name));
}
+ /**
+ * Reads the RANGE bounds of this partitioned table's existing child
+ * partitions from the catalog. Used to extend an existing table by partition
+ * bounds rather than by name, so legacy-named partitions (created outside
+ * pgslice) are recognized and never collided with or renamed.
+ *
+ * Non-RANGE bounds we don't manage are skipped.
+ *
+ * Must run inside a transaction already pinned to UTC
+ * (`SET LOCAL TIME ZONE 'UTC'`): for a `timestamptz` key, `pg_get_expr`
+ * renders each bound in the session timezone and the parser reads the
+ * rendered instant as UTC, so under a non-UTC session every bound would shift
+ * by the session offset. `addPartitions` pins UTC before calling this.
+ */
+ async rangePartitionBounds(tx: CommonQueryMethods): Promise {
+ const rows = await tx.any(
+ sql.type(z.object({ name: z.string(), bound: z.string() }))`
+ SELECT
+ child.relname AS name,
+ pg_get_expr(child.relpartbound, child.oid) AS bound
+ FROM pg_inherits
+ JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
+ JOIN pg_class child ON pg_inherits.inhrelid = child.oid
+ JOIN pg_namespace nmsp_parent ON nmsp_parent.oid = parent.relnamespace
+ WHERE
+ nmsp_parent.nspname = ${this.schema} AND
+ parent.relname = ${this.name}
+ `,
+ );
+
+ return rows.flatMap((r) => {
+ const parsed = parseRangeBound(r.bound);
+ return parsed ? [{ ...parsed, name: r.name }] : [];
+ });
+ }
+
/**
* Fetches the partition settings from this table's comment.
*/
diff --git a/src/testing/shapes.ts b/src/testing/shapes.ts
new file mode 100644
index 0000000..40743e7
--- /dev/null
+++ b/src/testing/shapes.ts
@@ -0,0 +1,280 @@
+import { sql, type DatabaseTransactionConnection } from "slonik";
+import { z } from "zod";
+
+import type { Cast, Period } from "../types.js";
+
+/**
+ * Shared fixtures for exercising the partition-retrofit engine against the
+ * range of real-world partitioned-table shapes: composite parent-owned primary
+ * keys, weekly / monthly / year-resetting periods, `timestamp` vs `timestamptz`
+ * keys, DEFAULT and MINVALUE catch-alls, schema-qualified tables, and a
+ * replication-style role whose grants new partitions must inherit.
+ *
+ * The names here are deliberately generic — they encode the *shape*, not any
+ * particular application's tables.
+ */
+
+export const TSTZ = sql.fragment`timestamp with time zone`;
+export const TS = sql.fragment`timestamp without time zone`;
+export const DATE = sql.fragment`date`;
+
+/** Splits `schema.table` (defaulting to the `public` schema) into its parts. */
+export function parts(qualified: string): [string, string] {
+ const split = qualified.includes(".")
+ ? qualified.split(".")
+ : ["public", qualified];
+ return [split[0], split[1]];
+}
+
+/** A SQL identifier token for a possibly schema-qualified table name. */
+export const ident = (qualified: string) => sql.identifier(parts(qualified));
+
+/** Creates the schema of a qualified name when it isn't `public`. */
+export async function ensureSchema(
+ tx: DatabaseTransactionConnection,
+ qualified: string,
+): Promise {
+ const [schema] = parts(qualified);
+ if (schema !== "public") {
+ await tx.query(
+ sql.unsafe`CREATE SCHEMA IF NOT EXISTS ${sql.identifier([schema])}`,
+ );
+ }
+}
+
+/** Writes the pgslice settings comment that marks a table as managed. */
+export async function setSettings(
+ tx: DatabaseTransactionConnection,
+ qualified: string,
+ column: string,
+ period: Period,
+ cast: Cast,
+ format?: string,
+): Promise {
+ const formatPart = format ? `,format:${format}` : "";
+ await tx.query(
+ sql.unsafe`COMMENT ON TABLE ${ident(qualified)} IS ${sql.literalValue(
+ `column:${column},period:${period},cast:${cast},version:3${formatPart}`,
+ )}`,
+ );
+}
+
+export interface ParentOptions {
+ /** Extra column definitions, e.g. ``sql.fragment`, session_id bigint, kind text` ``. */
+ extraColumns?: ReturnType;
+ /** Columns for an extra parent-level UNIQUE constraint. */
+ unique?: string[];
+ /** A parent-level CHECK expression, e.g. ``sql.fragment`kind <> ''` ``. */
+ check?: ReturnType;
+}
+
+/**
+ * Native parent: the partitioned parent owns the composite primary key, so
+ * Postgres propagates it (and any partitioned indexes) to each partition.
+ */
+export async function nativeParent(
+ tx: DatabaseTransactionConnection,
+ qualified: string,
+ column: string,
+ columnType: ReturnType,
+ period: Period,
+ cast: Cast,
+ options?: ParentOptions,
+): Promise {
+ await ensureSchema(tx, qualified);
+ const extraColumns = options?.extraColumns ?? sql.fragment``;
+ const unique = options?.unique
+ ? sql.fragment`,
+ UNIQUE (${sql.join(
+ options.unique.map((c) => sql.identifier([c])),
+ sql.fragment`, `,
+ )})`
+ : sql.fragment``;
+ const check = options?.check
+ ? sql.fragment`,
+ CHECK (${options.check})`
+ : sql.fragment``;
+ await tx.query(sql.unsafe`
+ CREATE TABLE ${ident(qualified)} (
+ id bigint NOT NULL,
+ ${sql.identifier([column])} ${columnType} NOT NULL,
+ payload text${extraColumns},
+ PRIMARY KEY (id, ${sql.identifier([column])})${unique}${check}
+ ) PARTITION BY RANGE (${sql.identifier([column])})
+ `);
+ await setSettings(tx, qualified, column, period, cast);
+}
+
+/**
+ * Classic pgslice parent: no primary key on the parent; each partition owns its
+ * own.
+ */
+export async function pgsliceParent(
+ tx: DatabaseTransactionConnection,
+ qualified: string,
+ column: string,
+ columnType: ReturnType,
+ period: Period,
+ cast: Cast,
+): Promise {
+ await ensureSchema(tx, qualified);
+ await tx.query(sql.unsafe`
+ CREATE TABLE ${ident(qualified)} (
+ id bigint NOT NULL,
+ ${sql.identifier([column])} ${columnType} NOT NULL,
+ payload text
+ ) PARTITION BY RANGE (${sql.identifier([column])})
+ `);
+ await setSettings(tx, qualified, column, period, cast);
+}
+
+/** Attaches a bounded range partition, optionally adding a per-child PK. */
+export async function addChild(
+ tx: DatabaseTransactionConnection,
+ parentQualified: string,
+ childName: string,
+ from: string,
+ to: string,
+ childPrimaryKey?: string[],
+): Promise {
+ const [schema] = parts(parentQualified);
+ const childQualified = `${schema}.${childName}`;
+ await tx.query(sql.unsafe`
+ CREATE TABLE ${ident(childQualified)}
+ PARTITION OF ${ident(parentQualified)}
+ FOR VALUES FROM (${sql.literalValue(from)}) TO (${sql.literalValue(to)})
+ `);
+ if (childPrimaryKey) {
+ await tx.query(sql.unsafe`
+ ALTER TABLE ${ident(childQualified)}
+ ADD PRIMARY KEY (${sql.join(
+ childPrimaryKey.map((c) => sql.identifier([c])),
+ sql.fragment`, `,
+ )})
+ `);
+ }
+}
+
+/** Attaches the DEFAULT catch-all partition. */
+export async function addDefault(
+ tx: DatabaseTransactionConnection,
+ parentQualified: string,
+ childName: string,
+): Promise {
+ const [schema] = parts(parentQualified);
+ await tx.query(sql.unsafe`
+ CREATE TABLE ${ident(`${schema}.${childName}`)}
+ PARTITION OF ${ident(parentQualified)} DEFAULT
+ `);
+}
+
+/** Attaches an unbounded-below (`MINVALUE`) historic partition. */
+export async function addMinvalueChild(
+ tx: DatabaseTransactionConnection,
+ parentQualified: string,
+ childName: string,
+ to: string,
+): Promise {
+ const [schema] = parts(parentQualified);
+ await tx.query(sql.unsafe`
+ CREATE TABLE ${ident(`${schema}.${childName}`)}
+ PARTITION OF ${ident(parentQualified)}
+ FOR VALUES FROM (MINVALUE) TO (${sql.literalValue(to)})
+ `);
+}
+
+/**
+ * Creates a replication-style role idempotently. Roles are cluster-global, so
+ * pass a name unique to the test file to avoid colliding with the parallel
+ * suite — the guarded `IF NOT EXISTS` alone can still race between concurrent
+ * transactions.
+ */
+export async function createCdcRole(
+ tx: DatabaseTransactionConnection,
+ roleName: string,
+): Promise {
+ await tx.query(sql.unsafe`
+ DO $$ BEGIN
+ IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ${sql.literalValue(
+ roleName,
+ )}) THEN
+ CREATE ROLE ${sql.identifier([roleName])} NOLOGIN;
+ END IF;
+ END $$;
+ `);
+}
+
+/** Returns the names of a partitioned table's child partitions, sorted. */
+export async function childNames(
+ tx: DatabaseTransactionConnection,
+ qualified: string,
+): Promise {
+ const [schema, name] = parts(qualified);
+ const rows = await tx.any(
+ sql.type(z.object({ name: z.string() }))`
+ SELECT child.relname AS name
+ FROM pg_inherits i
+ JOIN pg_class child ON child.oid = i.inhrelid
+ JOIN pg_class parent ON parent.oid = i.inhparent
+ JOIN pg_namespace np ON np.oid = parent.relnamespace
+ WHERE np.nspname = ${schema} AND parent.relname = ${name}
+ ORDER BY child.relname
+ `,
+ );
+ return rows.map((r) => r.name);
+}
+
+/**
+ * Returns each child partition's name and its `pg_get_expr(relpartbound)` text,
+ * sorted by name. Pins the session to UTC first so `timestamptz` bounds render
+ * deterministically as `+00`.
+ */
+export async function childBounds(
+ tx: DatabaseTransactionConnection,
+ qualified: string,
+): Promise {
+ await tx.query(sql.unsafe`SET LOCAL TIME ZONE 'UTC'`);
+ const [schema, name] = parts(qualified);
+ return tx.any(
+ sql.type(z.object({ name: z.string(), bound: z.string() }))`
+ SELECT child.relname AS name, pg_get_expr(child.relpartbound, child.oid) AS bound
+ FROM pg_inherits i
+ JOIN pg_class child ON child.oid = i.inhrelid
+ JOIN pg_class parent ON parent.oid = i.inhparent
+ JOIN pg_namespace np ON np.oid = parent.relnamespace
+ WHERE np.nspname = ${schema} AND parent.relname = ${name}
+ ORDER BY child.relname
+ `,
+ );
+}
+
+/**
+ * Asserts contiguity (no gap, no overlap) over a set of partition bounds.
+ * Only finite `FROM ('..') TO ('..')` bounds participate: MINVALUE/MAXVALUE and
+ * DEFAULT partitions have no finite endpoint and are ignored, so callers that
+ * want them considered must assert those edges separately.
+ */
+export function assertContiguous(
+ bounds: readonly { name: string; bound: string }[],
+): void {
+ // Bounds are read under a UTC-pinned session (see childBounds), so a
+ // timestamptz renders as `...+00` and a timestamp/date without an offset —
+ // both UTC. Parse the date/time components and treat them as UTC; appending
+ // `Z` to a `+00` string would be invalid ISO 8601 (NaN).
+ const toMs = (token: string): number => {
+ const date = token.match(/\d{4}-\d{2}-\d{2}/)?.[0];
+ const time = token.match(/\d{2}:\d{2}:\d{2}/)?.[0] ?? "00:00:00";
+ return date ? Date.parse(`${date}T${time}Z`) : NaN;
+ };
+ const ranges = bounds
+ .map((r) => r.bound.match(/FROM \('(.+?)'\) TO \('(.+?)'\)/))
+ .flatMap((m) => (m ? [{ lo: toMs(m[1]), hi: toMs(m[2]) }] : []))
+ .sort((a, b) => a.lo - b.lo);
+ for (let i = 1; i < ranges.length; i++) {
+ if (ranges[i].lo !== ranges[i - 1].hi) {
+ throw new Error(
+ `partitions are not contiguous near index ${i}: ${ranges[i - 1].hi} != ${ranges[i].lo}`,
+ );
+ }
+ }
+}
diff --git a/src/types.ts b/src/types.ts
index 4a2d130..e50f451 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,7 +1,7 @@
/**
* Valid partition periods for partitioned tables.
*/
-export const PERIODS = ["day", "month", "year"] as const;
+export const PERIODS = ["day", "week", "month", "year"] as const;
/**
* Type guard to check if a string is a valid Period.
@@ -34,6 +34,7 @@ export interface ColumnInfo {
*/
export const SQL_FORMAT = {
day: "YYYYMMDD",
+ week: 'IYYY"w"IW',
month: "YYYYMM",
year: "YYYY",
} as const satisfies Record;
@@ -63,6 +64,20 @@ export interface AddPartitionsOptions {
past?: number;
future?: number;
tablespace?: string;
+ /**
+ * When true (the default), each newly created partition is granted the same
+ * privileges that are present on the partitioned parent table. Postgres does
+ * not propagate parent grants to partitions automatically, so without this a
+ * role such as a CDC/replication user loses access to new
+ * partitions until a grant is run manually.
+ */
+ inheritGrants?: boolean;
+ /**
+ * The reference "now" for choosing the partition horizon. Defaults to the
+ * current time. Pass a single value across a fleet so a run that straddles a
+ * period boundary uses a consistent horizon for every table.
+ */
+ now?: Date;
}
import type { MirroringTargetType } from "./mirroring.js";
diff --git a/vitest.config.ts b/vitest.config.ts
index badd38a..521789c 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -7,5 +7,12 @@ export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
environment: "node",
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "html"],
+ include: ["src/**/*.ts"],
+ exclude: ["src/**/*.test.ts", "src/testing/**", "dist/**"],
+ // Measured, not gated: CI enforces no coverage thresholds.
+ },
},
});