@defrex/autobuild-postgres-store 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/store.ts CHANGED
@@ -1,3 +1,17 @@
1
+ import { readPersistedBuildDigests } from '@defrex/autobuild/store-adapter'
2
+ import { assertPublicSnapshotReducer } from '@defrex/autobuild/store-adapter'
3
+ import {
4
+ readReducerValue,
5
+ resolveReducerValue,
6
+ type ReducerValueRequest,
7
+ type ReducerValue,
8
+ } from '@defrex/autobuild/store-adapter'
9
+ import { validateTickFinish } from '@defrex/autobuild/store-adapter'
10
+ import type {
11
+ RepoTickCompletion,
12
+ RepoTickFinish,
13
+ RepoTickObservation,
14
+ } from '@defrex/autobuild/store-adapter'
1
15
  import { SQL } from 'bun'
2
16
  import {
3
17
  contentHash,
@@ -74,12 +88,9 @@ import {
74
88
  revisionsToPrune,
75
89
  } from '@defrex/autobuild/store-adapter'
76
90
  import {
77
- DIGEST_EVENT_TYPES,
78
- type DigestEventRow,
79
91
  REPOSITORY_STATE_EVENT_TYPES,
80
92
  REPOSITORY_LATEST_EVENT_TYPES,
81
93
  readRepoStateEventsWithAnchorRecheck,
82
- reduceBuildDigest,
83
94
  } from '@defrex/autobuild/store-adapter'
84
95
  import {
85
96
  DEFAULT_TICKET_ASSET_LIMITS,
@@ -214,6 +225,7 @@ export class PostgresBuildStore implements BuildStore {
214
225
 
215
226
  private repoRecord(row: Row): RepositoryRecord {
216
227
  return {
228
+ ...(row.tick ? { tick: structuredClone(row.tick) as RepoTickObservation } : {}),
217
229
  repo: String(row.repo),
218
230
  createdAt: iso(row.created_at),
219
231
  updatedAt: iso(row.updated_at),
@@ -248,72 +260,58 @@ export class PostgresBuildStore implements BuildStore {
248
260
  ON CONFLICT (slug) DO NOTHING RETURNING slug`
249
261
  const row = await this.lockBuild(q, input.slug)
250
262
  if (!inserted[0]) throw new Error(`build "${input.slug}" already exists`)
251
- if (created === undefined) return this.record(row)
263
+ if (created === undefined) return this.currentRecord(q, row)
252
264
  await this.appendLocked(q, input.slug, created, true)
253
- return this.record(await this.lockBuild(q, input.slug))
265
+ return this.currentRecord(q, await this.lockBuild(q, input.slug))
254
266
  })
255
267
  }
256
268
 
269
+ /** Current ticket identity is a read projection; event/artifact bytes remain historical. */
270
+ private async currentRecord(q: Exec, row: Row): Promise<BuildRecord> {
271
+ const record = this.record(row)
272
+ if (record.ticket?.source !== 'hosted') return record
273
+ const ticket = await this.hostedTicketRow(q, record.repo, record.ticket.id)
274
+ const id = ticket?.readable_id
275
+ return id ? { ...record, ticket: { ...record.ticket, id: String(id) } } : record
276
+ }
277
+
257
278
  async getBuild(slug: string): Promise<BuildRecord | null> {
258
279
  return this.run(async (q) => {
259
280
  const rows: Row[] = await q`SELECT * FROM builds WHERE slug = ${slug}`
260
- return rows[0] ? this.record(rows[0]) : null
281
+ return rows[0] ? this.currentRecord(q, rows[0]) : null
261
282
  })
262
283
  }
263
284
 
264
285
  async listBuilds(): Promise<BuildRecord[]> {
265
286
  return this.run(async (q) => {
266
- const rows: Row[] = await q`SELECT * FROM builds ORDER BY created_at, slug`
267
- return rows.map((row) => this.record(row))
287
+ if (!(await this.hasReadableTicketSchema(q))) {
288
+ const rows: Row[] = await q`SELECT * FROM builds ORDER BY created_at, slug`
289
+ return rows.map((row) => this.record(row))
290
+ }
291
+ const rows: Row[] = await q`SELECT b.*, t.readable_id AS projected_ticket_id FROM builds b
292
+ LEFT JOIN LATERAL (
293
+ SELECT readable_id FROM ab_tickets
294
+ WHERE b.ticket->>'source' = 'hosted' AND repo = b.repo
295
+ AND (id = b.ticket->>'id' OR lower(readable_id) = lower(b.ticket->>'id'))
296
+ LIMIT 1
297
+ ) t ON true
298
+ ORDER BY b.created_at, b.slug`
299
+ return rows.map((row) => {
300
+ const record = this.record(row)
301
+ return record.ticket && row.projected_ticket_id
302
+ ? { ...record, ticket: { ...record.ticket, id: String(row.projected_ticket_id) } }
303
+ : record
304
+ })
268
305
  })
269
306
  }
270
307
 
271
308
  async getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
272
- // From the builds side (AUT-487), matching the SQLite adapter: the type
273
- // filter lives in the ON clause so a build whose log holds none of the
274
- // digest-relevant event types still yields its row, and only those
275
- // types are fetched. The `events_type_build_seq` index (schema.ts) makes
276
- // the join's per-build probes type-leading, so the cost grows with the
277
- // actual signal, not with total history.
278
- const rows: Row[] = await this.run((q) =>
279
- q.unsafe(
280
- `SELECT b.slug AS slug, e.seq AS seq, e.ts AS ts, e.type AS type,
281
- CASE WHEN e.type = 'pr-attachment.reclaimed'
282
- THEN (e.payload->>'hostedSeq')::bigint END AS "hostedSeq",
283
- CASE WHEN e.type IN ('execution.started', 'execution.ended')
284
- THEN e.payload->>'instance' END AS instance
285
- FROM builds b
286
- LEFT JOIN events e
287
- ON e.build = b.slug AND e.type IN (${DIGEST_EVENT_TYPES.map((_, i) => `$${i + 2}`).join(', ')})
288
- WHERE b.repo = $1
289
- ORDER BY b.slug, e.seq`,
290
- [repo, ...DIGEST_EVENT_TYPES],
291
- ),
292
- )
293
- const eventsByBuild = new Map<string, DigestEventRow[]>()
294
- for (const row of rows) {
295
- if (row.seq === null || row.seq === undefined) continue
296
- const events = eventsByBuild.get(String(row.slug)) ?? []
297
- events.push({
298
- type: String(row.type) as AbEvent['type'],
299
- seq: num(row.seq),
300
- ts: iso(row.ts),
301
- ...(row.hostedSeq !== null && row.hostedSeq !== undefined
302
- ? { hostedSeq: num(row.hostedSeq) }
303
- : {}),
304
- ...(row.instance !== null && row.instance !== undefined
305
- ? { instance: String(row.instance) }
306
- : {}),
307
- })
308
- eventsByBuild.set(String(row.slug), events)
309
- }
310
- const digests = new Map<string, BuildDigest>()
311
- for (const row of rows) {
312
- const slug = String(row.slug)
313
- if (digests.has(slug)) continue
314
- digests.set(slug, { slug, ...reduceBuildDigest(eventsByBuild.get(slug) ?? []) })
315
- }
316
- return digests
309
+ const records = await this.run(async (q) => {
310
+ const rows: Row[] =
311
+ await q`SELECT * FROM builds WHERE repo = ${repo} ORDER BY created_at, slug`
312
+ return rows.map((row) => this.record(row))
313
+ })
314
+ return readPersistedBuildDigests(this, repo, records)
317
315
  }
318
316
 
319
317
  private async appendLocked(
@@ -561,6 +559,59 @@ export class PostgresBuildStore implements BuildStore {
561
559
  async claimLease(slug: string, holder: string, ttlMs: number): Promise<boolean> {
562
560
  return this.claim('builds', slug, holder, ttlMs)
563
561
  }
562
+ async beginRepoTick(repo: string, holder: string): Promise<string | null> {
563
+ return this.tx(async (q) => {
564
+ const row = await this.lockRepo(q, repo)
565
+ if (
566
+ row.lease_holder !== holder ||
567
+ !row.lease_expires_at ||
568
+ Date.parse(iso(row.lease_expires_at)) <= this.clock().getTime()
569
+ )
570
+ return null
571
+ const startedAt = this.now()
572
+ const previous = row.tick as RepoTickObservation | undefined
573
+ const tick: RepoTickObservation = {
574
+ run: holder,
575
+ open: { startedAt },
576
+ ...(previous?.finished ? { finished: previous.finished } : {}),
577
+ }
578
+ await q`UPDATE repo_streams SET tick=${tick}, updated_at=${startedAt} WHERE repo=${repo}`
579
+ return startedAt
580
+ })
581
+ }
582
+
583
+ async finishRepoTick(
584
+ repo: string,
585
+ holder: string,
586
+ completion: RepoTickCompletion,
587
+ events: RepositoryEventWrite[],
588
+ ): Promise<RepoTickFinish | null> {
589
+ const validated = validateTickFinish(holder, completion, events)
590
+ return this.tx(async (q) => {
591
+ const row = await this.lockRepo(q, repo)
592
+ if (
593
+ row.lease_holder !== holder ||
594
+ !row.lease_expires_at ||
595
+ Date.parse(iso(row.lease_expires_at)) <= this.clock().getTime()
596
+ )
597
+ return null
598
+ const previous = row.tick as RepoTickObservation | undefined
599
+ if (previous?.run !== holder || !previous.open) throw new Error('no open repository tick')
600
+ const completedAt = this.now()
601
+ const envelopes: RepositoryEvent[] = []
602
+ for (const event of validated)
603
+ envelopes.push(
604
+ (await this.appendRepoLocked(q, repo, event, true, completedAt)) as RepositoryEvent,
605
+ )
606
+ const observation: RepoTickObservation = {
607
+ run: holder,
608
+ finished: { ...completion, run: holder, startedAt: previous.open.startedAt, completedAt },
609
+ }
610
+ await q`UPDATE repo_streams SET tick=${observation}, updated_at=${completedAt} WHERE repo=${repo}`
611
+ return { observation, events: envelopes }
612
+ })
613
+ }
614
+
564
615
  async claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean> {
565
616
  return this.claim('repo_streams', repo, holder, ttlMs)
566
617
  }
@@ -883,12 +934,12 @@ export class PostgresBuildStore implements BuildStore {
883
934
  repo: string,
884
935
  event: RepositoryEventWrite,
885
936
  alreadyLocked = false,
937
+ ts = this.now(),
886
938
  ): Promise<RepositoryEventEnvelope> {
887
939
  if (!alreadyLocked) await this.lockRepo(q, repo)
888
940
  const tails: Row[] =
889
941
  await q`SELECT COALESCE(MAX(seq), 0) AS seq FROM repo_events WHERE repo=${repo}`
890
942
  const seq = num(tails[0]?.seq) + 1
891
- const ts = this.now()
892
943
  await q`INSERT INTO repo_events (repo, seq, ts, actor, type, payload) VALUES (${repo},${seq},${ts},${event.actor},${event.type},${event.payload})`
893
944
  await q`UPDATE repo_streams SET updated_at=${ts} WHERE repo=${repo}`
894
945
  return { repo, seq, ts, actor: event.actor, type: event.type, payload: event.payload }
@@ -1007,11 +1058,90 @@ export class PostgresBuildStore implements BuildStore {
1007
1058
  return code === '42P01'
1008
1059
  }
1009
1060
 
1061
+ async getReducerValue(scope: SnapshotScope, request: ReducerValueRequest): Promise<ReducerValue> {
1062
+ // Reject invalid requests before touching storage.
1063
+ resolveReducerValue(scope, request)
1064
+ return readReducerValue(scope, request, {
1065
+ highWater: async () => {
1066
+ const tail = await this.valueTail(scope)
1067
+ if (tail === undefined) throw new Error('unknown reducer scope')
1068
+ return tail
1069
+ },
1070
+ events: async (since, through) => {
1071
+ const key = scope.kind === 'build' ? scope.slug : scope.repo
1072
+ const rows: Row[] =
1073
+ scope.kind === 'build'
1074
+ ? await this.run(
1075
+ (q) =>
1076
+ q`SELECT * FROM events WHERE build=${key} AND seq>${since} AND seq<=${through} ORDER BY seq`,
1077
+ )
1078
+ : await this.run(
1079
+ (q) =>
1080
+ q`SELECT * FROM repo_events WHERE repo=${key} AND seq>${since} AND seq<=${through} ORDER BY seq`,
1081
+ )
1082
+ return rows.map((row) => ({
1083
+ ...(scope.kind === 'build' ? { build: key } : { repo: key }),
1084
+ seq: num(row.seq),
1085
+ ts: iso(row.ts),
1086
+ actor: json(row.actor),
1087
+ type: String(row.type),
1088
+ payload: json(row.payload),
1089
+ })) as (AbEvent | RepositoryEvent)[]
1090
+ },
1091
+ put: (key, snapshot) => this.putSnapshot(scope, key, snapshot),
1092
+ read: async (key) => {
1093
+ const scopeKey = scope.kind === 'build' ? scope.slug : scope.repo
1094
+ const rows: Row[] = await this.run(
1095
+ (q) =>
1096
+ q`SELECT version, cursor, state, state::text AS raw FROM reducer_snapshots WHERE scope_kind=${scope.kind} AND scope_key=${scopeKey} AND reducer=${key}`,
1097
+ )
1098
+ const row = rows[0]
1099
+ if (!row) return null
1100
+ let state: unknown = null
1101
+ try {
1102
+ state = json(row.state)
1103
+ } catch {
1104
+ /* malformed JSON cache string */
1105
+ }
1106
+ return {
1107
+ snapshot: { version: num(row.version), cursor: num(row.cursor), state },
1108
+ token: String(row.raw),
1109
+ }
1110
+ },
1111
+ repair: async (key, observed, snapshot) => {
1112
+ const scopeKey = scope.kind === 'build' ? scope.slug : scope.repo
1113
+ const rows: Row[] = await this.run(
1114
+ (q) =>
1115
+ q`UPDATE reducer_snapshots SET version=${snapshot.version}::integer, cursor=${snapshot.cursor}::bigint, state=${JSON.stringify(snapshot.state)}::jsonb, updated_at=${this.now()}::timestamptz WHERE scope_kind=${scope.kind} AND scope_key=${scopeKey} AND reducer=${key} AND version=${observed.snapshot.version}::integer AND cursor=${observed.snapshot.cursor}::bigint AND state::text=${observed.token}
1116
+ AND (${observed.snapshot.cursor}::bigint <= ${snapshot.cursor}::bigint OR ${observed.snapshot.cursor}::bigint > CASE WHEN ${scope.kind}='build' THEN COALESCE((SELECT MAX(seq) FROM events WHERE build=${scopeKey}),0) ELSE COALESCE((SELECT MAX(seq) FROM repo_events WHERE repo=${scopeKey}),0) END)
1117
+ AND ${snapshot.cursor}::bigint <= CASE WHEN ${scope.kind}='build' THEN COALESCE((SELECT MAX(seq) FROM events WHERE build=${scopeKey}),0) ELSE COALESCE((SELECT MAX(seq) FROM repo_events WHERE repo=${scopeKey}),0) END RETURNING 1`,
1118
+ )
1119
+ return rows.length > 0
1120
+ },
1121
+ })
1122
+ }
1123
+
1124
+ private async valueTail(scope: SnapshotScope): Promise<number | undefined> {
1125
+ const key = scope.kind === 'build' ? scope.slug : scope.repo
1126
+ const rows: Row[] =
1127
+ scope.kind === 'build'
1128
+ ? await this.run(
1129
+ (q) =>
1130
+ q`SELECT (SELECT COALESCE(MAX(seq),0) FROM events WHERE build=${key}) AS tail FROM builds WHERE slug=${key}`,
1131
+ )
1132
+ : await this.run(
1133
+ (q) =>
1134
+ q`SELECT (SELECT COALESCE(MAX(seq),0) FROM repo_events WHERE repo=${key}) AS tail FROM repo_streams WHERE repo=${key}`,
1135
+ )
1136
+ return rows[0] ? num(rows[0].tail) : undefined
1137
+ }
1138
+
1010
1139
  async getReducerSnapshot(
1011
1140
  scope: SnapshotScope,
1012
1141
  reducer: string,
1013
1142
  version: number,
1014
1143
  ): Promise<ReducerSnapshot | null> {
1144
+ assertPublicSnapshotReducer(reducer)
1015
1145
  const key = scope.kind === 'build' ? scope.slug : scope.repo
1016
1146
  try {
1017
1147
  const rows: Row[] = await this.run(
@@ -1038,6 +1168,15 @@ export class PostgresBuildStore implements BuildStore {
1038
1168
  scope: SnapshotScope,
1039
1169
  reducer: string,
1040
1170
  snapshot: ReducerSnapshot,
1171
+ ): Promise<boolean> {
1172
+ assertPublicSnapshotReducer(reducer)
1173
+ return this.putSnapshot(scope, reducer, snapshot)
1174
+ }
1175
+
1176
+ private async putSnapshot(
1177
+ scope: SnapshotScope,
1178
+ reducer: string,
1179
+ snapshot: ReducerSnapshot,
1041
1180
  ): Promise<boolean> {
1042
1181
  const key = scope.kind === 'build' ? scope.slug : scope.repo
1043
1182
  const state = JSON.stringify(snapshot.state ?? null)
@@ -1209,6 +1348,39 @@ export class PostgresBuildStore implements BuildStore {
1209
1348
  }
1210
1349
  }
1211
1350
 
1351
+ private readableTicketSchema = false
1352
+
1353
+ /** Ticket tables are an independently migrated, optional store extension. */
1354
+ private async hasReadableTicketSchema(q: Exec): Promise<boolean> {
1355
+ if (!this.readableTicketSchema) {
1356
+ const tables: Row[] = await q`SELECT to_regclass('ab_ticket_schema_migrations') AS tickets`
1357
+ if (!tables[0]?.tickets) return false
1358
+ const markers: Row[] =
1359
+ await q`SELECT version FROM ab_ticket_schema_migrations WHERE singleton = true`
1360
+ if (Number(markers[0]?.version ?? 0) < 3) return false
1361
+ // Cache only presence: a store opened before migration must discover a later upgrade.
1362
+ this.readableTicketSchema = true
1363
+ }
1364
+ return true
1365
+ }
1366
+
1367
+ private async hostedTicketRow(q: Exec, repo: string, id: string): Promise<Row | undefined> {
1368
+ if (!(await this.hasReadableTicketSchema(q))) return undefined
1369
+ const rows: Row[] = await q`SELECT id, readable_id FROM ab_tickets
1370
+ WHERE repo = ${repo} AND (id = ${id} OR lower(readable_id) = lower(${id}))`
1371
+ return rows[0]
1372
+ }
1373
+
1374
+ private async currentAssetMeta(q: Exec, row: Row): Promise<TicketAssetMeta> {
1375
+ const meta = this.ticketAssetMeta(row)
1376
+ const ticket = await this.hostedTicketRow(q, meta.repo, meta.ticketId)
1377
+ return ticket?.readable_id ? { ...meta, ticketId: String(ticket.readable_id) } : meta
1378
+ }
1379
+
1380
+ private async ticketStorageKey(q: Exec, repo: string, id: string): Promise<string> {
1381
+ return String((await this.hostedTicketRow(q, repo, id))?.id ?? id)
1382
+ }
1383
+
1212
1384
  async putTicketAsset(
1213
1385
  repo: string,
1214
1386
  ticketId: string,
@@ -1218,16 +1390,28 @@ export class PostgresBuildStore implements BuildStore {
1218
1390
  const entries = await storeTicketAssetBlobs(this.blobs, asset.entries)
1219
1391
  const manifest = JSON.stringify(entries)
1220
1392
  return this.tx(async (q) => {
1393
+ const key = await this.ticketStorageKey(q, repo, ticketId)
1221
1394
  // Serialize revision assignment per (repo, ticket, kind, name).
1222
- await q`SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([repo, ticketId, asset.kind, asset.name])}, 0))`
1395
+ await q`SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([repo, key, asset.kind, asset.name])}, 0))`
1223
1396
  const tails: Row[] =
1224
- 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}`
1397
+ await q`SELECT COALESCE(MAX(revision), -1) AS revision FROM ticket_assets WHERE repo=${repo} AND ticket_id=${key} AND kind=${asset.kind} AND name=${asset.name}`
1225
1398
  const revision = num(tails[0]?.revision) + 1
1226
1399
  const createdAt = this.now()
1227
- 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})`
1400
+ await q`INSERT INTO ticket_assets (repo,ticket_id,kind,name,revision,layout,manifest,size,removed,created_at) VALUES (${repo},${key},${asset.kind},${asset.name},${revision},${asset.layout},${manifest}::jsonb,${size},false,${createdAt})`
1401
+ const display = await this.currentAssetMeta(q, {
1402
+ repo,
1403
+ ticket_id: key,
1404
+ kind: asset.kind,
1405
+ name: asset.name,
1406
+ revision,
1407
+ layout: asset.layout,
1408
+ size,
1409
+ manifest: entries,
1410
+ created_at: createdAt,
1411
+ })
1228
1412
  return {
1229
1413
  repo,
1230
- ticketId,
1414
+ ticketId: display.ticketId,
1231
1415
  kind: asset.kind,
1232
1416
  name: asset.name,
1233
1417
  revision,
@@ -1246,14 +1430,15 @@ export class PostgresBuildStore implements BuildStore {
1246
1430
  name: string,
1247
1431
  rev?: number,
1248
1432
  ): Promise<TicketAsset | null> {
1249
- const rows: Row[] = await this.run((q) =>
1250
- rev === undefined
1251
- ? q`SELECT * FROM ticket_assets WHERE repo=${repo} AND ticket_id=${ticketId} AND kind=${kind} AND name=${name} ORDER BY revision DESC LIMIT 1`
1252
- : q`SELECT * FROM ticket_assets WHERE repo=${repo} AND ticket_id=${ticketId} AND kind=${kind} AND name=${name} AND revision=${rev}`,
1253
- )
1433
+ const rows: Row[] = await this.run(async (q) => {
1434
+ const key = await this.ticketStorageKey(q, repo, ticketId)
1435
+ return rev === undefined
1436
+ ? q`SELECT * FROM ticket_assets WHERE repo=${repo} AND ticket_id=${key} AND kind=${kind} AND name=${name} ORDER BY revision DESC LIMIT 1`
1437
+ : q`SELECT * FROM ticket_assets WHERE repo=${repo} AND ticket_id=${key} AND kind=${kind} AND name=${name} AND revision=${rev}`
1438
+ })
1254
1439
  const row = rows[0]
1255
1440
  if (!row || row.removed === true) return null
1256
- return loadTicketAsset(this.blobs, this.ticketAssetMeta(row))
1441
+ return loadTicketAsset(this.blobs, await this.run((q) => this.currentAssetMeta(q, row)))
1257
1442
  }
1258
1443
 
1259
1444
  async getPinnedTicketAsset(
@@ -1270,10 +1455,15 @@ export class PostgresBuildStore implements BuildStore {
1270
1455
  ticketId: string,
1271
1456
  opts?: { revisions?: boolean },
1272
1457
  ): Promise<TicketAssetSummary[]> {
1273
- const rows: Row[] = await this.run(
1274
- (q) =>
1275
- q`SELECT * FROM ticket_assets WHERE repo=${repo} AND ticket_id=${ticketId} ORDER BY kind, name, revision`,
1276
- )
1458
+ const rows: Row[] = await this.run(async (q) => {
1459
+ const key = await this.ticketStorageKey(q, repo, ticketId)
1460
+ const rows: Row[] =
1461
+ await q`SELECT * FROM ticket_assets WHERE repo=${repo} AND ticket_id=${key} ORDER BY kind, name, revision`
1462
+ const projected: Row[] = []
1463
+ for (const row of rows)
1464
+ projected.push({ ...row, ticket_id: (await this.currentAssetMeta(q, row)).ticketId })
1465
+ return projected
1466
+ })
1277
1467
  if (opts?.revisions) {
1278
1468
  return rows
1279
1469
  .filter((row) => row.removed !== true)
@@ -1293,13 +1483,14 @@ export class PostgresBuildStore implements BuildStore {
1293
1483
  name: string,
1294
1484
  ): Promise<TicketAssetMeta | null> {
1295
1485
  return this.tx(async (q) => {
1296
- await q`SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([repo, ticketId, kind, name])}, 0))`
1486
+ const key = await this.ticketStorageKey(q, repo, ticketId)
1487
+ await q`SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([repo, key, kind, name])}, 0))`
1297
1488
  const rows: Row[] =
1298
- 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`
1489
+ await q`SELECT * FROM ticket_assets WHERE repo=${repo} AND ticket_id=${key} AND kind=${kind} AND name=${name} ORDER BY revision DESC LIMIT 1`
1299
1490
  const latest = rows[0]
1300
1491
  if (!latest || latest.removed === true) return null
1301
- 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()})`
1302
- return this.ticketAssetMeta(latest)
1492
+ await q`INSERT INTO ticket_assets (repo,ticket_id,kind,name,revision,layout,manifest,size,removed,created_at) VALUES (${repo},${key},${kind},${name},${num(latest.revision) + 1},${String(latest.layout)},${'[]'}::jsonb,0,true,${this.now()})`
1493
+ return this.currentAssetMeta(q, latest)
1303
1494
  })
1304
1495
  }
1305
1496
 
@@ -0,0 +1,86 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import type { SQL } from 'bun'
3
+
4
+ export interface TicketGrant {
5
+ id: string
6
+ repo: string
7
+ execution: string
8
+ kind: 'runner' | 'phase'
9
+ parent: string | null
10
+ expiresAt: string
11
+ }
12
+ export interface TicketGrantStore {
13
+ authenticate(token: string, now: Date): Promise<TicketGrant | null>
14
+ issue(
15
+ repo: string,
16
+ execution: string,
17
+ kind: 'runner' | 'phase',
18
+ parent: TicketGrant | null,
19
+ now: Date,
20
+ ): Promise<{ token: string; grant: TicketGrant }>
21
+ revoke(id: string, repo: string): Promise<void>
22
+ renew(id: string, repo: string, now: Date): Promise<void>
23
+ }
24
+ const digest = (token: string) => createHash('sha256').update(token).digest('hex')
25
+ const ttl = 5 * 60_000
26
+ export class PostgresTicketGrantStore implements TicketGrantStore {
27
+ constructor(private readonly sql: SQL) {}
28
+ async authenticate(token: string, now: Date): Promise<TicketGrant | null> {
29
+ const [row] = await this.sql`SELECT g.* FROM ticket_grants g JOIN LATERAL
30
+ (SELECT seq, action FROM project_journal WHERE repo = g.repo ORDER BY seq DESC LIMIT 1) r
31
+ ON r.seq = g.registration AND r.action = 'added'
32
+ WHERE g.digest = ${digest(token)} AND g.revoked_at IS NULL AND g.expires_at > ${now.toISOString()}
33
+ AND (g.parent IS NULL OR EXISTS (SELECT 1 FROM ticket_grants p WHERE p.id = g.parent
34
+ AND p.revoked_at IS NULL AND p.expires_at > ${now.toISOString()} AND p.registration = g.registration))`
35
+ return row
36
+ ? {
37
+ id: row.id,
38
+ repo: row.repo,
39
+ execution: row.execution,
40
+ kind: row.kind,
41
+ parent: row.parent,
42
+ expiresAt: new Date(row.expires_at).toISOString(),
43
+ }
44
+ : null
45
+ }
46
+ async issue(
47
+ repo: string,
48
+ execution: string,
49
+ kind: 'runner' | 'phase',
50
+ parent: TicketGrant | null,
51
+ now: Date,
52
+ ) {
53
+ if (
54
+ (kind === 'phase' &&
55
+ (parent?.kind !== 'runner' || parent.repo !== repo || parent.execution !== execution)) ||
56
+ (kind === 'runner' && parent)
57
+ )
58
+ throw new Error('invalid ticket grant parent')
59
+ const id = randomBytes(16).toString('hex')
60
+ const token = `abt_${randomBytes(32).toString('hex')}`
61
+ const expiresAt = new Date(
62
+ Math.min(now.getTime() + ttl, parent ? Date.parse(parent.expiresAt) : Infinity),
63
+ ).toISOString()
64
+ const rows = await this
65
+ .sql`INSERT INTO ticket_grants (id, digest, repo, registration, execution, kind, parent, expires_at)
66
+ SELECT ${id}, ${digest(token)}, ${repo}, r.seq, ${execution}, ${kind}, ${parent?.id ?? null}, ${expiresAt}
67
+ FROM (SELECT seq, action FROM project_journal WHERE repo = ${repo} ORDER BY seq DESC LIMIT 1) r
68
+ WHERE r.action = 'added' AND (${parent?.id ?? null}::text IS NULL OR EXISTS
69
+ (SELECT 1 FROM ticket_grants p WHERE p.id = ${parent?.id ?? null} AND p.registration = r.seq
70
+ AND p.revoked_at IS NULL AND p.expires_at > ${now.toISOString()})) RETURNING id`
71
+ if (!rows.length) throw new Error('project or parent grant is no longer active')
72
+ return { token, grant: { id, repo, execution, kind, parent: parent?.id ?? null, expiresAt } }
73
+ }
74
+ async revoke(id: string, repo: string) {
75
+ await this
76
+ .sql`UPDATE ticket_grants SET revoked_at = COALESCE(revoked_at, ${new Date().toISOString()}) WHERE id = ${id} AND repo = ${repo}`
77
+ }
78
+ async renew(id: string, repo: string, now: Date) {
79
+ await this.sql`WITH renewed AS (
80
+ UPDATE ticket_grants SET expires_at = ${new Date(now.getTime() + ttl).toISOString()}
81
+ WHERE id = ${id} AND repo = ${repo} AND kind = 'runner' AND revoked_at IS NULL AND expires_at > ${now.toISOString()}
82
+ RETURNING id, expires_at)
83
+ UPDATE ticket_grants SET expires_at = renewed.expires_at FROM renewed
84
+ WHERE ticket_grants.parent = renewed.id AND ticket_grants.revoked_at IS NULL`
85
+ }
86
+ }