@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/README.md +48 -0
- package/package.json +18 -1
- package/src/auth-schema.ts +21 -6
- package/src/retry.ts +260 -0
- package/src/schema.ts +437 -31
- package/src/store.ts +526 -197
- package/src/tickets.ts +88 -59
- package/src/blobs.test.ts +0 -121
- package/src/config.test.ts +0 -174
- package/src/env.test.ts +0 -48
- package/src/event-wait-poll-ms.test.ts +0 -23
- package/src/migrate.test.ts +0 -640
- package/src/store.live.test.ts +0 -453
- package/src/testing/concurrent-worker.ts +0 -42
- package/src/tickets.live.test.ts +0 -49
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
|
-
|
|
82
|
-
|
|
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(
|
|
158
|
-
const rows: Row[] = await
|
|
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(
|
|
165
|
-
const rows: Row[] = await
|
|
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.
|
|
174
|
-
const inserted: Row[] = await
|
|
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(
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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(
|
|
312
|
+
if (!alreadyLocked) await this.lockBuild(q, slug)
|
|
201
313
|
const tails: Row[] =
|
|
202
|
-
await
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
226
|
-
await this.lockBuild(
|
|
335
|
+
return this.tx(async (q) => {
|
|
336
|
+
await this.lockBuild(q, slug)
|
|
227
337
|
const tails: Row[] =
|
|
228
|
-
await
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
260
|
-
await
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
314
|
-
await this.lockBuild(
|
|
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(
|
|
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(
|
|
442
|
+
await this.pruneBuildLocked(q, slug, kind)
|
|
333
443
|
}
|
|
334
|
-
const event = (await this.appendLocked(
|
|
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.
|
|
347
|
-
|
|
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.
|
|
371
|
-
await this.lockBuild(
|
|
372
|
-
return this.depositBuildLocked(
|
|
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
|
-
?
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
?
|
|
406
|
-
:
|
|
407
|
-
|
|
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.
|
|
418
|
-
const row = table === 'builds' ? await this.lockBuild(
|
|
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
|
|
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
|
|
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.
|
|
449
|
-
const row = table === 'builds' ? await this.lockBuild(
|
|
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
|
|
572
|
+
await q`UPDATE builds SET lease_expires_at=${expires}, heartbeat_at=${nowIso}, updated_at=${nowIso} WHERE slug=${key}`
|
|
461
573
|
else
|
|
462
|
-
await
|
|
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.
|
|
480
|
-
const row = table === 'builds' ? await this.lockBuild(
|
|
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
|
|
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
|
|
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(
|
|
500
|
-
const rows: Row[] = await
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
544
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
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(
|
|
677
|
+
if (!alreadyLocked) await this.lockSession(q, id)
|
|
562
678
|
const tails: Row[] =
|
|
563
|
-
await
|
|
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
|
|
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
|
|
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.
|
|
578
|
-
this.appendSessionLocked(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
651
|
-
await this.lockSession(
|
|
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(
|
|
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
|
-
|
|
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.
|
|
671
|
-
await this.lockSession(
|
|
672
|
-
return this.depositSessionLocked(
|
|
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
|
-
?
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
?
|
|
710
|
-
|
|
711
|
-
|
|
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.
|
|
720
|
-
await
|
|
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(
|
|
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
|
-
|
|
728
|
-
|
|
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
|
-
|
|
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(
|
|
874
|
+
if (!alreadyLocked) await this.lockRepo(q, repo)
|
|
738
875
|
const tails: Row[] =
|
|
739
|
-
await
|
|
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
|
|
743
|
-
await
|
|
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.
|
|
753
|
-
this.appendRepoLocked(
|
|
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
|
-
|
|
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
|
|
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
|
|
773
|
-
if (opts.prune) await this.pruneRepoLocked(
|
|
774
|
-
await
|
|
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.
|
|
793
|
-
await this.lockRepo(
|
|
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(
|
|
941
|
+
await this.pruneRepoLocked(q, repo, kind)
|
|
807
942
|
}
|
|
808
943
|
const event = (await this.appendRepoLocked(
|
|
809
|
-
|
|
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
|
-
|
|
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.
|
|
847
|
-
await this.lockRepo(
|
|
848
|
-
return this.depositRepoLocked(
|
|
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
|
-
?
|
|
872
|
-
|
|
873
|
-
|
|
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
|
-
?
|
|
886
|
-
:
|
|
887
|
-
|
|
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(
|
|
898
|
-
const rows: Row[] = await
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
966
|
-
if (scope.kind === 'build') await this.lockBuild(
|
|
967
|
-
else if (scope.kind === 'repo') await this.lockRepo(
|
|
968
|
-
else await this.lockSession(
|
|
969
|
-
await this.pruneStreamChunksLocked(
|
|
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
|
|
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(
|
|
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.
|
|
989
|
-
const row = await this.lockStream(
|
|
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
|
|
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
|
|
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
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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
|
|
1033
|
-
//
|
|
1034
|
-
//
|
|
1035
|
-
// stream open and
|
|
1036
|
-
|
|
1037
|
-
const row =
|
|
1038
|
-
|
|
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.
|
|
1061
|
-
const fresh = await this.lockStream(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
1134
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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> {
|