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