@defrex/autobuild-postgres-store 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/store.ts ADDED
@@ -0,0 +1,1176 @@
1
+ import { SQL } from 'bun'
2
+ import {
3
+ contentHash,
4
+ humanActor,
5
+ createBuildScopedStore,
6
+ createSessionScopedStore,
7
+ EVENT_WAIT_POLL_MS,
8
+ normalizeOperator,
9
+ pollingSubscribe,
10
+ systemClock,
11
+ toBytes,
12
+ validateEventWrite,
13
+ validateExpectedSeq,
14
+ validateRepositoryEventWrite,
15
+ validateSessionEventWrite,
16
+ type AbEvent,
17
+ type Artifact,
18
+ type ArtifactInput,
19
+ type ArtifactMeta,
20
+ type BlobStore,
21
+ type BuildRecord,
22
+ type BuildScopedStore,
23
+ type BuildStore,
24
+ type Clock,
25
+ type EventEnvelope,
26
+ type EventType,
27
+ type EventWrite,
28
+ type NewBuildInput,
29
+ type NewSessionInput,
30
+ type RepositoryArtifact,
31
+ type RepositoryArtifactMeta,
32
+ type RepositoryEvent,
33
+ type RepositoryEventEnvelope,
34
+ type RepositoryEventType,
35
+ type RepositoryEventWrite,
36
+ type RepositoryRecord,
37
+ type SessionArtifact,
38
+ type SessionArtifactMeta,
39
+ type SessionEvent,
40
+ type SessionEventEnvelope,
41
+ type SessionEventType,
42
+ type SessionEventWrite,
43
+ type SessionRecord,
44
+ type SessionScopedStore,
45
+ type SubscribeOptions,
46
+ type Unsubscribe,
47
+ } from '@defrex/autobuild/store-adapter'
48
+ import type {
49
+ StreamChunk,
50
+ StreamOutcome,
51
+ StreamPart,
52
+ StreamRead,
53
+ StreamRecord,
54
+ StreamScope,
55
+ } from '@defrex/autobuild/store-adapter'
56
+ import {
57
+ assembleUIMessageDocument,
58
+ readEventsWithWait,
59
+ readStreamWithWait,
60
+ serializedBatchSize,
61
+ STREAM_BATCH_MAX_BYTES,
62
+ STREAM_FORMAT,
63
+ StreamBatchTooLargeError,
64
+ StreamClosedError,
65
+ streamArtifactInput,
66
+ validateStreamParts,
67
+ } from '@defrex/autobuild/store-adapter'
68
+ import {
69
+ DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS,
70
+ isRetentionManagedKind,
71
+ revisionsToPrune,
72
+ } from '@defrex/autobuild/store-adapter'
73
+ import { assertSchema } from './schema'
74
+
75
+ // The held-read poll cadence for event waits — a re-export of the canonical
76
+ // core constant (AUT-388), not a second definition. See core
77
+ // `store/streams/wait.ts` for the budget's semantics and the AUT-334
78
+ // rationale for why the bound is nominal rather than hard.
79
+ export { EVENT_WAIT_POLL_MS }
80
+
81
+ type Row = Record<string, unknown>
82
+ type Tx = SQL
83
+ interface PreparedArtifact {
84
+ kind: string
85
+ blobRef: string
86
+ metadata: Record<string, unknown>
87
+ }
88
+
89
+ const iso = (value: unknown): string =>
90
+ value instanceof Date ? value.toISOString() : new Date(String(value)).toISOString()
91
+ const num = (value: unknown): number => Number(value)
92
+ const json = <T>(value: unknown): T => (typeof value === 'string' ? JSON.parse(value) : value) as T
93
+
94
+ export interface PostgresBuildStoreOptions {
95
+ sql: SQL
96
+ blobs: BlobStore
97
+ clock?: Clock
98
+ /** Artifact retention (store/retention.ts): how many newest revisions of
99
+ * each retention-managed dispatcher kind survive. Default 200. */
100
+ retention?: { maxRevisions?: number }
101
+ }
102
+
103
+ export class PostgresBuildStore implements BuildStore {
104
+ readonly blobs: BlobStore
105
+ private readonly clock: Clock
106
+ private readonly maxRevisions: number
107
+
108
+ constructor(
109
+ private readonly sql: SQL,
110
+ options: Omit<PostgresBuildStoreOptions, 'sql'>,
111
+ ) {
112
+ this.blobs = options.blobs
113
+ this.clock = options.clock ?? systemClock
114
+ this.maxRevisions = options.retention?.maxRevisions ?? DEFAULT_ARTIFACT_RETENTION_MAX_REVISIONS
115
+ }
116
+
117
+ scopeBuild(slug: string): BuildScopedStore {
118
+ return createBuildScopedStore(this, slug)
119
+ }
120
+
121
+ scopeSession(id: string): SessionScopedStore {
122
+ return createSessionScopedStore(this, id)
123
+ }
124
+
125
+ private now(): string {
126
+ return this.clock().toISOString()
127
+ }
128
+
129
+ private record(row: Row): BuildRecord {
130
+ return {
131
+ slug: String(row.slug),
132
+ repo: String(row.repo),
133
+ ...(row.ticket ? { ticket: json(row.ticket) } : {}),
134
+ ...(row.branch ? { branch: String(row.branch) } : {}),
135
+ ...(row.repo_origin ? { repoOrigin: String(row.repo_origin) } : {}),
136
+ createdAt: iso(row.created_at),
137
+ updatedAt: iso(row.updated_at),
138
+ ...(row.heartbeat_at ? { heartbeatAt: iso(row.heartbeat_at) } : {}),
139
+ ...(row.lease_holder && row.lease_expires_at
140
+ ? { lease: { holder: String(row.lease_holder), expiresAt: iso(row.lease_expires_at) } }
141
+ : {}),
142
+ }
143
+ }
144
+
145
+ private repoRecord(row: Row): RepositoryRecord {
146
+ return {
147
+ repo: String(row.repo),
148
+ createdAt: iso(row.created_at),
149
+ updatedAt: iso(row.updated_at),
150
+ ...(row.heartbeat_at ? { heartbeatAt: iso(row.heartbeat_at) } : {}),
151
+ ...(row.lease_holder && row.lease_expires_at
152
+ ? { lease: { holder: String(row.lease_holder), expiresAt: iso(row.lease_expires_at) } }
153
+ : {}),
154
+ }
155
+ }
156
+
157
+ private async lockBuild(tx: Tx, slug: string): Promise<Row> {
158
+ const rows: Row[] = await tx`SELECT * FROM builds WHERE slug = ${slug} FOR UPDATE`
159
+ const row = rows[0]
160
+ if (!row) throw new Error(`unknown build "${slug}"`)
161
+ return row
162
+ }
163
+
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`
166
+ const row = rows[0]
167
+ if (!row) throw new Error(`unknown repo "${repo}"`)
168
+ return row
169
+ }
170
+
171
+ async createBuild(input: NewBuildInput): Promise<BuildRecord> {
172
+ const ts = this.now()
173
+ return this.sql.begin(async (tx) => {
174
+ const inserted: Row[] = await tx`INSERT INTO builds
175
+ (slug, repo, ticket, branch, repo_origin, created_at, updated_at)
176
+ VALUES (${input.slug}, ${input.repo}, ${input.ticket ?? null}, ${input.branch ?? null}, ${input.repoOrigin ?? null}, ${ts}, ${ts})
177
+ ON CONFLICT (slug) DO NOTHING RETURNING slug`
178
+ const row = await this.lockBuild(tx, input.slug)
179
+ if (!inserted[0]) throw new Error(`build "${input.slug}" already exists`)
180
+ return this.record(row)
181
+ })
182
+ }
183
+
184
+ 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
187
+ }
188
+
189
+ 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))
192
+ }
193
+
194
+ private async appendLocked(
195
+ tx: Tx,
196
+ slug: string,
197
+ event: EventWrite,
198
+ alreadyLocked = false,
199
+ ): Promise<EventEnvelope> {
200
+ if (!alreadyLocked) await this.lockBuild(tx, slug)
201
+ const tails: Row[] =
202
+ await tx`SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE build = ${slug}`
203
+ const seq = num(tails[0]?.seq) + 1
204
+ const ts = this.now()
205
+ await tx`INSERT INTO events (build, seq, ts, actor, type, payload)
206
+ VALUES (${slug}, ${seq}, ${ts}, ${event.actor}, ${event.type}, ${event.payload})`
207
+ await tx`UPDATE builds SET updated_at = ${ts} WHERE slug = ${slug}`
208
+ return { build: slug, seq, ts, actor: event.actor, type: event.type, payload: event.payload }
209
+ }
210
+
211
+ async append<T extends EventType>(slug: string, event: EventWrite<T>): Promise<EventEnvelope<T>> {
212
+ const validated = validateEventWrite(event)
213
+ return (await this.sql.begin((tx) =>
214
+ this.appendLocked(tx, slug, validated),
215
+ )) as EventEnvelope<T>
216
+ }
217
+
218
+ async appendIfCurrent<T extends EventType>(
219
+ slug: string,
220
+ expectedSeq: number,
221
+ event: EventWrite<T>,
222
+ ): Promise<EventEnvelope<T> | null> {
223
+ validateExpectedSeq(expectedSeq)
224
+ const validated = validateEventWrite(event)
225
+ return this.sql.begin(async (tx) => {
226
+ await this.lockBuild(tx, slug)
227
+ const tails: Row[] =
228
+ await tx`SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE build = ${slug}`
229
+ if (num(tails[0]?.seq) !== expectedSeq) return null
230
+ return (await this.appendLocked(tx, slug, validated, true)) as EventEnvelope<T>
231
+ })
232
+ }
233
+
234
+ private async prepare(artifact: ArtifactInput): Promise<PreparedArtifact> {
235
+ if (!artifact.kind) throw new Error('artifact kind is required')
236
+ const bytes = toBytes(artifact.content)
237
+ const blobRef = contentHash(bytes)
238
+ await this.blobs.put(blobRef, bytes)
239
+ return { kind: artifact.kind, blobRef, metadata: structuredClone(artifact.metadata ?? {}) }
240
+ }
241
+
242
+ private async depositBuildLocked(
243
+ tx: Tx,
244
+ slug: string,
245
+ artifact: PreparedArtifact,
246
+ lockedKinds: Map<string, number>,
247
+ opts: { prune?: boolean } = { prune: true },
248
+ ): Promise<ArtifactMeta> {
249
+ let revision = lockedKinds.get(artifact.kind)
250
+ if (revision === undefined) {
251
+ const tails: Row[] = await tx`SELECT COALESCE(MAX(revision), -1) AS revision
252
+ FROM artifacts WHERE build = ${slug} AND kind = ${artifact.kind}`
253
+ revision = num(tails[0]?.revision) + 1
254
+ }
255
+ lockedKinds.set(artifact.kind, revision + 1)
256
+ const createdAt = this.now()
257
+ await tx`INSERT INTO artifacts (build, kind, revision, blob_ref, metadata, created_at)
258
+ 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}`
261
+ return {
262
+ build: slug,
263
+ kind: artifact.kind,
264
+ revision,
265
+ blobRef: artifact.blobRef,
266
+ metadata: artifact.metadata,
267
+ createdAt,
268
+ }
269
+ }
270
+
271
+ /** Deposit-time retention (store/retention.ts), inside the same locked
272
+ * transaction as the deposit: drop revisions past the bound for
273
+ * retention-managed kinds. The pruned values are store-assigned nonnegative
274
+ * integers (re-checked by `revisionsToPrune`), so the IN list expands
275
+ * safely. Non-family kinds are never touched. */
276
+ private async pruneBuildLocked(tx: Tx, slug: string, kind: string): Promise<void> {
277
+ if (!isRetentionManagedKind(kind)) return
278
+ const rows: Row[] =
279
+ await tx`SELECT revision FROM artifacts WHERE build = ${slug} AND kind = ${kind}`
280
+ const pruned = revisionsToPrune(
281
+ rows.map((row) => num(row.revision)),
282
+ this.maxRevisions,
283
+ )
284
+ if (pruned.length === 0) return
285
+ await tx.unsafe(
286
+ `DELETE FROM artifacts WHERE build = $1 AND kind = $2 AND revision IN (${pruned.join(', ')})`,
287
+ [slug, kind],
288
+ )
289
+ }
290
+
291
+ private async pruneRepoLocked(tx: Tx, repo: string, kind: string): Promise<void> {
292
+ if (!isRetentionManagedKind(kind)) return
293
+ const rows: Row[] =
294
+ await tx`SELECT revision FROM repo_artifacts WHERE repo = ${repo} AND kind = ${kind}`
295
+ const pruned = revisionsToPrune(
296
+ rows.map((row) => num(row.revision)),
297
+ this.maxRevisions,
298
+ )
299
+ if (pruned.length === 0) return
300
+ await tx.unsafe(
301
+ `DELETE FROM repo_artifacts WHERE repo = $1 AND kind = $2 AND revision IN (${pruned.join(', ')})`,
302
+ [repo, kind],
303
+ )
304
+ }
305
+
306
+ async appendWithArtifacts<T extends EventType>(
307
+ slug: string,
308
+ artifacts: ArtifactInput[],
309
+ makeEvent: (deposited: ArtifactMeta[]) => EventWrite<T>,
310
+ ): Promise<{ event: EventEnvelope<T>; artifacts: ArtifactMeta[] }> {
311
+ const prepared: PreparedArtifact[] = []
312
+ for (const artifact of artifacts) prepared.push(await this.prepare(artifact))
313
+ return this.sql.begin(async (tx) => {
314
+ await this.lockBuild(tx, slug)
315
+ // Ordering invariant (AUT-322): the batch event is validated BEFORE
316
+ // any retention prune runs. Deposits land unpruned, validation gates
317
+ // the whole batch, and only then does one prune per distinct batch
318
+ // kind execute — still inside this locked transaction. A same-kind
319
+ // batch whose prune scope covers a sibling therefore never deletes
320
+ // that sibling before the batch's event is validated. Pruning once
321
+ // per kind (not per deposit) is equivalent: `revisionsToPrune` is a
322
+ // pure function of the full post-batch revision set.
323
+ const revisions = new Map<string, number>()
324
+ const deposited: ArtifactMeta[] = []
325
+ for (const artifact of prepared) {
326
+ deposited.push(
327
+ await this.depositBuildLocked(tx, slug, artifact, revisions, { prune: false }),
328
+ )
329
+ }
330
+ const validated = validateEventWrite(makeEvent(structuredClone(deposited)))
331
+ for (const kind of new Set(deposited.map((meta) => meta.kind))) {
332
+ await this.pruneBuildLocked(tx, slug, kind)
333
+ }
334
+ const event = (await this.appendLocked(tx, slug, validated, true)) as EventEnvelope<T>
335
+ return { event, artifacts: deposited }
336
+ })
337
+ }
338
+
339
+ async getEvents(
340
+ slug: string,
341
+ sinceSeq = 0,
342
+ opts?: { waitSeconds?: number; signal?: AbortSignal },
343
+ ): Promise<AbEvent[]> {
344
+ const read = async (): Promise<AbEvent[]> => {
345
+ 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`
348
+ return rows.map((row) => ({
349
+ build: String(row.build),
350
+ seq: num(row.seq),
351
+ ts: iso(row.ts),
352
+ actor: json(row.actor),
353
+ type: String(row.type),
354
+ payload: json(row.payload),
355
+ })) as AbEvent[]
356
+ }
357
+ return readEventsWithWait({
358
+ read,
359
+ waitSeconds: opts?.waitSeconds,
360
+ signal: opts?.signal,
361
+ // Hosted budget: no held event request polls the database faster than
362
+ // once per second (AUT-334), so append-to-wake is typically under one
363
+ // second — worst case one poll interval plus the query round-trip.
364
+ pollMs: EVENT_WAIT_POLL_MS,
365
+ })
366
+ }
367
+
368
+ async putArtifact(slug: string, artifact: ArtifactInput): Promise<ArtifactMeta> {
369
+ 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())
373
+ })
374
+ }
375
+
376
+ private artifactMeta(row: Row): ArtifactMeta {
377
+ return {
378
+ build: String(row.build),
379
+ kind: String(row.kind),
380
+ revision: num(row.revision),
381
+ blobRef: String(row.blob_ref),
382
+ metadata: json(row.metadata),
383
+ createdAt: iso(row.created_at),
384
+ }
385
+ }
386
+
387
+ async getArtifact(slug: string, kind: string, rev?: number): Promise<Artifact | null> {
388
+ if (!(await this.getBuild(slug))) throw new Error(`unknown build "${slug}"`)
389
+ const rows: Row[] =
390
+ 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}`
395
+ const row = rows[0]
396
+ if (!row) return null
397
+ const content = await this.blobs.get(String(row.blob_ref))
398
+ return content ? { meta: this.artifactMeta(row), content } : null
399
+ }
400
+
401
+ async listArtifacts(slug: string, kind?: string): Promise<ArtifactMeta[]> {
402
+ if (!(await this.getBuild(slug))) throw new Error(`unknown build "${slug}"`)
403
+ const rows: Row[] =
404
+ 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`
408
+ return rows.map((row) => this.artifactMeta(row))
409
+ }
410
+
411
+ private async claim(
412
+ table: 'builds' | 'repo_streams',
413
+ key: string,
414
+ holder: string,
415
+ ttlMs: number,
416
+ ): Promise<boolean> {
417
+ return this.sql.begin(async (tx) => {
418
+ const row = table === 'builds' ? await this.lockBuild(tx, key) : await this.lockRepo(tx, key)
419
+ const now = this.clock().getTime()
420
+ const held =
421
+ row.lease_holder &&
422
+ row.lease_holder !== holder &&
423
+ row.lease_expires_at &&
424
+ Date.parse(iso(row.lease_expires_at)) > now
425
+ if (held) return false
426
+ const expires = new Date(now + ttlMs).toISOString()
427
+ const nowIso = new Date(now).toISOString()
428
+ 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}`
430
+ 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}`
432
+ return true
433
+ })
434
+ }
435
+
436
+ async claimLease(slug: string, holder: string, ttlMs: number): Promise<boolean> {
437
+ return this.claim('builds', slug, holder, ttlMs)
438
+ }
439
+ async claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean> {
440
+ return this.claim('repo_streams', repo, holder, ttlMs)
441
+ }
442
+
443
+ private async beat(
444
+ table: 'builds' | 'repo_streams',
445
+ key: string,
446
+ holder: string,
447
+ ): Promise<boolean> {
448
+ return this.sql.begin(async (tx) => {
449
+ const row = table === 'builds' ? await this.lockBuild(tx, key) : await this.lockRepo(tx, key)
450
+ const now = this.clock().getTime()
451
+ if (
452
+ row.lease_holder !== holder ||
453
+ !row.lease_expires_at ||
454
+ Date.parse(iso(row.lease_expires_at)) <= now
455
+ )
456
+ return false
457
+ const expires = new Date(now + num(row.lease_ttl_ms)).toISOString()
458
+ const nowIso = new Date(now).toISOString()
459
+ if (table === 'builds')
460
+ await tx`UPDATE builds SET lease_expires_at=${expires}, heartbeat_at=${nowIso}, updated_at=${nowIso} WHERE slug=${key}`
461
+ else
462
+ await tx`UPDATE repo_streams SET lease_expires_at=${expires}, heartbeat_at=${nowIso}, updated_at=${nowIso} WHERE repo=${key}`
463
+ return true
464
+ })
465
+ }
466
+
467
+ async heartbeat(slug: string, holder: string): Promise<boolean> {
468
+ return this.beat('builds', slug, holder)
469
+ }
470
+ async heartbeatRepo(repo: string, holder: string): Promise<boolean> {
471
+ return this.beat('repo_streams', repo, holder)
472
+ }
473
+
474
+ private async release(
475
+ table: 'builds' | 'repo_streams',
476
+ key: string,
477
+ holder: string,
478
+ ): Promise<void> {
479
+ await this.sql.begin(async (tx) => {
480
+ const row = table === 'builds' ? await this.lockBuild(tx, key) : await this.lockRepo(tx, key)
481
+ if (row.lease_holder !== holder) return
482
+ const now = this.now()
483
+ 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}`
485
+ 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}`
487
+ })
488
+ }
489
+
490
+ async releaseLease(slug: string, holder: string): Promise<void> {
491
+ return this.release('builds', slug, holder)
492
+ }
493
+ async releaseRepoLease(repo: string, holder: string): Promise<void> {
494
+ return this.release('repo_streams', repo, holder)
495
+ }
496
+
497
+ // ── Operator sessions (SPEC §7.1.1 — a third resource kind) ─────────
498
+
499
+ private async lockSession(tx: Tx, id: string): Promise<Row> {
500
+ const rows: Row[] = await tx`SELECT * FROM sessions WHERE id = ${id} FOR UPDATE`
501
+ const row = rows[0]
502
+ if (!row) throw new Error(`unknown session "${id}"`)
503
+ return row
504
+ }
505
+
506
+ private sessionRecord(row: Row): SessionRecord {
507
+ return {
508
+ id: String(row.id),
509
+ repo: String(row.repo),
510
+ operator: String(row.operator),
511
+ ...(row.title !== null && row.title !== undefined ? { title: String(row.title) } : {}),
512
+ createdAt: iso(row.created_at),
513
+ updatedAt: iso(row.updated_at),
514
+ }
515
+ }
516
+
517
+ async createSession(input: NewSessionInput): Promise<SessionRecord> {
518
+ const operator = normalizeOperator(input.operator)
519
+ if (!input.repo) throw new Error('repo is required')
520
+ const ts = this.now()
521
+ const id = `os_${crypto.randomUUID()}`
522
+ // The record and its first fact land together: `session.created` (seq 1,
523
+ // actor the operator) commits in the same transaction as the insert (D6).
524
+ const validated = validateSessionEventWrite({
525
+ actor: humanActor(operator),
526
+ type: 'session.created',
527
+ payload: input.title !== undefined ? { title: input.title } : {},
528
+ })
529
+ return this.sql.begin(async (tx) => {
530
+ // Store-assigned monotonic creation sequence (the listSessions
531
+ // same-timestamp tiebreak): the sequence is concurrency-safe and never
532
+ // repeats, so ties order by assignment. Sessions are never deleted, so
533
+ // the counter is never reused.
534
+ await tx`INSERT INTO sessions (id, repo, operator, title, creation_seq, created_at, updated_at)
535
+ 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)
537
+ VALUES (${id}, 1, ${ts}, ${validated.actor}, ${validated.type}, ${validated.payload})`
538
+ return this.sessionRecord(await this.lockSession(tx, id))
539
+ })
540
+ }
541
+
542
+ 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
545
+ }
546
+
547
+ async listSessions(repo: string): Promise<SessionRecord[]> {
548
+ // Pinned tiebreak (store/types.ts): createdAt ascending, then the
549
+ // 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))
553
+ }
554
+
555
+ private async appendSessionLocked(
556
+ tx: Tx,
557
+ id: string,
558
+ event: SessionEventWrite,
559
+ alreadyLocked = false,
560
+ ): Promise<SessionEventEnvelope> {
561
+ if (!alreadyLocked) await this.lockSession(tx, id)
562
+ const tails: Row[] =
563
+ await tx`SELECT COALESCE(MAX(seq), 0) AS seq FROM session_events WHERE session = ${id}`
564
+ const seq = num(tails[0]?.seq) + 1
565
+ const ts = this.now()
566
+ await tx`INSERT INTO session_events (session, seq, ts, actor, type, payload)
567
+ VALUES (${id}, ${seq}, ${ts}, ${event.actor}, ${event.type}, ${event.payload})`
568
+ await tx`UPDATE sessions SET updated_at = ${ts} WHERE id = ${id}`
569
+ return { session: id, seq, ts, actor: event.actor, type: event.type, payload: event.payload }
570
+ }
571
+
572
+ async appendSessionEvent<T extends SessionEventType>(
573
+ id: string,
574
+ event: SessionEventWrite<T>,
575
+ ): Promise<SessionEventEnvelope<T>> {
576
+ const validated = validateSessionEventWrite(event)
577
+ return (await this.sql.begin((tx) =>
578
+ this.appendSessionLocked(tx, id, validated),
579
+ )) as SessionEventEnvelope<T>
580
+ }
581
+
582
+ async getSessionEvents(
583
+ id: string,
584
+ sinceSeq = 0,
585
+ opts?: { waitSeconds?: number; signal?: AbortSignal },
586
+ ): Promise<SessionEvent[]> {
587
+ const read = async (): Promise<SessionEvent[]> => {
588
+ 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`
591
+ return rows.map((row) => ({
592
+ session: String(row.session),
593
+ seq: num(row.seq),
594
+ ts: iso(row.ts),
595
+ actor: json(row.actor),
596
+ type: String(row.type),
597
+ payload: json(row.payload),
598
+ })) as SessionEvent[]
599
+ }
600
+ return readEventsWithWait({
601
+ read,
602
+ waitSeconds: opts?.waitSeconds,
603
+ signal: opts?.signal,
604
+ // Hosted budget: no held event request polls the database faster than
605
+ // once per second (AUT-334, AUT-381), so append-to-wake is typically
606
+ // under one second — worst case one poll interval plus the query
607
+ // round-trip.
608
+ pollMs: EVENT_WAIT_POLL_MS,
609
+ })
610
+ }
611
+
612
+ private async depositSessionLocked(
613
+ tx: Tx,
614
+ id: string,
615
+ artifact: PreparedArtifact,
616
+ revisions: Map<string, number>,
617
+ ): Promise<SessionArtifactMeta> {
618
+ let revision = revisions.get(artifact.kind)
619
+ if (revision === undefined) {
620
+ const tails: Row[] =
621
+ await tx`SELECT COALESCE(MAX(revision), -1) AS revision FROM session_artifacts WHERE session=${id} AND kind=${artifact.kind}`
622
+ revision = num(tails[0]?.revision) + 1
623
+ }
624
+ revisions.set(artifact.kind, revision + 1)
625
+ const createdAt = this.now()
626
+ await tx`INSERT INTO session_artifacts (session, kind, revision, blob_ref, metadata, created_at)
627
+ VALUES (${id}, ${artifact.kind}, ${revision}, ${artifact.blobRef}, ${artifact.metadata}, ${createdAt})`
628
+ await tx`UPDATE sessions SET updated_at = ${createdAt} WHERE id = ${id}`
629
+ return {
630
+ session: id,
631
+ kind: artifact.kind,
632
+ revision,
633
+ blobRef: artifact.blobRef,
634
+ metadata: artifact.metadata,
635
+ createdAt,
636
+ }
637
+ }
638
+
639
+ async appendSessionWithArtifacts<T extends SessionEventType>(
640
+ id: string,
641
+ artifacts: ArtifactInput[],
642
+ makeEvent: (deposited: SessionArtifactMeta[]) => SessionEventWrite<T>,
643
+ ): Promise<{ event: SessionEventEnvelope<T>; artifacts: SessionArtifactMeta[] }> {
644
+ const prepared: PreparedArtifact[] = []
645
+ for (const artifact of artifacts) prepared.push(await this.prepare(artifact))
646
+ // Same shape as `appendWithArtifacts`: deposits land unpruned inside one
647
+ // locked transaction, the batch event is validated before any commit of
648
+ // the caller-visible state, and an invalid event throws, rolling back
649
+ // every deposit (D6). Session artifacts are not retention-managed.
650
+ return this.sql.begin(async (tx) => {
651
+ await this.lockSession(tx, id)
652
+ const revisions = new Map<string, number>()
653
+ const deposited: SessionArtifactMeta[] = []
654
+ for (const artifact of prepared) {
655
+ deposited.push(await this.depositSessionLocked(tx, id, artifact, revisions))
656
+ }
657
+ const validated = validateSessionEventWrite(makeEvent(structuredClone(deposited)))
658
+ const event = (await this.appendSessionLocked(
659
+ tx,
660
+ id,
661
+ validated,
662
+ true,
663
+ )) as SessionEventEnvelope<T>
664
+ return { event, artifacts: deposited }
665
+ })
666
+ }
667
+
668
+ async putSessionArtifact(id: string, artifact: ArtifactInput): Promise<SessionArtifactMeta> {
669
+ 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())
673
+ })
674
+ }
675
+
676
+ private sessionArtifactMeta(row: Row): SessionArtifactMeta {
677
+ return {
678
+ session: String(row.session),
679
+ kind: String(row.kind),
680
+ revision: num(row.revision),
681
+ blobRef: String(row.blob_ref),
682
+ metadata: json(row.metadata),
683
+ createdAt: iso(row.created_at),
684
+ }
685
+ }
686
+
687
+ async getSessionArtifact(
688
+ id: string,
689
+ kind: string,
690
+ rev?: number,
691
+ ): Promise<SessionArtifact | null> {
692
+ if (!(await this.getSession(id))) throw new Error(`unknown session "${id}"`)
693
+ const rows: Row[] =
694
+ 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}`
699
+ const row = rows[0]
700
+ if (!row) return null
701
+ const content = await this.blobs.get(String(row.blob_ref))
702
+ return content ? { meta: this.sessionArtifactMeta(row), content } : null
703
+ }
704
+
705
+ async listSessionArtifacts(id: string, kind?: string): Promise<SessionArtifactMeta[]> {
706
+ if (!(await this.getSession(id))) throw new Error(`unknown session "${id}"`)
707
+ const rows: Row[] =
708
+ 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`
713
+ return rows.map((row) => this.sessionArtifactMeta(row))
714
+ }
715
+
716
+ async ensureRepo(repo: string): Promise<RepositoryRecord> {
717
+ if (!repo) throw new Error('repo is required')
718
+ const ts = this.now()
719
+ return this.sql.begin(async (tx) => {
720
+ await tx`INSERT INTO repo_streams (repo, created_at, updated_at)
721
+ VALUES (${repo}, ${ts}, ${ts}) ON CONFLICT (repo) DO NOTHING`
722
+ return this.repoRecord(await this.lockRepo(tx, repo))
723
+ })
724
+ }
725
+
726
+ 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
729
+ }
730
+
731
+ private async appendRepoLocked(
732
+ tx: Tx,
733
+ repo: string,
734
+ event: RepositoryEventWrite,
735
+ alreadyLocked = false,
736
+ ): Promise<RepositoryEventEnvelope> {
737
+ if (!alreadyLocked) await this.lockRepo(tx, repo)
738
+ const tails: Row[] =
739
+ await tx`SELECT COALESCE(MAX(seq), 0) AS seq FROM repo_events WHERE repo=${repo}`
740
+ const seq = num(tails[0]?.seq) + 1
741
+ 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}`
744
+ return { repo, seq, ts, actor: event.actor, type: event.type, payload: event.payload }
745
+ }
746
+
747
+ async appendRepo<T extends RepositoryEventType>(
748
+ repo: string,
749
+ event: RepositoryEventWrite<T>,
750
+ ): Promise<RepositoryEventEnvelope<T>> {
751
+ const validated = validateRepositoryEventWrite(event)
752
+ return (await this.sql.begin((tx) =>
753
+ this.appendRepoLocked(tx, repo, validated),
754
+ )) as RepositoryEventEnvelope<T>
755
+ }
756
+
757
+ private async depositRepoLocked(
758
+ tx: Tx,
759
+ repo: string,
760
+ artifact: PreparedArtifact,
761
+ revisions: Map<string, number>,
762
+ opts: { prune?: boolean } = { prune: true },
763
+ ): Promise<RepositoryArtifactMeta> {
764
+ let revision = revisions.get(artifact.kind)
765
+ if (revision === undefined) {
766
+ const tails: Row[] =
767
+ await tx`SELECT COALESCE(MAX(revision), -1) AS revision FROM repo_artifacts WHERE repo=${repo} AND kind=${artifact.kind}`
768
+ revision = num(tails[0]?.revision) + 1
769
+ }
770
+ revisions.set(artifact.kind, revision + 1)
771
+ 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}`
775
+ return {
776
+ repo,
777
+ kind: artifact.kind,
778
+ revision,
779
+ blobRef: artifact.blobRef,
780
+ metadata: artifact.metadata,
781
+ createdAt,
782
+ }
783
+ }
784
+
785
+ async appendRepoWithArtifacts<T extends RepositoryEventType>(
786
+ repo: string,
787
+ artifacts: ArtifactInput[],
788
+ makeEvent: (deposited: RepositoryArtifactMeta[]) => RepositoryEventWrite<T>,
789
+ ): Promise<{ event: RepositoryEventEnvelope<T>; artifacts: RepositoryArtifactMeta[] }> {
790
+ const prepared: PreparedArtifact[] = []
791
+ for (const artifact of artifacts) prepared.push(await this.prepare(artifact))
792
+ return this.sql.begin(async (tx) => {
793
+ await this.lockRepo(tx, repo)
794
+ // Ordering invariant (AUT-322): same shape as `appendWithArtifacts` —
795
+ // deposit unpruned, validate the batch event, then prune once per
796
+ // distinct batch kind, all inside this locked transaction. Validation
797
+ // provably precedes any retention deletion.
798
+ const revisions = new Map<string, number>()
799
+ const deposited: RepositoryArtifactMeta[] = []
800
+ for (const artifact of prepared)
801
+ deposited.push(
802
+ await this.depositRepoLocked(tx, repo, artifact, revisions, { prune: false }),
803
+ )
804
+ const validated = validateRepositoryEventWrite(makeEvent(structuredClone(deposited)))
805
+ for (const kind of new Set(deposited.map((meta) => meta.kind))) {
806
+ await this.pruneRepoLocked(tx, repo, kind)
807
+ }
808
+ const event = (await this.appendRepoLocked(
809
+ tx,
810
+ repo,
811
+ validated,
812
+ true,
813
+ )) as RepositoryEventEnvelope<T>
814
+ return { event, artifacts: deposited }
815
+ })
816
+ }
817
+
818
+ async getRepoEvents(
819
+ repo: string,
820
+ sinceSeq = 0,
821
+ opts?: { waitSeconds?: number; signal?: AbortSignal },
822
+ ): Promise<RepositoryEvent[]> {
823
+ const read = async (): Promise<RepositoryEvent[]> => {
824
+ 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`
827
+ return rows.map((row) => ({
828
+ repo: String(row.repo),
829
+ seq: num(row.seq),
830
+ ts: iso(row.ts),
831
+ actor: json(row.actor),
832
+ type: String(row.type),
833
+ payload: json(row.payload),
834
+ })) as RepositoryEvent[]
835
+ }
836
+ return readEventsWithWait({
837
+ read,
838
+ waitSeconds: opts?.waitSeconds,
839
+ signal: opts?.signal,
840
+ pollMs: EVENT_WAIT_POLL_MS,
841
+ })
842
+ }
843
+
844
+ async putRepoArtifact(repo: string, artifact: ArtifactInput): Promise<RepositoryArtifactMeta> {
845
+ 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())
849
+ })
850
+ }
851
+
852
+ private repoArtifactMeta(row: Row): RepositoryArtifactMeta {
853
+ return {
854
+ repo: String(row.repo),
855
+ kind: String(row.kind),
856
+ revision: num(row.revision),
857
+ blobRef: String(row.blob_ref),
858
+ metadata: json(row.metadata),
859
+ createdAt: iso(row.created_at),
860
+ }
861
+ }
862
+
863
+ async getRepoArtifact(
864
+ repo: string,
865
+ kind: string,
866
+ rev?: number,
867
+ ): Promise<RepositoryArtifact | null> {
868
+ if (!(await this.getRepo(repo))) throw new Error(`unknown repo "${repo}"`)
869
+ const rows: Row[] =
870
+ 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}`
875
+ const row = rows[0]
876
+ if (!row) return null
877
+ const content = await this.blobs.get(String(row.blob_ref))
878
+ return content ? { meta: this.repoArtifactMeta(row), content } : null
879
+ }
880
+
881
+ async listRepoArtifacts(repo: string, kind?: string): Promise<RepositoryArtifactMeta[]> {
882
+ if (!(await this.getRepo(repo))) throw new Error(`unknown repo "${repo}"`)
883
+ const rows: Row[] =
884
+ 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`
888
+ return rows.map((row) => this.repoArtifactMeta(row))
889
+ }
890
+
891
+ subscribe(slug: string, opts: SubscribeOptions, onEvent: (event: AbEvent) => void): Unsubscribe {
892
+ return pollingSubscribe((since) => this.getEvents(slug, since), opts, onEvent)
893
+ }
894
+
895
+ // ── Streams (SPEC §7.6 — the third primitive) ───────────────────────────
896
+
897
+ private async lockStream(tx: Tx, streamId: string): Promise<Row> {
898
+ const rows: Row[] = await tx`SELECT * FROM streams WHERE id = ${streamId} FOR UPDATE`
899
+ const row = rows[0]
900
+ if (!row) throw new Error(`unknown stream "${streamId}"`)
901
+ return row
902
+ }
903
+
904
+ private streamScopeOf(row: Row): StreamScope {
905
+ if (row.scope_kind === 'build' && row.build !== null) {
906
+ return { kind: 'build', build: String(row.build) }
907
+ }
908
+ if (row.scope_kind === 'repo' && row.repo !== null) {
909
+ return { kind: 'repo', repo: String(row.repo) }
910
+ }
911
+ if (row.scope_kind === 'session' && row.session !== null) {
912
+ return { kind: 'session', session: String(row.session) }
913
+ }
914
+ throw new Error(`stream "${String(row.id)}" has an unreadable scope`)
915
+ }
916
+
917
+ private streamRecord(row: Row): StreamRecord {
918
+ const record: StreamRecord = {
919
+ id: String(row.id),
920
+ scope: this.streamScopeOf(row),
921
+ label: String(row.label),
922
+ format: String(row.format) as StreamRecord['format'],
923
+ status: String(row.status) as StreamRecord['status'],
924
+ createdAt: iso(row.created_at),
925
+ ...(row.closed_at ? { closedAt: iso(row.closed_at) } : {}),
926
+ ...(row.outcome ? { outcome: String(row.outcome) as StreamRecord['outcome'] } : {}),
927
+ ...(row.artifact_kind && row.artifact_revision !== null && row.artifact_blob_ref
928
+ ? {
929
+ artifact: {
930
+ kind: String(row.artifact_kind),
931
+ revision: num(row.artifact_revision),
932
+ blobRef: String(row.artifact_blob_ref),
933
+ },
934
+ }
935
+ : {}),
936
+ }
937
+ return record
938
+ }
939
+
940
+ /** Deposit-path, count-based chunk retention (SPEC §7.6), inside the
941
+ * create's locked transaction: keep every closed stream's chunks except
942
+ * the most recently closed one in this scope (closedAt, then id).
943
+ * Records, finalized artifacts, and open streams are never touched. */
944
+ private async pruneStreamChunksLocked(tx: Tx, scope: StreamScope): Promise<void> {
945
+ const owner =
946
+ scope.kind === 'build' ? scope.build : scope.kind === 'repo' ? scope.repo : scope.session
947
+ const rows: Row[] = await tx`
948
+ SELECT id FROM streams
949
+ WHERE status = 'closed' AND scope_kind = ${scope.kind}
950
+ AND (
951
+ (scope_kind = 'build' AND build = ${scope.kind === 'build' ? owner : null})
952
+ OR (scope_kind = 'repo' AND repo = ${scope.kind === 'repo' ? owner : null})
953
+ OR (scope_kind = 'session' AND session = ${scope.kind === 'session' ? owner : null})
954
+ )
955
+ ORDER BY closed_at DESC, id DESC OFFSET 1`
956
+ for (const row of rows) {
957
+ await tx`DELETE FROM stream_chunks WHERE stream = ${String(row.id)}`
958
+ }
959
+ }
960
+
961
+ async createStream(scope: StreamScope, label: string): Promise<StreamRecord> {
962
+ if (!label) throw new Error('stream label is required')
963
+ const id = `st_${crypto.randomUUID()}`
964
+ 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)
970
+ // Store-assigned monotonic creation sequence (the listStreams
971
+ // same-timestamp tiebreak): the sequence is concurrency-safe and never
972
+ // repeats, so ties order by assignment. Streams are never deleted
973
+ // (retention prunes stream_chunks only), so the counter is never reused.
974
+ await tx`INSERT INTO streams
975
+ (id, scope_kind, build, repo, session, label, format, status, creation_seq, created_at)
976
+ VALUES (${id}, ${scope.kind}, ${scope.kind === 'build' ? scope.build : null},
977
+ ${scope.kind === 'repo' ? scope.repo : null},
978
+ ${scope.kind === 'session' ? scope.session : null},
979
+ ${label}, ${STREAM_FORMAT}, 'open', nextval('streams_creation_seq'), ${ts})`
980
+ return this.streamRecord(await this.lockStream(tx, id))
981
+ })
982
+ }
983
+
984
+ async appendStreamParts(streamId: string, parts: StreamPart[]): Promise<StreamChunk> {
985
+ validateStreamParts(parts)
986
+ const bytes = serializedBatchSize(parts)
987
+ 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)
990
+ if (String(row.status) === 'closed') throw new StreamClosedError(streamId)
991
+ const tails: Row[] =
992
+ await tx`SELECT COALESCE(MAX(seq), 0) AS seq FROM stream_chunks WHERE stream = ${streamId}`
993
+ const chunk: StreamChunk = {
994
+ stream: streamId,
995
+ seq: num(tails[0]?.seq) + 1,
996
+ ts: this.now(),
997
+ parts: structuredClone(parts),
998
+ }
999
+ await tx`INSERT INTO stream_chunks (stream, seq, ts, parts)
1000
+ VALUES (${streamId}, ${chunk.seq}, ${chunk.ts}, ${JSON.stringify(chunk.parts)})`
1001
+ return chunk
1002
+ })
1003
+ }
1004
+
1005
+ async readStream(
1006
+ streamId: string,
1007
+ opts?: { since?: number; waitSeconds?: number; signal?: AbortSignal },
1008
+ ): Promise<StreamRead> {
1009
+ 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`
1015
+ const record = this.streamRecord(row)
1016
+ return {
1017
+ chunks: chunks.map((chunk) => ({
1018
+ stream: String(chunk.stream),
1019
+ seq: num(chunk.seq),
1020
+ ts: iso(chunk.ts),
1021
+ parts: json<StreamPart[]>(chunk.parts),
1022
+ })),
1023
+ status: record.status,
1024
+ ...(record.outcome !== undefined ? { outcome: record.outcome } : {}),
1025
+ ...(record.artifact !== undefined ? { artifact: record.artifact } : {}),
1026
+ }
1027
+ }
1028
+ return readStreamWithWait({ read, waitSeconds: opts?.waitSeconds, signal: opts?.signal })
1029
+ }
1030
+
1031
+ 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}"`)
1039
+ 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
+ const { document, droppedPartCount } = await assembleUIMessageDocument(
1043
+ chunkRows.flatMap((chunk) => json<StreamPart[]>(chunk.parts)),
1044
+ )
1045
+ const scope = this.streamScopeOf(row)
1046
+ const input = streamArtifactInput(
1047
+ String(row.id),
1048
+ scope,
1049
+ String(row.label),
1050
+ outcome,
1051
+ document,
1052
+ chunkRows.length,
1053
+ droppedPartCount,
1054
+ )
1055
+ const blobRef = contentHash(toBytes(input.content))
1056
+ await this.blobs.put(blobRef, toBytes(input.content))
1057
+ // Commit phase — one locked transaction: the artifact deposit and the
1058
+ // close land together. A close that raced us through the prepare phase
1059
+ // wins; ours re-reads and returns its record.
1060
+ return this.sql.begin(async (tx) => {
1061
+ const fresh = await this.lockStream(tx, streamId)
1062
+ if (String(fresh.status) === 'closed') return this.streamRecord(fresh)
1063
+ // Commit-time verification (AUT-348): the prepare-phase chunk snapshot
1064
+ // can go stale — an append from another connection may commit after the
1065
+ // snapshot and before this transaction takes the row lock. Re-read the
1066
+ // chunk rows under the lock; on a mismatch re-assemble and re-put the
1067
+ // blob so every acknowledged append is included in the finalized
1068
+ // artifact. An append that commits before the lock is taken lands here;
1069
+ // one that waits on the lock fails the closed check above instead.
1070
+ const lockedChunks: Row[] = await tx`SELECT * FROM stream_chunks
1071
+ WHERE stream = ${streamId} ORDER BY seq`
1072
+ let closeInput = input
1073
+ let closeRef = blobRef
1074
+ if (lockedChunks.length !== chunkRows.length) {
1075
+ const reassembled = await assembleUIMessageDocument(
1076
+ lockedChunks.flatMap((chunk) => json<StreamPart[]>(chunk.parts)),
1077
+ )
1078
+ closeInput = streamArtifactInput(
1079
+ String(row.id),
1080
+ scope,
1081
+ String(row.label),
1082
+ outcome,
1083
+ reassembled.document,
1084
+ lockedChunks.length,
1085
+ reassembled.droppedPartCount,
1086
+ )
1087
+ closeRef = contentHash(toBytes(closeInput.content))
1088
+ await this.blobs.put(closeRef, toBytes(closeInput.content))
1089
+ }
1090
+ const meta =
1091
+ scope.kind === 'build'
1092
+ ? await this.depositBuildLocked(
1093
+ tx,
1094
+ scope.build,
1095
+ {
1096
+ kind: closeInput.kind,
1097
+ blobRef: closeRef,
1098
+ metadata: structuredClone(closeInput.metadata),
1099
+ },
1100
+ new Map(),
1101
+ )
1102
+ : scope.kind === 'repo'
1103
+ ? await this.depositRepoLocked(
1104
+ tx,
1105
+ scope.repo,
1106
+ {
1107
+ kind: closeInput.kind,
1108
+ blobRef: closeRef,
1109
+ metadata: structuredClone(closeInput.metadata),
1110
+ },
1111
+ new Map(),
1112
+ )
1113
+ : await this.depositSessionLocked(
1114
+ tx,
1115
+ scope.session,
1116
+ {
1117
+ kind: closeInput.kind,
1118
+ blobRef: closeRef,
1119
+ metadata: structuredClone(closeInput.metadata),
1120
+ },
1121
+ new Map(),
1122
+ )
1123
+ await tx`UPDATE streams
1124
+ SET status = 'closed', outcome = ${outcome}, closed_at = ${meta.createdAt},
1125
+ artifact_kind = ${meta.kind}, artifact_revision = ${meta.revision},
1126
+ artifact_blob_ref = ${meta.blobRef}
1127
+ WHERE id = ${streamId}`
1128
+ return this.streamRecord(await this.lockStream(tx, streamId))
1129
+ })
1130
+ }
1131
+
1132
+ 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
1135
+ }
1136
+
1137
+ async listStreams(scope: StreamScope): Promise<StreamRecord[]> {
1138
+ const owner =
1139
+ scope.kind === 'build' ? scope.build : scope.kind === 'repo' ? scope.repo : scope.session
1140
+ // Pinned tiebreak (store/types.ts): createdAt ascending, then the
1141
+ // 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))
1152
+ }
1153
+
1154
+ async close(): Promise<void> {
1155
+ await this.sql.close()
1156
+ }
1157
+ }
1158
+
1159
+ export async function openPostgresBuildStore(
1160
+ url: string,
1161
+ blobs: BlobStore,
1162
+ options: { clock?: Clock; retention?: { maxRevisions?: number } } = {},
1163
+ ): Promise<PostgresBuildStore> {
1164
+ const sql = new SQL(url)
1165
+ try {
1166
+ await assertSchema(sql)
1167
+ return new PostgresBuildStore(sql, {
1168
+ blobs,
1169
+ ...(options.clock ? { clock: options.clock } : {}),
1170
+ ...(options.retention ? { retention: options.retention } : {}),
1171
+ })
1172
+ } catch (error) {
1173
+ await sql.close()
1174
+ throw error
1175
+ }
1176
+ }