@@ -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 */
254257export 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 */
325348async 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 */
401466export async function runArtifactMigrations (
402467 db : ArtifactDb ,
0 commit comments