@defrex/autobuild-postgres-store 0.9.0 → 0.10.1
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/index.ts +1 -0
- package/src/schema.ts +16 -0
- package/src/store.ts +94 -2
package/package.json
CHANGED
package/src/index.ts
CHANGED
package/src/schema.ts
CHANGED
|
@@ -807,6 +807,21 @@ export const TICKET_SCHEMA_CHECKSUM = new Bun.CryptoHasher('sha256')
|
|
|
807
807
|
.update(TICKET_SCHEMA_DDL)
|
|
808
808
|
.digest('hex')
|
|
809
809
|
|
|
810
|
+
/** Reducer snapshots (core `store/snapshots.ts`): a cache beside the event log.
|
|
811
|
+
* Deliberately outside `SCHEMA_DDL`, its marker, version and checksum, and
|
|
812
|
+
* outside `EXPECTED_COLUMNS`: a deployment at the current schema keeps serving
|
|
813
|
+
* across the upgrade, a new client against a database that lacks the table
|
|
814
|
+
* degrades to full replay (SQLSTATE 42P01 reads as "no snapshot"), and an older
|
|
815
|
+
* client never asks for it. Idempotent — `migratePostgres` creates it next to
|
|
816
|
+
* the ticket tables. */
|
|
817
|
+
export const SNAPSHOT_SCHEMA_DDL = `
|
|
818
|
+
CREATE TABLE IF NOT EXISTS reducer_snapshots (
|
|
819
|
+
scope_kind text NOT NULL, scope_key text NOT NULL, reducer text NOT NULL,
|
|
820
|
+
version integer NOT NULL, cursor bigint NOT NULL, state jsonb NOT NULL,
|
|
821
|
+
updated_at timestamptz NOT NULL,
|
|
822
|
+
PRIMARY KEY (scope_kind, scope_key, reducer)
|
|
823
|
+
);`.trim()
|
|
824
|
+
|
|
810
825
|
type ExpectedColumn = readonly [name: string, type: string, notNull: boolean, defaultValue?: string]
|
|
811
826
|
|
|
812
827
|
const EXPECTED_COLUMNS: Record<string, readonly ExpectedColumn[]> = {
|
|
@@ -1235,6 +1250,7 @@ export async function migratePostgres(url: string): Promise<void> {
|
|
|
1235
1250
|
await tx`SELECT pg_advisory_xact_lock(470281941)`
|
|
1236
1251
|
await tx.unsafe(SCHEMA_DDL)
|
|
1237
1252
|
await tx.unsafe(TICKET_SCHEMA_DDL)
|
|
1253
|
+
await tx.unsafe(SNAPSHOT_SCHEMA_DDL)
|
|
1238
1254
|
await tx.unsafe(AUTH_SCHEMA_DDL)
|
|
1239
1255
|
const rows: { version: number; checksum: string }[] =
|
|
1240
1256
|
await tx`SELECT version, checksum FROM ab_schema_migrations WHERE singleton = true FOR UPDATE`
|
package/src/store.ts
CHANGED
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
} from '@defrex/autobuild/store-adapter'
|
|
76
76
|
import {
|
|
77
77
|
DIGEST_EVENT_TYPES,
|
|
78
|
+
type DigestEventRow,
|
|
78
79
|
REPOSITORY_STATE_EVENT_TYPES,
|
|
79
80
|
readRepoStateEventsWithAnchorRecheck,
|
|
80
81
|
reduceBuildDigest,
|
|
@@ -92,6 +93,7 @@ import {
|
|
|
92
93
|
type TicketAssetMeta,
|
|
93
94
|
type TicketAssetSummary,
|
|
94
95
|
} from '@defrex/autobuild/store-adapter'
|
|
96
|
+
import type { ReducerSnapshot, SnapshotScope } from '@defrex/autobuild/store-adapter'
|
|
95
97
|
import { assertSchema } from './schema'
|
|
96
98
|
import { PlanRetryRunner, type Exec, type Row } from './retry'
|
|
97
99
|
|
|
@@ -274,7 +276,11 @@ export class PostgresBuildStore implements BuildStore {
|
|
|
274
276
|
// actual signal, not with total history.
|
|
275
277
|
const rows: Row[] = await this.run((q) =>
|
|
276
278
|
q.unsafe(
|
|
277
|
-
`SELECT b.slug AS slug, e.seq AS seq, e.ts AS ts, e.type AS type
|
|
279
|
+
`SELECT b.slug AS slug, e.seq AS seq, e.ts AS ts, e.type AS type,
|
|
280
|
+
CASE WHEN e.type = 'pr-attachment.reclaimed'
|
|
281
|
+
THEN (e.payload->>'hostedSeq')::bigint END AS "hostedSeq",
|
|
282
|
+
CASE WHEN e.type IN ('execution.started', 'execution.ended')
|
|
283
|
+
THEN e.payload->>'instance' END AS instance
|
|
278
284
|
FROM builds b
|
|
279
285
|
LEFT JOIN events e
|
|
280
286
|
ON e.build = b.slug AND e.type IN (${DIGEST_EVENT_TYPES.map((_, i) => `$${i + 2}`).join(', ')})
|
|
@@ -283,7 +289,7 @@ export class PostgresBuildStore implements BuildStore {
|
|
|
283
289
|
[repo, ...DIGEST_EVENT_TYPES],
|
|
284
290
|
),
|
|
285
291
|
)
|
|
286
|
-
const eventsByBuild = new Map<string,
|
|
292
|
+
const eventsByBuild = new Map<string, DigestEventRow[]>()
|
|
287
293
|
for (const row of rows) {
|
|
288
294
|
if (row.seq === null || row.seq === undefined) continue
|
|
289
295
|
const events = eventsByBuild.get(String(row.slug)) ?? []
|
|
@@ -291,6 +297,12 @@ export class PostgresBuildStore implements BuildStore {
|
|
|
291
297
|
type: String(row.type) as AbEvent['type'],
|
|
292
298
|
seq: num(row.seq),
|
|
293
299
|
ts: iso(row.ts),
|
|
300
|
+
...(row.hostedSeq !== null && row.hostedSeq !== undefined
|
|
301
|
+
? { hostedSeq: num(row.hostedSeq) }
|
|
302
|
+
: {}),
|
|
303
|
+
...(row.instance !== null && row.instance !== undefined
|
|
304
|
+
? { instance: String(row.instance) }
|
|
305
|
+
: {}),
|
|
294
306
|
})
|
|
295
307
|
eventsByBuild.set(String(row.slug), events)
|
|
296
308
|
}
|
|
@@ -977,6 +989,86 @@ export class PostgresBuildStore implements BuildStore {
|
|
|
977
989
|
})
|
|
978
990
|
}
|
|
979
991
|
|
|
992
|
+
async getRepoHighWater(repo: string): Promise<number> {
|
|
993
|
+
if (!(await this.getRepo(repo))) throw new Error(`unknown repo "${repo}"`)
|
|
994
|
+
const rows: Row[] = await this.run(
|
|
995
|
+
(q) => q`SELECT COALESCE(MAX(seq), 0) AS seq FROM repo_events WHERE repo=${repo}`,
|
|
996
|
+
)
|
|
997
|
+
return num(rows[0]?.seq ?? 0)
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** SQLSTATE 42P01: a database that has not run the additive snapshot
|
|
1001
|
+
* migration. Never cached, so a later `postgres:migrate` starts working
|
|
1002
|
+
* without a restart. */
|
|
1003
|
+
private static missingSnapshotTable(error: unknown): boolean {
|
|
1004
|
+
const code =
|
|
1005
|
+
(error as { code?: string; errno?: string }).errno ?? (error as { code?: string }).code
|
|
1006
|
+
return code === '42P01'
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
async getReducerSnapshot(
|
|
1010
|
+
scope: SnapshotScope,
|
|
1011
|
+
reducer: string,
|
|
1012
|
+
version: number,
|
|
1013
|
+
): Promise<ReducerSnapshot | null> {
|
|
1014
|
+
const key = scope.kind === 'build' ? scope.slug : scope.repo
|
|
1015
|
+
try {
|
|
1016
|
+
const rows: Row[] = await this.run(
|
|
1017
|
+
(q) => q`SELECT s.version, s.cursor, s.state FROM reducer_snapshots s
|
|
1018
|
+
WHERE s.scope_kind=${scope.kind} AND s.scope_key=${key} AND s.reducer=${reducer}
|
|
1019
|
+
AND s.version=${version}
|
|
1020
|
+
AND s.cursor <= CASE WHEN ${scope.kind}='build'
|
|
1021
|
+
THEN COALESCE((SELECT MAX(seq) FROM events WHERE build=${key}), 0)
|
|
1022
|
+
ELSE COALESCE((SELECT MAX(seq) FROM repo_events WHERE repo=${key}), 0) END
|
|
1023
|
+
AND CASE WHEN ${scope.kind}='build'
|
|
1024
|
+
THEN EXISTS (SELECT 1 FROM builds WHERE slug=${key})
|
|
1025
|
+
ELSE EXISTS (SELECT 1 FROM repo_streams WHERE repo=${key}) END`,
|
|
1026
|
+
)
|
|
1027
|
+
const row = rows[0]
|
|
1028
|
+
if (row === undefined) return null
|
|
1029
|
+
return { version: num(row.version), cursor: num(row.cursor), state: json(row.state) }
|
|
1030
|
+
} catch (error) {
|
|
1031
|
+
if (PostgresBuildStore.missingSnapshotTable(error)) return null
|
|
1032
|
+
throw error
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
async putReducerSnapshot(
|
|
1037
|
+
scope: SnapshotScope,
|
|
1038
|
+
reducer: string,
|
|
1039
|
+
snapshot: ReducerSnapshot,
|
|
1040
|
+
): Promise<boolean> {
|
|
1041
|
+
const key = scope.kind === 'build' ? scope.slug : scope.repo
|
|
1042
|
+
const state = JSON.stringify(snapshot.state ?? null)
|
|
1043
|
+
try {
|
|
1044
|
+
// One statement: the existence and log-tail guards, and the
|
|
1045
|
+
// never-decrease rule, evaluate atomically against concurrent writers.
|
|
1046
|
+
const rows: Row[] = await this.run(
|
|
1047
|
+
(q) => q`INSERT INTO reducer_snapshots
|
|
1048
|
+
(scope_kind, scope_key, reducer, version, cursor, state, updated_at)
|
|
1049
|
+
SELECT ${scope.kind}, ${key}, ${reducer}, ${snapshot.version}::integer,
|
|
1050
|
+
${snapshot.cursor}::bigint, ${state}::jsonb, ${this.now()}::timestamptz
|
|
1051
|
+
WHERE ${snapshot.cursor}::bigint <= CASE WHEN ${scope.kind}='build'
|
|
1052
|
+
THEN COALESCE((SELECT MAX(seq) FROM events WHERE build=${key}), 0)
|
|
1053
|
+
ELSE COALESCE((SELECT MAX(seq) FROM repo_events WHERE repo=${key}), 0) END
|
|
1054
|
+
AND CASE WHEN ${scope.kind}='build'
|
|
1055
|
+
THEN EXISTS (SELECT 1 FROM builds WHERE slug=${key})
|
|
1056
|
+
ELSE EXISTS (SELECT 1 FROM repo_streams WHERE repo=${key}) END
|
|
1057
|
+
ON CONFLICT (scope_kind, scope_key, reducer) DO UPDATE SET
|
|
1058
|
+
version = EXCLUDED.version, cursor = EXCLUDED.cursor,
|
|
1059
|
+
state = EXCLUDED.state, updated_at = EXCLUDED.updated_at
|
|
1060
|
+
WHERE EXCLUDED.cursor > reducer_snapshots.cursor
|
|
1061
|
+
OR (EXCLUDED.cursor = reducer_snapshots.cursor
|
|
1062
|
+
AND EXCLUDED.version > reducer_snapshots.version)
|
|
1063
|
+
RETURNING 1 AS written`,
|
|
1064
|
+
)
|
|
1065
|
+
return rows.length > 0
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
if (PostgresBuildStore.missingSnapshotTable(error)) return false
|
|
1068
|
+
throw error
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
980
1072
|
async getRepoStateEvents(repo: string): Promise<RepositoryEvent[]> {
|
|
981
1073
|
if (!(await this.getRepo(repo))) throw new Error(`unknown repo "${repo}"`)
|
|
982
1074
|
// Two queries, mirroring the oracle's derivation: the latest run-started
|