@defrex/autobuild-postgres-store 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/schema.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  assertAuthSchema,
9
9
  } from './auth-schema'
10
10
 
11
- export const SCHEMA_VERSION = 6
11
+ export const SCHEMA_VERSION = 9
12
12
  export const MIGRATE_COMMAND = 'bun run postgres:migrate (from a pinned Autobuild release checkout)'
13
13
 
14
14
  /** The frozen v3 DDL, kept verbatim so a deployed v3 marker's checksum can be
@@ -376,6 +376,296 @@ CREATE TABLE IF NOT EXISTS stream_chunks (
376
376
  );`.trim()
377
377
  export const SCHEMA_V5_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_V5_DDL).digest('hex')
378
378
 
379
+ /** The frozen v6 DDL, kept verbatim so a deployed v6 marker's checksum can be
380
+ * recognized and upgraded in place (see migratePostgres). v6 is the shape the
381
+ * hosted service ran immediately before the build-digest scan index (v7). */
382
+ export const SCHEMA_V6_DDL = `
383
+ CREATE TABLE IF NOT EXISTS ab_schema_migrations (
384
+ singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
385
+ version integer NOT NULL,
386
+ checksum text NOT NULL,
387
+ applied_at timestamptz NOT NULL
388
+ );
389
+ CREATE TABLE IF NOT EXISTS builds (
390
+ slug text PRIMARY KEY, repo text NOT NULL, ticket jsonb, branch text,
391
+ created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
392
+ lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
393
+ heartbeat_at timestamptz, repo_origin text
394
+ );
395
+ CREATE TABLE IF NOT EXISTS events (
396
+ build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
397
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
398
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (build, seq)
399
+ );
400
+ CREATE TABLE IF NOT EXISTS artifacts (
401
+ build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
402
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
403
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
404
+ PRIMARY KEY (build, kind, revision)
405
+ );
406
+ CREATE TABLE IF NOT EXISTS repo_streams (
407
+ repo text PRIMARY KEY, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
408
+ lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
409
+ heartbeat_at timestamptz
410
+ );
411
+ CREATE TABLE IF NOT EXISTS repo_events (
412
+ repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
413
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
414
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (repo, seq)
415
+ );
416
+ CREATE TABLE IF NOT EXISTS repo_artifacts (
417
+ repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
418
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
419
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
420
+ PRIMARY KEY (repo, kind, revision)
421
+ );
422
+ CREATE SEQUENCE IF NOT EXISTS sessions_creation_seq;
423
+ CREATE TABLE IF NOT EXISTS sessions (
424
+ id text PRIMARY KEY,
425
+ repo text NOT NULL,
426
+ operator text NOT NULL,
427
+ title text,
428
+ created_at timestamptz NOT NULL,
429
+ updated_at timestamptz NOT NULL,
430
+ creation_seq bigint NOT NULL
431
+ );
432
+ CREATE TABLE IF NOT EXISTS session_events (
433
+ session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
434
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
435
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (session, seq)
436
+ );
437
+ CREATE TABLE IF NOT EXISTS session_artifacts (
438
+ session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
439
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
440
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
441
+ PRIMARY KEY (session, kind, revision)
442
+ );
443
+ CREATE SEQUENCE IF NOT EXISTS streams_creation_seq;
444
+ CREATE TABLE IF NOT EXISTS streams (
445
+ id text PRIMARY KEY, scope_kind text NOT NULL,
446
+ build text REFERENCES builds(slug) ON DELETE CASCADE,
447
+ repo text REFERENCES repo_streams(repo) ON DELETE CASCADE,
448
+ label text NOT NULL, format text NOT NULL, status text NOT NULL,
449
+ outcome text, artifact_kind text, artifact_revision bigint,
450
+ artifact_blob_ref text, created_at timestamptz NOT NULL, closed_at timestamptz,
451
+ session text REFERENCES sessions(id) ON DELETE CASCADE,
452
+ creation_seq bigint NOT NULL,
453
+ CONSTRAINT streams_scope_kind_check CHECK (scope_kind IN ('build','repo','session')),
454
+ CONSTRAINT streams_status_check CHECK (status IN ('open','closed')),
455
+ CONSTRAINT streams_scope_exactly_one_check CHECK (
456
+ (scope_kind = 'build' AND build IS NOT NULL AND repo IS NULL AND session IS NULL)
457
+ OR (scope_kind = 'repo' AND build IS NULL AND repo IS NOT NULL AND session IS NULL)
458
+ OR (scope_kind = 'session' AND build IS NULL AND repo IS NULL AND session IS NOT NULL)
459
+ )
460
+ );
461
+ CREATE TABLE IF NOT EXISTS stream_chunks (
462
+ stream text NOT NULL REFERENCES streams(id) ON DELETE CASCADE,
463
+ seq bigint NOT NULL, ts timestamptz NOT NULL, parts jsonb NOT NULL,
464
+ PRIMARY KEY (stream, seq)
465
+ );`.trim()
466
+ export const SCHEMA_V6_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_V6_DDL).digest('hex')
467
+
468
+ /** The frozen v7 DDL, kept verbatim so a deployed v7 marker's checksum can be
469
+ * recognized and upgraded in place (see migratePostgres). v7 is the shape the
470
+ * hosted service ran immediately before the repository-journal state-read
471
+ * index (v8, AUT-489). Frozen pre-trimmed (the V1–V6 pattern): a deployed
472
+ * marker's checksum is taken over the trimmed DDL, so an untrimmed constant
473
+ * would hash surrounding whitespace no deployed database ever carried and the
474
+ * promotion branch would never match. */
475
+ export const SCHEMA_V7_DDL = `
476
+ CREATE TABLE IF NOT EXISTS ab_schema_migrations (
477
+ singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
478
+ version integer NOT NULL,
479
+ checksum text NOT NULL,
480
+ applied_at timestamptz NOT NULL
481
+ );
482
+ CREATE TABLE IF NOT EXISTS builds (
483
+ slug text PRIMARY KEY, repo text NOT NULL, ticket jsonb, branch text,
484
+ created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
485
+ lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
486
+ heartbeat_at timestamptz, repo_origin text
487
+ );
488
+ CREATE TABLE IF NOT EXISTS events (
489
+ build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
490
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
491
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (build, seq)
492
+ );
493
+ CREATE TABLE IF NOT EXISTS artifacts (
494
+ build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
495
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
496
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
497
+ PRIMARY KEY (build, kind, revision)
498
+ );
499
+ CREATE TABLE IF NOT EXISTS repo_streams (
500
+ repo text PRIMARY KEY, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
501
+ lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
502
+ heartbeat_at timestamptz
503
+ );
504
+ CREATE TABLE IF NOT EXISTS repo_events (
505
+ repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
506
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
507
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (repo, seq)
508
+ );
509
+ CREATE TABLE IF NOT EXISTS repo_artifacts (
510
+ repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
511
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
512
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
513
+ PRIMARY KEY (repo, kind, revision)
514
+ );
515
+ CREATE SEQUENCE IF NOT EXISTS sessions_creation_seq;
516
+ CREATE TABLE IF NOT EXISTS sessions (
517
+ id text PRIMARY KEY,
518
+ repo text NOT NULL,
519
+ operator text NOT NULL,
520
+ title text,
521
+ created_at timestamptz NOT NULL,
522
+ updated_at timestamptz NOT NULL,
523
+ creation_seq bigint NOT NULL
524
+ );
525
+ CREATE TABLE IF NOT EXISTS session_events (
526
+ session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
527
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
528
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (session, seq)
529
+ );
530
+ CREATE TABLE IF NOT EXISTS session_artifacts (
531
+ session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
532
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
533
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
534
+ PRIMARY KEY (session, kind, revision)
535
+ );
536
+ CREATE SEQUENCE IF NOT EXISTS streams_creation_seq;
537
+ CREATE TABLE IF NOT EXISTS streams (
538
+ id text PRIMARY KEY, scope_kind text NOT NULL,
539
+ build text REFERENCES builds(slug) ON DELETE CASCADE,
540
+ repo text REFERENCES repo_streams(repo) ON DELETE CASCADE,
541
+ label text NOT NULL, format text NOT NULL, status text NOT NULL,
542
+ outcome text, artifact_kind text, artifact_revision bigint,
543
+ artifact_blob_ref text, created_at timestamptz NOT NULL, closed_at timestamptz,
544
+ session text REFERENCES sessions(id) ON DELETE CASCADE,
545
+ creation_seq bigint NOT NULL,
546
+ CONSTRAINT streams_scope_kind_check CHECK (scope_kind IN ('build','repo','session')),
547
+ CONSTRAINT streams_status_check CHECK (status IN ('open','closed')),
548
+ CONSTRAINT streams_scope_exactly_one_check CHECK (
549
+ (scope_kind = 'build' AND build IS NOT NULL AND repo IS NULL AND session IS NULL)
550
+ OR (scope_kind = 'repo' AND build IS NULL AND repo IS NOT NULL AND session IS NULL)
551
+ OR (scope_kind = 'session' AND build IS NULL AND repo IS NULL AND session IS NOT NULL)
552
+ )
553
+ );
554
+ CREATE TABLE IF NOT EXISTS stream_chunks (
555
+ stream text NOT NULL REFERENCES streams(id) ON DELETE CASCADE,
556
+ seq bigint NOT NULL, ts timestamptz NOT NULL, parts jsonb NOT NULL,
557
+ PRIMARY KEY (stream, seq)
558
+ );
559
+
560
+ -- The build-digest scan index (AUT-487): type-leading, so the digest query's
561
+ -- cost grows with the observation/terminal events themselves, not with total
562
+ -- history. Created idempotently so pre-v7 databases gain it on migrate.
563
+ CREATE INDEX IF NOT EXISTS events_type_build_seq ON events (type, build, seq);`.trim()
564
+ export const SCHEMA_V7_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_V7_DDL).digest('hex')
565
+
566
+ /** The frozen v8 DDL, kept verbatim so a deployed v8 marker's checksum can be
567
+ * recognized and upgraded in place (see migratePostgres). v8 is the shape the
568
+ * hosted service ran immediately before ticket assets (v9). Frozen pre-trimmed
569
+ * (the V1–V7 pattern): a deployed marker's checksum is taken over the trimmed
570
+ * DDL. */
571
+ export const SCHEMA_V8_DDL = `
572
+ CREATE TABLE IF NOT EXISTS ab_schema_migrations (
573
+ singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
574
+ version integer NOT NULL,
575
+ checksum text NOT NULL,
576
+ applied_at timestamptz NOT NULL
577
+ );
578
+ CREATE TABLE IF NOT EXISTS builds (
579
+ slug text PRIMARY KEY, repo text NOT NULL, ticket jsonb, branch text,
580
+ created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
581
+ lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
582
+ heartbeat_at timestamptz, repo_origin text
583
+ );
584
+ CREATE TABLE IF NOT EXISTS events (
585
+ build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
586
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
587
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (build, seq)
588
+ );
589
+ CREATE TABLE IF NOT EXISTS artifacts (
590
+ build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
591
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
592
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
593
+ PRIMARY KEY (build, kind, revision)
594
+ );
595
+ CREATE TABLE IF NOT EXISTS repo_streams (
596
+ repo text PRIMARY KEY, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
597
+ lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
598
+ heartbeat_at timestamptz
599
+ );
600
+ CREATE TABLE IF NOT EXISTS repo_events (
601
+ repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
602
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
603
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (repo, seq)
604
+ );
605
+ CREATE TABLE IF NOT EXISTS repo_artifacts (
606
+ repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
607
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
608
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
609
+ PRIMARY KEY (repo, kind, revision)
610
+ );
611
+ CREATE SEQUENCE IF NOT EXISTS sessions_creation_seq;
612
+ CREATE TABLE IF NOT EXISTS sessions (
613
+ id text PRIMARY KEY,
614
+ repo text NOT NULL,
615
+ operator text NOT NULL,
616
+ title text,
617
+ created_at timestamptz NOT NULL,
618
+ updated_at timestamptz NOT NULL,
619
+ creation_seq bigint NOT NULL
620
+ );
621
+ CREATE TABLE IF NOT EXISTS session_events (
622
+ session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
623
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
624
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (session, seq)
625
+ );
626
+ CREATE TABLE IF NOT EXISTS session_artifacts (
627
+ session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
628
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
629
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
630
+ PRIMARY KEY (session, kind, revision)
631
+ );
632
+ CREATE SEQUENCE IF NOT EXISTS streams_creation_seq;
633
+ CREATE TABLE IF NOT EXISTS streams (
634
+ id text PRIMARY KEY, scope_kind text NOT NULL,
635
+ build text REFERENCES builds(slug) ON DELETE CASCADE,
636
+ repo text REFERENCES repo_streams(repo) ON DELETE CASCADE,
637
+ label text NOT NULL, format text NOT NULL, status text NOT NULL,
638
+ outcome text, artifact_kind text, artifact_revision bigint,
639
+ artifact_blob_ref text, created_at timestamptz NOT NULL, closed_at timestamptz,
640
+ session text REFERENCES sessions(id) ON DELETE CASCADE,
641
+ creation_seq bigint NOT NULL,
642
+ CONSTRAINT streams_scope_kind_check CHECK (scope_kind IN ('build','repo','session')),
643
+ CONSTRAINT streams_status_check CHECK (status IN ('open','closed')),
644
+ CONSTRAINT streams_scope_exactly_one_check CHECK (
645
+ (scope_kind = 'build' AND build IS NOT NULL AND repo IS NULL AND session IS NULL)
646
+ OR (scope_kind = 'repo' AND build IS NULL AND repo IS NOT NULL AND session IS NULL)
647
+ OR (scope_kind = 'session' AND build IS NULL AND repo IS NULL AND session IS NOT NULL)
648
+ )
649
+ );
650
+ CREATE TABLE IF NOT EXISTS stream_chunks (
651
+ stream text NOT NULL REFERENCES streams(id) ON DELETE CASCADE,
652
+ seq bigint NOT NULL, ts timestamptz NOT NULL, parts jsonb NOT NULL,
653
+ PRIMARY KEY (stream, seq)
654
+ );
655
+
656
+ -- The build-digest scan index (AUT-487): type-leading, so the digest query's
657
+ -- cost grows with the observation/terminal events themselves, not with total
658
+ -- history. Created idempotently so pre-v7 databases gain it on migrate.
659
+ CREATE INDEX IF NOT EXISTS events_type_build_seq ON events (type, build, seq);
660
+
661
+ -- The repository-journal state read index (AUT-489): (repo, type, seq), so
662
+ -- the bounded state read's anchor query — MAX(seq) over the repo's
663
+ -- run-started facts — and its durable-type select both scan only their own
664
+ -- signal, never the repo's whole journal. Created idempotently so pre-v8
665
+ -- databases gain it on migrate.
666
+ CREATE INDEX IF NOT EXISTS repo_events_type_repo_seq ON repo_events (repo, type, seq);`.trim()
667
+ export const SCHEMA_V8_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_V8_DDL).digest('hex')
668
+
379
669
  export const SCHEMA_DDL = `
380
670
  CREATE TABLE IF NOT EXISTS ab_schema_migrations (
381
671
  singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
@@ -459,6 +749,28 @@ CREATE TABLE IF NOT EXISTS stream_chunks (
459
749
  stream text NOT NULL REFERENCES streams(id) ON DELETE CASCADE,
460
750
  seq bigint NOT NULL, ts timestamptz NOT NULL, parts jsonb NOT NULL,
461
751
  PRIMARY KEY (stream, seq)
752
+ );
753
+
754
+ -- The build-digest scan index (AUT-487): type-leading, so the digest query's
755
+ -- cost grows with the observation/terminal events themselves, not with total
756
+ -- history. Created idempotently so pre-v7 databases gain it on migrate.
757
+ CREATE INDEX IF NOT EXISTS events_type_build_seq ON events (type, build, seq);
758
+
759
+ -- The repository-journal state read index (AUT-489): (repo, type, seq), so
760
+ -- the bounded state read's anchor query — MAX(seq) over the repo's
761
+ -- run-started facts — and its durable-type select both scan only their own
762
+ -- signal, never the repo's whole journal. Created idempotently so pre-v8
763
+ -- databases gain it on migrate.
764
+ CREATE INDEX IF NOT EXISTS repo_events_type_repo_seq ON repo_events (repo, type, seq);
765
+ -- Ticket assets (SPEC §7.1): one row per revision, manifest as jsonb, file
766
+ -- bytes in the blob store. A removal is a tombstone row (removed = true, empty
767
+ -- manifest) taking the next revision. No FK to repo_streams: assets can
768
+ -- precede any repo-journal row.
769
+ CREATE TABLE IF NOT EXISTS ticket_assets (
770
+ repo text NOT NULL, ticket_id text NOT NULL, kind text NOT NULL, name text NOT NULL,
771
+ revision bigint NOT NULL, layout text NOT NULL, manifest jsonb NOT NULL,
772
+ size bigint NOT NULL, removed boolean NOT NULL, created_at timestamptz NOT NULL,
773
+ PRIMARY KEY (repo, ticket_id, kind, name, revision)
462
774
  );`.trim()
463
775
 
464
776
  export const SCHEMA_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_DDL).digest('hex')
@@ -536,6 +848,18 @@ const EXPECTED_COLUMNS: Record<string, readonly ExpectedColumn[]> = {
536
848
  ['metadata', 'jsonb', true],
537
849
  ['created_at', 'timestamp with time zone', true],
538
850
  ],
851
+ ticket_assets: [
852
+ ['repo', 'text', true],
853
+ ['ticket_id', 'text', true],
854
+ ['kind', 'text', true],
855
+ ['name', 'text', true],
856
+ ['revision', 'bigint', true],
857
+ ['layout', 'text', true],
858
+ ['manifest', 'jsonb', true],
859
+ ['size', 'bigint', true],
860
+ ['removed', 'boolean', true],
861
+ ['created_at', 'timestamp with time zone', true],
862
+ ],
539
863
  repo_streams: [
540
864
  ['repo', 'text', true],
541
865
  ['created_at', 'timestamp with time zone', true],
@@ -647,12 +971,30 @@ const EXPECTED_CONSTRAINTS = [
647
971
  'streams|f|repo|repo_streams|repo|c|',
648
972
  'streams|f|session|sessions|id|c|',
649
973
  'streams|p|id||||',
974
+ 'ticket_assets|p|repo,ticket_id,kind,name,revision||||',
650
975
  ] as const
651
976
 
652
977
  export function schemaError(detail: string): Error {
653
978
  return new Error(`PostgreSQL BuildStore schema ${detail}; run: ${MIGRATE_COMMAND}`)
654
979
  }
655
980
 
981
+ /** The targeted cause text for a marker whose version matches the required
982
+ * version but whose checksum does not: exactly the shape a checksum-only
983
+ * re-pin leaves deployed databases in (AUT-548) — and also the shape of a
984
+ * database built by a different build of the same version, which a marker
985
+ * alone cannot distinguish, so both causes are named. The guard's failure
986
+ * output teaches the four-step rule pre-ship; this diagnostic catches the
987
+ * checksum-only shortcut after the fact. */
988
+ function checksumMismatchCause(version: number): string {
989
+ return (
990
+ `its version matches ${version}, but its checksum does not match this build's DDL — the ` +
991
+ `current DDL was likely edited in place without bumping the version, or the database was ` +
992
+ `built by a different build of this same version. Never edit deployed DDL; follow the ` +
993
+ `four-step rule (freeze the previous DDL, add an upgrade branch in migratePostgres, bump ` +
994
+ `the version, re-pin in schema-guard.test.ts) and redeploy`
995
+ )
996
+ }
997
+
656
998
  interface CatalogColumn {
657
999
  table_name: string
658
1000
  column_name: string
@@ -775,7 +1117,9 @@ export async function assertSchema(sql: SQL): Promise<void> {
775
1117
  if (Number(marker.version) !== SCHEMA_VERSION) {
776
1118
  throw schemaError(`version ${marker.version} does not match required version ${SCHEMA_VERSION}`)
777
1119
  }
778
- if (marker.checksum !== SCHEMA_CHECKSUM) throw schemaError('checksum is mismatched')
1120
+ if (marker.checksum !== SCHEMA_CHECKSUM) {
1121
+ throw schemaError(`checksum is mismatched: ${checksumMismatchCause(SCHEMA_VERSION)}`)
1122
+ }
779
1123
  await assertCatalogShape(sql)
780
1124
  }
781
1125
 
@@ -792,11 +1136,16 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
792
1136
  }
793
1137
  const marker = rows[0]
794
1138
  if (!marker) throw schemaError('ticket marker is missing')
795
- if (
796
- Number(marker.version) !== TICKET_SCHEMA_VERSION ||
797
- marker.checksum !== TICKET_SCHEMA_CHECKSUM
798
- ) {
799
- throw schemaError('ticket marker is incompatible')
1139
+ if (Number(marker.version) !== TICKET_SCHEMA_VERSION) {
1140
+ throw schemaError(
1141
+ `ticket marker is incompatible: version ${marker.version} does not match required version ` +
1142
+ `${TICKET_SCHEMA_VERSION}`,
1143
+ )
1144
+ }
1145
+ if (marker.checksum !== TICKET_SCHEMA_CHECKSUM) {
1146
+ throw schemaError(
1147
+ `ticket marker is incompatible: ${checksumMismatchCause(TICKET_SCHEMA_VERSION)}`,
1148
+ )
800
1149
  }
801
1150
  const expected: Record<string, Array<[string, string, boolean]>> = {
802
1151
  ab_ticket_schema_migrations: [
@@ -851,7 +1200,7 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
851
1200
  }
852
1201
  }
853
1202
 
854
- /** The guarded streams.creation_seq upgrade, shared by every pre-v6 marker
1203
+ /** The guarded streams.creation_seq upgrade, shared by every pre-v7 marker
855
1204
  * branch whose database carries a pre-existing streams table (v2, v3, v4, v5).
856
1205
  * The idempotent full DDL above has already created the
857
1206
  * streams_creation_seq sequence; the column is added only when missing, with
@@ -891,12 +1240,12 @@ export async function migratePostgres(url: string): Promise<void> {
891
1240
  await tx`SELECT version, checksum FROM ab_schema_migrations WHERE singleton = true FOR UPDATE`
892
1241
  const marker = rows[0]
893
1242
  if (marker) {
894
- // v1–v5 → v6: the guarded builds.repo_origin column runs for EVERY
895
- // pre-v6 marker, before the version branches — not only in a v4
1243
+ // v1–v7 → v9: the guarded builds.repo_origin column runs for EVERY
1244
+ // pre-v7 marker, before the version branches — not only in a v4
896
1245
  // branch. `CREATE TABLE IF NOT EXISTS builds` does not alter an
897
1246
  // existing builds table, so without this the internal assertSchema
898
1247
  // below would fail on every upgraded legacy database. The ALTER is
899
- // guarded and idempotent, so re-running it on a v6 database (or a
1248
+ // guarded and idempotent, so re-running it on a v7 database (or a
900
1249
  // marker that turns out to be rejected below, which rolls this
901
1250
  // transaction back) is a no-op.
902
1251
  await tx.unsafe(`
@@ -910,25 +1259,49 @@ export async function migratePostgres(url: string): Promise<void> {
910
1259
  END IF;
911
1260
  END $$;
912
1261
  `)
1262
+ // v6 → v7: the guarded build-digest scan index (AUT-487) likewise
1263
+ // runs for EVERY pre-v7 marker (and, with the state-read index below,
1264
+ // every pre-v8 marker) — the idempotent full DDL above only
1265
+ // creates an index on a database that lacks the events table, never
1266
+ // on a pre-existing one. Guarded and idempotent (the repo_origin
1267
+ // precedent), so it is a no-op on fresh installs, on v7 reruns, and
1268
+ // on markers rejected below (which roll the transaction back).
1269
+ await tx.unsafe(
1270
+ `CREATE INDEX IF NOT EXISTS events_type_build_seq ON events (type, build, seq)`,
1271
+ )
1272
+ // v7 → v9: the guarded repository-journal state-read index (AUT-489)
1273
+ // likewise runs for EVERY pre-v8 marker — same reasoning as the
1274
+ // digest-scan index above. Guarded and idempotent, so it is a no-op
1275
+ // on fresh installs, on v8 reruns, and on markers rejected below
1276
+ // (which roll the transaction back). Indexes are not catalog-asserted,
1277
+ // so no EXPECTED_* change.
1278
+ await tx.unsafe(
1279
+ `CREATE INDEX IF NOT EXISTS repo_events_type_repo_seq ON repo_events (repo, type, seq)`,
1280
+ )
913
1281
  const version = Number(marker.version)
914
1282
  if (version === SCHEMA_VERSION) {
915
- if (marker.checksum !== SCHEMA_CHECKSUM) throw schemaError('marker is incompatible')
1283
+ if (marker.checksum !== SCHEMA_CHECKSUM) {
1284
+ throw schemaError(`marker is incompatible: ${checksumMismatchCause(SCHEMA_VERSION)}`)
1285
+ }
916
1286
  } else if (version === 1 && marker.checksum === SCHEMA_V1_CHECKSUM) {
917
- // v1 → v6: the idempotent full DDL above already applied the deltas
1287
+ // v1 → v9: the idempotent full DDL above already applied the deltas
918
1288
  // (the stream tables and the session tables); v1 databases never had
919
1289
  // a streams or sessions table, so the full DDL created them with the
920
1290
  // session column, the widened CHECKs, and both creation_seq columns.
921
- // The guarded repo_origin ALTER above covered builds. Promote the
922
- // marker in this transaction.
1291
+ // The guarded repo_origin ALTER and the guarded digest-scan index
1292
+ // above covered the pre-existing tables. Promote the marker in this
1293
+ // transaction.
923
1294
  await tx`UPDATE ab_schema_migrations
924
1295
  SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
925
1296
  applied_at = ${new Date().toISOString()}
926
1297
  WHERE singleton = true`
927
1298
  } else if (version === 2 && marker.checksum === SCHEMA_V2_CHECKSUM) {
928
- // v2 → v6: the idempotent full DDL above created the session tables
1299
+ // v2 → v9: the idempotent full DDL above created the session tables
929
1300
  // (with the creation_seq column); a v2 database's streams table
930
1301
  // needs the guarded session column, the widened CHECK constraints,
931
- // and the guarded creation_seq column (shared with v3 and v4).
1302
+ // and the guarded creation_seq column (shared with v3 and v4). The
1303
+ // guarded repo_origin ALTER and the guarded digest-scan index above
1304
+ // covered the pre-existing builds and events tables.
932
1305
  await tx.unsafe(`
933
1306
  DO $$ BEGIN
934
1307
  IF NOT EXISTS (
@@ -958,7 +1331,7 @@ export async function migratePostgres(url: string): Promise<void> {
958
1331
  applied_at = ${new Date().toISOString()}
959
1332
  WHERE singleton = true`
960
1333
  } else if (version === 3 && marker.checksum === SCHEMA_V3_CHECKSUM) {
961
- // v3 → v6: the listSessions and listStreams creation-order
1334
+ // v3 → v9: the listSessions and listStreams creation-order
962
1335
  // tiebreaks (store/types.ts). The idempotent full DDL above created
963
1336
  // the sessions_creation_seq and streams_creation_seq sequences; a
964
1337
  // v3 database's sessions and streams tables need the guarded
@@ -967,8 +1340,8 @@ export async function migratePostgres(url: string): Promise<void> {
967
1340
  // unorderable, so any total order consistent with createdAt is
968
1341
  // acceptable — SET NOT NULL, and the sequence positioned above the
969
1342
  // backfilled values so the next nextval continues the counter
970
- // without collision. The guarded repo_origin ALTER above covered
971
- // builds.
1343
+ // without collision. The guarded repo_origin ALTER and the guarded
1344
+ // digest-scan index above covered the pre-existing tables.
972
1345
  await tx.unsafe(`
973
1346
  DO $$ BEGIN
974
1347
  IF NOT EXISTS (
@@ -994,32 +1367,58 @@ export async function migratePostgres(url: string): Promise<void> {
994
1367
  applied_at = ${new Date().toISOString()}
995
1368
  WHERE singleton = true`
996
1369
  } else if (version === 4 && marker.checksum === SCHEMA_V4_CHECKSUM) {
997
- // v4 → v6: the listStreams creation-order tiebreak
1370
+ // v4 → v9: the listStreams creation-order tiebreak
998
1371
  // (store/types.ts), mirroring the v3→v4 sessions treatment. The
999
1372
  // idempotent full DDL above created the streams_creation_seq
1000
1373
  // sequence; a v4 database's streams table needs the guarded
1001
1374
  // creation_seq column, backfill, NOT NULL, and sequence continuity
1002
1375
  // (the shared pre-v6 streams migration). The guarded repo_origin
1003
- // ALTER above covered builds.
1376
+ // ALTER and the guarded digest-scan index above covered the
1377
+ // pre-existing tables.
1004
1378
  await tx.unsafe(STREAMS_CREATION_SEQ_MIGRATION)
1005
1379
  await tx`UPDATE ab_schema_migrations
1006
1380
  SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
1007
1381
  applied_at = ${new Date().toISOString()}
1008
1382
  WHERE singleton = true`
1009
1383
  } else if (version === 5 && marker.checksum === SCHEMA_V5_CHECKSUM) {
1010
- // v5 → v6: the listStreams creation-order tiebreak (store/types.ts)
1384
+ // v5 → v9: the listStreams creation-order tiebreak (store/types.ts)
1011
1385
  // landed on the v5 DDL without a version bump, so the hosted
1012
1386
  // database carries the v5 marker with the pre-tiebreak checksum. The
1013
1387
  // idempotent full DDL above created the streams_creation_seq
1014
1388
  // sequence; a v5 database's streams table needs the guarded
1015
1389
  // creation_seq column, backfill, NOT NULL, and sequence continuity
1016
- // (the shared pre-v6 streams migration). Every other v6 table and
1017
- // column already exists in a v5 database.
1390
+ // (the shared pre-v6 streams migration). Every other pre-v7 table
1391
+ // and column already exists in a v5 database; the guarded
1392
+ // digest-scan index above covers events.
1018
1393
  await tx.unsafe(STREAMS_CREATION_SEQ_MIGRATION)
1019
1394
  await tx`UPDATE ab_schema_migrations
1020
1395
  SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
1021
1396
  applied_at = ${new Date().toISOString()}
1022
1397
  WHERE singleton = true`
1398
+ } else if (version === 6 && marker.checksum === SCHEMA_V6_CHECKSUM) {
1399
+ // v6 → v9: the guarded digest-scan index above is the v6→v7 delta;
1400
+ // every v6 table and column already exists. Promote the marker in
1401
+ // this transaction.
1402
+ await tx`UPDATE ab_schema_migrations
1403
+ SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
1404
+ applied_at = ${new Date().toISOString()}
1405
+ WHERE singleton = true`
1406
+ } else if (version === 7 && marker.checksum === SCHEMA_V7_CHECKSUM) {
1407
+ // v7 → v9: the guarded repository-journal state-read index above
1408
+ // is the entire delta; every v7 table and column already exists.
1409
+ // Promote the marker in this transaction.
1410
+ await tx`UPDATE ab_schema_migrations
1411
+ SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
1412
+ applied_at = ${new Date().toISOString()}
1413
+ WHERE singleton = true`
1414
+ } else if (version === 8 && marker.checksum === SCHEMA_V8_CHECKSUM) {
1415
+ // v8 → v9: the idempotent full DDL above created the ticket_assets
1416
+ // table, which is the entire delta (no pre-existing table changes).
1417
+ // Promote the marker in this transaction.
1418
+ await tx`UPDATE ab_schema_migrations
1419
+ SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
1420
+ applied_at = ${new Date().toISOString()}
1421
+ WHERE singleton = true`
1023
1422
  } else {
1024
1423
  throw schemaError('marker is incompatible')
1025
1424
  }
@@ -1031,11 +1430,16 @@ export async function migratePostgres(url: string): Promise<void> {
1031
1430
  await tx`SELECT version, checksum FROM ab_ticket_schema_migrations WHERE singleton = true FOR UPDATE`
1032
1431
  const ticketMarker = ticketRows[0]
1033
1432
  if (ticketMarker) {
1034
- if (
1035
- Number(ticketMarker.version) !== TICKET_SCHEMA_VERSION ||
1036
- ticketMarker.checksum !== TICKET_SCHEMA_CHECKSUM
1037
- ) {
1038
- throw schemaError('ticket marker is incompatible')
1433
+ if (Number(ticketMarker.version) !== TICKET_SCHEMA_VERSION) {
1434
+ throw schemaError(
1435
+ `ticket marker is incompatible: version ${ticketMarker.version} does not match ` +
1436
+ `required version ${TICKET_SCHEMA_VERSION}`,
1437
+ )
1438
+ }
1439
+ if (ticketMarker.checksum !== TICKET_SCHEMA_CHECKSUM) {
1440
+ throw schemaError(
1441
+ `ticket marker is incompatible: ${checksumMismatchCause(TICKET_SCHEMA_VERSION)}`,
1442
+ )
1039
1443
  }
1040
1444
  } else {
1041
1445
  await tx`INSERT INTO ab_ticket_schema_migrations
@@ -1062,7 +1466,9 @@ export async function migratePostgres(url: string): Promise<void> {
1062
1466
  const authVersion = Number(authMarker.version)
1063
1467
  if (authVersion === AUTH_SCHEMA_VERSION) {
1064
1468
  if (authMarker.checksum !== AUTH_SCHEMA_CHECKSUM) {
1065
- throw schemaError('auth marker is incompatible')
1469
+ throw schemaError(
1470
+ `auth marker is incompatible: ${checksumMismatchCause(AUTH_SCHEMA_VERSION)}`,
1471
+ )
1066
1472
  }
1067
1473
  } else if (authVersion === 1 && authMarker.checksum === AUTH_SCHEMA_V1_CHECKSUM) {
1068
1474
  // v1 → v3: the idempotent full DDL above already created the four