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