@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/store.ts CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  pollingSubscribe,
10
10
  systemClock,
11
11
  toBytes,
12
+ resolvePinnedAsset,
13
+ validateCreatedEvent,
12
14
  validateEventWrite,
13
15
  validateExpectedSeq,
14
16
  validateRepositoryEventWrite,
@@ -19,6 +21,7 @@ import {
19
21
  type ArtifactMeta,
20
22
  type BlobStore,
21
23
  type BuildRecord,
24
+ type BuildDigest,
22
25
  type BuildScopedStore,
23
26
  type BuildStore,
24
27
  type Clock,
@@ -70,7 +73,29 @@ import {
70
73
  isRetentionManagedKind,
71
74
  revisionsToPrune,
72
75
  } from '@defrex/autobuild/store-adapter'
76
+ import {
77
+ DIGEST_EVENT_TYPES,
78
+ type DigestEventRow,
79
+ REPOSITORY_STATE_EVENT_TYPES,
80
+ readRepoStateEventsWithAnchorRecheck,
81
+ reduceBuildDigest,
82
+ } from '@defrex/autobuild/store-adapter'
83
+ import {
84
+ DEFAULT_TICKET_ASSET_LIMITS,
85
+ loadTicketAsset,
86
+ storeTicketAssetBlobs,
87
+ summarizeTicketAsset,
88
+ validateTicketAssetInput,
89
+ type TicketAsset,
90
+ type TicketAssetEntry,
91
+ type TicketAssetInput,
92
+ type TicketAssetLimits,
93
+ type TicketAssetMeta,
94
+ type TicketAssetSummary,
95
+ } from '@defrex/autobuild/store-adapter'
96
+ import type { ReducerSnapshot, SnapshotScope } from '@defrex/autobuild/store-adapter'
73
97
  import { assertSchema } from './schema'
98
+ import { PlanRetryRunner, type Exec, type Row } from './retry'
74
99
 
75
100
  // The held-read poll cadence for event waits — a re-export of the canonical
76
101
  // core constant (AUT-388), not a second definition. See core
@@ -78,8 +103,29 @@ import { assertSchema } from './schema'
78
103
  // rationale for why the bound is nominal rather than hard.
79
104
  export { EVENT_WAIT_POLL_MS }
80
105
 
81
- type Row = Record<string, unknown>
82
- type Tx = SQL
106
+ // ── Cached-plan invalidation retry (AUT-396) ─────────────────────────
107
+ // After a migration adds a column, pooled connections hold cached plans for
108
+ // statements returning `*` from that table, and the next execution on each
109
+ // stale plan fails with SQLSTATE 0A000 ("cached plan must not change result
110
+ // type") — and keeps failing, because Bun reuses named prepared statements
111
+ // keyed by query text and never rebuilds a rejected plan. Every store read
112
+ // and write path therefore executes through the `run`/`tx` runners below,
113
+ // which pin one pooled connection and retry the whole operation exactly once
114
+ // on a plan-change error. The retry re-prepares every statement the body
115
+ // executes under freshly minted marked variants — a migration can have
116
+ // poisoned any `*`-returning plan the body touches, not only the statement
117
+ // that failed first — and the variants are memoized per statement text:
118
+ // after the first failure, later executions skip the poisoned plans
119
+ // entirely, so a migration costs one failed execution per connection per
120
+ // statement and a bounded number of extra prepared statements — not one per
121
+ // operation. Every
122
+ // `SELECT *` (and `RETURNING *` — none exist today) on the
123
+ // migration-extendable tables — `streams`, `sessions`, `builds`, and every
124
+ // other store table — is kept under that same-connection retry rather than
125
+ // replaced by explicit column lists, which would cover only the tables named
126
+ // today and drift with the schema. See `./retry` for the Bun/PostgreSQL
127
+ // mechanics.
128
+
83
129
  interface PreparedArtifact {
84
130
  kind: string
85
131
  blobRef: string
@@ -114,6 +160,29 @@ export class PostgresBuildStore implements BuildStore {
114
160
  this.maxRevisions = options.retention?.maxRevisions ?? DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS
115
161
  }
116
162
 
163
+ /** The shared plan-retry mechanics (`./retry`): one runner per pool owner,
164
+ * so its memoized markers are namespaced to this store instance (see
165
+ * `./retry` for why the memo and the per-instance namespace exist). */
166
+ private readonly retry = new PlanRetryRunner()
167
+
168
+ /** Run a non-transactional operation on one pinned pooled connection,
169
+ * retrying it exactly once — same connection, statements freshly prepared —
170
+ * when a cached plan fails to revalidate (AUT-396). A run body must not
171
+ * call a public store method: reserving inside a reservation yields a
172
+ * brand-new connection instead of the pinned one — and must execute its
173
+ * statements sequentially, for failure attribution. See `./retry`. */
174
+ private async run<T>(body: (q: Exec) => Promise<T>): Promise<T> {
175
+ return this.retry.run(this.sql, body)
176
+ }
177
+
178
+ /** Run a transactional operation on one pinned pooled connection, retrying
179
+ * the whole body exactly once on a plan-change error: the failed attempt
180
+ * rolls back, and the body re-runs in a fresh transaction on the same
181
+ * connection with freshly prepared statements (AUT-396). See `./retry`. */
182
+ private async tx<T>(body: (q: Exec) => Promise<T>): Promise<T> {
183
+ return this.retry.tx(this.sql, body)
184
+ }
185
+
117
186
  scopeBuild(slug: string): BuildScopedStore {
118
187
  return createBuildScopedStore(this, slug)
119
188
  }
@@ -154,65 +223,118 @@ export class PostgresBuildStore implements BuildStore {
154
223
  }
155
224
  }
156
225
 
157
- private async lockBuild(tx: Tx, slug: string): Promise<Row> {
158
- const rows: Row[] = await tx`SELECT * FROM builds WHERE slug = ${slug} FOR UPDATE`
226
+ private async lockBuild(q: Exec, slug: string): Promise<Row> {
227
+ const rows: Row[] = await q`SELECT * FROM builds WHERE slug = ${slug} FOR UPDATE`
159
228
  const row = rows[0]
160
229
  if (!row) throw new Error(`unknown build "${slug}"`)
161
230
  return row
162
231
  }
163
232
 
164
- private async lockRepo(tx: Tx, repo: string): Promise<Row> {
165
- const rows: Row[] = await tx`SELECT * FROM repo_streams WHERE repo = ${repo} FOR UPDATE`
233
+ private async lockRepo(q: Exec, repo: string): Promise<Row> {
234
+ const rows: Row[] = await q`SELECT * FROM repo_streams WHERE repo = ${repo} FOR UPDATE`
166
235
  const row = rows[0]
167
236
  if (!row) throw new Error(`unknown repo "${repo}"`)
168
237
  return row
169
238
  }
170
239
 
171
240
  async createBuild(input: NewBuildInput): Promise<BuildRecord> {
241
+ const created = validateCreatedEvent(input)
172
242
  const ts = this.now()
173
- return this.sql.begin(async (tx) => {
174
- const inserted: Row[] = await tx`INSERT INTO builds
243
+ return this.tx(async (q) => {
244
+ const inserted: Row[] = await q`INSERT INTO builds
175
245
  (slug, repo, ticket, branch, repo_origin, created_at, updated_at)
176
246
  VALUES (${input.slug}, ${input.repo}, ${input.ticket ?? null}, ${input.branch ?? null}, ${input.repoOrigin ?? null}, ${ts}, ${ts})
177
247
  ON CONFLICT (slug) DO NOTHING RETURNING slug`
178
- const row = await this.lockBuild(tx, input.slug)
248
+ const row = await this.lockBuild(q, input.slug)
179
249
  if (!inserted[0]) throw new Error(`build "${input.slug}" already exists`)
180
- return this.record(row)
250
+ if (created === undefined) return this.record(row)
251
+ await this.appendLocked(q, input.slug, created, true)
252
+ return this.record(await this.lockBuild(q, input.slug))
181
253
  })
182
254
  }
183
255
 
184
256
  async getBuild(slug: string): Promise<BuildRecord | null> {
185
- const rows: Row[] = await this.sql`SELECT * FROM builds WHERE slug = ${slug}`
186
- return rows[0] ? this.record(rows[0]) : null
257
+ return this.run(async (q) => {
258
+ const rows: Row[] = await q`SELECT * FROM builds WHERE slug = ${slug}`
259
+ return rows[0] ? this.record(rows[0]) : null
260
+ })
187
261
  }
188
262
 
189
263
  async listBuilds(): Promise<BuildRecord[]> {
190
- const rows: Row[] = await this.sql`SELECT * FROM builds ORDER BY created_at, slug`
191
- return rows.map((row) => this.record(row))
264
+ return this.run(async (q) => {
265
+ const rows: Row[] = await q`SELECT * FROM builds ORDER BY created_at, slug`
266
+ return rows.map((row) => this.record(row))
267
+ })
268
+ }
269
+
270
+ async getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
271
+ // From the builds side (AUT-487), matching the SQLite adapter: the type
272
+ // filter lives in the ON clause so a build whose log holds none of the
273
+ // digest-relevant event types still yields its row, and only those
274
+ // types are fetched. The `events_type_build_seq` index (schema.ts) makes
275
+ // the join's per-build probes type-leading, so the cost grows with the
276
+ // actual signal, not with total history.
277
+ const rows: Row[] = await this.run((q) =>
278
+ q.unsafe(
279
+ `SELECT b.slug AS slug, e.seq AS seq, e.ts AS ts, e.type AS type,
280
+ CASE WHEN e.type = 'pr-attachment.reclaimed'
281
+ THEN (e.payload->>'hostedSeq')::bigint END AS "hostedSeq",
282
+ CASE WHEN e.type IN ('execution.started', 'execution.ended')
283
+ THEN e.payload->>'instance' END AS instance
284
+ FROM builds b
285
+ LEFT JOIN events e
286
+ ON e.build = b.slug AND e.type IN (${DIGEST_EVENT_TYPES.map((_, i) => `$${i + 2}`).join(', ')})
287
+ WHERE b.repo = $1
288
+ ORDER BY b.slug, e.seq`,
289
+ [repo, ...DIGEST_EVENT_TYPES],
290
+ ),
291
+ )
292
+ const eventsByBuild = new Map<string, DigestEventRow[]>()
293
+ for (const row of rows) {
294
+ if (row.seq === null || row.seq === undefined) continue
295
+ const events = eventsByBuild.get(String(row.slug)) ?? []
296
+ events.push({
297
+ type: String(row.type) as AbEvent['type'],
298
+ seq: num(row.seq),
299
+ ts: iso(row.ts),
300
+ ...(row.hostedSeq !== null && row.hostedSeq !== undefined
301
+ ? { hostedSeq: num(row.hostedSeq) }
302
+ : {}),
303
+ ...(row.instance !== null && row.instance !== undefined
304
+ ? { instance: String(row.instance) }
305
+ : {}),
306
+ })
307
+ eventsByBuild.set(String(row.slug), events)
308
+ }
309
+ const digests = new Map<string, BuildDigest>()
310
+ for (const row of rows) {
311
+ const slug = String(row.slug)
312
+ if (digests.has(slug)) continue
313
+ digests.set(slug, { slug, ...reduceBuildDigest(eventsByBuild.get(slug) ?? []) })
314
+ }
315
+ return digests
192
316
  }
193
317
 
194
318
  private async appendLocked(
195
- tx: Tx,
319
+ q: Exec,
196
320
  slug: string,
197
321
  event: EventWrite,
198
322
  alreadyLocked = false,
199
323
  ): Promise<EventEnvelope> {
200
- if (!alreadyLocked) await this.lockBuild(tx, slug)
324
+ if (!alreadyLocked) await this.lockBuild(q, slug)
201
325
  const tails: Row[] =
202
- await tx`SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE build = ${slug}`
326
+ await q`SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE build = ${slug}`
203
327
  const seq = num(tails[0]?.seq) + 1
204
328
  const ts = this.now()
205
- await tx`INSERT INTO events (build, seq, ts, actor, type, payload)
329
+ await q`INSERT INTO events (build, seq, ts, actor, type, payload)
206
330
  VALUES (${slug}, ${seq}, ${ts}, ${event.actor}, ${event.type}, ${event.payload})`
207
- await tx`UPDATE builds SET updated_at = ${ts} WHERE slug = ${slug}`
331
+ await q`UPDATE builds SET updated_at = ${ts} WHERE slug = ${slug}`
208
332
  return { build: slug, seq, ts, actor: event.actor, type: event.type, payload: event.payload }
209
333
  }
210
334
 
211
335
  async append<T extends EventType>(slug: string, event: EventWrite<T>): Promise<EventEnvelope<T>> {
212
336
  const validated = validateEventWrite(event)
213
- return (await this.sql.begin((tx) =>
214
- this.appendLocked(tx, slug, validated),
215
- )) as EventEnvelope<T>
337
+ return (await this.tx((q) => this.appendLocked(q, slug, validated))) as EventEnvelope<T>
216
338
  }
217
339
 
218
340
  async appendIfCurrent<T extends EventType>(
@@ -222,12 +344,12 @@ export class PostgresBuildStore implements BuildStore {
222
344
  ): Promise<EventEnvelope<T> | null> {
223
345
  validateExpectedSeq(expectedSeq)
224
346
  const validated = validateEventWrite(event)
225
- return this.sql.begin(async (tx) => {
226
- await this.lockBuild(tx, slug)
347
+ return this.tx(async (q) => {
348
+ await this.lockBuild(q, slug)
227
349
  const tails: Row[] =
228
- await tx`SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE build = ${slug}`
350
+ await q`SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE build = ${slug}`
229
351
  if (num(tails[0]?.seq) !== expectedSeq) return null
230
- return (await this.appendLocked(tx, slug, validated, true)) as EventEnvelope<T>
352
+ return (await this.appendLocked(q, slug, validated, true)) as EventEnvelope<T>
231
353
  })
232
354
  }
233
355
 
@@ -240,7 +362,7 @@ export class PostgresBuildStore implements BuildStore {
240
362
  }
241
363
 
242
364
  private async depositBuildLocked(
243
- tx: Tx,
365
+ q: Exec,
244
366
  slug: string,
245
367
  artifact: PreparedArtifact,
246
368
  lockedKinds: Map<string, number>,
@@ -248,16 +370,16 @@ export class PostgresBuildStore implements BuildStore {
248
370
  ): Promise<ArtifactMeta> {
249
371
  let revision = lockedKinds.get(artifact.kind)
250
372
  if (revision === undefined) {
251
- const tails: Row[] = await tx`SELECT COALESCE(MAX(revision), -1) AS revision
373
+ const tails: Row[] = await q`SELECT COALESCE(MAX(revision), -1) AS revision
252
374
  FROM artifacts WHERE build = ${slug} AND kind = ${artifact.kind}`
253
375
  revision = num(tails[0]?.revision) + 1
254
376
  }
255
377
  lockedKinds.set(artifact.kind, revision + 1)
256
378
  const createdAt = this.now()
257
- await tx`INSERT INTO artifacts (build, kind, revision, blob_ref, metadata, created_at)
379
+ await q`INSERT INTO artifacts (build, kind, revision, blob_ref, metadata, created_at)
258
380
  VALUES (${slug}, ${artifact.kind}, ${revision}, ${artifact.blobRef}, ${artifact.metadata}, ${createdAt})`
259
- if (opts.prune) await this.pruneBuildLocked(tx, slug, artifact.kind)
260
- await tx`UPDATE builds SET updated_at = ${createdAt} WHERE slug = ${slug}`
381
+ if (opts.prune) await this.pruneBuildLocked(q, slug, artifact.kind)
382
+ await q`UPDATE builds SET updated_at = ${createdAt} WHERE slug = ${slug}`
261
383
  return {
262
384
  build: slug,
263
385
  kind: artifact.kind,
@@ -273,31 +395,31 @@ export class PostgresBuildStore implements BuildStore {
273
395
  * retention-managed kinds. The pruned values are store-assigned nonnegative
274
396
  * integers (re-checked by `revisionsToPrune`), so the IN list expands
275
397
  * safely. Non-family kinds are never touched. */
276
- private async pruneBuildLocked(tx: Tx, slug: string, kind: string): Promise<void> {
398
+ private async pruneBuildLocked(q: Exec, slug: string, kind: string): Promise<void> {
277
399
  if (!isRetentionManagedKind(kind)) return
278
400
  const rows: Row[] =
279
- await tx`SELECT revision FROM artifacts WHERE build = ${slug} AND kind = ${kind}`
401
+ await q`SELECT revision FROM artifacts WHERE build = ${slug} AND kind = ${kind}`
280
402
  const pruned = revisionsToPrune(
281
403
  rows.map((row) => num(row.revision)),
282
404
  this.maxRevisions,
283
405
  )
284
406
  if (pruned.length === 0) return
285
- await tx.unsafe(
407
+ await q.unsafe(
286
408
  `DELETE FROM artifacts WHERE build = $1 AND kind = $2 AND revision IN (${pruned.join(', ')})`,
287
409
  [slug, kind],
288
410
  )
289
411
  }
290
412
 
291
- private async pruneRepoLocked(tx: Tx, repo: string, kind: string): Promise<void> {
413
+ private async pruneRepoLocked(q: Exec, repo: string, kind: string): Promise<void> {
292
414
  if (!isRetentionManagedKind(kind)) return
293
415
  const rows: Row[] =
294
- await tx`SELECT revision FROM repo_artifacts WHERE repo = ${repo} AND kind = ${kind}`
416
+ await q`SELECT revision FROM repo_artifacts WHERE repo = ${repo} AND kind = ${kind}`
295
417
  const pruned = revisionsToPrune(
296
418
  rows.map((row) => num(row.revision)),
297
419
  this.maxRevisions,
298
420
  )
299
421
  if (pruned.length === 0) return
300
- await tx.unsafe(
422
+ await q.unsafe(
301
423
  `DELETE FROM repo_artifacts WHERE repo = $1 AND kind = $2 AND revision IN (${pruned.join(', ')})`,
302
424
  [repo, kind],
303
425
  )
@@ -310,8 +432,8 @@ export class PostgresBuildStore implements BuildStore {
310
432
  ): Promise<{ event: EventEnvelope<T>; artifacts: ArtifactMeta[] }> {
311
433
  const prepared: PreparedArtifact[] = []
312
434
  for (const artifact of artifacts) prepared.push(await this.prepare(artifact))
313
- return this.sql.begin(async (tx) => {
314
- await this.lockBuild(tx, slug)
435
+ return this.tx(async (q) => {
436
+ await this.lockBuild(q, slug)
315
437
  // Ordering invariant (AUT-322): the batch event is validated BEFORE
316
438
  // any retention prune runs. Deposits land unpruned, validation gates
317
439
  // the whole batch, and only then does one prune per distinct batch
@@ -324,14 +446,14 @@ export class PostgresBuildStore implements BuildStore {
324
446
  const deposited: ArtifactMeta[] = []
325
447
  for (const artifact of prepared) {
326
448
  deposited.push(
327
- await this.depositBuildLocked(tx, slug, artifact, revisions, { prune: false }),
449
+ await this.depositBuildLocked(q, slug, artifact, revisions, { prune: false }),
328
450
  )
329
451
  }
330
452
  const validated = validateEventWrite(makeEvent(structuredClone(deposited)))
331
453
  for (const kind of new Set(deposited.map((meta) => meta.kind))) {
332
- await this.pruneBuildLocked(tx, slug, kind)
454
+ await this.pruneBuildLocked(q, slug, kind)
333
455
  }
334
- const event = (await this.appendLocked(tx, slug, validated, true)) as EventEnvelope<T>
456
+ const event = (await this.appendLocked(q, slug, validated, true)) as EventEnvelope<T>
335
457
  return { event, artifacts: deposited }
336
458
  })
337
459
  }
@@ -343,8 +465,11 @@ export class PostgresBuildStore implements BuildStore {
343
465
  ): Promise<AbEvent[]> {
344
466
  const read = async (): Promise<AbEvent[]> => {
345
467
  if (!(await this.getBuild(slug))) throw new Error(`unknown build "${slug}"`)
346
- const rows: Row[] = await this.sql`SELECT * FROM events WHERE build = ${slug}
347
- AND seq > ${sinceSeq} ORDER BY seq`
468
+ const rows: Row[] = await this.run(
469
+ (q) =>
470
+ q`SELECT * FROM events WHERE build = ${slug}
471
+ AND seq > ${sinceSeq} ORDER BY seq`,
472
+ )
348
473
  return rows.map((row) => ({
349
474
  build: String(row.build),
350
475
  seq: num(row.seq),
@@ -367,9 +492,9 @@ export class PostgresBuildStore implements BuildStore {
367
492
 
368
493
  async putArtifact(slug: string, artifact: ArtifactInput): Promise<ArtifactMeta> {
369
494
  const prepared = await this.prepare(artifact)
370
- return this.sql.begin(async (tx) => {
371
- await this.lockBuild(tx, slug)
372
- return this.depositBuildLocked(tx, slug, prepared, new Map())
495
+ return this.tx(async (q) => {
496
+ await this.lockBuild(q, slug)
497
+ return this.depositBuildLocked(q, slug, prepared, new Map())
373
498
  })
374
499
  }
375
500
 
@@ -386,12 +511,11 @@ export class PostgresBuildStore implements BuildStore {
386
511
 
387
512
  async getArtifact(slug: string, kind: string, rev?: number): Promise<Artifact | null> {
388
513
  if (!(await this.getBuild(slug))) throw new Error(`unknown build "${slug}"`)
389
- const rows: Row[] =
514
+ const rows: Row[] = await this.run((q) =>
390
515
  rev === undefined
391
- ? await this
392
- .sql`SELECT * FROM artifacts WHERE build = ${slug} AND kind = ${kind} ORDER BY revision DESC LIMIT 1`
393
- : await this
394
- .sql`SELECT * FROM artifacts WHERE build = ${slug} AND kind = ${kind} AND revision = ${rev}`
516
+ ? q`SELECT * FROM artifacts WHERE build = ${slug} AND kind = ${kind} ORDER BY revision DESC LIMIT 1`
517
+ : q`SELECT * FROM artifacts WHERE build = ${slug} AND kind = ${kind} AND revision = ${rev}`,
518
+ )
395
519
  const row = rows[0]
396
520
  if (!row) return null
397
521
  const content = await this.blobs.get(String(row.blob_ref))
@@ -400,11 +524,11 @@ export class PostgresBuildStore implements BuildStore {
400
524
 
401
525
  async listArtifacts(slug: string, kind?: string): Promise<ArtifactMeta[]> {
402
526
  if (!(await this.getBuild(slug))) throw new Error(`unknown build "${slug}"`)
403
- const rows: Row[] =
527
+ const rows: Row[] = await this.run((q) =>
404
528
  kind === undefined
405
- ? await this.sql`SELECT * FROM artifacts WHERE build = ${slug} ORDER BY kind, revision`
406
- : await this
407
- .sql`SELECT * FROM artifacts WHERE build = ${slug} AND kind = ${kind} ORDER BY kind, revision`
529
+ ? q`SELECT * FROM artifacts WHERE build = ${slug} ORDER BY kind, revision`
530
+ : q`SELECT * FROM artifacts WHERE build = ${slug} AND kind = ${kind} ORDER BY kind, revision`,
531
+ )
408
532
  return rows.map((row) => this.artifactMeta(row))
409
533
  }
410
534
 
@@ -414,8 +538,8 @@ export class PostgresBuildStore implements BuildStore {
414
538
  holder: string,
415
539
  ttlMs: number,
416
540
  ): Promise<boolean> {
417
- return this.sql.begin(async (tx) => {
418
- const row = table === 'builds' ? await this.lockBuild(tx, key) : await this.lockRepo(tx, key)
541
+ return this.tx(async (q) => {
542
+ const row = table === 'builds' ? await this.lockBuild(q, key) : await this.lockRepo(q, key)
419
543
  const now = this.clock().getTime()
420
544
  const held =
421
545
  row.lease_holder &&
@@ -426,9 +550,9 @@ export class PostgresBuildStore implements BuildStore {
426
550
  const expires = new Date(now + ttlMs).toISOString()
427
551
  const nowIso = new Date(now).toISOString()
428
552
  if (table === 'builds')
429
- await tx`UPDATE builds SET lease_holder=${holder}, lease_expires_at=${expires}, lease_ttl_ms=${ttlMs}, updated_at=${nowIso} WHERE slug=${key}`
553
+ await q`UPDATE builds SET lease_holder=${holder}, lease_expires_at=${expires}, lease_ttl_ms=${ttlMs}, updated_at=${nowIso} WHERE slug=${key}`
430
554
  else
431
- await tx`UPDATE repo_streams SET lease_holder=${holder}, lease_expires_at=${expires}, lease_ttl_ms=${ttlMs}, updated_at=${nowIso} WHERE repo=${key}`
555
+ await q`UPDATE repo_streams SET lease_holder=${holder}, lease_expires_at=${expires}, lease_ttl_ms=${ttlMs}, updated_at=${nowIso} WHERE repo=${key}`
432
556
  return true
433
557
  })
434
558
  }
@@ -445,8 +569,8 @@ export class PostgresBuildStore implements BuildStore {
445
569
  key: string,
446
570
  holder: string,
447
571
  ): Promise<boolean> {
448
- return this.sql.begin(async (tx) => {
449
- const row = table === 'builds' ? await this.lockBuild(tx, key) : await this.lockRepo(tx, key)
572
+ return this.tx(async (q) => {
573
+ const row = table === 'builds' ? await this.lockBuild(q, key) : await this.lockRepo(q, key)
450
574
  const now = this.clock().getTime()
451
575
  if (
452
576
  row.lease_holder !== holder ||
@@ -457,9 +581,9 @@ export class PostgresBuildStore implements BuildStore {
457
581
  const expires = new Date(now + num(row.lease_ttl_ms)).toISOString()
458
582
  const nowIso = new Date(now).toISOString()
459
583
  if (table === 'builds')
460
- await tx`UPDATE builds SET lease_expires_at=${expires}, heartbeat_at=${nowIso}, updated_at=${nowIso} WHERE slug=${key}`
584
+ await q`UPDATE builds SET lease_expires_at=${expires}, heartbeat_at=${nowIso}, updated_at=${nowIso} WHERE slug=${key}`
461
585
  else
462
- await tx`UPDATE repo_streams SET lease_expires_at=${expires}, heartbeat_at=${nowIso}, updated_at=${nowIso} WHERE repo=${key}`
586
+ await q`UPDATE repo_streams SET lease_expires_at=${expires}, heartbeat_at=${nowIso}, updated_at=${nowIso} WHERE repo=${key}`
463
587
  return true
464
588
  })
465
589
  }
@@ -476,14 +600,14 @@ export class PostgresBuildStore implements BuildStore {
476
600
  key: string,
477
601
  holder: string,
478
602
  ): Promise<void> {
479
- await this.sql.begin(async (tx) => {
480
- const row = table === 'builds' ? await this.lockBuild(tx, key) : await this.lockRepo(tx, key)
603
+ await this.tx(async (q) => {
604
+ const row = table === 'builds' ? await this.lockBuild(q, key) : await this.lockRepo(q, key)
481
605
  if (row.lease_holder !== holder) return
482
606
  const now = this.now()
483
607
  if (table === 'builds')
484
- await tx`UPDATE builds SET lease_holder=NULL, lease_expires_at=NULL, lease_ttl_ms=NULL, updated_at=${now} WHERE slug=${key}`
608
+ await q`UPDATE builds SET lease_holder=NULL, lease_expires_at=NULL, lease_ttl_ms=NULL, updated_at=${now} WHERE slug=${key}`
485
609
  else
486
- await tx`UPDATE repo_streams SET lease_holder=NULL, lease_expires_at=NULL, lease_ttl_ms=NULL, updated_at=${now} WHERE repo=${key}`
610
+ await q`UPDATE repo_streams SET lease_holder=NULL, lease_expires_at=NULL, lease_ttl_ms=NULL, updated_at=${now} WHERE repo=${key}`
487
611
  })
488
612
  }
489
613
 
@@ -496,8 +620,8 @@ export class PostgresBuildStore implements BuildStore {
496
620
 
497
621
  // ── Operator sessions (SPEC §7.1.1 — a third resource kind) ─────────
498
622
 
499
- private async lockSession(tx: Tx, id: string): Promise<Row> {
500
- const rows: Row[] = await tx`SELECT * FROM sessions WHERE id = ${id} FOR UPDATE`
623
+ private async lockSession(q: Exec, id: string): Promise<Row> {
624
+ const rows: Row[] = await q`SELECT * FROM sessions WHERE id = ${id} FOR UPDATE`
501
625
  const row = rows[0]
502
626
  if (!row) throw new Error(`unknown session "${id}"`)
503
627
  return row
@@ -526,46 +650,50 @@ export class PostgresBuildStore implements BuildStore {
526
650
  type: 'session.created',
527
651
  payload: input.title !== undefined ? { title: input.title } : {},
528
652
  })
529
- return this.sql.begin(async (tx) => {
653
+ return this.tx(async (q) => {
530
654
  // Store-assigned monotonic creation sequence (the listSessions
531
655
  // same-timestamp tiebreak): the sequence is concurrency-safe and never
532
656
  // repeats, so ties order by assignment. Sessions are never deleted, so
533
657
  // the counter is never reused.
534
- await tx`INSERT INTO sessions (id, repo, operator, title, creation_seq, created_at, updated_at)
658
+ await q`INSERT INTO sessions (id, repo, operator, title, creation_seq, created_at, updated_at)
535
659
  VALUES (${id}, ${input.repo}, ${operator}, ${input.title ?? null}, nextval('sessions_creation_seq'), ${ts}, ${ts})`
536
- await tx`INSERT INTO session_events (session, seq, ts, actor, type, payload)
660
+ await q`INSERT INTO session_events (session, seq, ts, actor, type, payload)
537
661
  VALUES (${id}, 1, ${ts}, ${validated.actor}, ${validated.type}, ${validated.payload})`
538
- return this.sessionRecord(await this.lockSession(tx, id))
662
+ return this.sessionRecord(await this.lockSession(q, id))
539
663
  })
540
664
  }
541
665
 
542
666
  async getSession(id: string): Promise<SessionRecord | null> {
543
- const rows: Row[] = await this.sql`SELECT * FROM sessions WHERE id = ${id}`
544
- return rows[0] ? this.sessionRecord(rows[0]) : null
667
+ return this.run(async (q) => {
668
+ const rows: Row[] = await q`SELECT * FROM sessions WHERE id = ${id}`
669
+ return rows[0] ? this.sessionRecord(rows[0]) : null
670
+ })
545
671
  }
546
672
 
547
673
  async listSessions(repo: string): Promise<SessionRecord[]> {
548
674
  // Pinned tiebreak (store/types.ts): createdAt ascending, then the
549
675
  // store-assigned monotonic creation sequence — never the random id.
550
- const rows: Row[] = await this
551
- .sql`SELECT * FROM sessions WHERE repo = ${repo} ORDER BY created_at, creation_seq`
552
- return rows.map((row) => this.sessionRecord(row))
676
+ return this.run(async (q) => {
677
+ const rows: Row[] =
678
+ await q`SELECT * FROM sessions WHERE repo = ${repo} ORDER BY created_at, creation_seq`
679
+ return rows.map((row) => this.sessionRecord(row))
680
+ })
553
681
  }
554
682
 
555
683
  private async appendSessionLocked(
556
- tx: Tx,
684
+ q: Exec,
557
685
  id: string,
558
686
  event: SessionEventWrite,
559
687
  alreadyLocked = false,
560
688
  ): Promise<SessionEventEnvelope> {
561
- if (!alreadyLocked) await this.lockSession(tx, id)
689
+ if (!alreadyLocked) await this.lockSession(q, id)
562
690
  const tails: Row[] =
563
- await tx`SELECT COALESCE(MAX(seq), 0) AS seq FROM session_events WHERE session = ${id}`
691
+ await q`SELECT COALESCE(MAX(seq), 0) AS seq FROM session_events WHERE session = ${id}`
564
692
  const seq = num(tails[0]?.seq) + 1
565
693
  const ts = this.now()
566
- await tx`INSERT INTO session_events (session, seq, ts, actor, type, payload)
694
+ await q`INSERT INTO session_events (session, seq, ts, actor, type, payload)
567
695
  VALUES (${id}, ${seq}, ${ts}, ${event.actor}, ${event.type}, ${event.payload})`
568
- await tx`UPDATE sessions SET updated_at = ${ts} WHERE id = ${id}`
696
+ await q`UPDATE sessions SET updated_at = ${ts} WHERE id = ${id}`
569
697
  return { session: id, seq, ts, actor: event.actor, type: event.type, payload: event.payload }
570
698
  }
571
699
 
@@ -574,11 +702,30 @@ export class PostgresBuildStore implements BuildStore {
574
702
  event: SessionEventWrite<T>,
575
703
  ): Promise<SessionEventEnvelope<T>> {
576
704
  const validated = validateSessionEventWrite(event)
577
- return (await this.sql.begin((tx) =>
578
- this.appendSessionLocked(tx, id, validated),
705
+ return (await this.tx((q) =>
706
+ this.appendSessionLocked(q, id, validated),
579
707
  )) as SessionEventEnvelope<T>
580
708
  }
581
709
 
710
+ async appendSessionEventIfCurrent<T extends SessionEventType>(
711
+ id: string,
712
+ expectedSeq: number,
713
+ event: SessionEventWrite<T>,
714
+ ): Promise<SessionEventEnvelope<T> | null> {
715
+ validateExpectedSeq(expectedSeq)
716
+ const validated = validateSessionEventWrite(event)
717
+ return (await this.sql.begin(async (tx) => {
718
+ // The session advisory lock serializes this comparison and append
719
+ // against every concurrent writer of the session, exactly as
720
+ // `appendSessionEvent` serializes two plain appends.
721
+ await this.lockSession(tx, id)
722
+ const tails: Row[] =
723
+ await tx`SELECT COALESCE(MAX(seq), 0) AS seq FROM session_events WHERE session = ${id}`
724
+ if (num(tails[0]?.seq) !== expectedSeq) return null
725
+ return this.appendSessionLocked(tx, id, validated, true)
726
+ })) as SessionEventEnvelope<T> | null
727
+ }
728
+
582
729
  async getSessionEvents(
583
730
  id: string,
584
731
  sinceSeq = 0,
@@ -586,8 +733,10 @@ export class PostgresBuildStore implements BuildStore {
586
733
  ): Promise<SessionEvent[]> {
587
734
  const read = async (): Promise<SessionEvent[]> => {
588
735
  if (!(await this.getSession(id))) throw new Error(`unknown session "${id}"`)
589
- const rows: Row[] = await this
590
- .sql`SELECT * FROM session_events WHERE session = ${id} AND seq > ${sinceSeq} ORDER BY seq`
736
+ const rows: Row[] = await this.run(
737
+ (q) =>
738
+ q`SELECT * FROM session_events WHERE session = ${id} AND seq > ${sinceSeq} ORDER BY seq`,
739
+ )
591
740
  return rows.map((row) => ({
592
741
  session: String(row.session),
593
742
  seq: num(row.seq),
@@ -610,7 +759,7 @@ export class PostgresBuildStore implements BuildStore {
610
759
  }
611
760
 
612
761
  private async depositSessionLocked(
613
- tx: Tx,
762
+ q: Exec,
614
763
  id: string,
615
764
  artifact: PreparedArtifact,
616
765
  revisions: Map<string, number>,
@@ -618,14 +767,14 @@ export class PostgresBuildStore implements BuildStore {
618
767
  let revision = revisions.get(artifact.kind)
619
768
  if (revision === undefined) {
620
769
  const tails: Row[] =
621
- await tx`SELECT COALESCE(MAX(revision), -1) AS revision FROM session_artifacts WHERE session=${id} AND kind=${artifact.kind}`
770
+ await q`SELECT COALESCE(MAX(revision), -1) AS revision FROM session_artifacts WHERE session=${id} AND kind=${artifact.kind}`
622
771
  revision = num(tails[0]?.revision) + 1
623
772
  }
624
773
  revisions.set(artifact.kind, revision + 1)
625
774
  const createdAt = this.now()
626
- await tx`INSERT INTO session_artifacts (session, kind, revision, blob_ref, metadata, created_at)
775
+ await q`INSERT INTO session_artifacts (session, kind, revision, blob_ref, metadata, created_at)
627
776
  VALUES (${id}, ${artifact.kind}, ${revision}, ${artifact.blobRef}, ${artifact.metadata}, ${createdAt})`
628
- await tx`UPDATE sessions SET updated_at = ${createdAt} WHERE id = ${id}`
777
+ await q`UPDATE sessions SET updated_at = ${createdAt} WHERE id = ${id}`
629
778
  return {
630
779
  session: id,
631
780
  kind: artifact.kind,
@@ -647,16 +796,16 @@ export class PostgresBuildStore implements BuildStore {
647
796
  // locked transaction, the batch event is validated before any commit of
648
797
  // the caller-visible state, and an invalid event throws, rolling back
649
798
  // every deposit (D6). Session artifacts are not retention-managed.
650
- return this.sql.begin(async (tx) => {
651
- await this.lockSession(tx, id)
799
+ return this.tx(async (q) => {
800
+ await this.lockSession(q, id)
652
801
  const revisions = new Map<string, number>()
653
802
  const deposited: SessionArtifactMeta[] = []
654
803
  for (const artifact of prepared) {
655
- deposited.push(await this.depositSessionLocked(tx, id, artifact, revisions))
804
+ deposited.push(await this.depositSessionLocked(q, id, artifact, revisions))
656
805
  }
657
806
  const validated = validateSessionEventWrite(makeEvent(structuredClone(deposited)))
658
807
  const event = (await this.appendSessionLocked(
659
- tx,
808
+ q,
660
809
  id,
661
810
  validated,
662
811
  true,
@@ -667,9 +816,9 @@ export class PostgresBuildStore implements BuildStore {
667
816
 
668
817
  async putSessionArtifact(id: string, artifact: ArtifactInput): Promise<SessionArtifactMeta> {
669
818
  const prepared = await this.prepare(artifact)
670
- return this.sql.begin(async (tx) => {
671
- await this.lockSession(tx, id)
672
- return this.depositSessionLocked(tx, id, prepared, new Map())
819
+ return this.tx(async (q) => {
820
+ await this.lockSession(q, id)
821
+ return this.depositSessionLocked(q, id, prepared, new Map())
673
822
  })
674
823
  }
675
824
 
@@ -690,12 +839,11 @@ export class PostgresBuildStore implements BuildStore {
690
839
  rev?: number,
691
840
  ): Promise<SessionArtifact | null> {
692
841
  if (!(await this.getSession(id))) throw new Error(`unknown session "${id}"`)
693
- const rows: Row[] =
842
+ const rows: Row[] = await this.run((q) =>
694
843
  rev === undefined
695
- ? await this
696
- .sql`SELECT * FROM session_artifacts WHERE session = ${id} AND kind = ${kind} ORDER BY revision DESC LIMIT 1`
697
- : await this
698
- .sql`SELECT * FROM session_artifacts WHERE session = ${id} AND kind = ${kind} AND revision = ${rev}`
844
+ ? q`SELECT * FROM session_artifacts WHERE session = ${id} AND kind = ${kind} ORDER BY revision DESC LIMIT 1`
845
+ : q`SELECT * FROM session_artifacts WHERE session = ${id} AND kind = ${kind} AND revision = ${rev}`,
846
+ )
699
847
  const row = rows[0]
700
848
  if (!row) return null
701
849
  const content = await this.blobs.get(String(row.blob_ref))
@@ -704,43 +852,44 @@ export class PostgresBuildStore implements BuildStore {
704
852
 
705
853
  async listSessionArtifacts(id: string, kind?: string): Promise<SessionArtifactMeta[]> {
706
854
  if (!(await this.getSession(id))) throw new Error(`unknown session "${id}"`)
707
- const rows: Row[] =
855
+ const rows: Row[] = await this.run((q) =>
708
856
  kind === undefined
709
- ? await this
710
- .sql`SELECT * FROM session_artifacts WHERE session = ${id} ORDER BY kind, revision`
711
- : await this
712
- .sql`SELECT * FROM session_artifacts WHERE session = ${id} AND kind = ${kind} ORDER BY kind, revision`
857
+ ? q`SELECT * FROM session_artifacts WHERE session = ${id} ORDER BY kind, revision`
858
+ : q`SELECT * FROM session_artifacts WHERE session = ${id} AND kind = ${kind} ORDER BY kind, revision`,
859
+ )
713
860
  return rows.map((row) => this.sessionArtifactMeta(row))
714
861
  }
715
862
 
716
863
  async ensureRepo(repo: string): Promise<RepositoryRecord> {
717
864
  if (!repo) throw new Error('repo is required')
718
865
  const ts = this.now()
719
- return this.sql.begin(async (tx) => {
720
- await tx`INSERT INTO repo_streams (repo, created_at, updated_at)
866
+ return this.tx(async (q) => {
867
+ await q`INSERT INTO repo_streams (repo, created_at, updated_at)
721
868
  VALUES (${repo}, ${ts}, ${ts}) ON CONFLICT (repo) DO NOTHING`
722
- return this.repoRecord(await this.lockRepo(tx, repo))
869
+ return this.repoRecord(await this.lockRepo(q, repo))
723
870
  })
724
871
  }
725
872
 
726
873
  async getRepo(repo: string): Promise<RepositoryRecord | null> {
727
- const rows: Row[] = await this.sql`SELECT * FROM repo_streams WHERE repo=${repo}`
728
- return rows[0] ? this.repoRecord(rows[0]) : null
874
+ return this.run(async (q) => {
875
+ const rows: Row[] = await q`SELECT * FROM repo_streams WHERE repo=${repo}`
876
+ return rows[0] ? this.repoRecord(rows[0]) : null
877
+ })
729
878
  }
730
879
 
731
880
  private async appendRepoLocked(
732
- tx: Tx,
881
+ q: Exec,
733
882
  repo: string,
734
883
  event: RepositoryEventWrite,
735
884
  alreadyLocked = false,
736
885
  ): Promise<RepositoryEventEnvelope> {
737
- if (!alreadyLocked) await this.lockRepo(tx, repo)
886
+ if (!alreadyLocked) await this.lockRepo(q, repo)
738
887
  const tails: Row[] =
739
- await tx`SELECT COALESCE(MAX(seq), 0) AS seq FROM repo_events WHERE repo=${repo}`
888
+ await q`SELECT COALESCE(MAX(seq), 0) AS seq FROM repo_events WHERE repo=${repo}`
740
889
  const seq = num(tails[0]?.seq) + 1
741
890
  const ts = this.now()
742
- await tx`INSERT INTO repo_events (repo, seq, ts, actor, type, payload) VALUES (${repo},${seq},${ts},${event.actor},${event.type},${event.payload})`
743
- await tx`UPDATE repo_streams SET updated_at=${ts} WHERE repo=${repo}`
891
+ await q`INSERT INTO repo_events (repo, seq, ts, actor, type, payload) VALUES (${repo},${seq},${ts},${event.actor},${event.type},${event.payload})`
892
+ await q`UPDATE repo_streams SET updated_at=${ts} WHERE repo=${repo}`
744
893
  return { repo, seq, ts, actor: event.actor, type: event.type, payload: event.payload }
745
894
  }
746
895
 
@@ -749,13 +898,13 @@ export class PostgresBuildStore implements BuildStore {
749
898
  event: RepositoryEventWrite<T>,
750
899
  ): Promise<RepositoryEventEnvelope<T>> {
751
900
  const validated = validateRepositoryEventWrite(event)
752
- return (await this.sql.begin((tx) =>
753
- this.appendRepoLocked(tx, repo, validated),
901
+ return (await this.tx((q) =>
902
+ this.appendRepoLocked(q, repo, validated),
754
903
  )) as RepositoryEventEnvelope<T>
755
904
  }
756
905
 
757
906
  private async depositRepoLocked(
758
- tx: Tx,
907
+ q: Exec,
759
908
  repo: string,
760
909
  artifact: PreparedArtifact,
761
910
  revisions: Map<string, number>,
@@ -764,14 +913,14 @@ export class PostgresBuildStore implements BuildStore {
764
913
  let revision = revisions.get(artifact.kind)
765
914
  if (revision === undefined) {
766
915
  const tails: Row[] =
767
- await tx`SELECT COALESCE(MAX(revision), -1) AS revision FROM repo_artifacts WHERE repo=${repo} AND kind=${artifact.kind}`
916
+ await q`SELECT COALESCE(MAX(revision), -1) AS revision FROM repo_artifacts WHERE repo=${repo} AND kind=${artifact.kind}`
768
917
  revision = num(tails[0]?.revision) + 1
769
918
  }
770
919
  revisions.set(artifact.kind, revision + 1)
771
920
  const createdAt = this.now()
772
- await tx`INSERT INTO repo_artifacts (repo,kind,revision,blob_ref,metadata,created_at) VALUES (${repo},${artifact.kind},${revision},${artifact.blobRef},${artifact.metadata},${createdAt})`
773
- if (opts.prune) await this.pruneRepoLocked(tx, repo, artifact.kind)
774
- await tx`UPDATE repo_streams SET updated_at=${createdAt} WHERE repo=${repo}`
921
+ await q`INSERT INTO repo_artifacts (repo,kind,revision,blob_ref,metadata,created_at) VALUES (${repo},${artifact.kind},${revision},${artifact.blobRef},${artifact.metadata},${createdAt})`
922
+ if (opts.prune) await this.pruneRepoLocked(q, repo, artifact.kind)
923
+ await q`UPDATE repo_streams SET updated_at=${createdAt} WHERE repo=${repo}`
775
924
  return {
776
925
  repo,
777
926
  kind: artifact.kind,
@@ -789,8 +938,8 @@ export class PostgresBuildStore implements BuildStore {
789
938
  ): Promise<{ event: RepositoryEventEnvelope<T>; artifacts: RepositoryArtifactMeta[] }> {
790
939
  const prepared: PreparedArtifact[] = []
791
940
  for (const artifact of artifacts) prepared.push(await this.prepare(artifact))
792
- return this.sql.begin(async (tx) => {
793
- await this.lockRepo(tx, repo)
941
+ return this.tx(async (q) => {
942
+ await this.lockRepo(q, repo)
794
943
  // Ordering invariant (AUT-322): same shape as `appendWithArtifacts` —
795
944
  // deposit unpruned, validate the batch event, then prune once per
796
945
  // distinct batch kind, all inside this locked transaction. Validation
@@ -798,15 +947,13 @@ export class PostgresBuildStore implements BuildStore {
798
947
  const revisions = new Map<string, number>()
799
948
  const deposited: RepositoryArtifactMeta[] = []
800
949
  for (const artifact of prepared)
801
- deposited.push(
802
- await this.depositRepoLocked(tx, repo, artifact, revisions, { prune: false }),
803
- )
950
+ deposited.push(await this.depositRepoLocked(q, repo, artifact, revisions, { prune: false }))
804
951
  const validated = validateRepositoryEventWrite(makeEvent(structuredClone(deposited)))
805
952
  for (const kind of new Set(deposited.map((meta) => meta.kind))) {
806
- await this.pruneRepoLocked(tx, repo, kind)
953
+ await this.pruneRepoLocked(q, repo, kind)
807
954
  }
808
955
  const event = (await this.appendRepoLocked(
809
- tx,
956
+ q,
810
957
  repo,
811
958
  validated,
812
959
  true,
@@ -822,8 +969,9 @@ export class PostgresBuildStore implements BuildStore {
822
969
  ): Promise<RepositoryEvent[]> {
823
970
  const read = async (): Promise<RepositoryEvent[]> => {
824
971
  if (!(await this.getRepo(repo))) throw new Error(`unknown repo "${repo}"`)
825
- const rows: Row[] = await this
826
- .sql`SELECT * FROM repo_events WHERE repo=${repo} AND seq>${sinceSeq} ORDER BY seq`
972
+ const rows: Row[] = await this.run(
973
+ (q) => q`SELECT * FROM repo_events WHERE repo=${repo} AND seq>${sinceSeq} ORDER BY seq`,
974
+ )
827
975
  return rows.map((row) => ({
828
976
  repo: String(row.repo),
829
977
  seq: num(row.seq),
@@ -841,11 +989,160 @@ export class PostgresBuildStore implements BuildStore {
841
989
  })
842
990
  }
843
991
 
992
+ async getRepoHighWater(repo: string): Promise<number> {
993
+ if (!(await this.getRepo(repo))) throw new Error(`unknown repo "${repo}"`)
994
+ const rows: Row[] = await this.run(
995
+ (q) => q`SELECT COALESCE(MAX(seq), 0) AS seq FROM repo_events WHERE repo=${repo}`,
996
+ )
997
+ return num(rows[0]?.seq ?? 0)
998
+ }
999
+
1000
+ /** SQLSTATE 42P01: a database that has not run the additive snapshot
1001
+ * migration. Never cached, so a later `postgres:migrate` starts working
1002
+ * without a restart. */
1003
+ private static missingSnapshotTable(error: unknown): boolean {
1004
+ const code =
1005
+ (error as { code?: string; errno?: string }).errno ?? (error as { code?: string }).code
1006
+ return code === '42P01'
1007
+ }
1008
+
1009
+ async getReducerSnapshot(
1010
+ scope: SnapshotScope,
1011
+ reducer: string,
1012
+ version: number,
1013
+ ): Promise<ReducerSnapshot | null> {
1014
+ const key = scope.kind === 'build' ? scope.slug : scope.repo
1015
+ try {
1016
+ const rows: Row[] = await this.run(
1017
+ (q) => q`SELECT s.version, s.cursor, s.state FROM reducer_snapshots s
1018
+ WHERE s.scope_kind=${scope.kind} AND s.scope_key=${key} AND s.reducer=${reducer}
1019
+ AND s.version=${version}
1020
+ AND s.cursor <= CASE WHEN ${scope.kind}='build'
1021
+ THEN COALESCE((SELECT MAX(seq) FROM events WHERE build=${key}), 0)
1022
+ ELSE COALESCE((SELECT MAX(seq) FROM repo_events WHERE repo=${key}), 0) END
1023
+ AND CASE WHEN ${scope.kind}='build'
1024
+ THEN EXISTS (SELECT 1 FROM builds WHERE slug=${key})
1025
+ ELSE EXISTS (SELECT 1 FROM repo_streams WHERE repo=${key}) END`,
1026
+ )
1027
+ const row = rows[0]
1028
+ if (row === undefined) return null
1029
+ return { version: num(row.version), cursor: num(row.cursor), state: json(row.state) }
1030
+ } catch (error) {
1031
+ if (PostgresBuildStore.missingSnapshotTable(error)) return null
1032
+ throw error
1033
+ }
1034
+ }
1035
+
1036
+ async putReducerSnapshot(
1037
+ scope: SnapshotScope,
1038
+ reducer: string,
1039
+ snapshot: ReducerSnapshot,
1040
+ ): Promise<boolean> {
1041
+ const key = scope.kind === 'build' ? scope.slug : scope.repo
1042
+ const state = JSON.stringify(snapshot.state ?? null)
1043
+ try {
1044
+ // One statement: the existence and log-tail guards, and the
1045
+ // never-decrease rule, evaluate atomically against concurrent writers.
1046
+ const rows: Row[] = await this.run(
1047
+ (q) => q`INSERT INTO reducer_snapshots
1048
+ (scope_kind, scope_key, reducer, version, cursor, state, updated_at)
1049
+ SELECT ${scope.kind}, ${key}, ${reducer}, ${snapshot.version}::integer,
1050
+ ${snapshot.cursor}::bigint, ${state}::jsonb, ${this.now()}::timestamptz
1051
+ WHERE ${snapshot.cursor}::bigint <= CASE WHEN ${scope.kind}='build'
1052
+ THEN COALESCE((SELECT MAX(seq) FROM events WHERE build=${key}), 0)
1053
+ ELSE COALESCE((SELECT MAX(seq) FROM repo_events WHERE repo=${key}), 0) END
1054
+ AND CASE WHEN ${scope.kind}='build'
1055
+ THEN EXISTS (SELECT 1 FROM builds WHERE slug=${key})
1056
+ ELSE EXISTS (SELECT 1 FROM repo_streams WHERE repo=${key}) END
1057
+ ON CONFLICT (scope_kind, scope_key, reducer) DO UPDATE SET
1058
+ version = EXCLUDED.version, cursor = EXCLUDED.cursor,
1059
+ state = EXCLUDED.state, updated_at = EXCLUDED.updated_at
1060
+ WHERE EXCLUDED.cursor > reducer_snapshots.cursor
1061
+ OR (EXCLUDED.cursor = reducer_snapshots.cursor
1062
+ AND EXCLUDED.version > reducer_snapshots.version)
1063
+ RETURNING 1 AS written`,
1064
+ )
1065
+ return rows.length > 0
1066
+ } catch (error) {
1067
+ if (PostgresBuildStore.missingSnapshotTable(error)) return false
1068
+ throw error
1069
+ }
1070
+ }
1071
+
1072
+ async getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
1073
+ if (!(await this.getRepo(repo))) throw new Error(`unknown repo "${repo}"`)
1074
+ // Two queries, mirroring the oracle's derivation: the latest run-started
1075
+ // anchor (indexed by repo_events_type_repo_seq, v8 — without it MAX(seq)
1076
+ // over the repo's run-started facts degrades to a scan of the repo's
1077
+ // whole PK range, exactly the cost being removed), then one select of
1078
+ // durable types plus the tail from that anchor.
1079
+ //
1080
+ // `this.run` is deliberately non-transactional (a pinned pooled
1081
+ // connection, no BEGIN), so the probe and the select each get their own
1082
+ // READ COMMITTED snapshot — a concurrent append of the journal's first
1083
+ // `dispatcher.run-started` between them would be invisible to the probe,
1084
+ // absent from the durable-only selection, yet present in the journal by
1085
+ // the time this method returns (AUT-551). The shared helper below closes
1086
+ // that window with a one-shot anchor re-check on the durable-only
1087
+ // outcome: a durable-only result is returned only when the journal was
1088
+ // anchor-free as of the final probe. When the probe *does* find an
1089
+ // anchor, the anchored select's `seq >= anchor` arm is
1090
+ // monotone-inclusive, so any later run-started is picked up by that same
1091
+ // select; an append after the final re-probe is an ordinary post-read
1092
+ // concurrent append, not this race.
1093
+ const durableList = [...REPOSITORY_STATE_EVENT_TYPES]
1094
+ // `repo` is $1; the anchored select also spends $2 on the anchor, so the
1095
+ // type list's placeholders start at the right offset per branch.
1096
+ const placeholders = (start: number) =>
1097
+ durableList.map((_, index) => `$${index + start}`).join(', ')
1098
+ return readRepoStateEventsWithAnchorRecheck(
1099
+ async () => {
1100
+ // The probe below is an aggregate: MAX() always returns exactly one row —
1101
+ // NULL when the journal has no run-started fact — so the empty-journal
1102
+ // signal is that NULL, tested explicitly here via `no_anchor`, never row
1103
+ // absence. The NULL/undefined anchor must never be bound into `seq >= $2`
1104
+ // (in either dialect the comparison is never true, so the durable-only
1105
+ // outcome would then survive only by accident), and the no-anchor case
1106
+ // must not degenerate into `seq >= 0`, which would select the whole
1107
+ // journal. The `anchor === undefined` branch below is the durable-only
1108
+ // path.
1109
+ const probe = (
1110
+ await this.run(
1111
+ (q) =>
1112
+ q`SELECT MAX(seq) AS seq, MAX(seq) IS NULL AS no_anchor FROM repo_events WHERE repo=${repo} AND type='dispatcher.run-started'`,
1113
+ )
1114
+ )[0]
1115
+ return probe?.no_anchor ? undefined : num(probe?.seq)
1116
+ },
1117
+ async (anchor) => {
1118
+ const rows: Row[] = await this.run((q) =>
1119
+ anchor === undefined
1120
+ ? q.unsafe(
1121
+ `SELECT * FROM repo_events WHERE repo = $1 AND type IN (${placeholders(2)}) ORDER BY seq`,
1122
+ [repo, ...durableList],
1123
+ )
1124
+ : q.unsafe(
1125
+ `SELECT * FROM repo_events WHERE repo = $1 AND (seq >= $2 OR type IN (${placeholders(3)})) ORDER BY seq`,
1126
+ [repo, anchor, ...durableList],
1127
+ ),
1128
+ )
1129
+ return rows.map((row) => ({
1130
+ repo: String(row.repo),
1131
+ seq: num(row.seq),
1132
+ ts: iso(row.ts),
1133
+ actor: json(row.actor),
1134
+ type: String(row.type),
1135
+ payload: json(row.payload),
1136
+ })) as RepositoryEvent[]
1137
+ },
1138
+ )
1139
+ }
1140
+
844
1141
  async putRepoArtifact(repo: string, artifact: ArtifactInput): Promise<RepositoryArtifactMeta> {
845
1142
  const prepared = await this.prepare(artifact)
846
- return this.sql.begin(async (tx) => {
847
- await this.lockRepo(tx, repo)
848
- return this.depositRepoLocked(tx, repo, prepared, new Map())
1143
+ return this.tx(async (q) => {
1144
+ await this.lockRepo(q, repo)
1145
+ return this.depositRepoLocked(q, repo, prepared, new Map())
849
1146
  })
850
1147
  }
851
1148
 
@@ -866,12 +1163,11 @@ export class PostgresBuildStore implements BuildStore {
866
1163
  rev?: number,
867
1164
  ): Promise<RepositoryArtifact | null> {
868
1165
  if (!(await this.getRepo(repo))) throw new Error(`unknown repo "${repo}"`)
869
- const rows: Row[] =
1166
+ const rows: Row[] = await this.run((q) =>
870
1167
  rev === undefined
871
- ? await this
872
- .sql`SELECT * FROM repo_artifacts WHERE repo=${repo} AND kind=${kind} ORDER BY revision DESC LIMIT 1`
873
- : await this
874
- .sql`SELECT * FROM repo_artifacts WHERE repo=${repo} AND kind=${kind} AND revision=${rev}`
1168
+ ? q`SELECT * FROM repo_artifacts WHERE repo=${repo} AND kind=${kind} ORDER BY revision DESC LIMIT 1`
1169
+ : q`SELECT * FROM repo_artifacts WHERE repo=${repo} AND kind=${kind} AND revision=${rev}`,
1170
+ )
875
1171
  const row = rows[0]
876
1172
  if (!row) return null
877
1173
  const content = await this.blobs.get(String(row.blob_ref))
@@ -880,22 +1176,134 @@ export class PostgresBuildStore implements BuildStore {
880
1176
 
881
1177
  async listRepoArtifacts(repo: string, kind?: string): Promise<RepositoryArtifactMeta[]> {
882
1178
  if (!(await this.getRepo(repo))) throw new Error(`unknown repo "${repo}"`)
883
- const rows: Row[] =
1179
+ const rows: Row[] = await this.run((q) =>
884
1180
  kind === undefined
885
- ? await this.sql`SELECT * FROM repo_artifacts WHERE repo=${repo} ORDER BY kind,revision`
886
- : await this
887
- .sql`SELECT * FROM repo_artifacts WHERE repo=${repo} AND kind=${kind} ORDER BY kind,revision`
1181
+ ? q`SELECT * FROM repo_artifacts WHERE repo=${repo} ORDER BY kind,revision`
1182
+ : q`SELECT * FROM repo_artifacts WHERE repo=${repo} AND kind=${kind} ORDER BY kind,revision`,
1183
+ )
888
1184
  return rows.map((row) => this.repoArtifactMeta(row))
889
1185
  }
890
1186
 
1187
+ async ticketAssetLimits(_repo: string): Promise<TicketAssetLimits> {
1188
+ return { ...DEFAULT_TICKET_ASSET_LIMITS }
1189
+ }
1190
+
1191
+ private ticketAssetMeta(row: Row): TicketAssetMeta {
1192
+ return {
1193
+ repo: String(row.repo),
1194
+ ticketId: String(row.ticket_id),
1195
+ kind: String(row.kind),
1196
+ name: String(row.name),
1197
+ revision: num(row.revision),
1198
+ layout: String(row.layout) as TicketAssetMeta['layout'],
1199
+ size: num(row.size),
1200
+ entries: json<TicketAssetEntry[]>(row.manifest),
1201
+ createdAt: iso(row.created_at),
1202
+ }
1203
+ }
1204
+
1205
+ async putTicketAsset(
1206
+ repo: string,
1207
+ ticketId: string,
1208
+ asset: TicketAssetInput,
1209
+ ): Promise<TicketAssetMeta> {
1210
+ const size = validateTicketAssetInput(asset)
1211
+ const entries = await storeTicketAssetBlobs(this.blobs, asset.entries)
1212
+ const manifest = JSON.stringify(entries)
1213
+ return this.tx(async (q) => {
1214
+ // Serialize revision assignment per (repo, ticket, kind, name).
1215
+ await q`SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([repo, ticketId, asset.kind, asset.name])}, 0))`
1216
+ const tails: Row[] =
1217
+ await q`SELECT COALESCE(MAX(revision), -1) AS revision FROM ticket_assets WHERE repo=${repo} AND ticket_id=${ticketId} AND kind=${asset.kind} AND name=${asset.name}`
1218
+ const revision = num(tails[0]?.revision) + 1
1219
+ const createdAt = this.now()
1220
+ await q`INSERT INTO ticket_assets (repo,ticket_id,kind,name,revision,layout,manifest,size,removed,created_at) VALUES (${repo},${ticketId},${asset.kind},${asset.name},${revision},${asset.layout},${manifest}::jsonb,${size},false,${createdAt})`
1221
+ return {
1222
+ repo,
1223
+ ticketId,
1224
+ kind: asset.kind,
1225
+ name: asset.name,
1226
+ revision,
1227
+ layout: asset.layout,
1228
+ size,
1229
+ entries,
1230
+ createdAt,
1231
+ }
1232
+ })
1233
+ }
1234
+
1235
+ async getTicketAsset(
1236
+ repo: string,
1237
+ ticketId: string,
1238
+ kind: string,
1239
+ name: string,
1240
+ rev?: number,
1241
+ ): Promise<TicketAsset | null> {
1242
+ const rows: Row[] = await this.run((q) =>
1243
+ rev === undefined
1244
+ ? q`SELECT * FROM ticket_assets WHERE repo=${repo} AND ticket_id=${ticketId} AND kind=${kind} AND name=${name} ORDER BY revision DESC LIMIT 1`
1245
+ : q`SELECT * FROM ticket_assets WHERE repo=${repo} AND ticket_id=${ticketId} AND kind=${kind} AND name=${name} AND revision=${rev}`,
1246
+ )
1247
+ const row = rows[0]
1248
+ if (!row || row.removed === true) return null
1249
+ return loadTicketAsset(this.blobs, this.ticketAssetMeta(row))
1250
+ }
1251
+
1252
+ async getPinnedTicketAsset(
1253
+ slug: string,
1254
+ kind: string,
1255
+ name: string,
1256
+ rev?: number,
1257
+ ): Promise<TicketAsset | null> {
1258
+ return resolvePinnedAsset(this, slug, kind, name, rev)
1259
+ }
1260
+
1261
+ async listTicketAssets(
1262
+ repo: string,
1263
+ ticketId: string,
1264
+ opts?: { revisions?: boolean },
1265
+ ): Promise<TicketAssetSummary[]> {
1266
+ const rows: Row[] = await this.run(
1267
+ (q) =>
1268
+ q`SELECT * FROM ticket_assets WHERE repo=${repo} AND ticket_id=${ticketId} ORDER BY kind, name, revision`,
1269
+ )
1270
+ if (opts?.revisions) {
1271
+ return rows
1272
+ .filter((row) => row.removed !== true)
1273
+ .map((row) => summarizeTicketAsset(this.ticketAssetMeta(row)))
1274
+ }
1275
+ const latest = new Map<string, Row>()
1276
+ for (const row of rows) latest.set(JSON.stringify([row.kind, row.name]), row)
1277
+ return [...latest.values()]
1278
+ .filter((row) => row.removed !== true)
1279
+ .map((row) => summarizeTicketAsset(this.ticketAssetMeta(row)))
1280
+ }
1281
+
1282
+ async removeTicketAsset(
1283
+ repo: string,
1284
+ ticketId: string,
1285
+ kind: string,
1286
+ name: string,
1287
+ ): Promise<TicketAssetMeta | null> {
1288
+ return this.tx(async (q) => {
1289
+ await q`SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([repo, ticketId, kind, name])}, 0))`
1290
+ const rows: Row[] =
1291
+ await q`SELECT * FROM ticket_assets WHERE repo=${repo} AND ticket_id=${ticketId} AND kind=${kind} AND name=${name} ORDER BY revision DESC LIMIT 1`
1292
+ const latest = rows[0]
1293
+ if (!latest || latest.removed === true) return null
1294
+ await q`INSERT INTO ticket_assets (repo,ticket_id,kind,name,revision,layout,manifest,size,removed,created_at) VALUES (${repo},${ticketId},${kind},${name},${num(latest.revision) + 1},${String(latest.layout)},${'[]'}::jsonb,0,true,${this.now()})`
1295
+ return this.ticketAssetMeta(latest)
1296
+ })
1297
+ }
1298
+
891
1299
  subscribe(slug: string, opts: SubscribeOptions, onEvent: (event: AbEvent) => void): Unsubscribe {
892
1300
  return pollingSubscribe((since) => this.getEvents(slug, since), opts, onEvent)
893
1301
  }
894
1302
 
895
1303
  // ── Streams (SPEC §7.6 — the third primitive) ───────────────────────────
896
1304
 
897
- private async lockStream(tx: Tx, streamId: string): Promise<Row> {
898
- const rows: Row[] = await tx`SELECT * FROM streams WHERE id = ${streamId} FOR UPDATE`
1305
+ private async lockStream(q: Exec, streamId: string): Promise<Row> {
1306
+ const rows: Row[] = await q`SELECT * FROM streams WHERE id = ${streamId} FOR UPDATE`
899
1307
  const row = rows[0]
900
1308
  if (!row) throw new Error(`unknown stream "${streamId}"`)
901
1309
  return row
@@ -941,10 +1349,10 @@ export class PostgresBuildStore implements BuildStore {
941
1349
  * create's locked transaction: keep every closed stream's chunks except
942
1350
  * the most recently closed one in this scope (closedAt, then id).
943
1351
  * Records, finalized artifacts, and open streams are never touched. */
944
- private async pruneStreamChunksLocked(tx: Tx, scope: StreamScope): Promise<void> {
1352
+ private async pruneStreamChunksLocked(q: Exec, scope: StreamScope): Promise<void> {
945
1353
  const owner =
946
1354
  scope.kind === 'build' ? scope.build : scope.kind === 'repo' ? scope.repo : scope.session
947
- const rows: Row[] = await tx`
1355
+ const rows: Row[] = await q`
948
1356
  SELECT id FROM streams
949
1357
  WHERE status = 'closed' AND scope_kind = ${scope.kind}
950
1358
  AND (
@@ -954,7 +1362,7 @@ export class PostgresBuildStore implements BuildStore {
954
1362
  )
955
1363
  ORDER BY closed_at DESC, id DESC OFFSET 1`
956
1364
  for (const row of rows) {
957
- await tx`DELETE FROM stream_chunks WHERE stream = ${String(row.id)}`
1365
+ await q`DELETE FROM stream_chunks WHERE stream = ${String(row.id)}`
958
1366
  }
959
1367
  }
960
1368
 
@@ -962,22 +1370,22 @@ export class PostgresBuildStore implements BuildStore {
962
1370
  if (!label) throw new Error('stream label is required')
963
1371
  const id = `st_${crypto.randomUUID()}`
964
1372
  const ts = this.now()
965
- return this.sql.begin(async (tx) => {
966
- if (scope.kind === 'build') await this.lockBuild(tx, scope.build)
967
- else if (scope.kind === 'repo') await this.lockRepo(tx, scope.repo)
968
- else await this.lockSession(tx, scope.session)
969
- await this.pruneStreamChunksLocked(tx, scope)
1373
+ return this.tx(async (q) => {
1374
+ if (scope.kind === 'build') await this.lockBuild(q, scope.build)
1375
+ else if (scope.kind === 'repo') await this.lockRepo(q, scope.repo)
1376
+ else await this.lockSession(q, scope.session)
1377
+ await this.pruneStreamChunksLocked(q, scope)
970
1378
  // Store-assigned monotonic creation sequence (the listStreams
971
1379
  // same-timestamp tiebreak): the sequence is concurrency-safe and never
972
1380
  // repeats, so ties order by assignment. Streams are never deleted
973
1381
  // (retention prunes stream_chunks only), so the counter is never reused.
974
- await tx`INSERT INTO streams
1382
+ await q`INSERT INTO streams
975
1383
  (id, scope_kind, build, repo, session, label, format, status, creation_seq, created_at)
976
1384
  VALUES (${id}, ${scope.kind}, ${scope.kind === 'build' ? scope.build : null},
977
1385
  ${scope.kind === 'repo' ? scope.repo : null},
978
1386
  ${scope.kind === 'session' ? scope.session : null},
979
1387
  ${label}, ${STREAM_FORMAT}, 'open', nextval('streams_creation_seq'), ${ts})`
980
- return this.streamRecord(await this.lockStream(tx, id))
1388
+ return this.streamRecord(await this.lockStream(q, id))
981
1389
  })
982
1390
  }
983
1391
 
@@ -985,18 +1393,18 @@ export class PostgresBuildStore implements BuildStore {
985
1393
  validateStreamParts(parts)
986
1394
  const bytes = serializedBatchSize(parts)
987
1395
  if (bytes > STREAM_BATCH_MAX_BYTES) throw new StreamBatchTooLargeError(bytes)
988
- return this.sql.begin(async (tx) => {
989
- const row = await this.lockStream(tx, streamId)
1396
+ return this.tx(async (q) => {
1397
+ const row = await this.lockStream(q, streamId)
990
1398
  if (String(row.status) === 'closed') throw new StreamClosedError(streamId)
991
1399
  const tails: Row[] =
992
- await tx`SELECT COALESCE(MAX(seq), 0) AS seq FROM stream_chunks WHERE stream = ${streamId}`
1400
+ await q`SELECT COALESCE(MAX(seq), 0) AS seq FROM stream_chunks WHERE stream = ${streamId}`
993
1401
  const chunk: StreamChunk = {
994
1402
  stream: streamId,
995
1403
  seq: num(tails[0]?.seq) + 1,
996
1404
  ts: this.now(),
997
1405
  parts: structuredClone(parts),
998
1406
  }
999
- await tx`INSERT INTO stream_chunks (stream, seq, ts, parts)
1407
+ await q`INSERT INTO stream_chunks (stream, seq, ts, parts)
1000
1408
  VALUES (${streamId}, ${chunk.seq}, ${chunk.ts}, ${JSON.stringify(chunk.parts)})`
1001
1409
  return chunk
1002
1410
  })
@@ -1007,11 +1415,16 @@ export class PostgresBuildStore implements BuildStore {
1007
1415
  opts?: { since?: number; waitSeconds?: number; signal?: AbortSignal },
1008
1416
  ): Promise<StreamRead> {
1009
1417
  const read = async (): Promise<StreamRead> => {
1010
- const rows: Row[] = await this.sql`SELECT * FROM streams WHERE id = ${streamId}`
1011
- const row = rows[0]
1012
- if (!row) throw new Error(`unknown stream "${streamId}"`)
1013
- const chunks: Row[] = await this.sql`SELECT * FROM stream_chunks
1014
- WHERE stream = ${streamId} AND seq > ${opts?.since ?? 0} ORDER BY seq`
1418
+ // Record and chunks are one coherent read: both statements run on the
1419
+ // same pinned connection, and the retry re-executes the pair together.
1420
+ const { row, chunks } = await this.run(async (q) => {
1421
+ const rows: Row[] = await q`SELECT * FROM streams WHERE id = ${streamId}`
1422
+ const row = rows[0]
1423
+ if (!row) throw new Error(`unknown stream "${streamId}"`)
1424
+ const chunks: Row[] = await q`SELECT * FROM stream_chunks
1425
+ WHERE stream = ${streamId} AND seq > ${opts?.since ?? 0} ORDER BY seq`
1426
+ return { row, chunks }
1427
+ })
1015
1428
  const record = this.streamRecord(row)
1016
1429
  return {
1017
1430
  chunks: chunks.map((chunk) => ({
@@ -1029,16 +1442,20 @@ export class PostgresBuildStore implements BuildStore {
1029
1442
  }
1030
1443
 
1031
1444
  async closeStream(streamId: string, outcome: StreamOutcome): Promise<StreamRecord> {
1032
- // Prepare phase — lock-free read of record and chunks, assembly, and the
1033
- // blob write all happen before the transaction (D6 shape: content-
1034
- // addressed orphan blobs are harmless; a deposit failure leaves the
1035
- // stream open and unwritten).
1036
- const rows: Row[] = await this.sql`SELECT * FROM streams WHERE id = ${streamId}`
1037
- const row = rows[0]
1038
- if (!row) throw new Error(`unknown stream "${streamId}"`)
1445
+ // Prepare phase — lock-free read of record and chunks (one coherent read
1446
+ // on a pinned connection, retried together), assembly, and the blob write
1447
+ // all happen before the transaction (D6 shape: content-addressed orphan
1448
+ // blobs are harmless; a deposit failure leaves the stream open and
1449
+ // unwritten).
1450
+ const { row, chunkRows } = await this.run(async (q) => {
1451
+ const rows: Row[] = await q`SELECT * FROM streams WHERE id = ${streamId}`
1452
+ const row = rows[0]
1453
+ if (!row) throw new Error(`unknown stream "${streamId}"`)
1454
+ const chunkRows: Row[] = await q`SELECT * FROM stream_chunks
1455
+ WHERE stream = ${streamId} ORDER BY seq`
1456
+ return { row, chunkRows }
1457
+ })
1039
1458
  if (String(row.status) === 'closed') return this.streamRecord(row)
1040
- const chunkRows: Row[] = await this.sql`SELECT * FROM stream_chunks
1041
- WHERE stream = ${streamId} ORDER BY seq`
1042
1459
  const { document, droppedPartCount } = await assembleUIMessageDocument(
1043
1460
  chunkRows.flatMap((chunk) => json<StreamPart[]>(chunk.parts)),
1044
1461
  )
@@ -1057,8 +1474,8 @@ export class PostgresBuildStore implements BuildStore {
1057
1474
  // Commit phase — one locked transaction: the artifact deposit and the
1058
1475
  // close land together. A close that raced us through the prepare phase
1059
1476
  // wins; ours re-reads and returns its record.
1060
- return this.sql.begin(async (tx) => {
1061
- const fresh = await this.lockStream(tx, streamId)
1477
+ return this.tx(async (q) => {
1478
+ const fresh = await this.lockStream(q, streamId)
1062
1479
  if (String(fresh.status) === 'closed') return this.streamRecord(fresh)
1063
1480
  // Commit-time verification (AUT-348): the prepare-phase chunk snapshot
1064
1481
  // can go stale — an append from another connection may commit after the
@@ -1067,7 +1484,7 @@ export class PostgresBuildStore implements BuildStore {
1067
1484
  // blob so every acknowledged append is included in the finalized
1068
1485
  // artifact. An append that commits before the lock is taken lands here;
1069
1486
  // one that waits on the lock fails the closed check above instead.
1070
- const lockedChunks: Row[] = await tx`SELECT * FROM stream_chunks
1487
+ const lockedChunks: Row[] = await q`SELECT * FROM stream_chunks
1071
1488
  WHERE stream = ${streamId} ORDER BY seq`
1072
1489
  let closeInput = input
1073
1490
  let closeRef = blobRef
@@ -1090,7 +1507,7 @@ export class PostgresBuildStore implements BuildStore {
1090
1507
  const meta =
1091
1508
  scope.kind === 'build'
1092
1509
  ? await this.depositBuildLocked(
1093
- tx,
1510
+ q,
1094
1511
  scope.build,
1095
1512
  {
1096
1513
  kind: closeInput.kind,
@@ -1101,7 +1518,7 @@ export class PostgresBuildStore implements BuildStore {
1101
1518
  )
1102
1519
  : scope.kind === 'repo'
1103
1520
  ? await this.depositRepoLocked(
1104
- tx,
1521
+ q,
1105
1522
  scope.repo,
1106
1523
  {
1107
1524
  kind: closeInput.kind,
@@ -1111,7 +1528,7 @@ export class PostgresBuildStore implements BuildStore {
1111
1528
  new Map(),
1112
1529
  )
1113
1530
  : await this.depositSessionLocked(
1114
- tx,
1531
+ q,
1115
1532
  scope.session,
1116
1533
  {
1117
1534
  kind: closeInput.kind,
@@ -1120,18 +1537,20 @@ export class PostgresBuildStore implements BuildStore {
1120
1537
  },
1121
1538
  new Map(),
1122
1539
  )
1123
- await tx`UPDATE streams
1540
+ await q`UPDATE streams
1124
1541
  SET status = 'closed', outcome = ${outcome}, closed_at = ${meta.createdAt},
1125
1542
  artifact_kind = ${meta.kind}, artifact_revision = ${meta.revision},
1126
1543
  artifact_blob_ref = ${meta.blobRef}
1127
1544
  WHERE id = ${streamId}`
1128
- return this.streamRecord(await this.lockStream(tx, streamId))
1545
+ return this.streamRecord(await this.lockStream(q, streamId))
1129
1546
  })
1130
1547
  }
1131
1548
 
1132
1549
  async getStream(streamId: string): Promise<StreamRecord | null> {
1133
- const rows: Row[] = await this.sql`SELECT * FROM streams WHERE id = ${streamId}`
1134
- return rows[0] ? this.streamRecord(rows[0]) : null
1550
+ return this.run(async (q) => {
1551
+ const rows: Row[] = await q`SELECT * FROM streams WHERE id = ${streamId}`
1552
+ return rows[0] ? this.streamRecord(rows[0]) : null
1553
+ })
1135
1554
  }
1136
1555
 
1137
1556
  async listStreams(scope: StreamScope): Promise<StreamRecord[]> {
@@ -1139,16 +1558,18 @@ export class PostgresBuildStore implements BuildStore {
1139
1558
  scope.kind === 'build' ? scope.build : scope.kind === 'repo' ? scope.repo : scope.session
1140
1559
  // Pinned tiebreak (store/types.ts): createdAt ascending, then the
1141
1560
  // store-assigned monotonic creation sequence — never the random id.
1142
- const rows: Row[] = await this.sql`
1143
- SELECT * FROM streams
1144
- WHERE scope_kind = ${scope.kind}
1145
- AND (
1146
- (scope_kind = 'build' AND build = ${scope.kind === 'build' ? owner : null})
1147
- OR (scope_kind = 'repo' AND repo = ${scope.kind === 'repo' ? owner : null})
1148
- OR (scope_kind = 'session' AND session = ${scope.kind === 'session' ? owner : null})
1149
- )
1150
- ORDER BY created_at, creation_seq`
1151
- return rows.map((row) => this.streamRecord(row))
1561
+ return this.run(async (q) => {
1562
+ const rows: Row[] = await q`
1563
+ SELECT * FROM streams
1564
+ WHERE scope_kind = ${scope.kind}
1565
+ AND (
1566
+ (scope_kind = 'build' AND build = ${scope.kind === 'build' ? owner : null})
1567
+ OR (scope_kind = 'repo' AND repo = ${scope.kind === 'repo' ? owner : null})
1568
+ OR (scope_kind = 'session' AND session = ${scope.kind === 'session' ? owner : null})
1569
+ )
1570
+ ORDER BY created_at, creation_seq`
1571
+ return rows.map((row) => this.streamRecord(row))
1572
+ })
1152
1573
  }
1153
1574
 
1154
1575
  async close(): Promise<void> {