feat(db): classify migrations for application under the running release (#20913)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - `packages/db` owns the schema and its migrations. There are 467
migrations, and several ship each day
> - A zero-downtime deploy starts the new release while the previous
release still serves the same database
> - Core does not say whether a migration is safe to apply under the
previous release. A deployer must treat each pending migration as a
reason to pause, or a person must review it
> - On stacks that may not pause, releases wait for hours
> - Core owns the schema, so Core must own this knowledge and publish it
as data
> - This pull request adds a conservative classifier, an in-file
override, the published result, and a guard for new migrations
> - The benefit is that a deployer can apply routine compatible
migrations automatically, and incompatible changes become visible at
review time

## Linked Issues or Issue Description

**Subsystem affected**

Database migrations (`packages/db`), the cloud migrator artifact
(`scripts/cloud-migrator-artifacts.mjs`), and the pull-request policy
job.

**Problem or motivation**

Core has no machine-readable statement that a migration can be applied
while the previous release still reads and writes the database. A
deployer cannot tell an added nullable column from a dropped column. It
must pause for both, or refuse the release.

**Proposed solution**

Classify each migration as `online_compatible`, `requires_staging`, or
`unknown`. Publish the result in the migrator manifest. Fail a pull
request when a migration it adds is `unknown` and has no reviewed
override in the file.

**Alternatives considered**

- A hand-kept list in the deployer. It goes stale and it is not reviewed
with the SQL.
- A sibling JSON file in the artifact. It needs a workflow change, and
the attestation of `manifest.json` would not cover it.

**Roadmap alignment**

This is release infrastructure. It adds no product feature.

## What Changed

- `packages/db/src/migration-compatibility.ts`:
`classifyMigrationSql(sql)` returns `{ compatibility, classified,
reasons, override, overrideError }`. It parses statement by statement
(breakpoints, `;`, comments, string and dollar quoting, guarded `DO`
blocks). A statement that it does not recognize is `unknown`. A
migration takes the worst class of its statements.
- Lock and duration hazards have their own reason codes
(`lock_hazard_*`) and classify as `unknown`, not as compatible with a
warning.
- Override: `-- paperclip:migration-compat online_compatible:
<justification>` (or `requires_staging`) in the leading comment block of
the migration file. The justification is mandatory (10 characters or
more). A malformed, repeated, or misplaced directive is an error and
never improves the verdict.
- A table counts as new after a plain `CREATE TABLE`, or after `CREATE
TABLE IF NOT EXISTS` when no earlier migration created that table.
`classifyMigrationSequence` classifies migrations in apply order with
that knowledge.
- `packages/db/scripts/migration-compatibility.ts` (run with `pnpm exec
tsx` in `packages/db`): classify files, print the distribution, print
JSON, or run the guard (`--check`). No new package script.
- Published data: `manifest.json` of the cloud migrator artifact has a
new additive field `migrationCompatibility`. `version` stays `1`. No
existing field changes. The build reads the SQL from the packed
`db.tgz`, so the data describes what the migrator applies. `validate`
(publisher) and `verify` reject data that is malformed or does not match
the packed migrations. The workflow file does not change.
- Guard for new migrations:
`.github/scripts/check-pr-migration-order.mjs` (the existing "Validate
migration ordering against target branch" policy step) now also
classifies the migrations that the pull request adds. `check:migrations`
runs the same guard against the merge base with `origin/master`, so the
`@paperclipai/db` typecheck and build report it locally. `unknown`
without an override fails. `requires_staging` prints a notice and
passes. A new migration that uses `CONCURRENTLY` also fails when it is
not in `MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS`
(`packages/db/src/client.ts`), because the migrator would run it inside
a transaction.
- `doc/DATABASE.md`: new section "Migration compatibility with the
running release" under "Schema change workflow".

Published shape:

```json
"migrationCompatibility": {
  "version": 1,
  "migrations": [
    {
      "idx": 468,
      "tag": "0468_busy_wendell_rand",
      "sqlSha256": "56a6e272…",
      "compatibility": "online_compatible",
      "classified": "online_compatible",
      "override": false,
      "reasons": [
        { "code": "add_column", "compatibility": "online_compatible", "message": "Adds a column that is nullable or has a constant default.", "statements": [1] }
      ]
    }
  ]
}
```

`sqlSha256` is the hash that the migrator records in the migration
history. `overrideJustification` is present only when `override` is
`true`. Entries are in apply order (journal `idx`, then file name; the
shipped journal repeats `idx` 178).

### Distribution over the existing migrations

No existing migration is rewritten or annotated.

| Class | All 467 | Newest 100 |
| --- | --- | --- |
| `online_compatible` | 228 (48.8%) | 67 |
| `requires_staging` | 114 (24.4%) | 12 |
| `unknown` | 125 (26.8%) | 21 |

Migrations per blocking reason (one migration can have several):

| Migrations | Class | Reason |
| --- | --- | --- |
| 74 | `requires_staging` | `create_unique_index_existing_table` |
| 70 | `unknown` | `lock_hazard_create_index` |
| 66 | `unknown` | `lock_hazard_validated_foreign_key` |
| 56 | `unknown` | `lock_hazard_data_backfill` |
| 54 | `unknown` | `drop_constraint` |
| 41 | `requires_staging` | `add_validated_check` |
| 33 | `unknown` | `drop_index` |
| 16 | `unknown` | `do_block_procedural` |
| 15 | `unknown` | `replace_function` |
| 13 | `unknown` | `create_trigger_existing_table` |
| 12 | `unknown` | `data_change_existing_table` |
| 10 | `unknown` | `drop_object`, `lock_hazard_set_not_null`,
`set_default` (each) |
| 8 | `requires_staging` | `drop_column` |

## Verification

- `pnpm --filter @paperclipai/db exec vitest run
src/migration-compatibility.test.ts`: 113 tests pass. They cover a
positive and a negative case for each rule, each reason code, the
override (missing, short, misplaced, and repeated justification are
rejected), unknown by default, the guard, the shipped migrations, the
`CONCURRENTLY` list, and the command line.
- `node --test scripts/cloud-migrator-artifacts.test.mjs
.github/scripts/tests/check-pr-migration-order.test.mjs`: 21 tests pass.
- Real bundle at the first commit of this branch: `preview-artifacts.mjs
pack`, then `cloud-migrator-artifacts.mjs build`, `validate`, and
`verify-install` pass. The manifest lists 467 migrations and is 312 KB.
This run found the repeated journal `idx`, which the second commit
handles.
- `pnpm --filter @paperclipai/db run check:migrations` passes. With
`--check --base HEAD~150` the guard fails on three `unknown` migrations
and prints notices for two `requires_staging` migrations.
- `pnpm verify` exits 2 because `.github/scripts/**` is a global CI
input. Required CI supplies the broad coverage.
- Not run: the publish job (it needs the master-only AWS role), and no
deployer reads the new field yet.

## Risks

- The manifest grows from about 1 KB to about 312 KB. A consumer that
rejects unknown manifest fields would fail. The verifier in this
repository ignores unknown fields; consumers outside this repository
were not inspected.
- From this merge, an open pull request that adds an `unknown` migration
fails the policy step and the `@paperclipai/db` typecheck until the
author rewrites the migration or adds the override. `drizzle-kit` emits
validated foreign keys and plain indexes for existing tables, so this is
common. The error text and `doc/DATABASE.md` give the fix.
- `ADD CONSTRAINT ... NOT VALID` classifies as `online_compatible`.
PostgreSQL still enforces it on new writes, so the author must make sure
that the previous release does not write rows that violate it.
- The classifier reads SQL only. It cannot see that new code writes
values that the previous release cannot read (for example a new enum
value). The `requires_staging` override exists for that case.
- `master` cannot fail the guard: the guard only judges migrations that
are not on the merge base.

## Model Used

- Claude (Anthropic), model ID `claude-fable-5-2`, in Claude Code with
tool use and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Fable 5.2 authored and GitHub committed 2026-11-11 14:14:40 -06:00
1 parent fc14f1ade2
commit e8e548e856
9 files changed
+1621 -28

No files matched your search

+66 -9
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const MIGRATIONS_DIRECTORY = 'packages/db/src/migrations/';
@@ -65,6 +66,35 @@ export function checkMigrationOrder(baseMigrationFiles, prMigrationFiles) {
};
}
/**
* Judges whether the migrations a PR adds can be applied while the previous
* release is still running. `unknown` without an in-file override fails;
* `requires_staging` only produces a notice.
*/
export async function checkMigrationCompatibility(migrations, { priorMigrationSql, executorSource } = {}) {
// Type-stripped by Node: the policy job runs before dependencies are installed.
const { checkNewMigrationCompatibility, parseNonTransactionalMigrations } = await import(
new URL('../../packages/db/src/migration-compatibility.ts', import.meta.url).href
);
// `executorSource` is the text of packages/db/src/client.ts. It names the
// migrations that run outside a transaction, which CONCURRENTLY needs.
const nonTransactionalMigrations = executorSource === undefined
? undefined
: parseNonTransactionalMigrations(executorSource);
if (nonTransactionalMigrations === null) {
return {
passed: false,
errors: ['MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS was not found in packages/db/src/client.ts, so CONCURRENTLY migrations cannot be checked.'],
notices: [],
};
}
const { passed, errors, notices } = checkNewMigrationCompatibility(migrations, {
priorMigrationSql,
nonTransactionalMigrations,
});
return { passed, errors, notices };
}
function gitPaths(args) {
return execFileSync('git', args, { encoding: 'utf8' })
.split('\0')
@@ -78,7 +108,7 @@ function escapeWorkflowCommand(message) {
.replaceAll('\n', '%0A');
}
function main() {
async function main() {
const [baseSha, headSha] = process.argv.slice(2);
const shaPattern = /^[0-9a-f]{40}$/i;
if (!shaPattern.test(baseSha ?? '') || !shaPattern.test(headSha ?? '')) {
@@ -95,18 +125,45 @@ function main() {
]).filter((file) => file.endsWith('.sql'));
const result = checkMigrationOrder(baseMigrationFiles, prMigrationFiles);
if (result.passed) {
console.log(result.message);
return;
if (!result.passed) {
console.error(
`::error title=Migration numbers must follow the target branch::${escapeWorkflowCommand(result.message)}`,
);
console.error(result.message);
process.exit(1);
}
console.log(result.message);
console.error(
`::error title=Migration numbers must follow the target branch::${escapeWorkflowCommand(result.message)}`,
if (prMigrationFiles.length === 0) return;
// The checkout is the merge of the PR into the target branch, so it holds
// the target branch's migrations and the migrator source.
const root = fileURLToPath(new URL('../../', import.meta.url));
const compatibility = await checkMigrationCompatibility(
[...prMigrationFiles].sort().map((file) => ({
fileName: file,
sql: execFileSync('git', ['show', `${headSha}:${file}`], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }),
})),
{
priorMigrationSql: [...baseMigrationFiles].sort()
.filter((file) => existsSync(`${root}${file}`))
.map((file) => readFileSync(`${root}${file}`, 'utf8')),
executorSource: readFileSync(`${root}packages/db/src/client.ts`, 'utf8'),
},
);
console.error(result.message);
process.exit(1);
for (const notice of compatibility.notices) {
console.log(`::notice title=Migration compatibility::${escapeWorkflowCommand(notice)}`);
console.log(notice);
}
for (const error of compatibility.errors) {
console.error(
`::error title=Migration is not classified as safe under the running release::${escapeWorkflowCommand(error)}`,
);
console.error(error);
}
if (!compatibility.passed) process.exit(1);
console.log('Every new migration is classified for application under the running release.');
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main();
await main();
}
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { checkMigrationOrder } from '../check-pr-migration-order.mjs';
import { checkMigrationCompatibility, checkMigrationOrder } from '../check-pr-migration-order.mjs';
const migration = (name) => `packages/db/src/migrations/${name}.sql`;
@@ -41,3 +41,84 @@ test('fails when a PR inserts a migration before the target branch tip', () => {
assert.match(result.message, /0229_from_stale_branch\.sql/);
assert.doesNotMatch(result.message, /- packages\/db\/src\/migrations\/0231_valid_but_after_stale\.sql/);
});
test('fails a new migration the classifier cannot prove safe under the running release', async () => {
const result = await checkMigrationCompatibility([
{ fileName: migration('0231_additive'), sql: 'ALTER TABLE "issues" ADD COLUMN "note" text;' },
{ fileName: migration('0232_plain_index'), sql: 'CREATE INDEX "issues_note_idx" ON "issues" ("note");' },
]);
assert.equal(result.passed, false);
assert.equal(result.errors.length, 1);
assert.match(result.errors[0], /0232_plain_index\.sql/);
assert.match(result.errors[0], /lock_hazard_create_index/);
assert.match(result.errors[0], /-- paperclip:migration-compat online_compatible: </);
assert.match(result.errors[0], /expand migration now and a contract migration in a later release/);
});
test('passes an unknown migration with a justified in-file override', async () => {
const result = await checkMigrationCompatibility([{
fileName: migration('0232_plain_index'),
sql: '-- paperclip:migration-compat online_compatible: issues stays under 10k rows on every stack\nCREATE INDEX "issues_note_idx" ON "issues" ("note");',
}]);
assert.equal(result.passed, true);
assert.match(result.notices[0], /online_compatible by override/);
});
test('fails an override that has no justification', async () => {
const result = await checkMigrationCompatibility([{
fileName: migration('0232_plain_index'),
sql: '-- paperclip:migration-compat online_compatible:\nCREATE INDEX "issues_note_idx" ON "issues" ("note");',
}]);
assert.equal(result.passed, false);
assert.match(result.errors[0], /needs a justification/);
});
test('reports requires_staging as a notice without failing', async () => {
const result = await checkMigrationCompatibility([
{ fileName: migration('0233_drop'), sql: 'ALTER TABLE "issues" DROP COLUMN "note";' },
]);
assert.equal(result.passed, true);
assert.deepEqual(result.errors, []);
assert.match(result.notices[0], /0233_drop\.sql: requires_staging/);
});
test('passes when a PR adds no migrations', async () => {
assert.deepEqual(await checkMigrationCompatibility([]), { passed: true, errors: [], notices: [] });
});
const executorSource = 'const MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS = new Set([\n "0232_listed.sql",\n]);';
const concurrentIndex = 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "issues_note_idx" ON "issues" ("note");';
test('fails a CONCURRENTLY migration that the migrator would run in a transaction', async () => {
const result = await checkMigrationCompatibility(
[{ fileName: migration('0232_unlisted'), sql: concurrentIndex }, { fileName: migration('0232_listed'), sql: concurrentIndex }],
{ executorSource },
);
assert.equal(result.passed, false);
assert.equal(result.errors.length, 1);
assert.match(result.errors[0], /0232_unlisted\.sql: uses CONCURRENTLY/);
assert.match(result.errors[0], /Add "0232_unlisted\.sql" to MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS in packages\/db\/src\/client\.ts/);
});
test('fails closed when the migrator source no longer names its non-transactional migrations', async () => {
const result = await checkMigrationCompatibility([], { executorSource: 'export const nothing = 1;' });
assert.equal(result.passed, false);
assert.match(result.errors[0], /MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS was not found/);
});
test('treats CREATE TABLE IF NOT EXISTS as new only when the target branch never created the table', async () => {
const sql = 'CREATE TABLE IF NOT EXISTS "widgets" ("id" uuid);\nALTER TABLE "widgets" DROP COLUMN "title";';
const fresh = await checkMigrationCompatibility([{ fileName: migration('0231_widgets'), sql }], { priorMigrationSql: [] });
const existing = await checkMigrationCompatibility([{ fileName: migration('0231_widgets'), sql }], {
priorMigrationSql: ['CREATE TABLE "widgets" ("id" uuid, "title" text);'],
});
assert.deepEqual(fresh.notices, []);
assert.match(existing.notices[0], /0231_widgets\.sql: requires_staging/);
});
+71
View File
@@ -1317,6 +1317,77 @@ When you change the data model:
Then update every layer that the change affects: `packages/shared` types and
validators, `server` routes and services, and `ui` API clients and pages.
### Migration compatibility with the running release
Paperclip Cloud starts a new release while the previous one is still serving
against the same database. Every migration is therefore classified by
`packages/db/src/migration-compatibility.ts` as one of:
| Class | Meaning | Examples |
| --- | --- | --- |
| `online_compatible` | The previous release keeps working and no lock is held for long. A deployer applies it without a pause. | `CREATE TABLE` and anything on a table created in the same migration, `CREATE INDEX CONCURRENTLY`, `ADD COLUMN` that is nullable or has a constant default, `ADD CONSTRAINT ... NOT VALID`, `CREATE TYPE`, `ALTER TYPE ... ADD VALUE` |
| `requires_staging` | The previous release breaks or its writes are rejected. Ship it in two releases: expand first, contract later. | `DROP TABLE`/`DROP COLUMN`, renames, `ALTER COLUMN ... TYPE`, `SET NOT NULL`, `DROP DEFAULT`, a `NOT NULL` column without a default, a validated `CHECK`, a `UNIQUE` constraint or unique index on an existing table, `TRUNCATE`, `DELETE` |
| `unknown` | The classifier cannot prove either. This includes every statement it does not recognize and every lock or duration hazard (reason codes starting with `lock_hazard_`). | plain `CREATE INDEX` on an existing table, a validated foreign key on an existing table, `VALIDATE CONSTRAINT`, `UPDATE` backfills, procedural `DO` blocks, `CREATE OR REPLACE FUNCTION`, triggers on existing tables, `DROP INDEX`, `DROP CONSTRAINT` |
A migration takes the worst class of its statements. Lock and duration hazards
are `unknown`, not compatible with a warning.
A table counts as created in the migration when the statement is a plain
`CREATE TABLE`, or a `CREATE TABLE IF NOT EXISTS` for a table that no earlier
migration created. `IF NOT EXISTS` on a table from an earlier migration proves
nothing, so later statements on that table are judged as changes to an existing
table.
`drizzle-kit` emits validated foreign keys and plain indexes. For an existing
table, edit the generated SQL: append `NOT VALID` to the foreign key, and use
`CREATE INDEX CONCURRENTLY IF NOT EXISTS`.
PostgreSQL rejects `CONCURRENTLY` inside a transaction, and the migrator runs
each migration file in one transaction. A migration that uses `CONCURRENTLY`
must therefore be listed in `MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS` in
`packages/db/src/client.ts`, and every statement in it must be idempotent,
because a failed run starts again from the first statement. The pull-request
guard fails a new `CONCURRENTLY` migration that is not in that list.
**The rule for a new migration.** A pull request fails when a migration it adds
is `unknown`. Rewrite the migration, split it into expand and contract
migrations, or, when you have verified the migration yourself, state the class
in the first lines of the migration file:
```sql
-- paperclip:migration-compat online_compatible: <why the previous release keeps working and no lock is held long>
```
Use `requires_staging` in place of `online_compatible` when the previous
release cannot run against the migration. The justification is mandatory. The
override is read only from the migration file, so it is reviewed with the SQL
and travels with it. A contract migration (for example the `DROP COLUMN` that
follows an expand release) always classifies as `requires_staging`; mark it
`online_compatible` this way once no running release uses the column.
`requires_staging` does not fail the pull request yet; it prints a notice.
Check a migration locally:
```sh
cd packages/db
pnpm exec tsx scripts/migration-compatibility.ts src/migrations/0468_busy_wendell_rand.sql
pnpm exec tsx scripts/migration-compatibility.ts # distribution over all migrations
pnpm exec tsx scripts/migration-compatibility.ts --check # the pull-request guard
```
The guard runs in `check:migrations` (so in the `@paperclipai/db` typecheck and
build) against the merge base with `origin/master`, and in the pull-request
policy job through `.github/scripts/check-pr-migration-order.mjs`. Migrations
that were already on `master` when the classifier shipped are not annotated.
Deployers read the result from the cloud migrator manifest
(`cloud-migrators/v1/<sha>/manifest.json`, built by
`scripts/cloud-migrator-artifacts.mjs`). Its `migrationCompatibility.migrations`
array lists every packed migration in journal order with `idx`, `tag`,
`sqlSha256` (the hash the migrator records in the migration history),
`compatibility`, `classified` (the classifier's verdict before an override),
`override`, `overrideJustification`, and `reasons`.
## Migration authoring checklist
Older embedded databases can use `SQL_ASCII`. Test encoding-sensitive SQL on
+1 -1
View File
@@ -38,7 +38,7 @@
"embedded-postgres"
],
"scripts": {
"check:migrations": "tsx src/check-migration-numbering.ts && tsx src/check-migration-safety.ts",
"check:migrations": "tsx src/check-migration-numbering.ts && tsx src/check-migration-safety.ts && tsx scripts/migration-compatibility.ts --check",
"build": "pnpm run check:migrations && tsc && rm -rf dist/migrations && mkdir -p dist/migrations/meta && cp src/migrations/*.sql dist/migrations/ && cp src/migrations/meta/_journal.json dist/migrations/meta/",
"clean": "rm -rf dist",
"typecheck": "pnpm run check:migrations && tsc --noEmit",
@@ -0,0 +1,133 @@
// Reports whether migrations can be applied while the previous release is
// still running. See "Migration compatibility with the running release" in
// doc/DATABASE.md.
//
// tsx scripts/migration-compatibility.ts summary of every migration
// tsx scripts/migration-compatibility.ts --json per-migration results as JSON
// tsx scripts/migration-compatibility.ts <file.sql>... classify the named files
// tsx scripts/migration-compatibility.ts --check [--base <ref>]
// fail when a migration added since the merge base with <ref>
// (default origin/master, then master) is `unknown` without an override
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
checkNewMigrationCompatibility,
classifyMigrationSequence,
parseNonTransactionalMigrations,
summarizeMigrationCompatibility,
type MigrationCompatInput,
} from "../src/migration-compatibility.js";
const migrationsDir = fileURLToPath(new URL("../src/migrations", import.meta.url));
const executorPath = fileURLToPath(new URL("../src/client.ts", import.meta.url));
function journalFiles(): string[] {
const journal = JSON.parse(readFileSync(path.join(migrationsDir, "meta", "_journal.json"), "utf8")) as {
entries: { idx: number; tag: string }[];
};
// Apply order: journal idx, then file name (the journal repeats an idx).
return [...journal.entries]
.sort((left, right) => left.idx - right.idx || left.tag.localeCompare(right.tag))
.map((entry) => `${entry.tag}.sql`);
}
function read(file: string): MigrationCompatInput {
return { fileName: path.basename(file), sql: readFileSync(file, "utf8") };
}
function git(args: string[]): string | null {
try {
return execFileSync("git", args, { cwd: migrationsDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
} catch {
return null;
}
}
/** Journal migrations split by whether the merge base has them, or null without git history. */
function migrationsAddedSince(requestedBase: string | null): { base: string; prior: string[]; files: string[] } | null {
for (const ref of requestedBase ? [requestedBase] : ["origin/master", "master"]) {
const base = git(["merge-base", "HEAD", ref])?.trim();
if (!base) continue;
// ls-tree lists the current directory (the migrations folder) of the base.
const listed = git(["ls-tree", "--name-only", base]);
if (!listed?.includes(".sql")) continue;
const existing = new Set(listed.split("\n"));
const journal = journalFiles();
return { base, prior: journal.filter((file) => existing.has(file)), files: journal.filter((file) => !existing.has(file)) };
}
return null;
}
function check(requestedBase: string | null): number {
const added = migrationsAddedSince(requestedBase);
if (!added) {
// A source archive or shallow clone has no base to compare with. The pull
// request policy job runs the same guard with full history.
console.log("migration compatibility: no merge base available, skipped.");
return requestedBase ? 2 : 0;
}
const nonTransactionalMigrations = parseNonTransactionalMigrations(readFileSync(executorPath, "utf8"));
if (!nonTransactionalMigrations) {
console.error("error: MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS was not found in packages/db/src/client.ts.");
return 1;
}
const result = checkNewMigrationCompatibility(added.files.map((file) => read(path.join(migrationsDir, file))), {
priorMigrationSql: added.prior.map((file) => read(path.join(migrationsDir, file)).sql),
nonTransactionalMigrations,
});
for (const notice of result.notices) console.log(`notice: ${notice}`);
for (const error of result.errors) console.error(`error: ${error}`);
if (result.passed) {
console.log(`migration compatibility: ${added.files.length} new migration(s) since ${added.base.slice(0, 12)}, none unclassified.`);
}
return result.passed ? 0 : 1;
}
function main(): number {
const args = process.argv.slice(2);
const flag = (name: string) => args.includes(name);
const baseIndex = args.indexOf("--base");
const base = baseIndex === -1 ? null : args[baseIndex + 1] ?? null;
if (flag("--check")) return check(base);
const named = args.filter((arg, index) => !arg.startsWith("--") && (baseIndex === -1 || index !== baseIndex + 1));
const journal = journalFiles().map((file) => read(path.join(migrationsDir, file)));
// A named file is judged after the journal migrations that precede it, or
// after the whole journal when it is not in the journal yet.
const results = named.length === 0
? classifyMigrationSequence(journal.map((input) => input.sql)).map((result, index) => ({ fileName: journal[index].fileName, ...result }))
: named.map(read).map((input) => {
const position = journal.findIndex((entry) => entry.fileName === input.fileName);
const prior = (position === -1 ? journal : journal.slice(0, position)).map((entry) => entry.sql);
return { fileName: input.fileName, ...classifyMigrationSequence([input.sql], prior)[0] };
});
if (flag("--json")) {
console.log(JSON.stringify(results, null, 2));
return 0;
}
if (named.length > 0) {
for (const result of results) {
console.log(`${result.fileName}: ${result.compatibility}${result.override ? " (override)" : ""}`);
for (const reason of result.reasons) {
const where = reason.statements.length ? ` [statement ${reason.statements.join(", ")}]` : "";
console.log(` ${reason.compatibility} ${reason.code}${where}: ${reason.message}`);
}
}
return 0;
}
const summary = summarizeMigrationCompatibility(results);
const percent = (count: number) => `${((count / Math.max(summary.total, 1)) * 100).toFixed(1)}%`;
console.log(`${summary.total} migrations, ${summary.overrides} with an override`);
for (const [compatibility, count] of Object.entries(summary.byCompatibility)) {
console.log(` ${compatibility.padEnd(18)} ${String(count).padStart(4)} ${percent(count)}`);
}
console.log("Migrations per blocking reason:");
for (const reason of summary.blockingReasons) {
console.log(` ${String(reason.migrations).padStart(4)} ${reason.compatibility.padEnd(16)} ${reason.code}`);
}
return 0;
}
process.exitCode = main();
@@ -0,0 +1,378 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
MIGRATION_COMPAT_REASON_CODES,
checkNewMigrationCompatibility,
classifyMigrationSequence,
classifyMigrationSql,
collectCreatedTables,
parseMigrationCompatOverride,
parseNonTransactionalMigrations,
summarizeMigrationCompatibility,
type MigrationCompatibility,
type MigrationCompatReasonCode,
} from "./migration-compatibility.js";
const BP = "--> statement-breakpoint\n";
const NEW_TABLE = `CREATE TABLE "widgets" ("id" uuid PRIMARY KEY NOT NULL, "issue_id" uuid);${BP}`;
function codes(sql: string): MigrationCompatReasonCode[] {
return classifyMigrationSql(sql).reasons.map((reason) => reason.code);
}
type Case = readonly [name: string, sql: string, compatibility: MigrationCompatibility, code: MigrationCompatReasonCode];
const cases: readonly Case[] = [
// Compatible: additive changes the previous release never touches.
["create table", `CREATE TABLE "widgets" ("id" uuid PRIMARY KEY, "issue_id" uuid REFERENCES "issues"("id"));`, "online_compatible", "create_table"],
["create table if not exists", `CREATE TABLE IF NOT EXISTS "widgets" ("id" uuid PRIMARY KEY);`, "online_compatible", "create_table_if_not_exists"],
["create temp table", `CREATE TEMP TABLE scratch (old_id uuid) ON COMMIT DROP;`, "online_compatible", "create_temp_table"],
["plain index on a table created here", `${NEW_TABLE}CREATE UNIQUE INDEX "widgets_uq" ON "widgets" USING btree ("issue_id");`, "online_compatible", "create_index_new_table"],
["constraints on a table created here", `${NEW_TABLE}ALTER TABLE "widgets" ADD CONSTRAINT "widgets_issue_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;`, "online_compatible", "alter_new_table"],
["trigger on a table created here", `${NEW_TABLE}CREATE TRIGGER widgets_touch BEFORE UPDATE OF issue_id ON "widgets" FOR EACH ROW EXECUTE FUNCTION touch();`, "online_compatible", "create_trigger_new_table"],
["constant rows in a table created here", `${NEW_TABLE}INSERT INTO "widgets" ("id") VALUES ('00000000-0000-0000-0000-000000000001');`, "online_compatible", "seed_new_table"],
["concurrent index", `CREATE INDEX CONCURRENTLY IF NOT EXISTS "issues_x_idx" ON "issues" USING btree ("company_id","id") WHERE "issues"."status" = 'done';`, "online_compatible", "create_index_concurrently"],
["nullable column", `ALTER TABLE "issues" ADD COLUMN "time_zone" text;`, "online_compatible", "add_column"],
["column with a constant default", `ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "attempts" integer DEFAULT 0 NOT NULL;`, "online_compatible", "add_column"],
["column with a cast literal default", `ALTER TABLE "issues" ADD COLUMN "meta" jsonb NOT NULL DEFAULT '{"a": "b, c"}'::jsonb;`, "online_compatible", "add_column"],
["column with a stable timestamp default", `ALTER TABLE "issues" ADD COLUMN "seen_at" timestamp with time zone DEFAULT now() NOT NULL;`, "online_compatible", "add_column"],
["several additive actions", `ALTER TABLE "issues" ADD COLUMN "a" text, ADD COLUMN "b" numeric(24, 7) DEFAULT '0' NOT NULL;`, "online_compatible", "add_column"],
["foreign key added NOT VALID", `ALTER TABLE "issues" ADD CONSTRAINT "issues_owner_fk" FOREIGN KEY ("owner_id") REFERENCES "users"("id") NOT VALID;`, "online_compatible", "add_constraint_not_valid"],
["check added NOT VALID", `ALTER TABLE "issues" ADD CONSTRAINT "issues_kind_check" CHECK ("kind" IN ('a', 'b')) NOT VALID;`, "online_compatible", "add_constraint_not_valid"],
["drop not null", `ALTER TABLE "issues" ALTER COLUMN "title" DROP NOT NULL;`, "online_compatible", "drop_not_null"],
["create enum", `CREATE TYPE "public"."mood" AS ENUM('sad', 'ok');`, "online_compatible", "create_type"],
["add enum value", `ALTER TYPE "public"."mood" ADD VALUE IF NOT EXISTS 'happy';`, "online_compatible", "add_enum_value"],
["create sequence", `CREATE SEQUENCE "issue_numbers";`, "online_compatible", "create_sequence"],
["create extension", `CREATE EXTENSION IF NOT EXISTS "pg_trgm";`, "online_compatible", "create_extension"],
["new function", `CREATE FUNCTION touch() RETURNS trigger AS $fn$ BEGIN DELETE FROM issues; RETURN NEW; END; $fn$ LANGUAGE plpgsql;`, "online_compatible", "create_function"],
["new view", `CREATE VIEW "open_issues" AS SELECT * FROM "issues";`, "online_compatible", "create_view"],
["comment", `COMMENT ON COLUMN "issues"."title" IS 'DROP TABLE issues; is only text here';`, "online_compatible", "comment"],
["no-op select", `SELECT 1;`, "online_compatible", "no_op"],
["analyze", `ANALYZE "issues";`, "online_compatible", "analyze"],
["extended statistics", `CREATE STATISTICS IF NOT EXISTS "s" ON ("a"), ("b") FROM "issues";`, "online_compatible", "create_statistics"],
["guarded DO block around an additive statement", `DO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'x' AND conrelid = 'issues'::regclass) THEN\n ALTER TABLE "issues" ADD CONSTRAINT "x" FOREIGN KEY ("a") REFERENCES "b"("id") NOT VALID;\n END IF;\nEND $$;`, "online_compatible", "add_constraint_not_valid"],
["duplicate-tolerant DO block on a table created here", `${NEW_TABLE}DO $$ BEGIN\n ALTER TABLE "widgets" ADD CONSTRAINT "w_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade;\nEXCEPTION\n WHEN duplicate_object THEN null;\nEND $$;`, "online_compatible", "alter_new_table"],
// Requires staging: the previous release breaks or its writes are rejected.
["drop table", `DROP TABLE IF EXISTS "widgets" CASCADE;`, "requires_staging", "drop_table"],
["drop column", `ALTER TABLE "issues" DROP COLUMN IF EXISTS "legacy";`, "requires_staging", "drop_column"],
["rename table", `ALTER TABLE "issues" RENAME TO "tasks";`, "requires_staging", "rename_table"],
["rename column", `ALTER TABLE "issues" RENAME COLUMN "title" TO "name";`, "requires_staging", "rename_column"],
["alter column type", `ALTER TABLE "issues" ALTER COLUMN "number" SET DATA TYPE bigint USING "number"::bigint;`, "requires_staging", "alter_column_type"],
["set not null on an existing column", `ALTER TABLE "issues" ALTER COLUMN "title" SET NOT NULL;`, "requires_staging", "set_not_null"],
["not null column without a default", `ALTER TABLE "issues" ADD COLUMN "public_id" varchar(128) NOT NULL;`, "requires_staging", "add_not_null_column_without_default"],
["drop default", `ALTER TABLE "issues" ALTER COLUMN "status" DROP DEFAULT;`, "requires_staging", "drop_default"],
["validated check", `ALTER TABLE "issues" ADD CONSTRAINT "issues_kind_check" CHECK ("kind" IN ('a', 'b'));`, "requires_staging", "add_validated_check"],
["unique constraint", `ALTER TABLE "issues" ADD CONSTRAINT "issues_uq" UNIQUE("company_id","number");`, "requires_staging", "add_unique_constraint"],
["unique index on an existing table", `CREATE UNIQUE INDEX CONCURRENTLY "issues_uq" ON "issues" ("company_id","number");`, "requires_staging", "create_unique_index_existing_table"],
["drop type", `DROP TYPE "public"."mood";`, "requires_staging", "drop_type"],
["rename enum value", `ALTER TYPE "mood" RENAME VALUE 'ok' TO 'fine';`, "requires_staging", "rename_type"],
["truncate", `TRUNCATE TABLE "issues";`, "requires_staging", "truncate"],
["delete", `DELETE FROM "issues" WHERE "status" = 'cancelled';`, "requires_staging", "delete_rows"],
["delete inside a CTE", `WITH ranked AS (SELECT id FROM issues) DELETE FROM "issues" i USING ranked r WHERE i.id = r.id;`, "requires_staging", "delete_rows"],
["guarded DO block around a validated check", `DO $$ BEGIN\n ALTER TABLE "issues" ADD CONSTRAINT "c" CHECK ("a" > 0);\nEXCEPTION WHEN duplicate_object THEN null;\nEND $$;`, "requires_staging", "add_validated_check"],
// Lock and duration hazards: unknown, reported under lock_hazard_ codes.
["plain index on an existing table", `CREATE INDEX IF NOT EXISTS "issues_x_idx" ON "issues" USING btree ("company_id");`, "unknown", "lock_hazard_create_index"],
["validated foreign key on an existing table", `ALTER TABLE "issues" ADD CONSTRAINT "issues_owner_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;`, "unknown", "lock_hazard_validated_foreign_key"],
["validated foreign key in a guarded DO block", `DO $$ BEGIN\n IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'x') THEN\n ALTER TABLE "issues" ADD CONSTRAINT "x" FOREIGN KEY ("a") REFERENCES "b"("id");\n END IF;\nEND $$;`, "unknown", "lock_hazard_validated_foreign_key"],
["validate constraint", `ALTER TABLE "issues" VALIDATE CONSTRAINT "issues_owner_fk";`, "unknown", "lock_hazard_validate_constraint"],
["set not null on a column added here", `ALTER TABLE "issues" ADD COLUMN "kind" text DEFAULT 'task';${BP}ALTER TABLE "issues" ALTER COLUMN "kind" SET NOT NULL;`, "unknown", "lock_hazard_set_not_null"],
["column with a volatile default", `ALTER TABLE "issues" ADD COLUMN "token" uuid DEFAULT gen_random_uuid() NOT NULL;`, "unknown", "lock_hazard_table_rewrite"],
["generated column", `ALTER TABLE "issues" ADD COLUMN "n" bigint GENERATED ALWAYS AS IDENTITY;`, "unknown", "lock_hazard_table_rewrite"],
["column with an inline foreign key", `ALTER TABLE "issues" ADD COLUMN "owner_id" uuid REFERENCES "users"("id");`, "unknown", "lock_hazard_add_column_constraint"],
["update backfill", `UPDATE "issues" SET "kind" = 'task' WHERE "kind" IS NULL;`, "unknown", "lock_hazard_data_backfill"],
["CTE backfill", `WITH ranked AS (SELECT id FROM issues) UPDATE "issues" i SET n = 1 FROM ranked r WHERE i.id = r.id;`, "unknown", "lock_hazard_data_backfill"],
["batched backfill loop", `DO $$ DECLARE n integer; BEGIN LOOP UPDATE issues SET a = 1 WHERE id IN (SELECT id FROM issues LIMIT 1000); GET DIAGNOSTICS n = ROW_COUNT; EXIT WHEN n = 0; END LOOP; END $$;`, "unknown", "lock_hazard_data_backfill"],
["copy from live tables into a new table", `${NEW_TABLE}INSERT INTO "widgets" ("id") SELECT "id" FROM "issues";`, "unknown", "lock_hazard_copy_into_new_table"],
["lock table", `LOCK TABLE "issues" IN ACCESS EXCLUSIVE MODE;`, "unknown", "lock_hazard_lock_table"],
// Unknown: not positively recognized, or it changes what old code experiences.
["insert into an existing table", `INSERT INTO "issues" ("id") VALUES ('1');`, "unknown", "data_change_existing_table"],
["set default", `ALTER TABLE "issues" ALTER COLUMN "status" SET DEFAULT 'todo';`, "unknown", "set_default"],
["drop constraint", `ALTER TABLE "issues" DROP CONSTRAINT "issues_owner_fk";`, "unknown", "drop_constraint"],
["drop index", `DROP INDEX IF EXISTS "issues_x_idx";`, "unknown", "drop_index"],
["drop trigger", `DROP TRIGGER IF EXISTS t ON "issues";`, "unknown", "drop_object"],
["constraint from an existing index", `ALTER TABLE "issues" ADD CONSTRAINT "issues_uq" UNIQUE USING INDEX "issues_uq_idx";`, "unknown", "add_constraint_other"],
["replace function", `CREATE OR REPLACE FUNCTION touch() RETURNS trigger AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql;`, "unknown", "replace_function"],
["replace view", `CREATE OR REPLACE VIEW "open_issues" AS SELECT * FROM "issues";`, "unknown", "replace_view"],
["trigger on an existing table", `CREATE TRIGGER t BEFORE INSERT OR UPDATE OF a, b ON "issues" FOR EACH ROW EXECUTE FUNCTION touch();`, "unknown", "create_trigger_existing_table"],
["create table as", `CREATE TABLE "issues_copy" AS SELECT * FROM "issues";`, "unknown", "create_table_as"],
["procedural DO block", `DO $$ BEGIN EXECUTE format('ALTER TABLE %I ADD COLUMN x text', 'issues'); END $$;`, "unknown", "do_block_procedural"],
["DO block with an unrecognized guard", `DO $$ BEGIN IF current_setting('x') = 'y' THEN ALTER TABLE "issues" ADD COLUMN "a" text; END IF; END $$;`, "unknown", "do_block_procedural"],
["DO block that raises", `DO $$ BEGIN IF EXISTS (SELECT 1 FROM issues) THEN RAISE EXCEPTION 'no'; END IF; END $$;`, "unknown", "do_block_procedural"],
["unrecognized alter table action", `ALTER TABLE "issues" ENABLE ROW LEVEL SECURITY;`, "unknown", "unrecognized_alter_table_action"],
["unrecognized statement", `GRANT SELECT ON "issues" TO reader;`, "unknown", "unrecognized_statement"],
["vacuum", `VACUUM FULL "issues";`, "unknown", "unrecognized_statement"],
["empty file", `-- nothing here\n`, "unknown", "no_statements"],
];
describe("classifyMigrationSql", () => {
it.each(cases)("%s", (_name, sql, compatibility, code) => {
const result = classifyMigrationSql(sql);
expect(result.reasons.map((reason) => reason.code)).toContain(code);
expect(result.compatibility).toBe(compatibility);
expect(result.classified).toBe(compatibility);
expect(result.override).toBeNull();
});
it("has a test case for every reason code", () => {
const covered = new Set<string>([...cases.map((entry) => entry[3]), "invalid_override"]);
expect(MIGRATION_COMPAT_REASON_CODES.filter((code) => !covered.has(code))).toEqual([]);
});
it("takes the worst statement: staging over unknown over compatible", () => {
const additive = `ALTER TABLE "issues" ADD COLUMN "a" text;`;
const hazard = `CREATE INDEX "i" ON "issues" ("a");`;
const breaking = `ALTER TABLE "issues" DROP COLUMN "b";`;
expect(classifyMigrationSql([additive, hazard].join(BP)).compatibility).toBe("unknown");
expect(classifyMigrationSql([additive, hazard, breaking].join(BP)).compatibility).toBe("requires_staging");
expect(classifyMigrationSql([additive, hazard, breaking].join(BP)).reasons).toEqual([
expect.objectContaining({ code: "add_column", compatibility: "online_compatible", statements: [1] }),
expect.objectContaining({ code: "lock_hazard_create_index", compatibility: "unknown", statements: [2] }),
expect.objectContaining({ code: "drop_column", compatibility: "requires_staging", statements: [3] }),
]);
});
it("splits statements that share a chunk without a breakpoint", () => {
expect(codes(`ALTER TABLE "issues" ADD COLUMN "a" text;\nDROP TABLE "issues";`)).toEqual(["add_column", "drop_table"]);
});
it("classifies each action of a multi-action ALTER TABLE", () => {
expect(codes(`ALTER TABLE "issues" ADD COLUMN "a" text, DROP COLUMN "b", ALTER COLUMN "c" TYPE bigint;`))
.toEqual(["add_column", "drop_column", "alter_column_type"]);
});
it("does not let comments or string contents hide or invent statements", () => {
expect(classifyMigrationSql(`-- DROP TABLE "issues";\n/* TRUNCATE issues; */\nALTER TABLE "issues" ADD COLUMN "a" text DEFAULT 'x; DROP TABLE issues;';`).compatibility)
.toBe("online_compatible");
expect(classifyMigrationSql(`COMMENT ON TABLE "issues" IS E'it\\'s';${BP}DROP TABLE "issues";`).compatibility).toBe("requires_staging");
expect(classifyMigrationSql(`COMMENT ON TABLE "issues" IS 'it''s';\nDROP TABLE "issues";`).compatibility).toBe("requires_staging");
});
it("keeps a quoted identifier with an escaped quote distinct from other names", () => {
// "iss""ues" is a table named iss"ues, not issues.
const sql = `CREATE TABLE "iss""ues" (id uuid);${BP}DROP TABLE "issues";${BP}ALTER TABLE issues DROP COLUMN title;`;
expect(codes(sql)).toEqual(["create_table", "drop_table", "drop_column"]);
expect(classifyMigrationSql(sql).compatibility).toBe("requires_staging");
expect(collectCreatedTables(sql)).toEqual(['"iss%22ues"']);
expect(classifyMigrationSql(`CREATE TABLE "iss""ues" (id uuid);${BP}CREATE INDEX i ON "iss""ues" (id);`).compatibility).toBe("online_compatible");
expect(classifyMigrationSql(`CREATE TABLE "Issues" (id uuid);${BP}DROP TABLE issues;`).compatibility).toBe("requires_staging");
});
it("treats quoted, unquoted and schema-qualified names of a new table alike", () => {
expect(classifyMigrationSql(`CREATE TABLE public.widgets (id uuid);${BP}CREATE INDEX "w" ON "widgets" (id);${BP}ALTER TABLE "public"."widgets" ADD COLUMN a text NOT NULL;`).compatibility)
.toBe("online_compatible");
});
it("does not take CREATE TABLE IF NOT EXISTS as proof that a table is new", () => {
const sql = `CREATE TABLE IF NOT EXISTS issues (id uuid);${BP}ALTER TABLE issues DROP COLUMN title;${BP}CREATE INDEX "i" ON issues (id);`;
expect(classifyMigrationSql(sql)).toMatchObject({ compatibility: "requires_staging" });
expect(codes(sql)).toEqual(["create_table_if_not_exists", "drop_column", "lock_hazard_create_index"]);
// An earlier migration created the table: it exists.
expect(classifyMigrationSql(sql, { existingTables: new Set(["issues"]) }).compatibility).toBe("requires_staging");
// No earlier migration created it: the table is new, whatever the spelling.
expect(classifyMigrationSql(sql, { existingTables: new Set(["projects"]) }).compatibility).toBe("online_compatible");
expect(classifyMigrationSql(sql.replace("issues (id uuid)", `"public"."issues" (id uuid)`), { existingTables: new Set() }).compatibility)
.toBe("online_compatible");
// A temporary table always belongs to the migration session.
expect(classifyMigrationSql(`CREATE TEMP TABLE IF NOT EXISTS scratch (id uuid);${BP}DROP TABLE scratch;`).compatibility).toBe("online_compatible");
});
it("classifies a sequence knowing the tables earlier migrations created", () => {
const create = `DO $$ BEGIN IF to_regclass('public.widgets') IS NULL THEN CREATE TABLE "widgets" ("id" uuid); END IF; END $$;`;
const repair = `CREATE TABLE IF NOT EXISTS "widgets" ("id" uuid);${BP}ALTER TABLE "widgets" ADD COLUMN "n" integer NOT NULL;`;
const rename = `ALTER TABLE "widgets" RENAME TO "gadgets";`;
const again = `CREATE TABLE IF NOT EXISTS "gadgets" ("id" uuid);${BP}CREATE INDEX "g" ON "gadgets" ("id");`;
expect(collectCreatedTables(create)).toEqual(["widgets"]);
expect(collectCreatedTables(rename)).toEqual(["gadgets"]);
expect(classifyMigrationSequence([repair, create, repair, rename, again]).map((result) => result.compatibility))
.toEqual(["online_compatible", "online_compatible", "requires_staging", "requires_staging", "unknown"]);
expect(classifyMigrationSequence([repair], [create])[0].compatibility).toBe("requires_staging");
});
it("reports whether a migration needs to run outside a transaction", () => {
expect(classifyMigrationSql(`CREATE INDEX CONCURRENTLY "i" ON "issues" ("a");`).usesConcurrently).toBe(true);
expect(classifyMigrationSql(`DROP INDEX CONCURRENTLY IF EXISTS "i";`).usesConcurrently).toBe(true);
expect(classifyMigrationSql(`COMMENT ON TABLE "issues" IS 'built CONCURRENTLY';${BP}CREATE INDEX "i" ON "issues" ("a");`).usesConcurrently).toBe(false);
});
it("does not treat an existing table as new because another table was created", () => {
expect(classifyMigrationSql(`${NEW_TABLE}CREATE INDEX "i" ON "widgets_archive" ("id");`).compatibility).toBe("unknown");
});
it("never classifies an unrecognized ADD CONSTRAINT form as a column", () => {
expect(codes(`ALTER TABLE "issues" ADD CONSTRAINT "c" NOT NULL "title";`)).toEqual(["add_constraint_other"]);
});
it("classifies every statement inside a guarded DO block", () => {
const sql = `DO $$ BEGIN\n IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'x') THEN\n ALTER TABLE "issues" DROP CONSTRAINT "x";\n END IF;\n ALTER TABLE "issues" ADD CONSTRAINT "x" FOREIGN KEY ("a") REFERENCES "b"("id") NOT VALID;\nEND $$;`;
expect(codes(sql)).toEqual(["drop_constraint", "add_constraint_not_valid"]);
});
});
describe("migration-compat override", () => {
const hazard = `CREATE INDEX "i" ON "issues" ("a");`;
it("replaces the verdict and keeps the classifier's own result", () => {
const result = classifyMigrationSql(`-- paperclip:migration-compat online_compatible: issues has under 1k rows on every stack\n${hazard}`);
expect(result).toMatchObject({
compatibility: "online_compatible",
classified: "unknown",
override: { compatibility: "online_compatible", justification: "issues has under 1k rows on every stack" },
overrideError: null,
});
expect(result.reasons.map((reason) => reason.code)).toEqual(["lock_hazard_create_index"]);
});
it("can mark a contract migration compatible and an additive one staged", () => {
expect(classifyMigrationSql(`-- Contract step.\n\n-- paperclip:migration-compat online_compatible: no release since v0.3 reads issues.legacy\nALTER TABLE "issues" DROP COLUMN "legacy";`))
.toMatchObject({ compatibility: "online_compatible", classified: "requires_staging" });
expect(classifyMigrationSql(`-- paperclip:migration-compat requires_staging: old servers crash on the new enum value\nALTER TYPE "mood" ADD VALUE 'x';`))
.toMatchObject({ compatibility: "requires_staging", classified: "online_compatible" });
});
it.each([
["missing justification", `-- paperclip:migration-compat online_compatible\n${hazard}`],
["empty justification", `-- paperclip:migration-compat online_compatible: \n${hazard}`],
["token justification", `-- paperclip:migration-compat online_compatible: ok\n${hazard}`],
["unknown value", `-- paperclip:migration-compat unknown: we could not decide today\n${hazard}`],
["unsupported value", `-- paperclip:migration-compat safe: this one is certainly fine\n${hazard}`],
["missing colon", `-- paperclip:migration-compat online_compatible because it is fine\n${hazard}`],
["two directives", `-- paperclip:migration-compat online_compatible: the table is tiny everywhere\n-- paperclip:migration-compat requires_staging: or maybe it is not\n${hazard}`],
["after the first statement", `${hazard}\n-- paperclip:migration-compat online_compatible: the table is tiny everywhere\n`],
["inside a block comment", `/* paperclip:migration-compat online_compatible: the table is tiny everywhere */\n${hazard}`],
["on a breakpoint line", `--> statement-breakpoint paperclip:migration-compat online_compatible: the table is tiny\n${hazard}`],
])("rejects an override with %s", (_name, sql) => {
const result = classifyMigrationSql(sql);
expect(result.override).toBeNull();
expect(result.overrideError).toMatch(/paperclip:migration-compat/);
expect(result.compatibility).toBe("unknown");
expect(result.reasons.map((reason) => reason.code)).toContain("invalid_override");
});
it("never lets a rejected override improve the verdict", () => {
expect(classifyMigrationSql(`-- paperclip:migration-compat online_compatible\nDROP TABLE "issues";`).compatibility).toBe("requires_staging");
expect(classifyMigrationSql(`-- paperclip:migration-compat online_compatible\nALTER TABLE "issues" ADD COLUMN "a" text;`).compatibility).toBe("unknown");
});
it("reads nothing when the file has no directive", () => {
expect(parseMigrationCompatOverride(`-- An ordinary comment about compat.\n${hazard}`)).toEqual({ override: null, error: null });
});
});
describe("checkNewMigrationCompatibility", () => {
const additive = { fileName: "0500_additive.sql", sql: `ALTER TABLE "issues" ADD COLUMN "a" text;` };
const unknown = { fileName: "0501_unknown.sql", sql: `CREATE INDEX "i" ON "issues" ("a");${BP}UPDATE "issues" SET a = 'x';` };
const staged = { fileName: "0502_staged.sql", sql: `ALTER TABLE "issues" DROP COLUMN "a";` };
it("passes with no new migrations and with compatible ones", () => {
expect(checkNewMigrationCompatibility([])).toMatchObject({ passed: true, errors: [], notices: [] });
expect(checkNewMigrationCompatibility([additive])).toMatchObject({ passed: true, errors: [], notices: [] });
});
it("fails an unknown migration and tells the author what to do", () => {
const result = checkNewMigrationCompatibility([additive, unknown]);
expect(result.passed).toBe(false);
expect(result.errors).toHaveLength(1);
const [error] = result.errors;
expect(error).toContain("0501_unknown.sql");
expect(error).toContain("lock_hazard_create_index (statement 1)");
expect(error).toContain("lock_hazard_data_backfill (statement 2)");
expect(error).toContain("CREATE INDEX CONCURRENTLY");
expect(error).toContain("expand migration now and a contract migration in a later release");
expect(error).toContain("-- paperclip:migration-compat online_compatible: <why");
expect(error).toContain("-- paperclip:migration-compat requires_staging: <what");
expect(error).toContain("doc/DATABASE.md");
});
it("passes an unknown migration that carries a justified override, with a notice", () => {
const result = checkNewMigrationCompatibility([{ ...unknown, sql: `-- paperclip:migration-compat online_compatible: issues is small and the update is idempotent\n${unknown.sql}` }]);
expect(result).toMatchObject({ passed: true, errors: [] });
expect(result.notices).toEqual([expect.stringMatching(/0501_unknown\.sql: online_compatible by override \(classifier said unknown\): issues is small/)]);
});
it("fails an override without a justification, even on a compatible migration", () => {
const result = checkNewMigrationCompatibility([{ ...additive, sql: `-- paperclip:migration-compat online_compatible:\n${additive.sql}` }]);
expect(result.passed).toBe(false);
expect(result.errors).toEqual([expect.stringMatching(/0500_additive\.sql: .*needs a justification/)]);
});
it("fails a CONCURRENTLY migration that the migrator would run inside a transaction", () => {
const concurrent = { fileName: "packages/db/src/migrations/0503_index.sql", sql: `CREATE INDEX CONCURRENTLY IF NOT EXISTS "i" ON "issues" ("a");` };
const result = checkNewMigrationCompatibility([concurrent], { nonTransactionalMigrations: new Set(["0313_private_task_access.sql"]) });
expect(result.passed).toBe(false);
expect(result.errors).toEqual([expect.stringMatching(/0503_index\.sql: uses CONCURRENTLY[\s\S]*Add "0503_index\.sql" to MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS in packages\/db\/src\/client\.ts/)]);
expect(checkNewMigrationCompatibility([concurrent], { nonTransactionalMigrations: new Set(["0503_index.sql"]) }).passed).toBe(true);
// Without the list (a caller that cannot read the migrator) the check is skipped.
expect(checkNewMigrationCompatibility([concurrent]).passed).toBe(true);
});
it("judges new migrations after the ones already on the target branch", () => {
const repair = { fileName: "0504_repair.sql", sql: `CREATE TABLE IF NOT EXISTS "widgets" ("id" uuid);${BP}CREATE INDEX "w" ON "widgets" ("id");` };
expect(checkNewMigrationCompatibility([repair], { priorMigrationSql: [] }).passed).toBe(true);
expect(checkNewMigrationCompatibility([repair], { priorMigrationSql: [`CREATE TABLE "widgets" ("id" uuid);`] }).passed).toBe(false);
expect(checkNewMigrationCompatibility([repair]).passed).toBe(false);
// The second new migration sees the table the first one created.
expect(checkNewMigrationCompatibility([repair, { ...repair, fileName: "0505_again.sql" }], { priorMigrationSql: [] }).errors)
.toEqual([expect.stringContaining("0505_again.sql")]);
});
it("does not fail requires_staging, and prints a notice instead", () => {
const result = checkNewMigrationCompatibility([staged]);
expect(result).toMatchObject({ passed: true, errors: [] });
expect(result.notices).toEqual([expect.stringMatching(/0502_staged\.sql: requires_staging[\s\S]*drop_column \(statement 1\)[\s\S]*does not fail the check yet/)]);
});
});
describe("the shipped migrations", () => {
const dbDir = fileURLToPath(new URL("..", import.meta.url));
const migrationsDir = `${dbDir}src/migrations`;
const journal = JSON.parse(readFileSync(`${migrationsDir}/meta/_journal.json`, "utf8")) as { entries: { idx: number; tag: string }[] };
const files = [...journal.entries]
.sort((left, right) => left.idx - right.idx || left.tag.localeCompare(right.tag))
.map((entry) => `${entry.tag}.sql`);
const classified = classifyMigrationSequence(files.map((file) => readFileSync(`${migrationsDir}/${file}`, "utf8")));
const results = files.map((file, index) => ({ file, result: classified[index] }));
it("classifies every file without an invalid override and with a consistent summary", () => {
expect(results.filter(({ result }) => result.overrideError).map(({ file }) => file)).toEqual([]);
const summary = summarizeMigrationCompatibility(results.map(({ result }) => result));
expect(summary.total).toBe(results.length);
expect(Object.values(summary.byCompatibility).reduce((sum, count) => sum + count, 0)).toBe(results.length);
// The rules must keep recognizing the common additive shapes.
expect(summary.byCompatibility.online_compatible).toBeGreaterThan(results.length * 0.4);
});
it("keeps known hazardous migrations out of online_compatible", () => {
const verdict = (prefix: string) => results.find(({ file }) => file.startsWith(prefix))?.result.compatibility;
// 0224 adds a NOT NULL column without a default to a live table.
expect(verdict("0224_")).toBe("requires_staging");
// 0313 backfills privacy columns in batched loops.
expect(verdict("0313_")).not.toBe("online_compatible");
// 0466 drops a column the previous release wrote.
expect(verdict("0466_")).toBe("requires_staging");
});
it("finds the migrator's non-transactional list, which holds every CONCURRENTLY migration", () => {
const listed = parseNonTransactionalMigrations(readFileSync(`${dbDir}src/client.ts`, "utf8"));
expect(listed?.has("0313_private_task_access.sql")).toBe(true);
expect(results.filter(({ file, result }) => result.usesConcurrently && !listed?.has(file)).map(({ file }) => file)).toEqual([]);
expect(parseNonTransactionalMigrations("const other = new Set([]);")).toBeNull();
});
it("classifies the files named on the command line, after the migrations that precede them", () => {
const run = (...args: string[]) => execFileSync(process.execPath, ["--import", "tsx", "scripts/migration-compatibility.ts", ...args], { cwd: dbDir, encoding: "utf8" });
const one = run("src/migrations/0224_unified_adapter_auth_sessions.sql");
expect(one).toMatch(/^0224_unified_adapter_auth_sessions\.sql: requires_staging\n/);
expect(one).toContain("add_not_null_column_without_default");
expect(one.match(/^\S+\.sql: /gm)).toHaveLength(1);
const two = JSON.parse(run("--json", "src/migrations/0468_busy_wendell_rand.sql", "src/migrations/0466_drop_project_environment.sql")) as { fileName: string; compatibility: string }[];
expect(two.map(({ fileName, compatibility }) => [fileName, compatibility])).toEqual([
["0468_busy_wendell_rand.sql", "online_compatible"],
["0466_drop_project_environment.sql", "requires_staging"],
]);
}, 60_000);
});
+679
View File
@@ -0,0 +1,679 @@
// Classifies whether a migration can be applied while the previous release's
// application code is still reading and writing the same database.
//
// This file is loaded with Node's built-in type stripping by the release and
// policy scripts, which run before dependencies are installed. Keep it free of
// imports and of TypeScript syntax that needs transpiling (enums, namespaces,
// parameter properties).
export type MigrationCompatibility = "online_compatible" | "requires_staging" | "unknown";
export const MIGRATION_COMPATIBILITY_VALUES: readonly MigrationCompatibility[] = [
"online_compatible",
"requires_staging",
"unknown",
];
export const MIGRATION_COMPAT_DIRECTIVE = "paperclip:migration-compat";
export const MIGRATION_COMPAT_MIN_JUSTIFICATION_LENGTH = 10;
type ReasonDefinition = {
readonly compatibility: MigrationCompatibility;
readonly message: string;
};
// Codes that start with `lock_hazard_` mark statements whose only known problem
// is how long they hold a lock that blocks the old release. They classify as
// `unknown`, not as compatible with a warning: a deployer that may not pause
// must not start a statement whose duration grows with table size.
const REASONS = {
create_table: { compatibility: "online_compatible", message: "Creates a table the previous release does not use." },
create_table_if_not_exists: { compatibility: "online_compatible", message: "Creates a table only when it is missing. It may already exist, so later statements on it are judged as changes to an existing table." },
create_temp_table: { compatibility: "online_compatible", message: "Creates a temporary table visible only to the migration session." },
alter_new_table: { compatibility: "online_compatible", message: "Changes a table created earlier in this migration." },
create_index_new_table: { compatibility: "online_compatible", message: "Builds an index on a table created earlier in this migration." },
create_index_concurrently: { compatibility: "online_compatible", message: "Builds a non-unique index without blocking writes." },
add_column: { compatibility: "online_compatible", message: "Adds a column that is nullable or has a constant default." },
add_constraint_not_valid: { compatibility: "online_compatible", message: "Adds a NOT VALID constraint, which does not scan existing rows." },
drop_not_null: { compatibility: "online_compatible", message: "Relaxes a NOT NULL column." },
create_type: { compatibility: "online_compatible", message: "Creates a type the previous release does not use." },
add_enum_value: { compatibility: "online_compatible", message: "Adds a value to an enum type." },
create_sequence: { compatibility: "online_compatible", message: "Creates a sequence the previous release does not use." },
create_extension: { compatibility: "online_compatible", message: "Installs an extension." },
create_function: { compatibility: "online_compatible", message: "Creates a function that did not exist before." },
create_view: { compatibility: "online_compatible", message: "Creates a view that did not exist before." },
create_trigger_new_table: { compatibility: "online_compatible", message: "Creates a trigger on a table created earlier in this migration." },
comment: { compatibility: "online_compatible", message: "Sets a catalog comment." },
create_statistics: { compatibility: "online_compatible", message: "Creates extended planner statistics without blocking reads or writes." },
analyze: { compatibility: "online_compatible", message: "Refreshes planner statistics without blocking reads or writes." },
no_op: { compatibility: "online_compatible", message: "Has no effect on the schema or data." },
seed_new_table: { compatibility: "online_compatible", message: "Writes rows to a table created earlier in this migration." },
drop_table: { compatibility: "requires_staging", message: "Drops a table the previous release may still use." },
drop_column: { compatibility: "requires_staging", message: "Drops a column the previous release may still read or write." },
rename_table: { compatibility: "requires_staging", message: "Renames a table the previous release still addresses by its old name." },
rename_column: { compatibility: "requires_staging", message: "Renames a column the previous release still addresses by its old name." },
alter_column_type: { compatibility: "requires_staging", message: "Changes a column type, which rewrites the table and can reject the previous release's values." },
set_not_null: { compatibility: "requires_staging", message: "Sets NOT NULL on a column the previous release may still leave empty." },
add_not_null_column_without_default: { compatibility: "requires_staging", message: "Adds a NOT NULL column without a default; inserts from the previous release fail." },
drop_default: { compatibility: "requires_staging", message: "Drops a default the previous release may rely on when it inserts." },
add_validated_check: { compatibility: "requires_staging", message: "Adds a validated CHECK constraint that the previous release's writes could violate." },
add_unique_constraint: { compatibility: "requires_staging", message: "Adds a UNIQUE or PRIMARY KEY constraint to an existing table that the previous release's writes could violate." },
create_unique_index_existing_table: { compatibility: "requires_staging", message: "Adds a unique index to an existing table that the previous release's writes could violate." },
drop_type: { compatibility: "requires_staging", message: "Drops a type the previous release may still use." },
rename_type: { compatibility: "requires_staging", message: "Renames a type or enum value the previous release still uses." },
truncate: { compatibility: "requires_staging", message: "Truncates a table the previous release is using." },
delete_rows: { compatibility: "requires_staging", message: "Deletes rows from a table the previous release is using." },
lock_hazard_create_index: { compatibility: "unknown", message: "Builds an index on an existing table without CONCURRENTLY, blocking writes for the whole build." },
lock_hazard_validated_foreign_key: { compatibility: "unknown", message: "Adds a validated foreign key to an existing table, scanning it while writes are blocked. Add it NOT VALID instead." },
lock_hazard_validate_constraint: { compatibility: "unknown", message: "Validates a constraint by scanning the whole table." },
lock_hazard_set_not_null: { compatibility: "unknown", message: "Sets NOT NULL on a column added in this migration, scanning the table under an exclusive lock." },
lock_hazard_table_rewrite: { compatibility: "unknown", message: "Adds a column whose default, identity, or generated expression rewrites the whole table under an exclusive lock." },
lock_hazard_add_column_constraint: { compatibility: "unknown", message: "Adds a column with an inline constraint, which scans or indexes the table under an exclusive lock." },
lock_hazard_data_backfill: { compatibility: "unknown", message: "Rewrites rows of an existing table; duration and row locks grow with table size, and the previous release keeps writing the old shape." },
lock_hazard_copy_into_new_table: { compatibility: "unknown", message: "Copies rows from live tables into a new table; duration grows with the source while this migration's locks are held." },
lock_hazard_lock_table: { compatibility: "unknown", message: "Takes an explicit table lock." },
data_change_existing_table: { compatibility: "unknown", message: "Inserts rows into a table the previous release is using." },
set_default: { compatibility: "unknown", message: "Changes the default the previous release receives when it inserts." },
drop_constraint: { compatibility: "unknown", message: "Drops a constraint; the previous release may rely on it for upserts or cascades." },
drop_index: { compatibility: "unknown", message: "Drops an index; the previous release may rely on it for upserts or for query speed." },
drop_object: { compatibility: "unknown", message: "Drops a database object the previous release may depend on." },
add_constraint_other: { compatibility: "unknown", message: "Adds a constraint the classifier cannot prove safe." },
replace_function: { compatibility: "unknown", message: "Replaces a function, which can change behaviour the previous release depends on." },
replace_view: { compatibility: "unknown", message: "Replaces or materializes a view." },
create_trigger_existing_table: { compatibility: "unknown", message: "Adds a trigger to an existing table, changing what the previous release's writes do." },
create_table_as: { compatibility: "unknown", message: "Creates a table from a query; its duration grows with the source tables." },
do_block_procedural: { compatibility: "unknown", message: "Runs a procedural DO block the classifier does not interpret." },
unrecognized_alter_table_action: { compatibility: "unknown", message: "Uses an ALTER TABLE action the classifier does not recognize." },
unrecognized_statement: { compatibility: "unknown", message: "Uses a statement the classifier does not recognize." },
no_statements: { compatibility: "unknown", message: "Contains no SQL statements." },
invalid_override: { compatibility: "unknown", message: "Has a malformed migration-compat override." },
} as const satisfies Record<string, ReasonDefinition>;
export type MigrationCompatReasonCode = keyof typeof REASONS;
export const MIGRATION_COMPAT_REASON_CODES = Object.keys(REASONS) as MigrationCompatReasonCode[];
export type MigrationCompatReason = {
readonly code: MigrationCompatReasonCode;
readonly compatibility: MigrationCompatibility;
readonly message: string;
/** 1-based positions of the top-level statements that produced this reason. */
readonly statements: readonly number[];
};
export type MigrationCompatOverride = {
readonly compatibility: Exclude<MigrationCompatibility, "unknown">;
readonly justification: string;
};
export type MigrationCompatResult = {
/** The verdict a deployer acts on: the override when one is present and valid. */
readonly compatibility: MigrationCompatibility;
/** The classifier's own verdict, before any override. */
readonly classified: MigrationCompatibility;
readonly reasons: readonly MigrationCompatReason[];
readonly override: MigrationCompatOverride | null;
/** Set when the file carries an override directive that cannot be honoured. */
readonly overrideError: string | null;
/** True when a statement uses CONCURRENTLY, which cannot run in a transaction. */
readonly usesConcurrently: boolean;
};
export type MigrationCompatOptions = {
/**
* Tables created by the migrations that apply before this one. Without it,
* CREATE TABLE IF NOT EXISTS is never proof that a table is new.
*/
readonly existingTables?: ReadonlySet<string>;
};
type Statement = {
/** Lowercase, comments removed, literals masked as '?', dollar bodies as $?$. */
readonly text: string;
readonly dollarBodies: readonly string[];
};
type Context = {
readonly existingTables: ReadonlySet<string> | null;
readonly createdTables: Set<string>;
readonly addedColumns: Set<string>;
};
const IDENT = String.raw`(?:"[^"]+"|[a-z_][a-z0-9_$]*)`;
const QUALIFIED = String.raw`(?:${IDENT}\.)?${IDENT}`;
function dollarTagAt(sql: string, index: number): string | null {
const match = /^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(index, index + 130));
return match ? match[0] : null;
}
/** Splits SQL into statements and normalizes each for keyword matching. */
function parseStatements(sql: string): Statement[] {
const statements: Statement[] = [];
let text = "";
let dollarBodies: string[] = [];
const flush = () => {
const normalized = text.replace(/\s+/g, " ").trim();
if (normalized) statements.push({ text: normalized, dollarBodies });
text = "";
dollarBodies = [];
};
for (let i = 0; i < sql.length;) {
const char = sql[i];
const next = sql[i + 1];
if (char === "-" && next === "-") {
const end = sql.indexOf("\n", i);
const comment = sql.slice(i, end === -1 ? sql.length : end);
// Drizzle's separator is a comment; it always ends a statement.
if (/^-->\s*statement-breakpoint/.test(comment)) flush();
else text += " ";
i = end === -1 ? sql.length : end;
} else if (char === "/" && next === "*") {
let depth = 1;
i += 2;
while (i < sql.length && depth > 0) {
if (sql[i] === "/" && sql[i + 1] === "*") { depth += 1; i += 2; }
else if (sql[i] === "*" && sql[i + 1] === "/") { depth -= 1; i += 2; }
else i += 1;
}
text += " ";
} else if (char === "'") {
// E'...' strings escape quotes with a backslash.
const escapes = /(?:^|[^a-z0-9_$])e$/.test(text);
i += 1;
while (i < sql.length) {
if (escapes && sql[i] === "\\") i += 2;
else if (sql[i] === "'" && sql[i + 1] === "'") i += 2;
else if (sql[i] === "'") break;
else i += 1;
}
i += 1;
text += "'?'";
} else if (char === '"') {
// A doubled quote is a literal quote inside the identifier.
let name = "";
i += 1;
while (i < sql.length) {
if (sql[i] === '"' && sql[i + 1] === '"') { name += '"'; i += 2; }
else if (sql[i] === '"') break;
else { name += sql[i]; i += 1; }
}
i += 1;
// Plain lowercase names are equivalent quoted or not. Any other name
// stays quoted, with its own quotes encoded so it remains one token.
text += /^[a-z_][a-z0-9_$]*$/.test(name) ? name : `"${name.replaceAll("%", "%25").replaceAll('"', "%22")}"`;
} else if (char === "$" && dollarTagAt(sql, i)) {
const tag = dollarTagAt(sql, i)!;
const end = sql.indexOf(tag, i + tag.length);
dollarBodies.push(sql.slice(i + tag.length, end === -1 ? sql.length : end));
text += " $?$ ";
i = end === -1 ? sql.length : end + tag.length;
} else if (char === ";") {
flush();
i += 1;
} else {
text += char.toLowerCase();
i += 1;
}
}
flush();
return statements;
}
function tableName(name: string): string {
return name.replace(/^only /, "").replace(/^public\./, "");
}
/** Returns the index after the parenthesis group that opens at `start`, or -1. */
function closingParen(text: string, start: number): number {
let depth = 0;
for (let i = start; i < text.length; i += 1) {
if (text[i] === "(") depth += 1;
else if (text[i] === ")") {
depth -= 1;
if (depth === 0) return i + 1;
}
}
return -1;
}
function splitTopLevelCommas(text: string): string[] {
const parts: string[] = [];
let depth = 0;
let current = "";
for (const char of text) {
if (char === "(" || char === "[") depth += 1;
else if (char === ")" || char === "]") depth -= 1;
if (char === "," && depth === 0) {
parts.push(current.trim());
current = "";
} else current += char;
}
if (current.trim()) parts.push(current.trim());
return parts;
}
// A default that PostgreSQL stores in the catalog without rewriting the table:
// a literal, or a non-volatile timestamp function.
const CONSTANT_DEFAULT = new RegExp(
String.raw`^\(?(?:'\?'|-?\d+(?:\.\d+)?|true|false|null|now\(\)|current_timestamp|array\[\])\)?` +
String.raw`(?:::[a-z_][a-z0-9_ ]*(?:\(\d+(?:, ?\d+)?\))?(?:\[\])?)*$`,
);
function classifyAddColumn(table: string, column: string, definition: string, context: Context): MigrationCompatReasonCode {
if (/\bgenerated\b|\b(?:small|big)?serial\b/.test(definition)) return "lock_hazard_table_rewrite";
if (/\b(?:references|unique|primary key|check|exclude)\b/.test(definition)) return "lock_hazard_add_column_constraint";
const notNull = /\bnot null\b/.test(definition);
const defaultMatch = /\bdefault (.+?)(?: not null| null)?$/.exec(definition);
if (defaultMatch && !CONSTANT_DEFAULT.test(defaultMatch[1].trim())) return "lock_hazard_table_rewrite";
if (notNull && !defaultMatch) return "add_not_null_column_without_default";
context.addedColumns.add(`${table}.${column}`);
return "add_column";
}
function classifyAlterTableAction(table: string, action: string, context: Context): MigrationCompatReasonCode {
let match: RegExpExecArray | null;
if ((match = new RegExp(String.raw`^add (?:constraint ${IDENT} )?(foreign key|check|unique|primary key|exclude)\b(.*)$`).exec(action))) {
if (/\bnot valid$/.test(match[2])) return "add_constraint_not_valid";
if (match[1] === "foreign key") return "lock_hazard_validated_foreign_key";
if (match[1] === "check") return "add_validated_check";
if (match[1] === "exclude" || /\busing index\b/.test(match[2])) return "add_constraint_other";
return "add_unique_constraint";
}
if (/^add constraint\b/.test(action)) return "add_constraint_other";
if ((match = new RegExp(String.raw`^add (?:column )?(?:if not exists )?(${IDENT}) (.+)$`).exec(action))) {
return classifyAddColumn(table, match[1], match[2], context);
}
if (/^drop constraint\b/.test(action)) return "drop_constraint";
if (/^drop\b/.test(action)) return "drop_column";
if (/^rename constraint\b/.test(action)) return "unrecognized_alter_table_action";
if (/^rename to\b/.test(action)) return "rename_table";
if (/^rename\b/.test(action)) return "rename_column";
if (/^validate constraint\b/.test(action)) return "lock_hazard_validate_constraint";
if ((match = new RegExp(String.raw`^alter (?:column )?(${IDENT}) (.+)$`).exec(action))) {
const change = match[2];
if (/^(?:set data )?type\b/.test(change)) return "alter_column_type";
if (change === "set not null") {
return context.addedColumns.has(`${table}.${match[1]}`) ? "lock_hazard_set_not_null" : "set_not_null";
}
if (change === "drop not null") return "drop_not_null";
if (/^set default\b/.test(change)) return "set_default";
if (change === "drop default") return "drop_default";
}
return "unrecognized_alter_table_action";
}
function classifyDoBlock(statement: Statement, context: Context, nested: boolean): MigrationCompatReasonCode[] {
if (nested || statement.dollarBodies.length !== 1 || !/^do (?:language plpgsql )?\$\?\$(?: language plpgsql)?$/.test(statement.text)) {
return ["do_block_procedural"];
}
const pieces = parseStatements(statement.dollarBodies[0]);
const procedural = pieces.length === 0 || !/^begin\b/.test(pieces[0].text) ||
pieces.some((piece) => /^(?:begin )?(?:declare|loop|for|foreach|while|execute|perform|raise|return|else|elsif)\b/.test(piece.text) ||
/(?:^| )then (?:execute|perform|raise|return|loop|for|foreach|while)\b/.test(piece.text));
if (procedural) {
const codes: MigrationCompatReasonCode[] = ["do_block_procedural"];
if (pieces.some((piece) => /\b(?:update|insert into|delete from)\b/.test(piece.text))) codes.push("lock_hazard_data_backfill");
return codes;
}
// What remains is a guarded sequence: BEGIN <sql>; ... [EXCEPTION WHEN ...
// THEN NULL;] END, where <sql> may sit inside IF [NOT] EXISTS (...) THEN ...
// END IF. A guard only makes a statement conditional, so the statement is
// classified as if it always runs.
const codes: MigrationCompatReasonCode[] = [];
let depth = 0;
let closed = false;
for (let index = 0; index < pieces.length; index += 1) {
let text = pieces[index].text;
if (index === 0) text = text.replace(/^begin\b ?/, "");
if (closed) return ["do_block_procedural"];
while (/^if\b/.test(text)) {
let conditionEnd = -1;
const exists = /^if (?:not )?exists ?\(/.exec(text);
const regclass = /^if to_regclass\('\?'\) is (?:not )?null/.exec(text);
if (exists) conditionEnd = closingParen(text, exists[0].length - 1);
else if (regclass) conditionEnd = regclass[0].length;
if (conditionEnd === -1 || !text.slice(conditionEnd).startsWith(" then ")) return ["do_block_procedural"];
text = text.slice(conditionEnd + " then ".length);
depth += 1;
}
if (text === "" || text === "null") continue;
if (text === "end if") {
depth -= 1;
if (depth < 0) return ["do_block_procedural"];
} else if (text === "end") {
if (depth !== 0 || index !== pieces.length - 1) return ["do_block_procedural"];
closed = true;
} else if (/^exception when [a-z_ ]+ then null$/.test(text)) {
if (depth !== 0) return ["do_block_procedural"];
} else {
codes.push(...classifyStatement({ text, dollarBodies: pieces[index].dollarBodies }, context, true));
}
}
if (!closed || codes.length === 0) return ["do_block_procedural"];
return codes;
}
function classifyStatement(statement: Statement, context: Context, nested = false): MigrationCompatReasonCode[] {
const text = statement.text;
const isNew = (name: string) => context.createdTables.has(tableName(name));
let match: RegExpExecArray | null;
if (/^do\b/.test(text)) return classifyDoBlock(statement, context, nested);
if ((match = new RegExp(String.raw`^create (?:(?:global |local )?(temp|temporary) |unlogged )?table (if not exists )?(${QUALIFIED})(.*)$`).exec(text))) {
const temporary = Boolean(match[1]);
const name = tableName(match[3]);
// Plain CREATE TABLE fails when the table exists, and a temporary table
// belongs to this session. IF NOT EXISTS proves nothing by itself: the
// table is new only when no earlier migration created it.
const provenNew = temporary || !match[2] || (context.existingTables !== null && !context.existingTables.has(name));
let rest = match[4].trim();
if (rest.startsWith("(")) {
const end = closingParen(rest, 0);
rest = end === -1 ? rest : rest.slice(end);
}
if (/\b(?:partition of|inherits|like)\b/.test(rest)) return ["unrecognized_statement"];
if (/\bas (?:select|with|values|table)\b/.test(rest)) {
// A temporary copy only reads; it is still registered as migration-owned.
if (temporary) context.createdTables.add(name);
return ["create_table_as"];
}
if (!provenNew) return ["create_table_if_not_exists"];
context.createdTables.add(name);
return [temporary ? "create_temp_table" : "create_table"];
}
if ((match = new RegExp(String.raw`^create (unique )?index (concurrently )?(?:if not exists )?(?:${IDENT} )?on (?:only )?(${QUALIFIED})[ (]`).exec(text))) {
if (isNew(match[3])) return ["create_index_new_table"];
if (match[1]) return ["create_unique_index_existing_table"];
return [match[2] ? "create_index_concurrently" : "lock_hazard_create_index"];
}
if ((match = new RegExp(String.raw`^alter table (?:if exists )?(?:only )?(${QUALIFIED}) (.+)$`).exec(text))) {
if (isNew(match[1])) return ["alter_new_table"];
const table = tableName(match[1]);
return splitTopLevelCommas(match[2]).map((action) => classifyAlterTableAction(table, action, context));
}
if ((match = new RegExp(String.raw`^drop table (?:if exists )?(.+?)(?: cascade| restrict)?$`).exec(text))) {
return splitTopLevelCommas(match[1]).every(isNew) ? ["alter_new_table"] : ["drop_table"];
}
if (/^drop index\b/.test(text)) return ["drop_index"];
if (/^drop (?:type|domain)\b/.test(text)) return ["drop_type"];
if (/^drop\b/.test(text)) return ["drop_object"];
if (/^truncate\b/.test(text)) return ["truncate"];
if ((match = new RegExp(String.raw`^delete from (?:only )?(${QUALIFIED})\b`).exec(text))) {
return [isNew(match[1]) ? "seed_new_table" : "delete_rows"];
}
if ((match = new RegExp(String.raw`^update (?:only )?(${QUALIFIED})\b`).exec(text))) {
return [isNew(match[1]) ? "seed_new_table" : "lock_hazard_data_backfill"];
}
if ((match = new RegExp(String.raw`^insert into (${QUALIFIED})\b(.*)$`).exec(text))) {
if (!isNew(match[1])) return ["data_change_existing_table"];
// Copying from live tables can run long while this migration's locks are held.
return [/\b(?:select|with|table)\b/.test(match[2]) ? "lock_hazard_copy_into_new_table" : "seed_new_table"];
}
if (/^with\b/.test(text)) {
if (/\bdelete from\b/.test(text)) return ["delete_rows"];
if (/\b(?:update|insert into)\b/.test(text)) return ["lock_hazard_data_backfill"];
return ["unrecognized_statement"];
}
if (/^create type\b/.test(text)) return ["create_type"];
if (new RegExp(String.raw`^alter type ${QUALIFIED} add value\b`).test(text)) return ["add_enum_value"];
if (new RegExp(String.raw`^alter type ${QUALIFIED} rename\b`).test(text)) return ["rename_type"];
if (/^create (?:temp |temporary )?sequence\b/.test(text)) return ["create_sequence"];
if (/^create extension\b/.test(text)) return ["create_extension"];
if (/^create (?:function|procedure)\b/.test(text)) return ["create_function"];
if (/^create or replace (?:function|procedure)\b/.test(text)) return ["replace_function"];
if (/^create (?:temp |temporary )?view\b/.test(text)) return ["create_view"];
if (/^create (?:or replace |materialized )(?:temp |temporary )?view\b/.test(text)) return ["replace_view"];
if ((match = new RegExp(String.raw`^create (?:or replace )?(?:constraint )?trigger ${IDENT} .*? on (?:only )?(${QUALIFIED})\b`).exec(text))) {
return [isNew(match[1]) && !/^create or replace\b/.test(text) ? "create_trigger_new_table" : "create_trigger_existing_table"];
}
if (/^comment on\b/.test(text)) return ["comment"];
if (/^create statistics\b/.test(text)) return ["create_statistics"];
if (/^analyze\b/.test(text)) return ["analyze"];
if (text === "select 1") return ["no_op"];
if (/^lock (?:table )?/.test(text)) return ["lock_hazard_lock_table"];
return ["unrecognized_statement"];
}
function worst(values: readonly MigrationCompatibility[]): MigrationCompatibility {
if (values.includes("requires_staging")) return "requires_staging";
if (values.includes("unknown")) return "unknown";
return "online_compatible";
}
type OverrideParse = { readonly override: MigrationCompatOverride | null; readonly error: string | null };
/**
* Reads the override directive from the migration file's leading comment block.
* Nothing outside the file can supply or change an override.
*/
export function parseMigrationCompatOverride(sql: string): OverrideParse {
const lines = sql.split(/\r?\n/);
const directive = new RegExp(String.raw`^\s*--\s*${MIGRATION_COMPAT_DIRECTIVE}(?![\w-])(.*)$`);
const found: string[] = [];
let leading = true;
let misplaced = false;
for (const line of lines) {
const trimmed = line.trim();
if (leading && trimmed !== "" && (!trimmed.startsWith("--") || trimmed.startsWith("-->"))) leading = false;
if (!line.includes(MIGRATION_COMPAT_DIRECTIVE)) continue;
const match = leading ? directive.exec(line) : null;
if (match) found.push(match[1]);
else misplaced = true;
}
const usage = `Write it as the file's first lines: "-- ${MIGRATION_COMPAT_DIRECTIVE} online_compatible: <why this is safe>" (or requires_staging).`;
if (misplaced) return { override: null, error: `The ${MIGRATION_COMPAT_DIRECTIVE} directive must be a "--" comment in the leading comment block, before any SQL. ${usage}` };
if (found.length === 0) return { override: null, error: null };
if (found.length > 1) return { override: null, error: `The file has more than one ${MIGRATION_COMPAT_DIRECTIVE} directive. ${usage}` };
const match = /^\s+([a-z_]+)\s*(?::\s*(.*))?$/.exec(found[0]);
const compatibility = match?.[1];
if (!match || (compatibility !== "online_compatible" && compatibility !== "requires_staging")) {
return { override: null, error: `The ${MIGRATION_COMPAT_DIRECTIVE} directive must name online_compatible or requires_staging. ${usage}` };
}
const justification = (match[2] ?? "").trim();
if (justification.length < MIGRATION_COMPAT_MIN_JUSTIFICATION_LENGTH) {
return { override: null, error: `The ${MIGRATION_COMPAT_DIRECTIVE} directive needs a justification of at least ${MIGRATION_COMPAT_MIN_JUSTIFICATION_LENGTH} characters after the colon. ${usage}` };
}
return { override: { compatibility, justification }, error: null };
}
/**
* Decides whether a migration can be applied while the previous release's app
* code is still reading and writing the database, without breaking that code
* and without long blocking locks. Anything the classifier does not positively
* recognize is `unknown`.
*/
export function classifyMigrationSql(sql: string, options: MigrationCompatOptions = {}): MigrationCompatResult {
const context: Context = { existingTables: options.existingTables ?? null, createdTables: new Set(), addedColumns: new Set() };
const statementsByCode = new Map<MigrationCompatReasonCode, number[]>();
const record = (code: MigrationCompatReasonCode, position: number) => {
const positions = statementsByCode.get(code) ?? [];
if (!positions.includes(position)) positions.push(position);
statementsByCode.set(code, positions);
};
const statements = parseStatements(sql);
if (statements.length === 0) record("no_statements", 0);
statements.forEach((statement, index) => {
for (const code of classifyStatement(statement, context)) record(code, index + 1);
});
const { override, error } = parseMigrationCompatOverride(sql);
if (error) record("invalid_override", 0);
const reasons: MigrationCompatReason[] = [...statementsByCode].map(([code, positions]) => ({
code,
compatibility: REASONS[code].compatibility,
message: code === "invalid_override" && error ? error : REASONS[code].message,
statements: positions.filter((position) => position > 0),
}));
const classified = worst(reasons.filter((reason) => reason.code !== "invalid_override").map((reason) => reason.compatibility));
return {
compatibility: override ? override.compatibility : worst(reasons.map((reason) => reason.compatibility)),
classified,
reasons,
override,
overrideError: error,
usesConcurrently: statements.some((statement) => /\bconcurrently\b/.test(statement.text)),
};
}
/** Names of the tables a migration creates or renames to, including inside DO blocks. */
export function collectCreatedTables(sql: string): string[] {
const names: string[] = [];
const created = new RegExp(String.raw`\bcreate (?:unlogged )?table (?:if not exists )?(${QUALIFIED})`, "g");
const renamed = new RegExp(String.raw`\brename to (${IDENT})`, "g");
const visit = (statements: Statement[]) => {
for (const statement of statements) {
for (const match of statement.text.matchAll(created)) names.push(tableName(match[1]));
for (const match of statement.text.matchAll(renamed)) names.push(tableName(match[1]));
for (const body of statement.dollarBodies) visit(parseStatements(body));
}
};
visit(parseStatements(sql));
return names;
}
/**
* Classifies migrations in apply order. Each one is judged knowing the tables
* that `priorSql` and the earlier entries of `sqls` created.
*/
export function classifyMigrationSequence(sqls: readonly string[], priorSql: readonly string[] = []): MigrationCompatResult[] {
const existingTables = new Set<string>(priorSql.flatMap(collectCreatedTables));
return sqls.map((sql) => {
const result = classifyMigrationSql(sql, { existingTables });
for (const table of collectCreatedTables(sql)) existingTables.add(table);
return result;
});
}
/**
* Reads, from the source of packages/db/src/client.ts, the migration files the
* migrator runs outside a transaction. Returns null when the list is not found.
*/
export function parseNonTransactionalMigrations(executorSource: string): Set<string> | null {
const match = /MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS\s*=\s*new Set(?:<[^>]*>)?\(\[([\s\S]*?)\]\)/.exec(executorSource);
if (!match) return null;
return new Set([...match[1].matchAll(/"([^"]+\.sql)"/g)].map((entry) => entry[1]));
}
export type MigrationCompatInput = { readonly fileName: string; readonly sql: string };
export type MigrationCompatGuardResult = {
readonly passed: boolean;
/** One entry per migration that blocks the pull request. */
readonly errors: readonly string[];
/** Informational lines: staged migrations and accepted overrides. */
readonly notices: readonly string[];
readonly results: readonly { readonly fileName: string; readonly result: MigrationCompatResult }[];
};
function describeReasons(result: MigrationCompatResult, compatibility: MigrationCompatibility): string[] {
return result.reasons
.filter((reason) => reason.compatibility === compatibility)
.map((reason) => ` - ${reason.code}${reason.statements.length ? ` (statement ${reason.statements.join(", ")})` : ""}: ${reason.message}`);
}
export type MigrationCompatGuardOptions = {
/**
* SQL of every migration that applies before the new ones. Without it,
* CREATE TABLE IF NOT EXISTS never counts as creating a new table.
*/
readonly priorMigrationSql?: readonly string[];
/**
* File names the migrator runs outside a transaction (see
* parseNonTransactionalMigrations). When given, a new migration that uses
* CONCURRENTLY must be in it.
*/
readonly nonTransactionalMigrations?: ReadonlySet<string>;
};
/**
* The pull-request guard for migrations a change adds, in apply order.
* `unknown` without a valid override fails. `requires_staging` passes with a
* notice until deployers enforce staging.
*/
export function checkNewMigrationCompatibility(
migrations: readonly MigrationCompatInput[],
options: MigrationCompatGuardOptions = {},
): MigrationCompatGuardResult {
const errors: string[] = [];
const notices: string[] = [];
const sqls = migrations.map((migration) => migration.sql);
const classified = options.priorMigrationSql
? classifyMigrationSequence(sqls, options.priorMigrationSql)
: sqls.map((sql) => classifyMigrationSql(sql));
const results = migrations.map(({ fileName }, index) => ({ fileName, result: classified[index] }));
for (const { fileName, result } of results) {
const baseName = fileName.split("/").pop() ?? fileName;
if (result.usesConcurrently && options.nonTransactionalMigrations && !options.nonTransactionalMigrations.has(baseName)) {
errors.push([
`${fileName}: uses CONCURRENTLY, which PostgreSQL rejects inside a transaction, and the migrator runs each file in one transaction.`,
` Add "${baseName}" to MIGRATIONS_WITH_TOP_LEVEL_STATEMENTS in packages/db/src/client.ts.`,
" Make every statement in the file idempotent (IF NOT EXISTS), because a failed run starts again from the first statement.",
].join("\n"));
}
if (result.overrideError) {
errors.push(`${fileName}: ${result.overrideError}`);
} else if (result.compatibility === "unknown") {
errors.push([
`${fileName}: cannot tell whether this migration is safe to apply while the previous release is still running.`,
...describeReasons(result, "unknown"),
" Do one of the following:",
" 1. Rewrite it so every statement is recognized as compatible, for example CREATE INDEX CONCURRENTLY,",
" ADD CONSTRAINT ... NOT VALID, or a nullable column or constant default.",
" 2. Split it into an expand migration now and a contract migration in a later release.",
" 3. If you have verified it yourself, make these the first lines of the file:",
` -- ${MIGRATION_COMPAT_DIRECTIVE} online_compatible: <why the previous release keeps working and no lock is held long>`,
` or, if the previous release cannot run against it:`,
` -- ${MIGRATION_COMPAT_DIRECTIVE} requires_staging: <what has to ship first>`,
" See \"Migration compatibility with the running release\" in doc/DATABASE.md.",
].join("\n"));
} else if (result.compatibility === "requires_staging") {
notices.push([
`${fileName}: requires_staging. The previous release cannot keep running against this migration, so a deployer that may not pause will hold it back.`,
...(result.override ? [` - override: ${result.override.justification}`] : describeReasons(result, "requires_staging")),
" This does not fail the check yet. Prefer an expand migration now and the contract migration in a later release.",
].join("\n"));
} else if (result.override) {
notices.push(`${fileName}: online_compatible by override (classifier said ${result.classified}): ${result.override.justification}`);
}
}
return { passed: errors.length === 0, errors, notices, results };
}
export type MigrationCompatSummary = {
readonly total: number;
readonly byCompatibility: Record<MigrationCompatibility, number>;
readonly overrides: number;
/** Migrations per blocking reason code, most frequent first. */
readonly blockingReasons: readonly { readonly code: MigrationCompatReasonCode; readonly compatibility: MigrationCompatibility; readonly migrations: number }[];
};
export function summarizeMigrationCompatibility(results: readonly MigrationCompatResult[]): MigrationCompatSummary {
const byCompatibility: Record<MigrationCompatibility, number> = { online_compatible: 0, requires_staging: 0, unknown: 0 };
const counts = new Map<MigrationCompatReasonCode, number>();
for (const result of results) {
byCompatibility[result.compatibility] += 1;
for (const reason of result.reasons) {
if (reason.compatibility !== "online_compatible") counts.set(reason.code, (counts.get(reason.code) ?? 0) + 1);
}
}
return {
total: results.length,
byCompatibility,
overrides: results.filter((result) => result.override).length,
blockingReasons: [...counts]
.map(([code, migrations]) => ({ code, compatibility: REASONS[code].compatibility, migrations }))
.sort((left, right) => right.migrations - left.migrations || left.code.localeCompare(right.code)),
};
}
+116 -3
View File
@@ -7,6 +7,7 @@ import { copyFileSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { gunzipSync } from "node:zlib";
import { assertMetadata, tarManifest, versionFor } from "./preview-artifacts.mjs";
export const artifactBase = "https://d1p6rlowie26tp.cloudfront.net/cloud-migrators/v1";
@@ -30,10 +31,111 @@ function assertDescriptor(pin, extension) {
}
}
const compatibilityValues = ["online_compatible", "requires_staging", "unknown"];
const migrationsPrefix = "package/dist/migrations/";
/** Regular files under `prefix` in a package archive, by archive path. */
function tarFiles(bytes, prefix) {
const tar = gunzipSync(bytes, { maxOutputLength: 128 * 1024 * 1024 });
const files = new Map();
for (let offset = 0; offset + 512 <= tar.length;) {
const h = tar.subarray(offset, offset + 512);
if (h.every((v) => v === 0)) break;
const field = (start, size) => h.subarray(start, start + size).toString("utf8").split("\0")[0].trim();
const sizeText = field(124, 12);
if (!/^[0-7]+$/.test(sizeText)) throw new Error("Invalid package archive.");
const size = Number.parseInt(sizeText, 8);
if (offset + 512 + size > tar.length) throw new Error("Truncated package archive.");
const name = `${field(345, 155) ? field(345, 155) + "/" : ""}${field(0, 100)}`;
if ([0, 48].includes(h[156]) && name.startsWith(prefix)) {
if (files.has(name)) throw new Error("Duplicate package archive entry.");
files.set(name, tar.subarray(offset + 512, offset + 512 + size));
}
offset += 512 + Math.ceil(size / 512) * 512;
}
return files;
}
/** The journal of the packed migrator, in apply order, with each file's bytes. */
function packedMigrations(dbBytes) {
const files = tarFiles(dbBytes, migrationsPrefix);
const journal = files.get(`${migrationsPrefix}meta/_journal.json`);
if (!journal) throw new Error("Migrator package has no migration journal.");
const entries = JSON.parse(journal.toString("utf8")).entries;
if (!Array.isArray(entries) || entries.length === 0) throw new Error("Migrator package has an empty migration journal.");
// The migrator's apply order: journal idx, then file name. The shipped
// journal repeats an idx, so idx alone does not identify a migration.
return [...entries].sort((left, right) => left.idx - right.idx || (String(left.tag) < String(right.tag) ? -1 : 1)).map(({ idx, tag }) => {
const sql = files.get(`${migrationsPrefix}${tag}.sql`);
if (!sql) throw new Error(`Migrator package is missing migration ${tag}.`);
// The same digest the migrator records in the database's migration history.
return { idx, tag, sql, sqlSha256: createHash("sha256").update(sql).digest("hex") };
});
}
/**
* Whether each packed migration can be applied while the previous release is
* still running. `classify` is classifyMigrationSequence from @paperclipai/db:
* it takes the SQL of every migration in apply order. The overrides it honours
* come only from the packed SQL itself.
*/
export function migrationCompatibility(dbBytes, classify) {
const packed = packedMigrations(dbBytes);
const results = classify(packed.map(({ sql }) => sql.toString("utf8")));
if (!Array.isArray(results) || results.length !== packed.length) throw new Error("The classifier did not return one result per migration.");
return {
version: 1,
migrations: packed.map(({ idx, tag, sqlSha256 }, position) => {
const result = results[position];
return {
idx, tag, sqlSha256,
compatibility: result.compatibility,
classified: result.classified,
override: Boolean(result.override),
...(result.override ? { overrideJustification: result.override.justification } : {}),
reasons: result.reasons.map(({ code, compatibility, message, statements }) => ({ code, compatibility, message, statements: [...statements] })),
};
}),
};
}
function assertMigrationCompatibility(value) {
const fail = () => { throw new Error("Invalid migration compatibility data."); };
if (value?.version !== 1 || !Array.isArray(value.migrations) || value.migrations.length === 0) fail();
const text = (candidate) => typeof candidate === "string" && candidate.length > 0 && candidate.length <= 2000;
value.migrations.forEach((migration, position) => {
const previous = value.migrations[position - 1];
if (!Number.isSafeInteger(migration?.idx) || migration.idx < 0 ||
(previous && (migration.idx < previous.idx || (migration.idx === previous.idx && !(migration.tag > previous.tag)))) ||
typeof migration.tag !== "string" || !/^\d{4}_[A-Za-z0-9_]+$/.test(migration.tag) ||
typeof migration.sqlSha256 !== "string" || !/^[0-9a-f]{64}$/.test(migration.sqlSha256) ||
!compatibilityValues.includes(migration.compatibility) || !compatibilityValues.includes(migration.classified) ||
typeof migration.override !== "boolean" || !Array.isArray(migration.reasons)) fail();
// An override always carries its justification and never yields `unknown`.
if (migration.override ? !text(migration.overrideJustification) || migration.compatibility === "unknown" : migration.overrideJustification !== undefined) fail();
for (const reason of migration.reasons) {
if (typeof reason?.code !== "string" || !/^[a-z_]+$/.test(reason.code) || !compatibilityValues.includes(reason.compatibility) || !text(reason.message) ||
!Array.isArray(reason.statements) || !reason.statements.every((statement) => Number.isSafeInteger(statement) && statement > 0)) fail();
}
});
}
export function assertManifest(manifest, sha) {
if (manifest?.version !== 1 || manifest.sourceSha !== sha || manifest.packageVersion !== versionFor(sha)) throw new Error("Artifact source identity mismatch.");
for (const name of names) assertDescriptor(manifest.packages?.[name], "tgz");
assertDescriptor(manifest.lockfile, "json");
// Additive since the classifier shipped; manifests published earlier lack it.
if (manifest.migrationCompatibility !== undefined) assertMigrationCompatibility(manifest.migrationCompatibility);
}
/** The published classification must describe exactly the migrations in db.tgz. */
function assertCompatibilityMatchesPackage(manifest, dbBytes) {
const published = manifest.migrationCompatibility?.migrations;
const packed = packedMigrations(dbBytes);
if (!published || published.length !== packed.length || packed.some(({ idx, tag, sqlSha256 }, position) =>
published[position].idx !== idx || published[position].tag !== tag || published[position].sqlSha256 !== sqlSha256)) {
throw new Error("Migration compatibility data does not match the packed migrations.");
}
}
export function assertLockfile(lock, manifest) {
@@ -61,8 +163,9 @@ export function assertLockfile(lock, manifest) {
}
}
export function buildBundle(directory, sha, { exec = execFileSync } = {}) {
export function buildBundle(directory, sha, { exec = execFileSync, classify } = {}) {
versionFor(sha);
if (typeof classify !== "function") throw new Error("buildBundle needs the migration compatibility classifier.");
directory = path.resolve(directory);
const packages = {};
for (const name of names) {
@@ -83,8 +186,11 @@ export function buildBundle(directory, sha, { exec = execFileSync } = {}) {
lock.packages[""].dependencies = { "@paperclipai/db": versionFor(sha) };
for (const name of names) lock.packages[`node_modules/@paperclipai/${name}`].resolved = packages[name].url;
const lockBytes = Buffer.from(JSON.stringify(lock) + "\n");
const manifest = { version: 1, sourceSha: sha, packageVersion: versionFor(sha), packages, lockfile: descriptor(lockBytes, "json") };
const dbBytes = readFileSync(path.join(directory, "db.tgz"));
const manifest = { version: 1, sourceSha: sha, packageVersion: versionFor(sha), packages, lockfile: descriptor(lockBytes, "json"),
migrationCompatibility: migrationCompatibility(dbBytes, classify) };
assertManifest(manifest, sha);
assertCompatibilityMatchesPackage(manifest, dbBytes);
assertLockfile(lock, manifest);
writeFileSync(path.join(directory, "package-lock.json"), lockBytes);
writeFileSync(path.join(directory, "manifest.json"), JSON.stringify(manifest) + "\n");
@@ -103,6 +209,7 @@ export function validateBundle(directory, sha) {
const bytes = readFileSync(path.join(directory, `${name}.tgz`));
verifyBytes(bytes, manifest.packages[name]);
assertMetadata(tarManifest(bytes), `@paperclipai/${name}`, sha);
if (name === "db") assertCompatibilityMatchesPackage(manifest, bytes);
}
const bytes = readFileSync(path.join(directory, "package-lock.json"));
verifyBytes(bytes, manifest.lockfile);
@@ -160,6 +267,7 @@ export async function verifyPublished(sha, fetchImpl = fetch, { verifyProvenance
const bytes = await download(manifest.packages[name].url, fetchImpl);
verifyBytes(bytes, manifest.packages[name]);
assertMetadata(tarManifest(bytes), `@paperclipai/${name}`, sha);
if (name === "db" && manifest.migrationCompatibility !== undefined) assertCompatibilityMatchesPackage(manifest, bytes);
}));
const lock = await download(manifest.lockfile.url, fetchImpl);
verifyBytes(lock, manifest.lockfile);
@@ -200,7 +308,12 @@ export async function publishBundle(directory, sha, { exec = execFileSync, fetch
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const [command, directory, sha] = process.argv.slice(2);
try {
if (command === "build") buildBundle(directory, sha);
if (command === "build") {
// Type-stripped by Node; the module has no imports, so the build job
// needs nothing beyond the checkout.
const { classifyMigrationSequence } = await import(new URL("../packages/db/src/migration-compatibility.ts", import.meta.url).href);
buildBundle(directory, sha, { classify: classifyMigrationSequence });
}
else if (command === "validate") validateBundle(directory, sha);
else if (command === "verify-install") verifyInstall(directory, sha);
else if (command === "publish") await publishBundle(directory, sha);
+95 -14
View File
@@ -7,25 +7,45 @@ import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { gzipSync } from "node:zlib";
import { artifactBase, assertManifest, assertLockfile, buildBundle, descriptor, validateBundle, verifyPublished, publishBundle } from "./cloud-migrator-artifacts.mjs";
import { createHash } from "node:crypto";
import { artifactBase, assertManifest, assertLockfile, buildBundle, descriptor, migrationCompatibility, validateBundle, verifyPublished, publishBundle } from "./cloud-migrator-artifacts.mjs";
import { previewManifest, versionFor } from "./preview-artifacts.mjs";
import { classifyMigrationSequence as classify } from "../packages/db/src/migration-compatibility.ts";
const sha = "a".repeat(40);
const migrations = {
"0000_first": 'CREATE TABLE IF NOT EXISTS "widgets" ("id" uuid PRIMARY KEY NOT NULL);\n--> statement-breakpoint\nCREATE INDEX "widgets_idx" ON "widgets" ("id");\n',
// IF NOT EXISTS does not make a table from an earlier migration new again.
"0001_second": 'CREATE TABLE IF NOT EXISTS "widgets" ("id" uuid);\n--> statement-breakpoint\nCREATE INDEX "widgets_again_idx" ON "widgets" ("id");\n',
"0002_third": '-- paperclip:migration-compat online_compatible: no release since 0.2 reads issues.legacy\nALTER TABLE "issues" DROP COLUMN "legacy";\n',
};
function tarEntry(name, content) {
const bytes = Buffer.from(content);
const header = Buffer.alloc(512);
header.write(name); header.write(bytes.length.toString(8).padStart(11, "0"), 124, 11); header[156] = 48;
// A real tar header, so npm can install this fixture as well as inspect it.
header.fill(32, 148, 156);
const sum = header.reduce((a, b) => a + b, 0);
header.write(sum.toString(8).padStart(6, "0") + "\0 ", 148, 8);
const padded = Buffer.alloc(Math.ceil(bytes.length / 512) * 512); bytes.copy(padded);
return Buffer.concat([header, padded]);
}
function dbArchive(files = migrations) {
const journal = { version: "7", dialect: "postgresql", entries: Object.keys(files).map((tag, idx) => ({ idx, version: "7", when: idx, tag, breakpoints: true })) };
return gzipSync(Buffer.concat([
tarEntry("package/package.json", JSON.stringify(previewManifest({ name: "@paperclipai/db", dependencies: {} }, sha))),
tarEntry("package/dist/migrations/meta/_journal.json", JSON.stringify(journal)),
...Object.entries(files).map(([tag, sql]) => tarEntry(`package/dist/migrations/${tag}.sql`, sql)),
Buffer.alloc(1024),
]));
}
function fixture(t) {
const dir = mkdtempSync(path.join(os.tmpdir(), "migrator-artifact-test-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
for (const name of ["db", "shared"]) {
const bytes = Buffer.from(JSON.stringify(previewManifest({ name: `@paperclipai/${name}`, dependencies: {} }, sha)));
const header = Buffer.alloc(512);
header.write("package/package.json"); header.write(bytes.length.toString(8).padStart(11, "0"), 124, 11); header[156] = 48;
// A real tar header, so npm can install this fixture as well as inspect it.
header.fill(32, 148, 156);
const sum = header.reduce((a, b) => a + b, 0);
header.write(sum.toString(8).padStart(6, "0") + "\0 ", 148, 8);
const padded = Buffer.alloc(Math.ceil(bytes.length / 512) * 512); bytes.copy(padded);
writeFileSync(path.join(dir, `${name}.tgz`), gzipSync(Buffer.concat([header, padded, Buffer.alloc(1024)])));
}
const manifest = buildBundle(dir, sha, { exec: (cmd, args, options) => {
writeFileSync(path.join(dir, "db.tgz"), dbArchive());
writeFileSync(path.join(dir, "shared.tgz"), gzipSync(Buffer.concat([
tarEntry("package/package.json", JSON.stringify(previewManifest({ name: "@paperclipai/shared", dependencies: {} }, sha))), Buffer.alloc(1024)])));
const manifest = buildBundle(dir, sha, { classify, exec: (cmd, args, options) => {
assert.equal(cmd, "npm"); assert.ok(args.includes("--ignore-scripts"));
const localRoot = JSON.parse(readFileSync(path.join(options.cwd, "package.json")));
assert.deepEqual(localRoot.dependencies, { "@paperclipai/db": "file:db.tgz", "@paperclipai/shared": "file:shared.tgz" });
@@ -53,6 +73,67 @@ test("bundle pins the exact source pair and complete lockfile without new npm lo
assert.deepEqual(await verifyPublished(sha, fetchImpl), manifest);
});
test("manifest publishes how each packed migration behaves under the previous release", (t) => {
const { manifest } = fixture(t);
const digest = (tag) => createHash("sha256").update(migrations[tag]).digest("hex");
assert.deepEqual(manifest.migrationCompatibility.version, 1);
assert.deepEqual(manifest.migrationCompatibility.migrations.map(({ reasons, ...entry }) => ({ ...entry, reasons: reasons.map((reason) => reason.code) })), [
{ idx: 0, tag: "0000_first", sqlSha256: digest("0000_first"), compatibility: "online_compatible", classified: "online_compatible", override: false,
reasons: ["create_table", "create_index_new_table"] },
{ idx: 1, tag: "0001_second", sqlSha256: digest("0001_second"), compatibility: "unknown", classified: "unknown", override: false,
reasons: ["create_table_if_not_exists", "lock_hazard_create_index"] },
{ idx: 2, tag: "0002_third", sqlSha256: digest("0002_third"), compatibility: "online_compatible", classified: "requires_staging", override: true,
overrideJustification: "no release since 0.2 reads issues.legacy", reasons: ["drop_column"] },
]);
const reason = manifest.migrationCompatibility.migrations[1].reasons[1];
assert.deepEqual(Object.keys(reason), ["code", "compatibility", "message", "statements"]);
assert.deepEqual([reason.compatibility, reason.statements], ["unknown", [2]]);
// Everything a consumer pinned before this field existed is unchanged.
assert.deepEqual(Object.keys(manifest), ["version", "sourceSha", "packageVersion", "packages", "lockfile", "migrationCompatibility"]);
assert.equal(manifest.version, 1);
});
test("migration compatibility is optional for earlier manifests and fails closed when malformed or stale", async (t) => {
const { dir, manifest, files, fetchImpl } = fixture(t);
const earlier = structuredClone(manifest); delete earlier.migrationCompatibility;
assert.doesNotThrow(() => assertManifest(earlier, sha));
for (const mutate of [
(c) => { c.version = 2; },
(c) => { c.migrations = []; },
(c) => { c.migrations[0].compatibility = "safe"; },
(c) => { c.migrations[0].classified = undefined; },
(c) => { c.migrations[0].sqlSha256 = "abc"; },
(c) => { c.migrations[2].idx = 0; },
(c) => { c.migrations[1].tag = c.migrations[0].tag; c.migrations[1].idx = 0; },
(c) => { c.migrations[0].override = true; },
(c) => { c.migrations[2].overrideJustification = ""; },
(c) => { c.migrations[2].compatibility = "unknown"; },
(c) => { c.migrations[0].overrideJustification = "without an override"; },
(c) => { c.migrations[1].reasons[0].statements = [0]; },
(c) => { c.migrations[1].reasons[1].compatibility = "fine"; },
(c) => { c.migrations[1].reasons[0].code = 7; },
]) {
const bad = structuredClone(manifest); mutate(bad.migrationCompatibility);
assert.throws(() => assertManifest(bad, sha), /Invalid migration compatibility data/);
}
assert.throws(() => buildBundle(dir, sha), /classifier/);
// A well-formed classification of different SQL must not be published or accepted.
const stale = structuredClone(manifest);
stale.migrationCompatibility = migrationCompatibility(dbArchive({ ...migrations, "0001_second": 'DROP TABLE "issues";\n' }), classify);
assertManifest(stale, sha);
files.set(`${artifactBase}/${sha}/manifest.json`, Buffer.from(JSON.stringify(stale)));
await assert.rejects(verifyPublished(sha, fetchImpl), /does not match the packed migrations/);
writeFileSync(path.join(dir, "manifest.json"), JSON.stringify(stale));
assert.throws(() => validateBundle(dir, sha), /does not match the packed migrations/);
writeFileSync(path.join(dir, "manifest.json"), JSON.stringify(earlier));
assert.throws(() => validateBundle(dir, sha), /does not match the packed migrations/);
assert.throws(() => migrationCompatibility(dbArchive({}), classify), /empty migration journal/);
assert.throws(() => migrationCompatibility(dbArchive(), () => []), /one result per migration/);
// The shipped journal repeats idx 178; order then follows the file name, as in the migrator.
const repeated = structuredClone(manifest); repeated.migrationCompatibility.migrations[1].idx = 0;
assert.doesNotThrow(() => assertManifest(repeated, sha));
});
test("source identity, content hashes, size, and origin fail closed", async (t) => {
const { dir, manifest, files, fetchImpl } = fixture(t);
for (const mutate of [
@@ -109,7 +190,7 @@ test("download failures and oversized objects never count as available", async (
test("real npm ci installs the new pair from pinned archives with an empty cache", async (t) => {
const { dir } = fixture(t);
// Real npm resolution uses local archives; neither package version exists on npm.
const manifest = buildBundle(dir, sha);
const manifest = buildBundle(dir, sha, { classify });
const lock = JSON.parse(readFileSync(path.join(dir, "package-lock.json")));
const requests = [];
const server = createServer((req, res) => {