@defrex/autobuild-postgres-store 0.13.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/package.json +1 -1
- package/src/schema.ts +9 -2
- package/src/store.ts +163 -49
package/package.json
CHANGED
package/src/schema.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
assertAuthSchema,
|
|
17
17
|
} from './auth-schema'
|
|
18
18
|
|
|
19
|
-
export const SCHEMA_VERSION =
|
|
19
|
+
export const SCHEMA_VERSION = 14
|
|
20
20
|
export const MIGRATE_COMMAND = 'bun run postgres:migrate (from a pinned Autobuild release checkout)'
|
|
21
21
|
|
|
22
22
|
/** The frozen v3 DDL, kept verbatim so a deployed v3 marker's checksum can be
|
|
@@ -836,7 +836,11 @@ CREATE TABLE IF NOT EXISTS ticket_grants (
|
|
|
836
836
|
execution text NOT NULL, kind text NOT NULL CHECK (kind IN ('runner','phase')),
|
|
837
837
|
parent text REFERENCES ticket_grants(id), expires_at timestamptz NOT NULL, revoked_at timestamptz
|
|
838
838
|
);`
|
|
839
|
-
export const
|
|
839
|
+
export const SCHEMA_V13_DDL = `${SCHEMA_V12_DDL}\n${TICKET_POLICY_SCHEMA_DDL}\n${TICKET_GRANT_SCHEMA_DDL}`
|
|
840
|
+
export const SCHEMA_V13_CHECKSUM = new Bun.CryptoHasher('sha256')
|
|
841
|
+
.update(SCHEMA_V13_DDL)
|
|
842
|
+
.digest('hex')
|
|
843
|
+
export const SCHEMA_DDL = `${SCHEMA_V13_DDL}\nALTER TABLE repo_streams ADD COLUMN IF NOT EXISTS tick jsonb;`
|
|
840
844
|
export const SCHEMA_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_DDL).digest('hex')
|
|
841
845
|
|
|
842
846
|
/** Ticket persistence evolves independently so deployed v1 BuildStore markers
|
|
@@ -1038,6 +1042,7 @@ const EXPECTED_COLUMNS: Record<string, readonly ExpectedColumn[]> = {
|
|
|
1038
1042
|
['lease_expires_at', 'timestamp with time zone', false],
|
|
1039
1043
|
['lease_ttl_ms', 'bigint', false],
|
|
1040
1044
|
['heartbeat_at', 'timestamp with time zone', false],
|
|
1045
|
+
['tick', 'jsonb', false],
|
|
1041
1046
|
],
|
|
1042
1047
|
repo_events: [
|
|
1043
1048
|
['repo', 'text', true],
|
|
@@ -1660,6 +1665,8 @@ export async function migratePostgres(
|
|
|
1660
1665
|
SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
|
|
1661
1666
|
applied_at = ${new Date().toISOString()}
|
|
1662
1667
|
WHERE singleton = true`
|
|
1668
|
+
} else if (version === 13 && marker.checksum === SCHEMA_V13_CHECKSUM) {
|
|
1669
|
+
await tx`UPDATE ab_schema_migrations SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM}, applied_at = ${new Date().toISOString()} WHERE singleton = true`
|
|
1663
1670
|
} else if (version === 12 && marker.checksum === SCHEMA_V12_CHECKSUM) {
|
|
1664
1671
|
await tx`UPDATE ab_schema_migrations SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
|
|
1665
1672
|
applied_at = ${new Date().toISOString()} WHERE singleton = true`
|
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),
|
|
@@ -294,51 +306,12 @@ export class PostgresBuildStore implements BuildStore {
|
|
|
294
306
|
}
|
|
295
307
|
|
|
296
308
|
async getRepoBuildDigests(repo: string): Promise<Map<string, BuildDigest>> {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const rows: Row[] = await this.run((q) =>
|
|
304
|
-
q.unsafe(
|
|
305
|
-
`SELECT b.slug AS slug, e.seq AS seq, e.ts AS ts, e.type AS type,
|
|
306
|
-
CASE WHEN e.type = 'pr-attachment.reclaimed'
|
|
307
|
-
THEN (e.payload->>'hostedSeq')::bigint END AS "hostedSeq",
|
|
308
|
-
CASE WHEN e.type IN ('execution.started', 'execution.ended')
|
|
309
|
-
THEN e.payload->>'instance' END AS instance
|
|
310
|
-
FROM builds b
|
|
311
|
-
LEFT JOIN events e
|
|
312
|
-
ON e.build = b.slug AND e.type IN (${DIGEST_EVENT_TYPES.map((_, i) => `$${i + 2}`).join(', ')})
|
|
313
|
-
WHERE b.repo = $1
|
|
314
|
-
ORDER BY b.slug, e.seq`,
|
|
315
|
-
[repo, ...DIGEST_EVENT_TYPES],
|
|
316
|
-
),
|
|
317
|
-
)
|
|
318
|
-
const eventsByBuild = new Map<string, DigestEventRow[]>()
|
|
319
|
-
for (const row of rows) {
|
|
320
|
-
if (row.seq === null || row.seq === undefined) continue
|
|
321
|
-
const events = eventsByBuild.get(String(row.slug)) ?? []
|
|
322
|
-
events.push({
|
|
323
|
-
type: String(row.type) as AbEvent['type'],
|
|
324
|
-
seq: num(row.seq),
|
|
325
|
-
ts: iso(row.ts),
|
|
326
|
-
...(row.hostedSeq !== null && row.hostedSeq !== undefined
|
|
327
|
-
? { hostedSeq: num(row.hostedSeq) }
|
|
328
|
-
: {}),
|
|
329
|
-
...(row.instance !== null && row.instance !== undefined
|
|
330
|
-
? { instance: String(row.instance) }
|
|
331
|
-
: {}),
|
|
332
|
-
})
|
|
333
|
-
eventsByBuild.set(String(row.slug), events)
|
|
334
|
-
}
|
|
335
|
-
const digests = new Map<string, BuildDigest>()
|
|
336
|
-
for (const row of rows) {
|
|
337
|
-
const slug = String(row.slug)
|
|
338
|
-
if (digests.has(slug)) continue
|
|
339
|
-
digests.set(slug, { slug, ...reduceBuildDigest(eventsByBuild.get(slug) ?? []) })
|
|
340
|
-
}
|
|
341
|
-
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)
|
|
342
315
|
}
|
|
343
316
|
|
|
344
317
|
private async appendLocked(
|
|
@@ -586,6 +559,59 @@ export class PostgresBuildStore implements BuildStore {
|
|
|
586
559
|
async claimLease(slug: string, holder: string, ttlMs: number): Promise<boolean> {
|
|
587
560
|
return this.claim('builds', slug, holder, ttlMs)
|
|
588
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
|
+
|
|
589
615
|
async claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean> {
|
|
590
616
|
return this.claim('repo_streams', repo, holder, ttlMs)
|
|
591
617
|
}
|
|
@@ -908,12 +934,12 @@ export class PostgresBuildStore implements BuildStore {
|
|
|
908
934
|
repo: string,
|
|
909
935
|
event: RepositoryEventWrite,
|
|
910
936
|
alreadyLocked = false,
|
|
937
|
+
ts = this.now(),
|
|
911
938
|
): Promise<RepositoryEventEnvelope> {
|
|
912
939
|
if (!alreadyLocked) await this.lockRepo(q, repo)
|
|
913
940
|
const tails: Row[] =
|
|
914
941
|
await q`SELECT COALESCE(MAX(seq), 0) AS seq FROM repo_events WHERE repo=${repo}`
|
|
915
942
|
const seq = num(tails[0]?.seq) + 1
|
|
916
|
-
const ts = this.now()
|
|
917
943
|
await q`INSERT INTO repo_events (repo, seq, ts, actor, type, payload) VALUES (${repo},${seq},${ts},${event.actor},${event.type},${event.payload})`
|
|
918
944
|
await q`UPDATE repo_streams SET updated_at=${ts} WHERE repo=${repo}`
|
|
919
945
|
return { repo, seq, ts, actor: event.actor, type: event.type, payload: event.payload }
|
|
@@ -1032,11 +1058,90 @@ export class PostgresBuildStore implements BuildStore {
|
|
|
1032
1058
|
return code === '42P01'
|
|
1033
1059
|
}
|
|
1034
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
|
+
|
|
1035
1139
|
async getReducerSnapshot(
|
|
1036
1140
|
scope: SnapshotScope,
|
|
1037
1141
|
reducer: string,
|
|
1038
1142
|
version: number,
|
|
1039
1143
|
): Promise<ReducerSnapshot | null> {
|
|
1144
|
+
assertPublicSnapshotReducer(reducer)
|
|
1040
1145
|
const key = scope.kind === 'build' ? scope.slug : scope.repo
|
|
1041
1146
|
try {
|
|
1042
1147
|
const rows: Row[] = await this.run(
|
|
@@ -1063,6 +1168,15 @@ export class PostgresBuildStore implements BuildStore {
|
|
|
1063
1168
|
scope: SnapshotScope,
|
|
1064
1169
|
reducer: string,
|
|
1065
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,
|
|
1066
1180
|
): Promise<boolean> {
|
|
1067
1181
|
const key = scope.kind === 'build' ? scope.slug : scope.repo
|
|
1068
1182
|
const state = JSON.stringify(snapshot.state ?? null)
|