@defrex/autobuild-postgres-store 0.8.0 → 0.10.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')
@@ -495,6 +807,21 @@ export const TICKET_SCHEMA_CHECKSUM = new Bun.CryptoHasher('sha256')
495
807
  .update(TICKET_SCHEMA_DDL)
496
808
  .digest('hex')
497
809
 
810
+ /** Reducer snapshots (core `store/snapshots.ts`): a cache beside the event log.
811
+ * Deliberately outside `SCHEMA_DDL`, its marker, version and checksum, and
812
+ * outside `EXPECTED_COLUMNS`: a deployment at the current schema keeps serving
813
+ * across the upgrade, a new client against a database that lacks the table
814
+ * degrades to full replay (SQLSTATE 42P01 reads as "no snapshot"), and an older
815
+ * client never asks for it. Idempotent — `migratePostgres` creates it next to
816
+ * the ticket tables. */
817
+ export const SNAPSHOT_SCHEMA_DDL = `
818
+ CREATE TABLE IF NOT EXISTS reducer_snapshots (
819
+ scope_kind text NOT NULL, scope_key text NOT NULL, reducer text NOT NULL,
820
+ version integer NOT NULL, cursor bigint NOT NULL, state jsonb NOT NULL,
821
+ updated_at timestamptz NOT NULL,
822
+ PRIMARY KEY (scope_kind, scope_key, reducer)
823
+ );`.trim()
824
+
498
825
  type ExpectedColumn = readonly [name: string, type: string, notNull: boolean, defaultValue?: string]
499
826
 
500
827
  const EXPECTED_COLUMNS: Record<string, readonly ExpectedColumn[]> = {
@@ -536,6 +863,18 @@ const EXPECTED_COLUMNS: Record<string, readonly ExpectedColumn[]> = {
536
863
  ['metadata', 'jsonb', true],
537
864
  ['created_at', 'timestamp with time zone', true],
538
865
  ],
866
+ ticket_assets: [
867
+ ['repo', 'text', true],
868
+ ['ticket_id', 'text', true],
869
+ ['kind', 'text', true],
870
+ ['name', 'text', true],
871
+ ['revision', 'bigint', true],
872
+ ['layout', 'text', true],
873
+ ['manifest', 'jsonb', true],
874
+ ['size', 'bigint', true],
875
+ ['removed', 'boolean', true],
876
+ ['created_at', 'timestamp with time zone', true],
877
+ ],
539
878
  repo_streams: [
540
879
  ['repo', 'text', true],
541
880
  ['created_at', 'timestamp with time zone', true],
@@ -647,12 +986,30 @@ const EXPECTED_CONSTRAINTS = [
647
986
  'streams|f|repo|repo_streams|repo|c|',
648
987
  'streams|f|session|sessions|id|c|',
649
988
  'streams|p|id||||',
989
+ 'ticket_assets|p|repo,ticket_id,kind,name,revision||||',
650
990
  ] as const
651
991
 
652
992
  export function schemaError(detail: string): Error {
653
993
  return new Error(`PostgreSQL BuildStore schema ${detail}; run: ${MIGRATE_COMMAND}`)
654
994
  }
655
995
 
996
+ /** The targeted cause text for a marker whose version matches the required
997
+ * version but whose checksum does not: exactly the shape a checksum-only
998
+ * re-pin leaves deployed databases in (AUT-548) — and also the shape of a
999
+ * database built by a different build of the same version, which a marker
1000
+ * alone cannot distinguish, so both causes are named. The guard's failure
1001
+ * output teaches the four-step rule pre-ship; this diagnostic catches the
1002
+ * checksum-only shortcut after the fact. */
1003
+ function checksumMismatchCause(version: number): string {
1004
+ return (
1005
+ `its version matches ${version}, but its checksum does not match this build's DDL — the ` +
1006
+ `current DDL was likely edited in place without bumping the version, or the database was ` +
1007
+ `built by a different build of this same version. Never edit deployed DDL; follow the ` +
1008
+ `four-step rule (freeze the previous DDL, add an upgrade branch in migratePostgres, bump ` +
1009
+ `the version, re-pin in schema-guard.test.ts) and redeploy`
1010
+ )
1011
+ }
1012
+
656
1013
  interface CatalogColumn {
657
1014
  table_name: string
658
1015
  column_name: string
@@ -775,7 +1132,9 @@ export async function assertSchema(sql: SQL): Promise<void> {
775
1132
  if (Number(marker.version) !== SCHEMA_VERSION) {
776
1133
  throw schemaError(`version ${marker.version} does not match required version ${SCHEMA_VERSION}`)
777
1134
  }
778
- if (marker.checksum !== SCHEMA_CHECKSUM) throw schemaError('checksum is mismatched')
1135
+ if (marker.checksum !== SCHEMA_CHECKSUM) {
1136
+ throw schemaError(`checksum is mismatched: ${checksumMismatchCause(SCHEMA_VERSION)}`)
1137
+ }
779
1138
  await assertCatalogShape(sql)
780
1139
  }
781
1140
 
@@ -792,11 +1151,16 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
792
1151
  }
793
1152
  const marker = rows[0]
794
1153
  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')
1154
+ if (Number(marker.version) !== TICKET_SCHEMA_VERSION) {
1155
+ throw schemaError(
1156
+ `ticket marker is incompatible: version ${marker.version} does not match required version ` +
1157
+ `${TICKET_SCHEMA_VERSION}`,
1158
+ )
1159
+ }
1160
+ if (marker.checksum !== TICKET_SCHEMA_CHECKSUM) {
1161
+ throw schemaError(
1162
+ `ticket marker is incompatible: ${checksumMismatchCause(TICKET_SCHEMA_VERSION)}`,
1163
+ )
800
1164
  }
801
1165
  const expected: Record<string, Array<[string, string, boolean]>> = {
802
1166
  ab_ticket_schema_migrations: [
@@ -851,7 +1215,7 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
851
1215
  }
852
1216
  }
853
1217
 
854
- /** The guarded streams.creation_seq upgrade, shared by every pre-v6 marker
1218
+ /** The guarded streams.creation_seq upgrade, shared by every pre-v7 marker
855
1219
  * branch whose database carries a pre-existing streams table (v2, v3, v4, v5).
856
1220
  * The idempotent full DDL above has already created the
857
1221
  * streams_creation_seq sequence; the column is added only when missing, with
@@ -886,17 +1250,18 @@ export async function migratePostgres(url: string): Promise<void> {
886
1250
  await tx`SELECT pg_advisory_xact_lock(470281941)`
887
1251
  await tx.unsafe(SCHEMA_DDL)
888
1252
  await tx.unsafe(TICKET_SCHEMA_DDL)
1253
+ await tx.unsafe(SNAPSHOT_SCHEMA_DDL)
889
1254
  await tx.unsafe(AUTH_SCHEMA_DDL)
890
1255
  const rows: { version: number; checksum: string }[] =
891
1256
  await tx`SELECT version, checksum FROM ab_schema_migrations WHERE singleton = true FOR UPDATE`
892
1257
  const marker = rows[0]
893
1258
  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
1259
+ // v1–v7 → v9: the guarded builds.repo_origin column runs for EVERY
1260
+ // pre-v7 marker, before the version branches — not only in a v4
896
1261
  // branch. `CREATE TABLE IF NOT EXISTS builds` does not alter an
897
1262
  // existing builds table, so without this the internal assertSchema
898
1263
  // 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
1264
+ // guarded and idempotent, so re-running it on a v7 database (or a
900
1265
  // marker that turns out to be rejected below, which rolls this
901
1266
  // transaction back) is a no-op.
902
1267
  await tx.unsafe(`
@@ -910,25 +1275,49 @@ export async function migratePostgres(url: string): Promise<void> {
910
1275
  END IF;
911
1276
  END $$;
912
1277
  `)
1278
+ // v6 → v7: the guarded build-digest scan index (AUT-487) likewise
1279
+ // runs for EVERY pre-v7 marker (and, with the state-read index below,
1280
+ // every pre-v8 marker) — the idempotent full DDL above only
1281
+ // creates an index on a database that lacks the events table, never
1282
+ // on a pre-existing one. Guarded and idempotent (the repo_origin
1283
+ // precedent), so it is a no-op on fresh installs, on v7 reruns, and
1284
+ // on markers rejected below (which roll the transaction back).
1285
+ await tx.unsafe(
1286
+ `CREATE INDEX IF NOT EXISTS events_type_build_seq ON events (type, build, seq)`,
1287
+ )
1288
+ // v7 → v9: the guarded repository-journal state-read index (AUT-489)
1289
+ // likewise runs for EVERY pre-v8 marker — same reasoning as the
1290
+ // digest-scan index above. Guarded and idempotent, so it is a no-op
1291
+ // on fresh installs, on v8 reruns, and on markers rejected below
1292
+ // (which roll the transaction back). Indexes are not catalog-asserted,
1293
+ // so no EXPECTED_* change.
1294
+ await tx.unsafe(
1295
+ `CREATE INDEX IF NOT EXISTS repo_events_type_repo_seq ON repo_events (repo, type, seq)`,
1296
+ )
913
1297
  const version = Number(marker.version)
914
1298
  if (version === SCHEMA_VERSION) {
915
- if (marker.checksum !== SCHEMA_CHECKSUM) throw schemaError('marker is incompatible')
1299
+ if (marker.checksum !== SCHEMA_CHECKSUM) {
1300
+ throw schemaError(`marker is incompatible: ${checksumMismatchCause(SCHEMA_VERSION)}`)
1301
+ }
916
1302
  } else if (version === 1 && marker.checksum === SCHEMA_V1_CHECKSUM) {
917
- // v1 → v6: the idempotent full DDL above already applied the deltas
1303
+ // v1 → v9: the idempotent full DDL above already applied the deltas
918
1304
  // (the stream tables and the session tables); v1 databases never had
919
1305
  // a streams or sessions table, so the full DDL created them with the
920
1306
  // 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.
1307
+ // The guarded repo_origin ALTER and the guarded digest-scan index
1308
+ // above covered the pre-existing tables. Promote the marker in this
1309
+ // transaction.
923
1310
  await tx`UPDATE ab_schema_migrations
924
1311
  SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
925
1312
  applied_at = ${new Date().toISOString()}
926
1313
  WHERE singleton = true`
927
1314
  } else if (version === 2 && marker.checksum === SCHEMA_V2_CHECKSUM) {
928
- // v2 → v6: the idempotent full DDL above created the session tables
1315
+ // v2 → v9: the idempotent full DDL above created the session tables
929
1316
  // (with the creation_seq column); a v2 database's streams table
930
1317
  // needs the guarded session column, the widened CHECK constraints,
931
- // and the guarded creation_seq column (shared with v3 and v4).
1318
+ // and the guarded creation_seq column (shared with v3 and v4). The
1319
+ // guarded repo_origin ALTER and the guarded digest-scan index above
1320
+ // covered the pre-existing builds and events tables.
932
1321
  await tx.unsafe(`
933
1322
  DO $$ BEGIN
934
1323
  IF NOT EXISTS (
@@ -958,7 +1347,7 @@ export async function migratePostgres(url: string): Promise<void> {
958
1347
  applied_at = ${new Date().toISOString()}
959
1348
  WHERE singleton = true`
960
1349
  } else if (version === 3 && marker.checksum === SCHEMA_V3_CHECKSUM) {
961
- // v3 → v6: the listSessions and listStreams creation-order
1350
+ // v3 → v9: the listSessions and listStreams creation-order
962
1351
  // tiebreaks (store/types.ts). The idempotent full DDL above created
963
1352
  // the sessions_creation_seq and streams_creation_seq sequences; a
964
1353
  // v3 database's sessions and streams tables need the guarded
@@ -967,8 +1356,8 @@ export async function migratePostgres(url: string): Promise<void> {
967
1356
  // unorderable, so any total order consistent with createdAt is
968
1357
  // acceptable — SET NOT NULL, and the sequence positioned above the
969
1358
  // backfilled values so the next nextval continues the counter
970
- // without collision. The guarded repo_origin ALTER above covered
971
- // builds.
1359
+ // without collision. The guarded repo_origin ALTER and the guarded
1360
+ // digest-scan index above covered the pre-existing tables.
972
1361
  await tx.unsafe(`
973
1362
  DO $$ BEGIN
974
1363
  IF NOT EXISTS (
@@ -994,32 +1383,58 @@ export async function migratePostgres(url: string): Promise<void> {
994
1383
  applied_at = ${new Date().toISOString()}
995
1384
  WHERE singleton = true`
996
1385
  } else if (version === 4 && marker.checksum === SCHEMA_V4_CHECKSUM) {
997
- // v4 → v6: the listStreams creation-order tiebreak
1386
+ // v4 → v9: the listStreams creation-order tiebreak
998
1387
  // (store/types.ts), mirroring the v3→v4 sessions treatment. The
999
1388
  // idempotent full DDL above created the streams_creation_seq
1000
1389
  // sequence; a v4 database's streams table needs the guarded
1001
1390
  // creation_seq column, backfill, NOT NULL, and sequence continuity
1002
1391
  // (the shared pre-v6 streams migration). The guarded repo_origin
1003
- // ALTER above covered builds.
1392
+ // ALTER and the guarded digest-scan index above covered the
1393
+ // pre-existing tables.
1004
1394
  await tx.unsafe(STREAMS_CREATION_SEQ_MIGRATION)
1005
1395
  await tx`UPDATE ab_schema_migrations
1006
1396
  SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
1007
1397
  applied_at = ${new Date().toISOString()}
1008
1398
  WHERE singleton = true`
1009
1399
  } else if (version === 5 && marker.checksum === SCHEMA_V5_CHECKSUM) {
1010
- // v5 → v6: the listStreams creation-order tiebreak (store/types.ts)
1400
+ // v5 → v9: the listStreams creation-order tiebreak (store/types.ts)
1011
1401
  // landed on the v5 DDL without a version bump, so the hosted
1012
1402
  // database carries the v5 marker with the pre-tiebreak checksum. The
1013
1403
  // idempotent full DDL above created the streams_creation_seq
1014
1404
  // sequence; a v5 database's streams table needs the guarded
1015
1405
  // 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.
1406
+ // (the shared pre-v6 streams migration). Every other pre-v7 table
1407
+ // and column already exists in a v5 database; the guarded
1408
+ // digest-scan index above covers events.
1018
1409
  await tx.unsafe(STREAMS_CREATION_SEQ_MIGRATION)
1019
1410
  await tx`UPDATE ab_schema_migrations
1020
1411
  SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
1021
1412
  applied_at = ${new Date().toISOString()}
1022
1413
  WHERE singleton = true`
1414
+ } else if (version === 6 && marker.checksum === SCHEMA_V6_CHECKSUM) {
1415
+ // v6 → v9: the guarded digest-scan index above is the v6→v7 delta;
1416
+ // every v6 table and column already exists. Promote the marker in
1417
+ // 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`
1422
+ } else if (version === 7 && marker.checksum === SCHEMA_V7_CHECKSUM) {
1423
+ // v7 → v9: the guarded repository-journal state-read index above
1424
+ // is the entire delta; every v7 table and column already exists.
1425
+ // Promote the marker in this transaction.
1426
+ await tx`UPDATE ab_schema_migrations
1427
+ SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
1428
+ applied_at = ${new Date().toISOString()}
1429
+ WHERE singleton = true`
1430
+ } else if (version === 8 && marker.checksum === SCHEMA_V8_CHECKSUM) {
1431
+ // v8 → v9: the idempotent full DDL above created the ticket_assets
1432
+ // table, which is the entire delta (no pre-existing table changes).
1433
+ // Promote the marker in this transaction.
1434
+ await tx`UPDATE ab_schema_migrations
1435
+ SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
1436
+ applied_at = ${new Date().toISOString()}
1437
+ WHERE singleton = true`
1023
1438
  } else {
1024
1439
  throw schemaError('marker is incompatible')
1025
1440
  }
@@ -1031,11 +1446,16 @@ export async function migratePostgres(url: string): Promise<void> {
1031
1446
  await tx`SELECT version, checksum FROM ab_ticket_schema_migrations WHERE singleton = true FOR UPDATE`
1032
1447
  const ticketMarker = ticketRows[0]
1033
1448
  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')
1449
+ if (Number(ticketMarker.version) !== TICKET_SCHEMA_VERSION) {
1450
+ throw schemaError(
1451
+ `ticket marker is incompatible: version ${ticketMarker.version} does not match ` +
1452
+ `required version ${TICKET_SCHEMA_VERSION}`,
1453
+ )
1454
+ }
1455
+ if (ticketMarker.checksum !== TICKET_SCHEMA_CHECKSUM) {
1456
+ throw schemaError(
1457
+ `ticket marker is incompatible: ${checksumMismatchCause(TICKET_SCHEMA_VERSION)}`,
1458
+ )
1039
1459
  }
1040
1460
  } else {
1041
1461
  await tx`INSERT INTO ab_ticket_schema_migrations
@@ -1062,7 +1482,9 @@ export async function migratePostgres(url: string): Promise<void> {
1062
1482
  const authVersion = Number(authMarker.version)
1063
1483
  if (authVersion === AUTH_SCHEMA_VERSION) {
1064
1484
  if (authMarker.checksum !== AUTH_SCHEMA_CHECKSUM) {
1065
- throw schemaError('auth marker is incompatible')
1485
+ throw schemaError(
1486
+ `auth marker is incompatible: ${checksumMismatchCause(AUTH_SCHEMA_VERSION)}`,
1487
+ )
1066
1488
  }
1067
1489
  } else if (authVersion === 1 && authMarker.checksum === AUTH_SCHEMA_V1_CHECKSUM) {
1068
1490
  // v1 → v3: the idempotent full DDL above already created the four