Skip to content

Commit 7faf502

Browse files
committed
fix(artifacts): tighten adopt shape for nullability and CHECKs
Empty-ledger adopt only checked column presence and types, so a schema missing tenant_id NOT NULL or the 0003 version/size CHECKs could still be stamped. Validate those invariants too, document the bar, and clean up migration tests that left half-applied state for later runs.
1 parent 371bc29 commit 7faf502

5 files changed

Lines changed: 208 additions & 66 deletions

File tree

‎ARCHITECTURE.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,10 @@ every boot of every replica.
213213
- A later ledgered migration sets `artifact.tenant_id NOT NULL` and adds the
214214
version/size CHECKs. If null-tenant rows still exist, that migration raises
215215
before altering the column so the operator can clean them up first.
216+
- Empty ledger + pre-existing package objects fails closed
217+
(`MigrationAdoptError`). `{ adopt: true }` records checksums without re-DDL
218+
only after shape validation: tables, column types, required nullability, and
219+
the named CHECK constraints. Column presence alone is not enough.
216220

217221
**The package owns its own Postgres schema.** Every table, index and the ledger
218222
live in `artifacts`, created by the runner and qualified in every

‎packages/artifacts/README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,12 @@ install multi-table triggers for that alignment (see ARCHITECTURE.md).
226226
replica: concurrent cold starts serialize on a transaction-scoped advisory lock, and a
227227
re-run prints nothing.
228228

229+
If the ledger is empty but package tables already exist (restored dump, dropped
230+
ledger), the runner fails closed with `MigrationAdoptError`. Operators who have
231+
confirmed the live schema may pass `{ adopt: true }` to record checksums without
232+
re-running DDL. Adopt validates tables, column types, `artifact.tenant_id NOT NULL`,
233+
and the named version/size CHECK constraints — not a columns-only glance.
234+
229235
Event timestamps are `timestamptz` so list keyset cursors and date filters stay
230236
stable under a non-UTC session `TimeZone`. A ledgered retype migration converts
231237
legacy zoneless columns with `USING col AT TIME ZONE 'UTC'` (existing walls were

‎packages/artifacts/src/artifacts.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ describe("create", () => {
7474
files: { "index.html": "<p>hi</p>" },
7575
});
7676
});
77-
test("rejects oversize title and content before insert", async () => {
77+
test("rejects oversize title and content before insert", async () => {
7878
const db = await testDb();
7979
await expect(
8080
db.transaction((tx) =>

‎packages/artifacts/src/migrations.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,68 @@ describe("migrations", () => {
415415
expect(again.length).toBe(MIGRATIONS.length);
416416
});
417417

418+
test("empty ledger + columns without 0003 CHECKs refuses adopt", async () => {
419+
assertDestructiveArtifactTestsAllowed(DATABASE_URL);
420+
await db.execute(sql`DROP SCHEMA IF EXISTS ${sql.identifier(SCHEMA)} CASCADE`);
421+
// Honest migrate, drop ledger, then strip a row-local CHECK so columns and
422+
// types still match but 0003 invariants are gone — adopt must not stamp.
423+
await runArtifactMigrations(db);
424+
await db.execute(
425+
sql`DROP TABLE ${sql.identifier(SCHEMA)}.${sql.identifier(LEDGER)}`,
426+
);
427+
await db.execute(sql`
428+
ALTER TABLE ${sql.identifier(SCHEMA)}."artifact"
429+
DROP CONSTRAINT "artifact_version_gte_1"
430+
`);
431+
432+
await expect(
433+
runArtifactMigrations(db, { adopt: true }),
434+
).rejects.toThrow(MigrationAdoptError);
435+
await expect(
436+
runArtifactMigrations(db, { adopt: true }),
437+
).rejects.toThrow(/artifact_version_gte_1/);
438+
439+
const ledgerTables = await db.execute<{ n: number }>(sql`
440+
SELECT count(*)::int AS n FROM information_schema.tables
441+
WHERE table_schema = ${SCHEMA} AND table_name = ${LEDGER}
442+
`);
443+
expect(ledgerTables[0]!.n).toBe(0);
444+
445+
// Later tests call runArtifactMigrations without a reset; restore a
446+
// fully-ledgered schema so they are not stranded on an empty ledger.
447+
await db.execute(sql`DROP SCHEMA IF EXISTS ${sql.identifier(SCHEMA)} CASCADE`);
448+
await runArtifactMigrations(db);
449+
});
450+
451+
test("empty ledger + nullable tenant_id refuses adopt", async () => {
452+
assertDestructiveArtifactTestsAllowed(DATABASE_URL);
453+
await db.execute(sql`DROP SCHEMA IF EXISTS ${sql.identifier(SCHEMA)} CASCADE`);
454+
await runArtifactMigrations(db);
455+
await db.execute(
456+
sql`DROP TABLE ${sql.identifier(SCHEMA)}.${sql.identifier(LEDGER)}`,
457+
);
458+
await db.execute(sql`
459+
ALTER TABLE ${sql.identifier(SCHEMA)}."artifact"
460+
ALTER COLUMN "tenant_id" DROP NOT NULL
461+
`);
462+
463+
await expect(
464+
runArtifactMigrations(db, { adopt: true }),
465+
).rejects.toThrow(MigrationAdoptError);
466+
await expect(
467+
runArtifactMigrations(db, { adopt: true }),
468+
).rejects.toThrow(/tenant_id.*NOT NULL/i);
469+
470+
const ledgerTables = await db.execute<{ n: number }>(sql`
471+
SELECT count(*)::int AS n FROM information_schema.tables
472+
WHERE table_schema = ${SCHEMA} AND table_name = ${LEDGER}
473+
`);
474+
expect(ledgerTables[0]!.n).toBe(0);
475+
476+
await db.execute(sql`DROP SCHEMA IF EXISTS ${sql.identifier(SCHEMA)} CASCADE`);
477+
await runArtifactMigrations(db);
478+
});
479+
418480
/**
419481
* DB invariants: tenant_id is required on every artifact row; version and size
420482
* stay non-negative. Principal↔tenant alignment is host-owned (resolvePrincipal)
@@ -567,5 +629,10 @@ describe("migrations", () => {
567629
"0001_artifacts",
568630
"0002_timestamptz",
569631
]);
632+
633+
// Full suite (and re-runs of this file) must not inherit null-tenant rows
634+
// and a half-applied ledger.
635+
await db.execute(sql`DROP SCHEMA IF EXISTS ${sql.identifier(SCHEMA)} CASCADE`);
636+
await runArtifactMigrations(db);
570637
});
571638
});

‎packages/artifacts/src/migrations.ts‎

Lines changed: 130 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -249,65 +249,87 @@ export class MigrationAdoptError extends Error {
249249
* `adopt` is an operator escape hatch for the rare case where package tables
250250
* already exist (restored dump, manual DDL, ledger dropped) and the operator
251251
* has confirmed they match the expected shape. It is never set by default and
252-
* must not be passed on ordinary boots.
252+
* must not be passed on ordinary boots. Adopt validates tables, column types,
253+
* required nullability (`artifact.tenant_id`), and the named CHECK constraints
254+
* from `0003_schema_invariants` before writing ledger rows — it does not
255+
* re-run DDL.
253256
*/
254257
export type RunArtifactMigrationsOptions = {
255258
adopt?: boolean;
256259
};
257260

258261
/** Package-owned tables (excluding the ledger) and the columns each must have. */
259-
const EXPECTED_OWNED_SHAPE: Readonly<
260-
Record<string, readonly { name: string; udt: string }[]>
261-
> = {
262-
artifact: [
263-
{ name: "id", udt: "text" },
264-
{ name: "tenant_id", udt: "text" },
265-
{ name: "principal_id", udt: "text" },
266-
{ name: "owner_principal_id", udt: "text" },
267-
{ name: "kind", udt: "text" },
268-
{ name: "title", udt: "text" },
269-
{ name: "content", udt: "text" },
270-
{ name: "source", udt: "jsonb" },
271-
{ name: "version", udt: "int4" },
272-
{ name: "archived_at", udt: "timestamptz" },
273-
{ name: "created_at", udt: "timestamptz" },
274-
{ name: "updated_at", udt: "timestamptz" },
275-
],
276-
artifact_version: [
277-
{ name: "id", udt: "text" },
278-
{ name: "artifact_id", udt: "text" },
279-
{ name: "version", udt: "int4" },
280-
{ name: "title", udt: "text" },
281-
{ name: "content", udt: "text" },
282-
{ name: "author_id", udt: "text" },
283-
{ name: "created_at", udt: "timestamptz" },
284-
],
285-
upload: [
286-
{ name: "id", udt: "text" },
287-
{ name: "tenant_id", udt: "text" },
288-
{ name: "principal_id", udt: "text" },
289-
{ name: "filename", udt: "text" },
290-
{ name: "mime_type", udt: "text" },
291-
{ name: "content", udt: "bytea" },
292-
{ name: "size", udt: "int4" },
293-
{ name: "created_at", udt: "timestamptz" },
294-
],
295-
mail_attachment_ref: [
296-
{ name: "id", udt: "text" },
297-
{ name: "tenant_id", udt: "text" },
298-
{ name: "principal_id", udt: "text" },
299-
{ name: "instance_id", udt: "text" },
300-
{ name: "mail_id", udt: "text" },
301-
{ name: "artifact_id", udt: "text" },
302-
{ name: "name", udt: "text" },
303-
{ name: "mime_type", udt: "text" },
304-
{ name: "size", udt: "int4" },
305-
{ name: "created_at", udt: "timestamptz" },
306-
],
262+
type ExpectedColumn = {
263+
name: string;
264+
udt: string;
265+
/** When set, live `is_nullable` must be `NO`. */
266+
notNull?: true;
307267
};
308268

309-
async function listOwnedTables(tx: ArtifactTx): Promise<string[]> {
269+
const EXPECTED_OWNED_SHAPE: Readonly<Record<string, readonly ExpectedColumn[]>> =
270+
{
271+
artifact: [
272+
{ name: "id", udt: "text" },
273+
{ name: "tenant_id", udt: "text", notNull: true },
274+
{ name: "principal_id", udt: "text" },
275+
{ name: "owner_principal_id", udt: "text" },
276+
{ name: "kind", udt: "text" },
277+
{ name: "title", udt: "text" },
278+
{ name: "content", udt: "text" },
279+
{ name: "source", udt: "jsonb" },
280+
{ name: "version", udt: "int4" },
281+
{ name: "archived_at", udt: "timestamptz" },
282+
{ name: "created_at", udt: "timestamptz" },
283+
{ name: "updated_at", udt: "timestamptz" },
284+
],
285+
artifact_version: [
286+
{ name: "id", udt: "text" },
287+
{ name: "artifact_id", udt: "text" },
288+
{ name: "version", udt: "int4" },
289+
{ name: "title", udt: "text" },
290+
{ name: "content", udt: "text" },
291+
{ name: "author_id", udt: "text" },
292+
{ name: "created_at", udt: "timestamptz" },
293+
],
294+
upload: [
295+
{ name: "id", udt: "text" },
296+
{ name: "tenant_id", udt: "text" },
297+
{ name: "principal_id", udt: "text" },
298+
{ name: "filename", udt: "text" },
299+
{ name: "mime_type", udt: "text" },
300+
{ name: "content", udt: "bytea" },
301+
{ name: "size", udt: "int4" },
302+
{ name: "created_at", udt: "timestamptz" },
303+
],
304+
mail_attachment_ref: [
305+
{ name: "id", udt: "text" },
306+
{ name: "tenant_id", udt: "text" },
307+
{ name: "principal_id", udt: "text" },
308+
{ name: "instance_id", udt: "text" },
309+
{ name: "mail_id", udt: "text" },
310+
{ name: "artifact_id", udt: "text" },
311+
{ name: "name", udt: "text" },
312+
{ name: "mime_type", udt: "text" },
313+
{ name: "size", udt: "int4" },
314+
{ name: "created_at", udt: "timestamptz" },
315+
],
316+
};
310317

318+
/**
319+
* Row-local CHECKs applied by `0003_schema_invariants`. Adopt must see these
320+
* names so stamping 0003 without the constraints cannot pass shape validation.
321+
*/
322+
const EXPECTED_CHECK_CONSTRAINTS: ReadonlyArray<{
323+
table: string;
324+
name: string;
325+
}> = [
326+
{ table: "artifact", name: "artifact_version_gte_1" },
327+
{ table: "artifact_version", name: "artifact_version_version_gte_1" },
328+
{ table: "upload", name: "upload_size_gte_0" },
329+
{ table: "mail_attachment_ref", name: "mail_attachment_ref_size_gte_0" },
330+
];
331+
332+
async function listOwnedTables(tx: ArtifactTx): Promise<string[]> {
311333
const rows = await tx.execute<{ table_name: string }>(sql`
312334
SELECT table_name FROM information_schema.tables
313335
WHERE table_schema = ${ARTIFACTS_SCHEMA}
@@ -319,11 +341,11 @@ async function listOwnedTables(tx: ArtifactTx): Promise<string[]> {
319341
}
320342

321343
/**
322-
* Compare live catalogue columns against the shape the migrations create.
323-
* Returns a human-readable mismatch list (empty when compatible).
344+
* Compare live catalogue against the shape the migrations create: tables,
345+
* column types, required nullability, and named CHECK constraints. Returns a
346+
* human-readable mismatch list (empty when compatible).
324347
*/
325348
async function shapeMismatches(tx: ArtifactTx): Promise<string[]> {
326-
327349
const owned = await listOwnedTables(tx);
328350
const expectedTables = Object.keys(EXPECTED_OWNED_SHAPE).sort();
329351
const mismatches: string[] = [];
@@ -347,38 +369,80 @@ async function shapeMismatches(tx: ArtifactTx): Promise<string[]> {
347369
table_name: string;
348370
column_name: string;
349371
udt_name: string;
372+
is_nullable: string;
350373
}>(sql`
351-
SELECT table_name, column_name, udt_name
374+
SELECT table_name, column_name, udt_name, is_nullable
352375
FROM information_schema.columns
353376
WHERE table_schema = ${ARTIFACTS_SCHEMA}
354377
AND table_name <> ${LEDGER_TABLE}
355378
`);
356379

357-
const byTable = new Map<string, Map<string, string>>();
380+
const byTable = new Map<string, Map<string, { udt: string; nullable: string }>>();
358381
for (const col of columns) {
359382
let cols = byTable.get(col.table_name);
360383
if (!cols) {
361384
cols = new Map();
362385
byTable.set(col.table_name, cols);
363386
}
364-
cols.set(col.column_name, col.udt_name);
387+
cols.set(col.column_name, {
388+
udt: col.udt_name,
389+
nullable: col.is_nullable,
390+
});
365391
}
366392

367393
for (const table of expectedTables) {
368394
const expectedCols = EXPECTED_OWNED_SHAPE[table]!;
369395
const live = byTable.get(table) ?? new Map();
370-
for (const { name, udt } of expectedCols) {
371-
const liveUdt = live.get(name);
372-
if (liveUdt === undefined) {
373-
mismatches.push(`missing column ${ARTIFACTS_SCHEMA}.${table}.${name}`);
374-
} else if (liveUdt !== udt) {
396+
for (const expected of expectedCols) {
397+
const liveCol = live.get(expected.name);
398+
if (liveCol === undefined) {
375399
mismatches.push(
376-
`column ${ARTIFACTS_SCHEMA}.${table}.${name} has type ${liveUdt}, expected ${udt}`,
400+
`missing column ${ARTIFACTS_SCHEMA}.${table}.${expected.name}`,
401+
);
402+
} else if (liveCol.udt !== expected.udt) {
403+
mismatches.push(
404+
`column ${ARTIFACTS_SCHEMA}.${table}.${expected.name} has type ${liveCol.udt}, expected ${expected.udt}`,
405+
);
406+
} else if (expected.notNull && liveCol.nullable !== "NO") {
407+
mismatches.push(
408+
`column ${ARTIFACTS_SCHEMA}.${table}.${expected.name} is nullable, expected NOT NULL`,
377409
);
378410
}
379411
}
380412
}
381413

414+
if (mismatches.length > 0) return mismatches;
415+
416+
// Named CHECKs from 0003 — without these, adopt would stamp the ledger over a
417+
// schema that never gained the row-local invariants.
418+
const checks = await tx.execute<{ table_name: string; constraint_name: string }>(
419+
sql`
420+
SELECT c.relname AS table_name, con.conname AS constraint_name
421+
FROM pg_catalog.pg_constraint con
422+
JOIN pg_catalog.pg_class c ON c.oid = con.conrelid
423+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
424+
WHERE n.nspname = ${ARTIFACTS_SCHEMA}
425+
AND con.contype = 'c'
426+
`,
427+
);
428+
const checkByTable = new Map<string, Set<string>>();
429+
for (const row of checks) {
430+
let names = checkByTable.get(row.table_name);
431+
if (!names) {
432+
names = new Set();
433+
checkByTable.set(row.table_name, names);
434+
}
435+
names.add(row.constraint_name);
436+
}
437+
for (const { table, name } of EXPECTED_CHECK_CONSTRAINTS) {
438+
const live = checkByTable.get(table);
439+
if (!live?.has(name)) {
440+
mismatches.push(
441+
`missing CHECK constraint ${name} on ${ARTIFACTS_SCHEMA}.${table}`,
442+
);
443+
}
444+
}
445+
382446
return mismatches;
383447
}
384448

@@ -394,9 +458,10 @@ async function shapeMismatches(tx: ArtifactTx): Promise<string[]> {
394458
*
395459
* When the ledger is empty but package-owned objects already exist, the runner
396460
* fails closed with {@link MigrationAdoptError} unless `{ adopt: true }` is
397-
* passed and the live shape matches what the migrations would create. That
398-
* path records checksums without re-running DDL. Ledger checksum drift still
399-
* throws {@link MigrationChecksumError}.
461+
* passed and the live shape matches what the migrations would create (tables,
462+
* column types, required nullability, and named CHECK constraints). That path
463+
* records checksums without re-running DDL. Ledger checksum drift still throws
464+
* {@link MigrationChecksumError}.
400465
*/
401466
export async function runArtifactMigrations(
402467
db: ArtifactDb,

0 commit comments

Comments
 (0)