@defrex/autobuild-postgres-store 0.8.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 ADDED
@@ -0,0 +1,1099 @@
1
+ import { SQL } from 'bun'
2
+ import {
3
+ AUTH_SCHEMA_CHECKSUM,
4
+ AUTH_SCHEMA_DDL,
5
+ AUTH_SCHEMA_V1_CHECKSUM,
6
+ AUTH_SCHEMA_V2_CHECKSUM,
7
+ AUTH_SCHEMA_VERSION,
8
+ assertAuthSchema,
9
+ } from './auth-schema'
10
+
11
+ export const SCHEMA_VERSION = 6
12
+ export const MIGRATE_COMMAND = 'bun run postgres:migrate (from a pinned Autobuild release checkout)'
13
+
14
+ /** The frozen v3 DDL, kept verbatim so a deployed v3 marker's checksum can be
15
+ * recognized and upgraded in place (see migratePostgres). */
16
+ export const SCHEMA_V3_DDL = `
17
+ CREATE TABLE IF NOT EXISTS ab_schema_migrations (
18
+ singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
19
+ version integer NOT NULL,
20
+ checksum text NOT NULL,
21
+ applied_at timestamptz NOT NULL
22
+ );
23
+ CREATE TABLE IF NOT EXISTS builds (
24
+ slug text PRIMARY KEY, repo text NOT NULL, ticket jsonb, branch text,
25
+ created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
26
+ lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
27
+ heartbeat_at timestamptz
28
+ );
29
+ CREATE TABLE IF NOT EXISTS events (
30
+ build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
31
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
32
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (build, seq)
33
+ );
34
+ CREATE TABLE IF NOT EXISTS artifacts (
35
+ build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
36
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
37
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
38
+ PRIMARY KEY (build, kind, revision)
39
+ );
40
+ CREATE TABLE IF NOT EXISTS repo_streams (
41
+ repo text PRIMARY KEY, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
42
+ lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
43
+ heartbeat_at timestamptz
44
+ );
45
+ CREATE TABLE IF NOT EXISTS repo_events (
46
+ repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
47
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
48
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (repo, seq)
49
+ );
50
+ CREATE TABLE IF NOT EXISTS repo_artifacts (
51
+ repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
52
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
53
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
54
+ PRIMARY KEY (repo, kind, revision)
55
+ );
56
+ CREATE TABLE IF NOT EXISTS sessions (
57
+ id text PRIMARY KEY,
58
+ repo text NOT NULL,
59
+ operator text NOT NULL,
60
+ title text,
61
+ created_at timestamptz NOT NULL,
62
+ updated_at timestamptz NOT NULL
63
+ );
64
+ CREATE TABLE IF NOT EXISTS session_events (
65
+ session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
66
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
67
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (session, seq)
68
+ );
69
+ CREATE TABLE IF NOT EXISTS session_artifacts (
70
+ session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
71
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
72
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
73
+ PRIMARY KEY (session, kind, revision)
74
+ );
75
+ CREATE TABLE IF NOT EXISTS streams (
76
+ id text PRIMARY KEY, scope_kind text NOT NULL,
77
+ build text REFERENCES builds(slug) ON DELETE CASCADE,
78
+ repo text REFERENCES repo_streams(repo) ON DELETE CASCADE,
79
+ label text NOT NULL, format text NOT NULL, status text NOT NULL,
80
+ outcome text, artifact_kind text, artifact_revision bigint,
81
+ artifact_blob_ref text, created_at timestamptz NOT NULL, closed_at timestamptz,
82
+ session text REFERENCES sessions(id) ON DELETE CASCADE,
83
+ CONSTRAINT streams_scope_kind_check CHECK (scope_kind IN ('build','repo','session')),
84
+ CONSTRAINT streams_status_check CHECK (status IN ('open','closed')),
85
+ CONSTRAINT streams_scope_exactly_one_check CHECK (
86
+ (scope_kind = 'build' AND build IS NOT NULL AND repo IS NULL AND session IS NULL)
87
+ OR (scope_kind = 'repo' AND build IS NULL AND repo IS NOT NULL AND session IS NULL)
88
+ OR (scope_kind = 'session' AND build IS NULL AND repo IS NULL AND session IS NOT NULL)
89
+ )
90
+ );
91
+ CREATE TABLE IF NOT EXISTS stream_chunks (
92
+ stream text NOT NULL REFERENCES streams(id) ON DELETE CASCADE,
93
+ seq bigint NOT NULL, ts timestamptz NOT NULL, parts jsonb NOT NULL,
94
+ PRIMARY KEY (stream, seq)
95
+ );`.trim()
96
+ export const SCHEMA_V3_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_V3_DDL).digest('hex')
97
+
98
+ /** The frozen v2 DDL, kept verbatim so a deployed v2 marker's checksum can be
99
+ * recognized and upgraded in place (see migratePostgres). */
100
+ export const SCHEMA_V2_DDL = `
101
+ CREATE TABLE IF NOT EXISTS ab_schema_migrations (
102
+ singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
103
+ version integer NOT NULL,
104
+ checksum text NOT NULL,
105
+ applied_at timestamptz NOT NULL
106
+ );
107
+ CREATE TABLE IF NOT EXISTS builds (
108
+ slug text PRIMARY KEY, repo text NOT NULL, ticket jsonb, branch text,
109
+ created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
110
+ lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
111
+ heartbeat_at timestamptz
112
+ );
113
+ CREATE TABLE IF NOT EXISTS events (
114
+ build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
115
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
116
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (build, seq)
117
+ );
118
+ CREATE TABLE IF NOT EXISTS artifacts (
119
+ build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
120
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
121
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
122
+ PRIMARY KEY (build, kind, revision)
123
+ );
124
+ CREATE TABLE IF NOT EXISTS repo_streams (
125
+ repo text PRIMARY KEY, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
126
+ lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
127
+ heartbeat_at timestamptz
128
+ );
129
+ CREATE TABLE IF NOT EXISTS repo_events (
130
+ repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
131
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
132
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (repo, seq)
133
+ );
134
+ CREATE TABLE IF NOT EXISTS repo_artifacts (
135
+ repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
136
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
137
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
138
+ PRIMARY KEY (repo, kind, revision)
139
+ );
140
+ CREATE TABLE IF NOT EXISTS streams (
141
+ id text PRIMARY KEY, scope_kind text NOT NULL,
142
+ build text REFERENCES builds(slug) ON DELETE CASCADE,
143
+ repo text REFERENCES repo_streams(repo) ON DELETE CASCADE,
144
+ label text NOT NULL, format text NOT NULL, status text NOT NULL,
145
+ outcome text, artifact_kind text, artifact_revision bigint,
146
+ artifact_blob_ref text, created_at timestamptz NOT NULL, closed_at timestamptz,
147
+ CONSTRAINT streams_scope_kind_check CHECK (scope_kind IN ('build','repo')),
148
+ CONSTRAINT streams_status_check CHECK (status IN ('open','closed')),
149
+ CONSTRAINT streams_scope_exactly_one_check CHECK (
150
+ (scope_kind = 'build' AND build IS NOT NULL AND repo IS NULL)
151
+ OR (scope_kind = 'repo' AND build IS NULL AND repo IS NOT NULL)
152
+ )
153
+ );
154
+ CREATE TABLE IF NOT EXISTS stream_chunks (
155
+ stream text NOT NULL REFERENCES streams(id) ON DELETE CASCADE,
156
+ seq bigint NOT NULL, ts timestamptz NOT NULL, parts jsonb NOT NULL,
157
+ PRIMARY KEY (stream, seq)
158
+ );`.trim()
159
+ export const SCHEMA_V2_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_V2_DDL).digest('hex')
160
+
161
+ /** The v1 DDL, kept verbatim so a deployed v1 marker's checksum can be
162
+ * recognized and upgraded in place (see migratePostgres). */
163
+ export const SCHEMA_V1_DDL = `
164
+ CREATE TABLE IF NOT EXISTS ab_schema_migrations (
165
+ singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
166
+ version integer NOT NULL,
167
+ checksum text NOT NULL,
168
+ applied_at timestamptz NOT NULL
169
+ );
170
+ CREATE TABLE IF NOT EXISTS builds (
171
+ slug text PRIMARY KEY, repo text NOT NULL, ticket jsonb, branch text,
172
+ created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
173
+ lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
174
+ heartbeat_at timestamptz
175
+ );
176
+ CREATE TABLE IF NOT EXISTS events (
177
+ build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
178
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
179
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (build, seq)
180
+ );
181
+ CREATE TABLE IF NOT EXISTS artifacts (
182
+ build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
183
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
184
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
185
+ PRIMARY KEY (build, kind, revision)
186
+ );
187
+ CREATE TABLE IF NOT EXISTS repo_streams (
188
+ repo text PRIMARY KEY, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
189
+ lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
190
+ heartbeat_at timestamptz
191
+ );
192
+ CREATE TABLE IF NOT EXISTS repo_events (
193
+ repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
194
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
195
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (repo, seq)
196
+ );
197
+ CREATE TABLE IF NOT EXISTS repo_artifacts (
198
+ repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
199
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
200
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
201
+ PRIMARY KEY (repo, kind, revision)
202
+ );`.trim()
203
+ export const SCHEMA_V1_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_V1_DDL).digest('hex')
204
+
205
+ /** The frozen v4 DDL, kept verbatim so a deployed v4 marker's checksum can be
206
+ * recognized and upgraded in place (see migratePostgres). */
207
+ export const SCHEMA_V4_DDL = `
208
+ CREATE TABLE IF NOT EXISTS ab_schema_migrations (
209
+ singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
210
+ version integer NOT NULL,
211
+ checksum text NOT NULL,
212
+ applied_at timestamptz NOT NULL
213
+ );
214
+ CREATE TABLE IF NOT EXISTS builds (
215
+ slug text PRIMARY KEY, repo text NOT NULL, ticket jsonb, branch text,
216
+ created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
217
+ lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
218
+ heartbeat_at timestamptz
219
+ );
220
+ CREATE TABLE IF NOT EXISTS events (
221
+ build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
222
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
223
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (build, seq)
224
+ );
225
+ CREATE TABLE IF NOT EXISTS artifacts (
226
+ build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
227
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
228
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
229
+ PRIMARY KEY (build, kind, revision)
230
+ );
231
+ CREATE TABLE IF NOT EXISTS repo_streams (
232
+ repo text PRIMARY KEY, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
233
+ lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
234
+ heartbeat_at timestamptz
235
+ );
236
+ CREATE TABLE IF NOT EXISTS repo_events (
237
+ repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
238
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
239
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (repo, seq)
240
+ );
241
+ CREATE TABLE IF NOT EXISTS repo_artifacts (
242
+ repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
243
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
244
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
245
+ PRIMARY KEY (repo, kind, revision)
246
+ );
247
+ CREATE SEQUENCE IF NOT EXISTS sessions_creation_seq;
248
+ CREATE TABLE IF NOT EXISTS sessions (
249
+ id text PRIMARY KEY,
250
+ repo text NOT NULL,
251
+ operator text NOT NULL,
252
+ title text,
253
+ created_at timestamptz NOT NULL,
254
+ updated_at timestamptz NOT NULL,
255
+ creation_seq bigint NOT NULL
256
+ );
257
+ CREATE TABLE IF NOT EXISTS session_events (
258
+ session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
259
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
260
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (session, seq)
261
+ );
262
+ CREATE TABLE IF NOT EXISTS session_artifacts (
263
+ session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
264
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
265
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
266
+ PRIMARY KEY (session, kind, revision)
267
+ );
268
+ CREATE TABLE IF NOT EXISTS streams (
269
+ id text PRIMARY KEY, scope_kind text NOT NULL,
270
+ build text REFERENCES builds(slug) ON DELETE CASCADE,
271
+ repo text REFERENCES repo_streams(repo) ON DELETE CASCADE,
272
+ label text NOT NULL, format text NOT NULL, status text NOT NULL,
273
+ outcome text, artifact_kind text, artifact_revision bigint,
274
+ artifact_blob_ref text, created_at timestamptz NOT NULL, closed_at timestamptz,
275
+ session text REFERENCES sessions(id) ON DELETE CASCADE,
276
+ CONSTRAINT streams_scope_kind_check CHECK (scope_kind IN ('build','repo','session')),
277
+ CONSTRAINT streams_status_check CHECK (status IN ('open','closed')),
278
+ CONSTRAINT streams_scope_exactly_one_check CHECK (
279
+ (scope_kind = 'build' AND build IS NOT NULL AND repo IS NULL AND session IS NULL)
280
+ OR (scope_kind = 'repo' AND build IS NULL AND repo IS NOT NULL AND session IS NULL)
281
+ OR (scope_kind = 'session' AND build IS NULL AND repo IS NULL AND session IS NOT NULL)
282
+ )
283
+ );
284
+ CREATE TABLE IF NOT EXISTS stream_chunks (
285
+ stream text NOT NULL REFERENCES streams(id) ON DELETE CASCADE,
286
+ seq bigint NOT NULL, ts timestamptz NOT NULL, parts jsonb NOT NULL,
287
+ PRIMARY KEY (stream, seq)
288
+ );`.trim()
289
+ export const SCHEMA_V4_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_V4_DDL).digest('hex')
290
+
291
+ /** The frozen v5 DDL, kept verbatim so a deployed v5 marker's checksum can be
292
+ * recognized and upgraded in place (see migratePostgres). v5 is the shape the
293
+ * hosted service ran when the streams.creation_seq tiebreak landed without a
294
+ * version bump; every deploy after that failed the marker check until v6. */
295
+ export const SCHEMA_V5_DDL = `
296
+ CREATE TABLE IF NOT EXISTS ab_schema_migrations (
297
+ singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
298
+ version integer NOT NULL,
299
+ checksum text NOT NULL,
300
+ applied_at timestamptz NOT NULL
301
+ );
302
+ CREATE TABLE IF NOT EXISTS builds (
303
+ slug text PRIMARY KEY, repo text NOT NULL, ticket jsonb, branch text,
304
+ created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
305
+ lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
306
+ heartbeat_at timestamptz, repo_origin text
307
+ );
308
+ CREATE TABLE IF NOT EXISTS events (
309
+ build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
310
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
311
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (build, seq)
312
+ );
313
+ CREATE TABLE IF NOT EXISTS artifacts (
314
+ build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
315
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
316
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
317
+ PRIMARY KEY (build, kind, revision)
318
+ );
319
+ CREATE TABLE IF NOT EXISTS repo_streams (
320
+ repo text PRIMARY KEY, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
321
+ lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
322
+ heartbeat_at timestamptz
323
+ );
324
+ CREATE TABLE IF NOT EXISTS repo_events (
325
+ repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
326
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
327
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (repo, seq)
328
+ );
329
+ CREATE TABLE IF NOT EXISTS repo_artifacts (
330
+ repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
331
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
332
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
333
+ PRIMARY KEY (repo, kind, revision)
334
+ );
335
+ CREATE SEQUENCE IF NOT EXISTS sessions_creation_seq;
336
+ CREATE TABLE IF NOT EXISTS sessions (
337
+ id text PRIMARY KEY,
338
+ repo text NOT NULL,
339
+ operator text NOT NULL,
340
+ title text,
341
+ created_at timestamptz NOT NULL,
342
+ updated_at timestamptz NOT NULL,
343
+ creation_seq bigint NOT NULL
344
+ );
345
+ CREATE TABLE IF NOT EXISTS session_events (
346
+ session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
347
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
348
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (session, seq)
349
+ );
350
+ CREATE TABLE IF NOT EXISTS session_artifacts (
351
+ session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
352
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
353
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
354
+ PRIMARY KEY (session, kind, revision)
355
+ );
356
+ CREATE TABLE IF NOT EXISTS streams (
357
+ id text PRIMARY KEY, scope_kind text NOT NULL,
358
+ build text REFERENCES builds(slug) ON DELETE CASCADE,
359
+ repo text REFERENCES repo_streams(repo) ON DELETE CASCADE,
360
+ label text NOT NULL, format text NOT NULL, status text NOT NULL,
361
+ outcome text, artifact_kind text, artifact_revision bigint,
362
+ artifact_blob_ref text, created_at timestamptz NOT NULL, closed_at timestamptz,
363
+ session text REFERENCES sessions(id) ON DELETE CASCADE,
364
+ CONSTRAINT streams_scope_kind_check CHECK (scope_kind IN ('build','repo','session')),
365
+ CONSTRAINT streams_status_check CHECK (status IN ('open','closed')),
366
+ CONSTRAINT streams_scope_exactly_one_check CHECK (
367
+ (scope_kind = 'build' AND build IS NOT NULL AND repo IS NULL AND session IS NULL)
368
+ OR (scope_kind = 'repo' AND build IS NULL AND repo IS NOT NULL AND session IS NULL)
369
+ OR (scope_kind = 'session' AND build IS NULL AND repo IS NULL AND session IS NOT NULL)
370
+ )
371
+ );
372
+ CREATE TABLE IF NOT EXISTS stream_chunks (
373
+ stream text NOT NULL REFERENCES streams(id) ON DELETE CASCADE,
374
+ seq bigint NOT NULL, ts timestamptz NOT NULL, parts jsonb NOT NULL,
375
+ PRIMARY KEY (stream, seq)
376
+ );`.trim()
377
+ export const SCHEMA_V5_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_V5_DDL).digest('hex')
378
+
379
+ export const SCHEMA_DDL = `
380
+ CREATE TABLE IF NOT EXISTS ab_schema_migrations (
381
+ singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
382
+ version integer NOT NULL,
383
+ checksum text NOT NULL,
384
+ applied_at timestamptz NOT NULL
385
+ );
386
+ CREATE TABLE IF NOT EXISTS builds (
387
+ slug text PRIMARY KEY, repo text NOT NULL, ticket jsonb, branch text,
388
+ created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
389
+ lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
390
+ heartbeat_at timestamptz, repo_origin text
391
+ );
392
+ CREATE TABLE IF NOT EXISTS events (
393
+ build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
394
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
395
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (build, seq)
396
+ );
397
+ CREATE TABLE IF NOT EXISTS artifacts (
398
+ build text NOT NULL REFERENCES builds(slug) ON DELETE CASCADE,
399
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
400
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
401
+ PRIMARY KEY (build, kind, revision)
402
+ );
403
+ CREATE TABLE IF NOT EXISTS repo_streams (
404
+ repo text PRIMARY KEY, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
405
+ lease_holder text, lease_expires_at timestamptz, lease_ttl_ms bigint,
406
+ heartbeat_at timestamptz
407
+ );
408
+ CREATE TABLE IF NOT EXISTS repo_events (
409
+ repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
410
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
411
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (repo, seq)
412
+ );
413
+ CREATE TABLE IF NOT EXISTS repo_artifacts (
414
+ repo text NOT NULL REFERENCES repo_streams(repo) ON DELETE CASCADE,
415
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
416
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
417
+ PRIMARY KEY (repo, kind, revision)
418
+ );
419
+ CREATE SEQUENCE IF NOT EXISTS sessions_creation_seq;
420
+ CREATE TABLE IF NOT EXISTS sessions (
421
+ id text PRIMARY KEY,
422
+ repo text NOT NULL,
423
+ operator text NOT NULL,
424
+ title text,
425
+ created_at timestamptz NOT NULL,
426
+ updated_at timestamptz NOT NULL,
427
+ creation_seq bigint NOT NULL
428
+ );
429
+ CREATE TABLE IF NOT EXISTS session_events (
430
+ session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
431
+ seq bigint NOT NULL, ts timestamptz NOT NULL, actor jsonb NOT NULL,
432
+ type text NOT NULL, payload jsonb NOT NULL, PRIMARY KEY (session, seq)
433
+ );
434
+ CREATE TABLE IF NOT EXISTS session_artifacts (
435
+ session text NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
436
+ kind text NOT NULL, revision bigint NOT NULL, blob_ref text NOT NULL,
437
+ metadata jsonb NOT NULL, created_at timestamptz NOT NULL,
438
+ PRIMARY KEY (session, kind, revision)
439
+ );
440
+ CREATE SEQUENCE IF NOT EXISTS streams_creation_seq;
441
+ CREATE TABLE IF NOT EXISTS streams (
442
+ id text PRIMARY KEY, scope_kind text NOT NULL,
443
+ build text REFERENCES builds(slug) ON DELETE CASCADE,
444
+ repo text REFERENCES repo_streams(repo) ON DELETE CASCADE,
445
+ label text NOT NULL, format text NOT NULL, status text NOT NULL,
446
+ outcome text, artifact_kind text, artifact_revision bigint,
447
+ artifact_blob_ref text, created_at timestamptz NOT NULL, closed_at timestamptz,
448
+ session text REFERENCES sessions(id) ON DELETE CASCADE,
449
+ creation_seq bigint NOT NULL,
450
+ CONSTRAINT streams_scope_kind_check CHECK (scope_kind IN ('build','repo','session')),
451
+ CONSTRAINT streams_status_check CHECK (status IN ('open','closed')),
452
+ CONSTRAINT streams_scope_exactly_one_check CHECK (
453
+ (scope_kind = 'build' AND build IS NOT NULL AND repo IS NULL AND session IS NULL)
454
+ OR (scope_kind = 'repo' AND build IS NULL AND repo IS NOT NULL AND session IS NULL)
455
+ OR (scope_kind = 'session' AND build IS NULL AND repo IS NULL AND session IS NOT NULL)
456
+ )
457
+ );
458
+ CREATE TABLE IF NOT EXISTS stream_chunks (
459
+ stream text NOT NULL REFERENCES streams(id) ON DELETE CASCADE,
460
+ seq bigint NOT NULL, ts timestamptz NOT NULL, parts jsonb NOT NULL,
461
+ PRIMARY KEY (stream, seq)
462
+ );`.trim()
463
+
464
+ export const SCHEMA_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_DDL).digest('hex')
465
+
466
+ /** Ticket persistence evolves independently so deployed v1 BuildStore markers
467
+ * remain valid while the hosted ticket tables are added. */
468
+ export const TICKET_SCHEMA_VERSION = 1
469
+ export const TICKET_SCHEMA_DDL = `
470
+ CREATE TABLE IF NOT EXISTS ab_ticket_schema_migrations (
471
+ singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
472
+ version integer NOT NULL, checksum text NOT NULL, applied_at timestamptz NOT NULL
473
+ );
474
+ CREATE TABLE IF NOT EXISTS ab_tickets (
475
+ team text NOT NULL, id text NOT NULL, creation_key text,
476
+ title text NOT NULL, body text NOT NULL, state text NOT NULL,
477
+ labels text[] NOT NULL DEFAULT '{}', created_at timestamptz NOT NULL,
478
+ updated_at timestamptz NOT NULL,
479
+ PRIMARY KEY (team, id), UNIQUE (team, creation_key)
480
+ );
481
+ CREATE TABLE IF NOT EXISTS ab_ticket_comments (
482
+ team text NOT NULL, ticket_id text NOT NULL, seq bigint NOT NULL,
483
+ body text NOT NULL, created_at timestamptz NOT NULL,
484
+ PRIMARY KEY (team, ticket_id, seq),
485
+ FOREIGN KEY (team, ticket_id) REFERENCES ab_tickets(team, id) ON DELETE CASCADE
486
+ );
487
+ CREATE TABLE IF NOT EXISTS ab_ticket_blockers (
488
+ team text NOT NULL, ticket_id text NOT NULL, blocker_id text NOT NULL,
489
+ PRIMARY KEY (team, ticket_id, blocker_id),
490
+ FOREIGN KEY (team, ticket_id) REFERENCES ab_tickets(team, id) ON DELETE CASCADE,
491
+ FOREIGN KEY (team, blocker_id) REFERENCES ab_tickets(team, id) ON DELETE CASCADE,
492
+ CHECK (ticket_id <> blocker_id)
493
+ );`.trim()
494
+ export const TICKET_SCHEMA_CHECKSUM = new Bun.CryptoHasher('sha256')
495
+ .update(TICKET_SCHEMA_DDL)
496
+ .digest('hex')
497
+
498
+ type ExpectedColumn = readonly [name: string, type: string, notNull: boolean, defaultValue?: string]
499
+
500
+ const EXPECTED_COLUMNS: Record<string, readonly ExpectedColumn[]> = {
501
+ ab_schema_migrations: [
502
+ ['singleton', 'boolean', true, 'true'],
503
+ ['version', 'integer', true],
504
+ ['checksum', 'text', true],
505
+ ['applied_at', 'timestamp with time zone', true],
506
+ ],
507
+ builds: [
508
+ ['slug', 'text', true],
509
+ ['repo', 'text', true],
510
+ ['ticket', 'jsonb', false],
511
+ ['branch', 'text', false],
512
+ ['created_at', 'timestamp with time zone', true],
513
+ ['updated_at', 'timestamp with time zone', true],
514
+ ['lease_holder', 'text', false],
515
+ ['lease_expires_at', 'timestamp with time zone', false],
516
+ ['lease_ttl_ms', 'bigint', false],
517
+ ['heartbeat_at', 'timestamp with time zone', false],
518
+ // The repo_origin column rides last: the guarded v4→v5 ALTER adds it at
519
+ // the end, so migrated and fresh databases assert identically (the
520
+ // sessions.creation_seq and streams.session comments above).
521
+ ['repo_origin', 'text', false],
522
+ ],
523
+ events: [
524
+ ['build', 'text', true],
525
+ ['seq', 'bigint', true],
526
+ ['ts', 'timestamp with time zone', true],
527
+ ['actor', 'jsonb', true],
528
+ ['type', 'text', true],
529
+ ['payload', 'jsonb', true],
530
+ ],
531
+ artifacts: [
532
+ ['build', 'text', true],
533
+ ['kind', 'text', true],
534
+ ['revision', 'bigint', true],
535
+ ['blob_ref', 'text', true],
536
+ ['metadata', 'jsonb', true],
537
+ ['created_at', 'timestamp with time zone', true],
538
+ ],
539
+ repo_streams: [
540
+ ['repo', 'text', true],
541
+ ['created_at', 'timestamp with time zone', true],
542
+ ['updated_at', 'timestamp with time zone', true],
543
+ ['lease_holder', 'text', false],
544
+ ['lease_expires_at', 'timestamp with time zone', false],
545
+ ['lease_ttl_ms', 'bigint', false],
546
+ ['heartbeat_at', 'timestamp with time zone', false],
547
+ ],
548
+ repo_events: [
549
+ ['repo', 'text', true],
550
+ ['seq', 'bigint', true],
551
+ ['ts', 'timestamp with time zone', true],
552
+ ['actor', 'jsonb', true],
553
+ ['type', 'text', true],
554
+ ['payload', 'jsonb', true],
555
+ ],
556
+ repo_artifacts: [
557
+ ['repo', 'text', true],
558
+ ['kind', 'text', true],
559
+ ['revision', 'bigint', true],
560
+ ['blob_ref', 'text', true],
561
+ ['metadata', 'jsonb', true],
562
+ ['created_at', 'timestamp with time zone', true],
563
+ ],
564
+ sessions: [
565
+ ['id', 'text', true],
566
+ ['repo', 'text', true],
567
+ ['operator', 'text', true],
568
+ ['title', 'text', false],
569
+ ['created_at', 'timestamp with time zone', true],
570
+ ['updated_at', 'timestamp with time zone', true],
571
+ // The creation-order tiebreak column rides last: the guarded v3→v4 ALTER
572
+ // adds it at the end, so migrated and fresh databases assert identically
573
+ // (the streams.session precedent).
574
+ ['creation_seq', 'bigint', true],
575
+ ],
576
+ session_events: [
577
+ ['session', 'text', true],
578
+ ['seq', 'bigint', true],
579
+ ['ts', 'timestamp with time zone', true],
580
+ ['actor', 'jsonb', true],
581
+ ['type', 'text', true],
582
+ ['payload', 'jsonb', true],
583
+ ],
584
+ session_artifacts: [
585
+ ['session', 'text', true],
586
+ ['kind', 'text', true],
587
+ ['revision', 'bigint', true],
588
+ ['blob_ref', 'text', true],
589
+ ['metadata', 'jsonb', true],
590
+ ['created_at', 'timestamp with time zone', true],
591
+ ],
592
+ streams: [
593
+ ['id', 'text', true],
594
+ ['scope_kind', 'text', true],
595
+ ['build', 'text', false],
596
+ ['repo', 'text', false],
597
+ ['label', 'text', true],
598
+ ['format', 'text', true],
599
+ ['status', 'text', true],
600
+ ['outcome', 'text', false],
601
+ ['artifact_kind', 'text', false],
602
+ ['artifact_revision', 'bigint', false],
603
+ ['artifact_blob_ref', 'text', false],
604
+ ['created_at', 'timestamp with time zone', true],
605
+ ['closed_at', 'timestamp with time zone', false],
606
+ // The session column rides last-but-one and the creation-order tiebreak
607
+ // column rides last: the guarded v2→v3 ALTER adds session at the end and
608
+ // the guarded tiebreak ALTERs append creation_seq after it, so migrated
609
+ // and fresh databases assert identically.
610
+ ['session', 'text', false],
611
+ ['creation_seq', 'bigint', true],
612
+ ],
613
+ stream_chunks: [
614
+ ['stream', 'text', true],
615
+ ['seq', 'bigint', true],
616
+ ['ts', 'timestamp with time zone', true],
617
+ ['parts', 'jsonb', true],
618
+ ],
619
+ }
620
+
621
+ const EXPECTED_CONSTRAINTS = [
622
+ // CHECK constraints are compared by presence only (their pg_get_expr text
623
+ // is version-sensitive); PK/FK rows compare fully.
624
+ 'ab_schema_migrations|c|||||',
625
+ 'ab_schema_migrations|p|singleton||||',
626
+ 'artifacts|f|build|builds|slug|c|',
627
+ 'artifacts|p|build,kind,revision||||',
628
+ 'builds|p|slug||||',
629
+ 'events|f|build|builds|slug|c|',
630
+ 'events|p|build,seq||||',
631
+ 'repo_artifacts|f|repo|repo_streams|repo|c|',
632
+ 'repo_artifacts|p|repo,kind,revision||||',
633
+ 'repo_events|f|repo|repo_streams|repo|c|',
634
+ 'repo_events|p|repo,seq||||',
635
+ 'repo_streams|p|repo||||',
636
+ 'session_artifacts|f|session|sessions|id|c|',
637
+ 'session_artifacts|p|session,kind,revision||||',
638
+ 'session_events|f|session|sessions|id|c|',
639
+ 'session_events|p|session,seq||||',
640
+ 'sessions|p|id||||',
641
+ 'stream_chunks|f|stream|streams|id|c|',
642
+ 'stream_chunks|p|stream,seq||||',
643
+ 'streams|c|||||',
644
+ 'streams|c|||||',
645
+ 'streams|c|||||',
646
+ 'streams|f|build|builds|slug|c|',
647
+ 'streams|f|repo|repo_streams|repo|c|',
648
+ 'streams|f|session|sessions|id|c|',
649
+ 'streams|p|id||||',
650
+ ] as const
651
+
652
+ export function schemaError(detail: string): Error {
653
+ return new Error(`PostgreSQL BuildStore schema ${detail}; run: ${MIGRATE_COMMAND}`)
654
+ }
655
+
656
+ interface CatalogColumn {
657
+ table_name: string
658
+ column_name: string
659
+ formatted_type: string
660
+ not_null: boolean
661
+ default_expression: string | null
662
+ }
663
+
664
+ interface CatalogConstraint {
665
+ table_name: string
666
+ constraint_type: string
667
+ columns: unknown
668
+ referenced_table: string | null
669
+ referenced_columns: unknown
670
+ delete_action: string
671
+ check_expression: string | null
672
+ }
673
+
674
+ function stringArray(value: unknown): string[] {
675
+ if (Array.isArray(value) && value.every((entry) => typeof entry === 'string')) return value
676
+ throw schemaError('catalog shape is unreadable')
677
+ }
678
+
679
+ async function assertCatalogShape(sql: SQL): Promise<void> {
680
+ const columns: CatalogColumn[] = await sql`
681
+ SELECT c.relname AS table_name, a.attname AS column_name,
682
+ pg_catalog.format_type(a.atttypid, a.atttypmod) AS formatted_type,
683
+ a.attnotnull AS not_null,
684
+ CASE WHEN ad.oid IS NULL THEN NULL ELSE pg_catalog.pg_get_expr(ad.adbin, ad.adrelid) END
685
+ AS default_expression
686
+ FROM pg_catalog.pg_class c
687
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
688
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid
689
+ LEFT JOIN pg_catalog.pg_attrdef ad ON ad.adrelid = c.oid AND ad.adnum = a.attnum
690
+ WHERE n.nspname = current_schema() AND c.relkind IN ('r', 'p')
691
+ AND a.attnum > 0 AND NOT a.attisdropped
692
+ ORDER BY c.relname, a.attnum`
693
+
694
+ for (const [table, expected] of Object.entries(EXPECTED_COLUMNS)) {
695
+ const actual = columns
696
+ .filter((column) => column.table_name === table)
697
+ .map((column) => [
698
+ column.column_name,
699
+ column.formatted_type,
700
+ column.not_null,
701
+ column.default_expression ?? undefined,
702
+ ])
703
+ const normalizedExpected = expected.map(([name, type, notNull, defaultValue]) => [
704
+ name,
705
+ type,
706
+ notNull,
707
+ defaultValue,
708
+ ])
709
+ if (JSON.stringify(actual) !== JSON.stringify(normalizedExpected)) {
710
+ throw schemaError(`table ${table} is missing or mismatched`)
711
+ }
712
+ }
713
+
714
+ const constraints: CatalogConstraint[] = await sql`
715
+ SELECT owner.relname AS table_name, con.contype AS constraint_type,
716
+ to_jsonb(ARRAY(
717
+ SELECT attribute.attname
718
+ FROM unnest(con.conkey) WITH ORDINALITY AS key(attnum, position)
719
+ JOIN pg_catalog.pg_attribute attribute
720
+ ON attribute.attrelid = con.conrelid AND attribute.attnum = key.attnum
721
+ ORDER BY key.position
722
+ )) AS columns,
723
+ referenced.relname AS referenced_table,
724
+ to_jsonb(ARRAY(
725
+ SELECT attribute.attname
726
+ FROM unnest(con.confkey) WITH ORDINALITY AS key(attnum, position)
727
+ JOIN pg_catalog.pg_attribute attribute
728
+ ON attribute.attrelid = con.confrelid AND attribute.attnum = key.attnum
729
+ ORDER BY key.position
730
+ )) AS referenced_columns,
731
+ con.confdeltype AS delete_action,
732
+ CASE WHEN con.contype = 'c' THEN pg_catalog.pg_get_expr(con.conbin, con.conrelid) ELSE NULL END
733
+ AS check_expression
734
+ FROM pg_catalog.pg_constraint con
735
+ JOIN pg_catalog.pg_class owner ON owner.oid = con.conrelid
736
+ JOIN pg_catalog.pg_namespace n ON n.oid = owner.relnamespace
737
+ LEFT JOIN pg_catalog.pg_class referenced ON referenced.oid = con.confrelid
738
+ WHERE n.nspname = current_schema() AND con.contype IN ('p', 'f', 'c')`
739
+ const expectedTables = new Set(Object.keys(EXPECTED_COLUMNS))
740
+ const actualConstraints = constraints
741
+ .filter((constraint) => expectedTables.has(constraint.table_name))
742
+ .map((constraint) =>
743
+ // CHECK constraints are asserted by presence; their pg_get_expr text
744
+ // varies across PostgreSQL versions and is not design-critical.
745
+ constraint.constraint_type === 'c'
746
+ ? [constraint.table_name, 'c', '', '', '', '', ''].join('|')
747
+ : [
748
+ constraint.table_name,
749
+ constraint.constraint_type,
750
+ stringArray(constraint.columns).join(','),
751
+ constraint.referenced_table ?? '',
752
+ stringArray(constraint.referenced_columns).join(','),
753
+ constraint.constraint_type === 'f' ? constraint.delete_action : '',
754
+ constraint.check_expression ?? '',
755
+ ].join('|'),
756
+ )
757
+ .sort()
758
+ if (JSON.stringify(actualConstraints) !== JSON.stringify(EXPECTED_CONSTRAINTS)) {
759
+ throw schemaError('constraints are missing or mismatched')
760
+ }
761
+ }
762
+
763
+ export async function assertSchema(sql: SQL): Promise<void> {
764
+ let rows: { version: number; checksum: string }[]
765
+ try {
766
+ rows = await sql`SELECT version, checksum FROM ab_schema_migrations WHERE singleton = true`
767
+ } catch (error) {
768
+ const code =
769
+ (error as { code?: string; errno?: string }).errno ?? (error as { code?: string }).code
770
+ if (code === '42P01') throw schemaError('is missing')
771
+ throw error
772
+ }
773
+ const marker = rows[0]
774
+ if (!marker) throw schemaError('marker is missing')
775
+ if (Number(marker.version) !== SCHEMA_VERSION) {
776
+ throw schemaError(`version ${marker.version} does not match required version ${SCHEMA_VERSION}`)
777
+ }
778
+ if (marker.checksum !== SCHEMA_CHECKSUM) throw schemaError('checksum is mismatched')
779
+ await assertCatalogShape(sql)
780
+ }
781
+
782
+ export async function assertTicketSchema(sql: SQL): Promise<void> {
783
+ let rows: { version: number; checksum: string }[]
784
+ try {
785
+ rows =
786
+ await sql`SELECT version, checksum FROM ab_ticket_schema_migrations WHERE singleton = true`
787
+ } catch (error) {
788
+ const code =
789
+ (error as { code?: string; errno?: string }).errno ?? (error as { code?: string }).code
790
+ if (code === '42P01') throw schemaError('ticket tables are missing')
791
+ throw error
792
+ }
793
+ const marker = rows[0]
794
+ 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')
800
+ }
801
+ const expected: Record<string, Array<[string, string, boolean]>> = {
802
+ ab_ticket_schema_migrations: [
803
+ ['singleton', 'boolean', true],
804
+ ['version', 'integer', true],
805
+ ['checksum', 'text', true],
806
+ ['applied_at', 'timestamp with time zone', true],
807
+ ],
808
+ ab_tickets: [
809
+ ['team', 'text', true],
810
+ ['id', 'text', true],
811
+ ['creation_key', 'text', false],
812
+ ['title', 'text', true],
813
+ ['body', 'text', true],
814
+ ['state', 'text', true],
815
+ ['labels', 'text[]', true],
816
+ ['created_at', 'timestamp with time zone', true],
817
+ ['updated_at', 'timestamp with time zone', true],
818
+ ],
819
+ ab_ticket_comments: [
820
+ ['team', 'text', true],
821
+ ['ticket_id', 'text', true],
822
+ ['seq', 'bigint', true],
823
+ ['body', 'text', true],
824
+ ['created_at', 'timestamp with time zone', true],
825
+ ],
826
+ ab_ticket_blockers: [
827
+ ['team', 'text', true],
828
+ ['ticket_id', 'text', true],
829
+ ['blocker_id', 'text', true],
830
+ ],
831
+ }
832
+ const columns: CatalogColumn[] = await sql`
833
+ SELECT c.relname AS table_name, a.attname AS column_name,
834
+ pg_catalog.format_type(a.atttypid, a.atttypmod) AS formatted_type,
835
+ a.attnotnull AS not_null, NULL::text AS default_expression
836
+ FROM pg_catalog.pg_class c
837
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
838
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid
839
+ WHERE n.nspname = current_schema() AND c.relkind IN ('r', 'p')
840
+ AND a.attnum > 0 AND NOT a.attisdropped
841
+ AND c.relname = ANY(ARRAY['ab_ticket_schema_migrations', 'ab_tickets',
842
+ 'ab_ticket_comments', 'ab_ticket_blockers'])
843
+ ORDER BY c.relname, a.attnum`
844
+ for (const [table, shape] of Object.entries(expected)) {
845
+ const actual = columns
846
+ .filter((column) => column.table_name === table)
847
+ .map((column) => [column.column_name, column.formatted_type, column.not_null])
848
+ if (JSON.stringify(actual) !== JSON.stringify(shape)) {
849
+ throw schemaError(`ticket table ${table} is missing or mismatched`)
850
+ }
851
+ }
852
+ }
853
+
854
+ /** The guarded streams.creation_seq upgrade, shared by every pre-v6 marker
855
+ * branch whose database carries a pre-existing streams table (v2, v3, v4, v5).
856
+ * The idempotent full DDL above has already created the
857
+ * streams_creation_seq sequence; the column is added only when missing, with
858
+ * a (created_at, id)-ordered backfill — legacy same-millisecond ties are
859
+ * genuinely unorderable, so any total order consistent with createdAt is
860
+ * acceptable — SET NOT NULL, and the sequence positioned above the backfilled
861
+ * values so the next nextval continues the counter without collision. */
862
+ const STREAMS_CREATION_SEQ_MIGRATION = `
863
+ DO $$ BEGIN
864
+ IF NOT EXISTS (
865
+ SELECT 1 FROM information_schema.columns
866
+ WHERE table_schema = current_schema() AND table_name = 'streams'
867
+ AND column_name = 'creation_seq'
868
+ ) THEN
869
+ ALTER TABLE streams ADD COLUMN creation_seq bigint;
870
+ WITH numbered AS (
871
+ SELECT id, row_number() OVER (ORDER BY created_at, id) AS rn FROM streams
872
+ )
873
+ UPDATE streams SET creation_seq = numbered.rn
874
+ FROM numbered WHERE streams.id = numbered.id;
875
+ ALTER TABLE streams ALTER COLUMN creation_seq SET NOT NULL;
876
+ PERFORM setval('streams_creation_seq',
877
+ (SELECT COALESCE(MAX(creation_seq), 0) FROM streams) + 1, false);
878
+ END IF;
879
+ END $$;
880
+ `
881
+
882
+ export async function migratePostgres(url: string): Promise<void> {
883
+ const sql = new SQL(url)
884
+ try {
885
+ await sql.begin(async (tx) => {
886
+ await tx`SELECT pg_advisory_xact_lock(470281941)`
887
+ await tx.unsafe(SCHEMA_DDL)
888
+ await tx.unsafe(TICKET_SCHEMA_DDL)
889
+ await tx.unsafe(AUTH_SCHEMA_DDL)
890
+ const rows: { version: number; checksum: string }[] =
891
+ await tx`SELECT version, checksum FROM ab_schema_migrations WHERE singleton = true FOR UPDATE`
892
+ const marker = rows[0]
893
+ 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
896
+ // branch. `CREATE TABLE IF NOT EXISTS builds` does not alter an
897
+ // existing builds table, so without this the internal assertSchema
898
+ // 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
900
+ // marker that turns out to be rejected below, which rolls this
901
+ // transaction back) is a no-op.
902
+ await tx.unsafe(`
903
+ DO $$ BEGIN
904
+ IF NOT EXISTS (
905
+ SELECT 1 FROM information_schema.columns
906
+ WHERE table_schema = current_schema() AND table_name = 'builds'
907
+ AND column_name = 'repo_origin'
908
+ ) THEN
909
+ ALTER TABLE builds ADD COLUMN repo_origin text;
910
+ END IF;
911
+ END $$;
912
+ `)
913
+ const version = Number(marker.version)
914
+ if (version === SCHEMA_VERSION) {
915
+ if (marker.checksum !== SCHEMA_CHECKSUM) throw schemaError('marker is incompatible')
916
+ } else if (version === 1 && marker.checksum === SCHEMA_V1_CHECKSUM) {
917
+ // v1 → v6: the idempotent full DDL above already applied the deltas
918
+ // (the stream tables and the session tables); v1 databases never had
919
+ // a streams or sessions table, so the full DDL created them with the
920
+ // 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.
923
+ await tx`UPDATE ab_schema_migrations
924
+ SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
925
+ applied_at = ${new Date().toISOString()}
926
+ WHERE singleton = true`
927
+ } else if (version === 2 && marker.checksum === SCHEMA_V2_CHECKSUM) {
928
+ // v2 → v6: the idempotent full DDL above created the session tables
929
+ // (with the creation_seq column); a v2 database's streams table
930
+ // needs the guarded session column, the widened CHECK constraints,
931
+ // and the guarded creation_seq column (shared with v3 and v4).
932
+ await tx.unsafe(`
933
+ DO $$ BEGIN
934
+ IF NOT EXISTS (
935
+ SELECT 1 FROM information_schema.columns
936
+ WHERE table_schema = current_schema() AND table_name = 'streams'
937
+ AND column_name = 'session'
938
+ ) THEN
939
+ ALTER TABLE streams
940
+ ADD COLUMN session text REFERENCES sessions(id) ON DELETE CASCADE;
941
+ END IF;
942
+ END $$;
943
+ ALTER TABLE streams DROP CONSTRAINT IF EXISTS streams_scope_kind_check;
944
+ ALTER TABLE streams
945
+ ADD CONSTRAINT streams_scope_kind_check
946
+ CHECK (scope_kind IN ('build','repo','session'));
947
+ ALTER TABLE streams DROP CONSTRAINT IF EXISTS streams_scope_exactly_one_check;
948
+ ALTER TABLE streams
949
+ ADD CONSTRAINT streams_scope_exactly_one_check CHECK (
950
+ (scope_kind = 'build' AND build IS NOT NULL AND repo IS NULL AND session IS NULL)
951
+ OR (scope_kind = 'repo' AND build IS NULL AND repo IS NOT NULL AND session IS NULL)
952
+ OR (scope_kind = 'session' AND build IS NULL AND repo IS NULL AND session IS NOT NULL)
953
+ );
954
+ `)
955
+ await tx.unsafe(STREAMS_CREATION_SEQ_MIGRATION)
956
+ await tx`UPDATE ab_schema_migrations
957
+ SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
958
+ applied_at = ${new Date().toISOString()}
959
+ WHERE singleton = true`
960
+ } else if (version === 3 && marker.checksum === SCHEMA_V3_CHECKSUM) {
961
+ // v3 → v6: the listSessions and listStreams creation-order
962
+ // tiebreaks (store/types.ts). The idempotent full DDL above created
963
+ // the sessions_creation_seq and streams_creation_seq sequences; a
964
+ // v3 database's sessions and streams tables need the guarded
965
+ // creation_seq columns, each with a (created_at, id)-ordered
966
+ // backfill — legacy same-millisecond ties are genuinely
967
+ // unorderable, so any total order consistent with createdAt is
968
+ // acceptable — SET NOT NULL, and the sequence positioned above the
969
+ // backfilled values so the next nextval continues the counter
970
+ // without collision. The guarded repo_origin ALTER above covered
971
+ // builds.
972
+ await tx.unsafe(`
973
+ DO $$ BEGIN
974
+ IF NOT EXISTS (
975
+ SELECT 1 FROM information_schema.columns
976
+ WHERE table_schema = current_schema() AND table_name = 'sessions'
977
+ AND column_name = 'creation_seq'
978
+ ) THEN
979
+ ALTER TABLE sessions ADD COLUMN creation_seq bigint;
980
+ WITH numbered AS (
981
+ SELECT id, row_number() OVER (ORDER BY created_at, id) AS rn FROM sessions
982
+ )
983
+ UPDATE sessions SET creation_seq = numbered.rn
984
+ FROM numbered WHERE sessions.id = numbered.id;
985
+ ALTER TABLE sessions ALTER COLUMN creation_seq SET NOT NULL;
986
+ PERFORM setval('sessions_creation_seq',
987
+ (SELECT COALESCE(MAX(creation_seq), 0) FROM sessions) + 1, false);
988
+ END IF;
989
+ END $$;
990
+ `)
991
+ await tx.unsafe(STREAMS_CREATION_SEQ_MIGRATION)
992
+ await tx`UPDATE ab_schema_migrations
993
+ SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
994
+ applied_at = ${new Date().toISOString()}
995
+ WHERE singleton = true`
996
+ } else if (version === 4 && marker.checksum === SCHEMA_V4_CHECKSUM) {
997
+ // v4 → v6: the listStreams creation-order tiebreak
998
+ // (store/types.ts), mirroring the v3→v4 sessions treatment. The
999
+ // idempotent full DDL above created the streams_creation_seq
1000
+ // sequence; a v4 database's streams table needs the guarded
1001
+ // creation_seq column, backfill, NOT NULL, and sequence continuity
1002
+ // (the shared pre-v6 streams migration). The guarded repo_origin
1003
+ // ALTER above covered builds.
1004
+ await tx.unsafe(STREAMS_CREATION_SEQ_MIGRATION)
1005
+ await tx`UPDATE ab_schema_migrations
1006
+ SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
1007
+ applied_at = ${new Date().toISOString()}
1008
+ WHERE singleton = true`
1009
+ } else if (version === 5 && marker.checksum === SCHEMA_V5_CHECKSUM) {
1010
+ // v5 → v6: the listStreams creation-order tiebreak (store/types.ts)
1011
+ // landed on the v5 DDL without a version bump, so the hosted
1012
+ // database carries the v5 marker with the pre-tiebreak checksum. The
1013
+ // idempotent full DDL above created the streams_creation_seq
1014
+ // sequence; a v5 database's streams table needs the guarded
1015
+ // 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.
1018
+ await tx.unsafe(STREAMS_CREATION_SEQ_MIGRATION)
1019
+ await tx`UPDATE ab_schema_migrations
1020
+ SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
1021
+ applied_at = ${new Date().toISOString()}
1022
+ WHERE singleton = true`
1023
+ } else {
1024
+ throw schemaError('marker is incompatible')
1025
+ }
1026
+ } else {
1027
+ await tx`INSERT INTO ab_schema_migrations (singleton, version, checksum, applied_at)
1028
+ VALUES (true, ${SCHEMA_VERSION}, ${SCHEMA_CHECKSUM}, ${new Date().toISOString()})`
1029
+ }
1030
+ const ticketRows: { version: number; checksum: string }[] =
1031
+ await tx`SELECT version, checksum FROM ab_ticket_schema_migrations WHERE singleton = true FOR UPDATE`
1032
+ const ticketMarker = ticketRows[0]
1033
+ 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')
1039
+ }
1040
+ } else {
1041
+ await tx`INSERT INTO ab_ticket_schema_migrations
1042
+ (singleton, version, checksum, applied_at)
1043
+ VALUES (true, ${TICKET_SCHEMA_VERSION}, ${TICKET_SCHEMA_CHECKSUM}, ${new Date().toISOString()})`
1044
+ }
1045
+ const authRows: { version: number; checksum: string }[] =
1046
+ await tx`SELECT version, checksum FROM ab_auth_schema_migrations WHERE singleton = true FOR UPDATE`
1047
+ const authMarker = authRows[0]
1048
+ // v2 → v3: the guarded oauthApplication.authenticationScheme column
1049
+ // runs for EVERY pre-v3 auth marker, before the version branches —
1050
+ // `CREATE TABLE IF NOT EXISTS "oauthApplication"` does not alter an
1051
+ // existing table, so without this the internal assertAuthSchema below
1052
+ // would fail on every upgraded v1/v2 database. Guarded and idempotent
1053
+ // (the builds.repo_origin precedent), so a no-op on fresh installs,
1054
+ // v1 upgrades (full DDL already created the column), and current-v3
1055
+ // reruns. The column rides last in the v3 DDL so fresh and migrated
1056
+ // databases assert identically.
1057
+ await tx.unsafe(`
1058
+ ALTER TABLE "oauthApplication"
1059
+ ADD COLUMN IF NOT EXISTS "authenticationScheme" text;
1060
+ `)
1061
+ if (authMarker) {
1062
+ const authVersion = Number(authMarker.version)
1063
+ if (authVersion === AUTH_SCHEMA_VERSION) {
1064
+ if (authMarker.checksum !== AUTH_SCHEMA_CHECKSUM) {
1065
+ throw schemaError('auth marker is incompatible')
1066
+ }
1067
+ } else if (authVersion === 1 && authMarker.checksum === AUTH_SCHEMA_V1_CHECKSUM) {
1068
+ // v1 → v3: the idempotent full DDL above already created the four
1069
+ // MCP-plugin tables with the column (CREATE TABLE IF NOT EXISTS is
1070
+ // a no-op on the existing core tables); promote the marker in this
1071
+ // transaction.
1072
+ await tx`UPDATE ab_auth_schema_migrations
1073
+ SET version = ${AUTH_SCHEMA_VERSION}, checksum = ${AUTH_SCHEMA_CHECKSUM},
1074
+ applied_at = ${new Date().toISOString()}
1075
+ WHERE singleton = true`
1076
+ } else if (authVersion === 2 && authMarker.checksum === AUTH_SCHEMA_V2_CHECKSUM) {
1077
+ // v2 → v3: the guarded ALTER above added the column to the
1078
+ // pre-existing oauthApplication table; promote the marker in this
1079
+ // transaction.
1080
+ await tx`UPDATE ab_auth_schema_migrations
1081
+ SET version = ${AUTH_SCHEMA_VERSION}, checksum = ${AUTH_SCHEMA_CHECKSUM},
1082
+ applied_at = ${new Date().toISOString()}
1083
+ WHERE singleton = true`
1084
+ } else {
1085
+ throw schemaError('auth marker is incompatible')
1086
+ }
1087
+ } else {
1088
+ await tx`INSERT INTO ab_auth_schema_migrations
1089
+ (singleton, version, checksum, applied_at)
1090
+ VALUES (true, ${AUTH_SCHEMA_VERSION}, ${AUTH_SCHEMA_CHECKSUM}, ${new Date().toISOString()})`
1091
+ }
1092
+ await assertSchema(tx)
1093
+ await assertTicketSchema(tx)
1094
+ await assertAuthSchema(tx)
1095
+ })
1096
+ } finally {
1097
+ await sql.close()
1098
+ }
1099
+ }