@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/README.md +3 -3
- package/package.json +9 -1
- package/src/schema.ts +153 -9
- package/src/store.ts +263 -72
- package/src/ticket-grants.ts +86 -0
- package/src/ticket-migration.ts +96 -35
- package/src/ticket-policy.ts +60 -0
- package/src/ticket-states.ts +15 -0
- package/src/tickets.ts +217 -82
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.
|
|
263
|
+
if (created === undefined) return this.currentRecord(q, row)
|
|
252
264
|
await this.appendLocked(q, input.slug, created, true)
|
|
253
|
-
return this.
|
|
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.
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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,
|
|
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=${
|
|
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},${
|
|
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
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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=${
|
|
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},${
|
|
1302
|
-
return this.
|
|
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
|
+
}
|