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. + }, }, });