@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defrex/autobuild-postgres-store",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
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 = 13
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 SCHEMA_DDL = `${SCHEMA_V12_DDL}\n${TICKET_POLICY_SCHEMA_DDL}\n${TICKET_GRANT_SCHEMA_DDL}`
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
- // From the builds side (AUT-487), matching the SQLite adapter: the type
298
- // filter lives in the ON clause so a build whose log holds none of the
299
- // digest-relevant event types still yields its row, and only those
300
- // types are fetched. The `events_type_build_seq` index (schema.ts) makes
301
- // the join's per-build probes type-leading, so the cost grows with the
302
- // actual signal, not with total history.
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)