@defrex/autobuild-postgres-store 0.9.0 → 0.10.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defrex/autobuild-postgres-store",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ export {
19
19
  MIGRATE_COMMAND,
20
20
  SCHEMA_CHECKSUM,
21
21
  SCHEMA_VERSION,
22
+ SNAPSHOT_SCHEMA_DDL,
22
23
  TICKET_SCHEMA_CHECKSUM,
23
24
  TICKET_SCHEMA_VERSION,
24
25
  assertSchema,
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, Pick<AbEvent, 'type' | 'seq' | 'ts'>[]>()
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