@defrex/autobuild-postgres-store 0.8.0 → 0.9.0

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