@defrex/autobuild-postgres-store 0.13.0 → 1.0.0-beta.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": "1.0.0-beta.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -42,13 +42,17 @@
42
42
  "./ticket-grants": {
43
43
  "types": "./src/ticket-grants.ts",
44
44
  "import": "./src/ticket-grants.ts"
45
+ },
46
+ "./request-context": {
47
+ "types": "./src/request-context.ts",
48
+ "import": "./src/request-context.ts"
45
49
  }
46
50
  },
47
51
  "bin": {
48
52
  "ab-postgres-store": "./src/bin.ts"
49
53
  },
50
54
  "peerDependencies": {
51
- "@defrex/autobuild": ">=0.7.0"
55
+ "@defrex/autobuild": ">=0.7.0 || >=1.0.0-0 <1.0.0"
52
56
  },
53
57
  "dependencies": {
54
58
  "@aws-sdk/client-s3": "^3.0.0",
@@ -19,6 +19,7 @@ export interface CredentialMetadata {
19
19
  purpose: 'project-owner' | 'dispatcher'
20
20
  }
21
21
  export interface CredentialReader {
22
+ /** Success includes fresh project registration validation. */
22
23
  authenticate(token: string, now: Date): Promise<CredentialMetadata | null>
23
24
  }
24
25
  export interface CredentialStore extends CredentialReader {
@@ -36,6 +37,7 @@ export class ProjectNotRegisteredError extends Error {
36
37
  super('project not registered')
37
38
  }
38
39
  }
40
+ export const LAST_USE_INTERVAL_MS = 60_000
39
41
  const hash = (secret: string) => createHash('sha256').update(secret).digest()
40
42
  const stamp = (value: unknown): string | null =>
41
43
  value == null ? null : new Date(value as string).toISOString()
@@ -113,7 +115,9 @@ export class PostgresCredentialStore implements CredentialStore {
113
115
  async authenticate(token: string, now: Date): Promise<CredentialMetadata | null> {
114
116
  const parts = /^abp_1_([a-f0-9]{32})_([a-f0-9]{64})$/.exec(token)
115
117
  if (!parts) return null
116
- const [row] = await this.sql`SELECT * FROM project_credentials WHERE id = ${parts[1]!}`
118
+ const [row] = await this.sql`SELECT c.*, (SELECT action FROM project_journal
119
+ WHERE repo = c.repo ORDER BY seq DESC LIMIT 1) AS project_action
120
+ FROM project_credentials c WHERE id = ${parts[1]!}`
117
121
  if (
118
122
  !row ||
119
123
  !timingSafeEqual(hash(parts[2]!), Buffer.from(row.digest, 'hex')) ||
@@ -121,11 +125,21 @@ export class PostgresCredentialStore implements CredentialStore {
121
125
  (row.expires_at && new Date(row.expires_at) <= now)
122
126
  )
123
127
  return null
124
- if (!(await this.projects.has(row.repo))) throw new ProjectNotRegisteredError()
125
- // The conditional update also closes a revoke racing the initial read.
128
+ if (row.project_action !== 'added') throw new ProjectNotRegisteredError()
129
+ const cutoff = new Date(now.getTime() - LAST_USE_INTERVAL_MS).toISOString()
130
+ if (
131
+ row.last_used_at &&
132
+ new Date(row.last_used_at).getTime() > now.getTime() - LAST_USE_INTERVAL_MS
133
+ )
134
+ return metadata(row)
135
+ // Competing writers recheck the cutoff after waiting for the row lock.
136
+ // An in-flight authentication may finish after revocation.
126
137
  const [used] = await this.sql`UPDATE project_credentials SET last_used_at = ${now.toISOString()}
127
- WHERE id = ${row.id} AND revoked_at IS NULL RETURNING *`
128
- return used ? metadata(used) : null
138
+ WHERE id = ${row.id} AND revoked_at IS NULL
139
+ AND (expires_at IS NULL OR expires_at > ${now.toISOString()})
140
+ AND (last_used_at IS NULL OR last_used_at <= ${cutoff})
141
+ RETURNING *`
142
+ return metadata(used ?? row)
129
143
  }
130
144
  async list(raw: string): Promise<CredentialMetadata[]> {
131
145
  const rows = await this
@@ -0,0 +1,51 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+
3
+ interface ReadContext {
4
+ records: WeakMap<object, Map<string, Promise<unknown>>>
5
+ existence: WeakMap<object, Map<string, boolean>>
6
+ }
7
+ const reads = new AsyncLocalStorage<ReadContext>()
8
+
9
+ /** One HTTP invocation; nested server composition shares only repository reads. */
10
+ export function withRepositoryReads<T>(work: () => T): T {
11
+ return reads.getStore()
12
+ ? work()
13
+ : reads.run({ records: new WeakMap(), existence: new WeakMap() }, work)
14
+ }
15
+
16
+ export function hasRepositoryReadContext(): boolean {
17
+ return reads.getStore() !== undefined
18
+ }
19
+
20
+ export function repositoryRead<T>(store: object, repo: string, read: () => Promise<T>): Promise<T> {
21
+ const context = reads.getStore()
22
+ if (!context) return read()
23
+ let entries = context.records.get(store)
24
+ if (!entries) {
25
+ entries = new Map()
26
+ context.records.set(store, entries)
27
+ }
28
+ let result = entries.get(repo)
29
+ if (!result) {
30
+ result = read()
31
+ entries.set(repo, result)
32
+ }
33
+ return result as Promise<T>
34
+ }
35
+
36
+ /** Existence only: a positive event tail proves its foreign-key parent exists.
37
+ * Never use this evidence instead of a mutation lock or an authorization check. */
38
+ export function knownRepositoryExists(store: object, repo: string): boolean | undefined {
39
+ return reads.getStore()?.existence.get(store)?.get(repo)
40
+ }
41
+
42
+ export function recordRepositoryExistence(store: object, repo: string, exists: boolean): void {
43
+ const context = reads.getStore()
44
+ if (!context) return
45
+ let entries = context.existence.get(store)
46
+ if (!entries) {
47
+ entries = new Map()
48
+ context.existence.set(store, entries)
49
+ }
50
+ entries.set(repo, exists)
51
+ }
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 = 15
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,12 +836,23 @@ 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_V14_DDL = `${SCHEMA_V13_DDL}\nALTER TABLE repo_streams ADD COLUMN IF NOT EXISTS tick jsonb;`
844
+ export const SCHEMA_V14_CHECKSUM = new Bun.CryptoHasher('sha256')
845
+ .update(SCHEMA_V14_DDL)
846
+ .digest('hex')
847
+ const NODE_WRITER_DDL = `ALTER TABLE builds ADD COLUMN IF NOT EXISTS node_writer_holder text;
848
+ ALTER TABLE builds ADD COLUMN IF NOT EXISTS node_writer_expires_at timestamptz;
849
+ ALTER TABLE builds ADD COLUMN IF NOT EXISTS node_writer_ttl_ms bigint;`
850
+ export const SCHEMA_DDL = `${SCHEMA_V14_DDL}\n${NODE_WRITER_DDL}`
840
851
  export const SCHEMA_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_DDL).digest('hex')
841
852
 
842
853
  /** Ticket persistence evolves independently so deployed v1 BuildStore markers
843
854
  * remain valid while the hosted ticket tables are added. */
844
- export const TICKET_SCHEMA_VERSION = 5
855
+ export const TICKET_SCHEMA_VERSION = 6
845
856
  export const TICKET_SCHEMA_V1_DDL = `
846
857
  CREATE TABLE IF NOT EXISTS ab_ticket_schema_migrations (
847
858
  singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
@@ -891,7 +902,7 @@ export const TICKET_SCHEMA_V4_DDL = `${TICKET_SCHEMA_V3_DDL}\n${TICKET_STATES_DD
891
902
  export const TICKET_SCHEMA_V4_CHECKSUM = new Bun.CryptoHasher('sha256')
892
903
  .update(TICKET_SCHEMA_V4_DDL)
893
904
  .digest('hex')
894
- export const TICKET_SCHEMA_DDL =
905
+ export const TICKET_SCHEMA_V5_DDL =
895
906
  TICKET_SCHEMA_V4_DDL +
896
907
  `
897
908
  ALTER TABLE ab_tickets ADD COLUMN IF NOT EXISTS readable_id text;
@@ -899,6 +910,19 @@ ALTER TABLE ab_tickets ADD COLUMN IF NOT EXISTS ticket_number bigint;
899
910
  CREATE TABLE IF NOT EXISTS ab_ticket_counters (repo text PRIMARY KEY, last_number bigint NOT NULL CHECK (last_number >= 0));
900
911
  ` +
901
912
  READABLE_TICKET_CONSTRAINTS.trim()
913
+ export const TICKET_SCHEMA_V5_CHECKSUM = new Bun.CryptoHasher('sha256')
914
+ .update(TICKET_SCHEMA_V5_DDL)
915
+ .digest('hex')
916
+
917
+ const TICKET_TREE_DELTA = `
918
+ ALTER TABLE ab_tickets ADD COLUMN kind text NOT NULL DEFAULT 'work';
919
+ ALTER TABLE ab_tickets ADD COLUMN target_id text;
920
+ ALTER TABLE ab_tickets ADD CONSTRAINT ab_tickets_kind_check CHECK (kind IN ('work', 'node'));
921
+ ALTER TABLE ab_tickets ADD CONSTRAINT ab_tickets_target_fk FOREIGN KEY (repo, target_id) REFERENCES ab_tickets(repo, id);
922
+ ALTER TABLE ab_tickets ADD CONSTRAINT ab_tickets_target_self_check CHECK (target_id <> id);
923
+ CREATE INDEX ab_tickets_target_idx ON ab_tickets (repo, target_id);
924
+ `
925
+ export const TICKET_SCHEMA_DDL = TICKET_SCHEMA_V5_DDL + TICKET_TREE_DELTA
902
926
  export const TICKET_SCHEMA_CHECKSUM = new Bun.CryptoHasher('sha256')
903
927
  .update(TICKET_SCHEMA_DDL)
904
928
  .digest('hex')
@@ -1001,6 +1025,9 @@ const EXPECTED_COLUMNS: Record<string, readonly ExpectedColumn[]> = {
1001
1025
  // the end, so migrated and fresh databases assert identically (the
1002
1026
  // sessions.creation_seq and streams.session comments above).
1003
1027
  ['repo_origin', 'text', false],
1028
+ ['node_writer_holder', 'text', false],
1029
+ ['node_writer_expires_at', 'timestamp with time zone', false],
1030
+ ['node_writer_ttl_ms', 'bigint', false],
1004
1031
  ],
1005
1032
  events: [
1006
1033
  ['build', 'text', true],
@@ -1038,6 +1065,7 @@ const EXPECTED_COLUMNS: Record<string, readonly ExpectedColumn[]> = {
1038
1065
  ['lease_expires_at', 'timestamp with time zone', false],
1039
1066
  ['lease_ttl_ms', 'bigint', false],
1040
1067
  ['heartbeat_at', 'timestamp with time zone', false],
1068
+ ['tick', 'jsonb', false],
1041
1069
  ],
1042
1070
  repo_events: [
1043
1071
  ['repo', 'text', true],
@@ -1351,6 +1379,8 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
1351
1379
  ['revision', 'bigint', true],
1352
1380
  ['readable_id', 'text', true],
1353
1381
  ['ticket_number', 'bigint', true],
1382
+ ['kind', 'text', true],
1383
+ ['target_id', 'text', false],
1354
1384
  ],
1355
1385
  ab_ticket_counters: [
1356
1386
  ['repo', 'text', true],
@@ -1402,6 +1432,10 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
1402
1432
  !String(indexes[0].indexdef).endsWith('(repo, lower(readable_id))')
1403
1433
  )
1404
1434
  throw schemaError('ticket readable id unique index is missing or mismatched')
1435
+ const targetIndexes =
1436
+ await sql`SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND tablename = 'ab_tickets' AND indexname = 'ab_tickets_target_idx'`
1437
+ if (!targetIndexes[0] || !String(targetIndexes[0].indexdef).endsWith('(repo, target_id)'))
1438
+ throw schemaError('ticket target index is missing or mismatched')
1405
1439
  const constraints: { table_name: string; definition: string }[] = await sql`
1406
1440
  SELECT c.relname AS table_name, pg_get_constraintdef(k.oid) AS definition
1407
1441
  FROM pg_constraint k JOIN pg_class c ON c.oid = k.conrelid
@@ -1418,6 +1452,9 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
1418
1452
  'UNIQUE (repo, creation_key)',
1419
1453
  'UNIQUE (repo, ticket_number)',
1420
1454
  'CHECK ((ticket_number > 0))',
1455
+ "CHECK ((kind = ANY (ARRAY['work'::text, 'node'::text])))",
1456
+ 'FOREIGN KEY (repo, target_id) REFERENCES ab_tickets(repo, id)',
1457
+ 'CHECK ((target_id <> id))',
1421
1458
  ],
1422
1459
  ab_ticket_counters: ['PRIMARY KEY (repo)', 'CHECK ((last_number >= 0))'],
1423
1460
  ab_ticket_comments: [
@@ -1480,10 +1517,11 @@ export async function migratePostgres(
1480
1517
  try {
1481
1518
  await sql.begin(async (tx) => {
1482
1519
  await tx`SELECT pg_advisory_xact_lock(470281941)`
1483
- await tx.unsafe(SCHEMA_DDL)
1520
+ await tx.unsafe(SCHEMA_V14_DDL)
1484
1521
  await tx.unsafe(TICKET_SCHEMA_V2_DDL)
1485
1522
  await tx.unsafe(TICKET_STATES_DDL)
1486
1523
  await tx.unsafe(SNAPSHOT_SCHEMA_DDL)
1524
+ await tx.unsafe(REPO_CHANGE_DDL)
1487
1525
  await tx.unsafe(AUTH_SCHEMA_DDL)
1488
1526
  const rows: { version: number; checksum: string }[] =
1489
1527
  await tx`SELECT version, checksum FROM ab_schema_migrations WHERE singleton = true FOR UPDATE`
@@ -1660,6 +1698,10 @@ export async function migratePostgres(
1660
1698
  SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
1661
1699
  applied_at = ${new Date().toISOString()}
1662
1700
  WHERE singleton = true`
1701
+ } else if (version === 14 && marker.checksum === SCHEMA_V14_CHECKSUM) {
1702
+ await tx`UPDATE ab_schema_migrations SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM}, applied_at = ${new Date().toISOString()} WHERE singleton = true`
1703
+ } else if (version === 13 && marker.checksum === SCHEMA_V13_CHECKSUM) {
1704
+ await tx`UPDATE ab_schema_migrations SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM}, applied_at = ${new Date().toISOString()} WHERE singleton = true`
1663
1705
  } else if (version === 12 && marker.checksum === SCHEMA_V12_CHECKSUM) {
1664
1706
  await tx`UPDATE ab_schema_migrations SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
1665
1707
  applied_at = ${new Date().toISOString()} WHERE singleton = true`
@@ -1690,6 +1732,9 @@ export async function migratePostgres(
1690
1732
  await tx`INSERT INTO ab_schema_migrations (singleton, version, checksum, applied_at)
1691
1733
  VALUES (true, ${SCHEMA_VERSION}, ${SCHEMA_CHECKSUM}, ${new Date().toISOString()})`
1692
1734
  }
1735
+ // Preserve historical physical column order: legacy repo_origin must
1736
+ // precede the new writer columns just as it does in fresh databases.
1737
+ await tx.unsafe(NODE_WRITER_DDL)
1693
1738
  if (options.legacyProjectEnv !== undefined) {
1694
1739
  await new PostgresProjectRegistry(tx as SQL).initializeFromEnvInTransaction(
1695
1740
  options.legacyProjectEnv,
@@ -1706,6 +1751,7 @@ export async function migratePostgres(
1706
1751
  await migrateLegacyTickets(tx as SQL, options.openBlobs)
1707
1752
  await tx`ALTER TABLE ab_tickets ADD COLUMN IF NOT EXISTS revision bigint NOT NULL DEFAULT 1`
1708
1753
  await migrateReadableTickets(tx as SQL, options.openBlobs)
1754
+ await tx.unsafe(TICKET_TREE_DELTA)
1709
1755
  await tx`UPDATE ab_ticket_schema_migrations SET version = ${TICKET_SCHEMA_VERSION},
1710
1756
  checksum = ${TICKET_SCHEMA_CHECKSUM}, applied_at = ${new Date().toISOString()} WHERE singleton = true`
1711
1757
  } else if (
@@ -1715,6 +1761,7 @@ export async function migratePostgres(
1715
1761
  ) {
1716
1762
  await tx`ALTER TABLE ab_tickets ADD COLUMN revision bigint NOT NULL DEFAULT 1`
1717
1763
  await migrateReadableTickets(tx as SQL, options.openBlobs)
1764
+ await tx.unsafe(TICKET_TREE_DELTA)
1718
1765
  await tx`UPDATE ab_ticket_schema_migrations SET version = ${TICKET_SCHEMA_VERSION}, checksum = ${TICKET_SCHEMA_CHECKSUM}, applied_at = ${new Date().toISOString()} WHERE singleton = true`
1719
1766
  } else if (
1720
1767
  ticketMarker &&
@@ -1722,6 +1769,7 @@ export async function migratePostgres(
1722
1769
  ticketMarker.checksum === TICKET_SCHEMA_V3_CHECKSUM
1723
1770
  ) {
1724
1771
  await migrateReadableTickets(tx as SQL, options.openBlobs)
1772
+ await tx.unsafe(TICKET_TREE_DELTA)
1725
1773
  await tx`UPDATE ab_ticket_schema_migrations SET version = ${TICKET_SCHEMA_VERSION}, checksum = ${TICKET_SCHEMA_CHECKSUM}, applied_at = ${new Date().toISOString()} WHERE singleton = true`
1726
1774
  } else if (
1727
1775
  ticketMarker &&
@@ -1729,6 +1777,14 @@ export async function migratePostgres(
1729
1777
  ticketMarker.checksum === TICKET_SCHEMA_V4_CHECKSUM
1730
1778
  ) {
1731
1779
  await migrateReadableTickets(tx as SQL, options.openBlobs)
1780
+ await tx.unsafe(TICKET_TREE_DELTA)
1781
+ await tx`UPDATE ab_ticket_schema_migrations SET version = ${TICKET_SCHEMA_VERSION}, checksum = ${TICKET_SCHEMA_CHECKSUM}, applied_at = ${new Date().toISOString()} WHERE singleton = true`
1782
+ } else if (
1783
+ ticketMarker &&
1784
+ Number(ticketMarker.version) === 5 &&
1785
+ ticketMarker.checksum === TICKET_SCHEMA_V5_CHECKSUM
1786
+ ) {
1787
+ await tx.unsafe(TICKET_TREE_DELTA)
1732
1788
  await tx`UPDATE ab_ticket_schema_migrations SET version = ${TICKET_SCHEMA_VERSION}, checksum = ${TICKET_SCHEMA_CHECKSUM}, applied_at = ${new Date().toISOString()} WHERE singleton = true`
1733
1789
  } else if (ticketMarker) {
1734
1790
  if (Number(ticketMarker.version) !== TICKET_SCHEMA_VERSION) {
@@ -1745,6 +1801,7 @@ export async function migratePostgres(
1745
1801
  } else {
1746
1802
  await tx`ALTER TABLE ab_tickets ADD COLUMN IF NOT EXISTS revision bigint NOT NULL DEFAULT 1`
1747
1803
  await migrateReadableTickets(tx as SQL, options.openBlobs)
1804
+ await tx.unsafe(TICKET_TREE_DELTA)
1748
1805
  await tx`INSERT INTO ab_ticket_schema_migrations
1749
1806
  (singleton, version, checksum, applied_at)
1750
1807
  VALUES (true, ${TICKET_SCHEMA_VERSION}, ${TICKET_SCHEMA_CHECKSUM}, ${new Date().toISOString()})`
@@ -1813,3 +1870,31 @@ export async function migratePostgres(
1813
1870
  await sql.close()
1814
1871
  }
1815
1872
  }
1873
+
1874
+ /** Additive invalidation plumbing; independent of the core lockstep marker. */
1875
+ export const REPO_CHANGE_DDL = `
1876
+ CREATE TABLE IF NOT EXISTS repo_change_positions (repo text PRIMARY KEY, position bigint NOT NULL);
1877
+ CREATE INDEX IF NOT EXISTS builds_repo_created_slug ON builds(repo, created_at, slug);
1878
+ CREATE OR REPLACE FUNCTION ab_repo_changed() RETURNS trigger LANGUAGE plpgsql AS $$
1879
+ BEGIN
1880
+ INSERT INTO repo_change_positions(repo, position) VALUES (NEW.repo, 1)
1881
+ ON CONFLICT(repo) DO UPDATE SET position = repo_change_positions.position + 1;
1882
+ RETURN NEW;
1883
+ END $$;
1884
+ CREATE OR REPLACE FUNCTION ab_build_changed() RETURNS trigger LANGUAGE plpgsql AS $$
1885
+ BEGIN
1886
+ INSERT INTO repo_change_positions(repo, position)
1887
+ SELECT repo, 1 FROM builds WHERE slug = NEW.build
1888
+ ON CONFLICT(repo) DO UPDATE SET position = repo_change_positions.position + 1;
1889
+ RETURN NEW;
1890
+ END $$;
1891
+ DROP TRIGGER IF EXISTS builds_repo_change ON builds;
1892
+ CREATE TRIGGER builds_repo_change AFTER INSERT ON builds FOR EACH ROW EXECUTE FUNCTION ab_repo_changed();
1893
+ DROP TRIGGER IF EXISTS repo_events_repo_change ON repo_events;
1894
+ CREATE TRIGGER repo_events_repo_change AFTER INSERT ON repo_events FOR EACH ROW EXECUTE FUNCTION ab_repo_changed();
1895
+ DROP TRIGGER IF EXISTS events_repo_change ON events;
1896
+ CREATE TRIGGER events_repo_change AFTER INSERT ON events FOR EACH ROW EXECUTE FUNCTION ab_build_changed();
1897
+ DROP TRIGGER IF EXISTS repo_holder_change ON repo_streams;
1898
+ CREATE TRIGGER repo_holder_change AFTER UPDATE OF lease_holder ON repo_streams
1899
+ FOR EACH ROW WHEN (OLD.lease_holder IS DISTINCT FROM NEW.lease_holder) EXECUTE FUNCTION ab_repo_changed();
1900
+ `
package/src/store.ts CHANGED
@@ -1,3 +1,23 @@
1
+ import {
2
+ repositoryRead,
3
+ hasRepositoryReadContext,
4
+ knownRepositoryExists,
5
+ recordRepositoryExistence,
6
+ } from './request-context'
7
+ import { readPersistedBuildDigests } from '@defrex/autobuild/store-adapter'
8
+ import { assertPublicSnapshotReducer } from '@defrex/autobuild/store-adapter'
9
+ import {
10
+ readReducerValue,
11
+ resolveReducerValue,
12
+ type ReducerValueRequest,
13
+ type ReducerValue,
14
+ } from '@defrex/autobuild/store-adapter'
15
+ import { validateTickFinish } from '@defrex/autobuild/store-adapter'
16
+ import type {
17
+ RepoTickCompletion,
18
+ RepoTickFinish,
19
+ RepoTickObservation,
20
+ } from '@defrex/autobuild/store-adapter'
1
21
  import { SQL } from 'bun'
2
22
  import {
3
23
  contentHash,
@@ -74,12 +94,9 @@ import {
74
94
  revisionsToPrune,
75
95
  } from '@defrex/autobuild/store-adapter'
76
96
  import {
77
- DIGEST_EVENT_TYPES,
78
- type DigestEventRow,
79
97
  REPOSITORY_STATE_EVENT_TYPES,
80
98
  REPOSITORY_LATEST_EVENT_TYPES,
81
99
  readRepoStateEventsWithAnchorRecheck,
82
- reduceBuildDigest,
83
100
  } from '@defrex/autobuild/store-adapter'
84
101
  import {
85
102
  DEFAULT_TICKET_ASSET_LIMITS,
@@ -214,6 +231,7 @@ export class PostgresBuildStore implements BuildStore {
214
231
 
215
232
  private repoRecord(row: Row): RepositoryRecord {
216
233
  return {
234
+ ...(row.tick ? { tick: structuredClone(row.tick) as RepoTickObservation } : {}),
217
235
  repo: String(row.repo),
218
236
  createdAt: iso(row.created_at),
219
237
  updatedAt: iso(row.updated_at),
@@ -271,12 +289,42 @@ export class PostgresBuildStore implements BuildStore {
271
289
  }
272
290
 
273
291
  async listBuilds(): Promise<BuildRecord[]> {
292
+ return this.listBuildRecords()
293
+ }
294
+
295
+ async getRepoChangePosition(repo: string): Promise<string> {
296
+ return this.run(async (q) => {
297
+ const rows: Row[] =
298
+ await q`SELECT position::text AS position FROM repo_change_positions WHERE repo = ${repo}`
299
+ return rows[0] ? String(rows[0].position) : '0'
300
+ })
301
+ }
302
+
303
+ async listBuildsForRepo(repo: string): Promise<BuildRecord[]> {
304
+ return this.listBuildRecords(repo)
305
+ }
306
+
307
+ private async listBuildRecords(repo?: string): Promise<BuildRecord[]> {
274
308
  return this.run(async (q) => {
275
309
  if (!(await this.hasReadableTicketSchema(q))) {
276
- const rows: Row[] = await q`SELECT * FROM builds ORDER BY created_at, slug`
310
+ const rows: Row[] =
311
+ repo === undefined
312
+ ? await q`SELECT * FROM builds ORDER BY created_at, slug`
313
+ : await q`SELECT * FROM builds WHERE repo = ${repo} ORDER BY created_at, slug`
277
314
  return rows.map((row) => this.record(row))
278
315
  }
279
- const rows: Row[] = await q`SELECT b.*, t.readable_id AS projected_ticket_id FROM builds b
316
+ const rows: Row[] =
317
+ repo === undefined
318
+ ? await q`SELECT b.*, t.readable_id AS projected_ticket_id FROM builds b
319
+ LEFT JOIN LATERAL (
320
+ SELECT readable_id FROM ab_tickets
321
+ WHERE b.ticket->>'source' = 'hosted' AND repo = b.repo
322
+ AND (id = b.ticket->>'id' OR lower(readable_id) = lower(b.ticket->>'id'))
323
+ LIMIT 1
324
+ ) t ON true
325
+ ORDER BY b.created_at, b.slug`
326
+ : await q`SELECT b.*, t.readable_id AS projected_ticket_id
327
+ FROM (SELECT * FROM builds WHERE repo = ${repo}) b
280
328
  LEFT JOIN LATERAL (
281
329
  SELECT readable_id FROM ab_tickets
282
330
  WHERE b.ticket->>'source' = 'hosted' AND repo = b.repo
@@ -294,51 +342,12 @@ export class PostgresBuildStore implements BuildStore {
294
342
  }
295
343
 
296
344
  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
345
+ const records = await this.run(async (q) => {
346
+ const rows: Row[] =
347
+ await q`SELECT * FROM builds WHERE repo = ${repo} ORDER BY created_at, slug`
348
+ return rows.map((row) => this.record(row))
349
+ })
350
+ return readPersistedBuildDigests(this, repo, records)
342
351
  }
343
352
 
344
353
  private async appendLocked(
@@ -583,9 +592,99 @@ export class PostgresBuildStore implements BuildStore {
583
592
  })
584
593
  }
585
594
 
595
+ async claimNodeWriter(slug: string, holder: string, ttlMs: number): Promise<boolean> {
596
+ return this.tx(async (q) => {
597
+ const row = await this.lockBuild(q, slug)
598
+ const now = this.clock().getTime()
599
+ if (
600
+ row.node_writer_holder &&
601
+ row.node_writer_holder !== holder &&
602
+ row.node_writer_expires_at &&
603
+ Date.parse(iso(row.node_writer_expires_at)) > now
604
+ )
605
+ return false
606
+ const expires = new Date(now + ttlMs).toISOString()
607
+ await q`UPDATE builds SET node_writer_holder=${holder}, node_writer_expires_at=${expires}, node_writer_ttl_ms=${ttlMs}, updated_at=${new Date(now).toISOString()} WHERE slug=${slug}`
608
+ return true
609
+ })
610
+ }
611
+ async renewNodeWriter(slug: string, holder: string): Promise<boolean> {
612
+ return this.tx(async (q) => {
613
+ const row = await this.lockBuild(q, slug)
614
+ const now = this.clock().getTime()
615
+ if (
616
+ row.node_writer_holder !== holder ||
617
+ !row.node_writer_expires_at ||
618
+ Date.parse(iso(row.node_writer_expires_at)) <= now
619
+ )
620
+ return false
621
+ const expires = new Date(now + num(row.node_writer_ttl_ms)).toISOString()
622
+ await q`UPDATE builds SET node_writer_expires_at=${expires}, updated_at=${new Date(now).toISOString()} WHERE slug=${slug}`
623
+ return true
624
+ })
625
+ }
626
+ async releaseNodeWriter(slug: string, holder: string): Promise<void> {
627
+ await this.tx(async (q) => {
628
+ await this.lockBuild(q, slug)
629
+ await q`UPDATE builds SET node_writer_holder=NULL, node_writer_expires_at=NULL, node_writer_ttl_ms=NULL, updated_at=${this.now()} WHERE slug=${slug} AND node_writer_holder=${holder}`
630
+ })
631
+ }
586
632
  async claimLease(slug: string, holder: string, ttlMs: number): Promise<boolean> {
587
633
  return this.claim('builds', slug, holder, ttlMs)
588
634
  }
635
+ async beginRepoTick(repo: string, holder: string): Promise<string | null> {
636
+ return this.tx(async (q) => {
637
+ const row = await this.lockRepo(q, repo)
638
+ if (
639
+ row.lease_holder !== holder ||
640
+ !row.lease_expires_at ||
641
+ Date.parse(iso(row.lease_expires_at)) <= this.clock().getTime()
642
+ )
643
+ return null
644
+ const startedAt = this.now()
645
+ const previous = row.tick as RepoTickObservation | undefined
646
+ const tick: RepoTickObservation = {
647
+ run: holder,
648
+ open: { startedAt },
649
+ ...(previous?.finished ? { finished: previous.finished } : {}),
650
+ }
651
+ await q`UPDATE repo_streams SET tick=${tick}, updated_at=${startedAt} WHERE repo=${repo}`
652
+ return startedAt
653
+ })
654
+ }
655
+
656
+ async finishRepoTick(
657
+ repo: string,
658
+ holder: string,
659
+ completion: RepoTickCompletion,
660
+ events: RepositoryEventWrite[],
661
+ ): Promise<RepoTickFinish | null> {
662
+ const validated = validateTickFinish(holder, completion, events)
663
+ return this.tx(async (q) => {
664
+ const row = await this.lockRepo(q, repo)
665
+ if (
666
+ row.lease_holder !== holder ||
667
+ !row.lease_expires_at ||
668
+ Date.parse(iso(row.lease_expires_at)) <= this.clock().getTime()
669
+ )
670
+ return null
671
+ const previous = row.tick as RepoTickObservation | undefined
672
+ if (previous?.run !== holder || !previous.open) throw new Error('no open repository tick')
673
+ const completedAt = this.now()
674
+ const envelopes: RepositoryEvent[] = []
675
+ for (const event of validated)
676
+ envelopes.push(
677
+ (await this.appendRepoLocked(q, repo, event, true, completedAt)) as RepositoryEvent,
678
+ )
679
+ const observation: RepoTickObservation = {
680
+ run: holder,
681
+ finished: { ...completion, run: holder, startedAt: previous.open.startedAt, completedAt },
682
+ }
683
+ await q`UPDATE repo_streams SET tick=${observation}, updated_at=${completedAt} WHERE repo=${repo}`
684
+ return { observation, events: envelopes }
685
+ })
686
+ }
687
+
589
688
  async claimRepoLease(repo: string, holder: string, ttlMs: number): Promise<boolean> {
590
689
  return this.claim('repo_streams', repo, holder, ttlMs)
591
690
  }
@@ -897,10 +996,13 @@ export class PostgresBuildStore implements BuildStore {
897
996
  }
898
997
 
899
998
  async getRepo(repo: string): Promise<RepositoryRecord | null> {
900
- return this.run(async (q) => {
901
- const rows: Row[] = await q`SELECT * FROM repo_streams WHERE repo=${repo}`
902
- return rows[0] ? this.repoRecord(rows[0]) : null
903
- })
999
+ return repositoryRead(this, repo, () =>
1000
+ this.run(async (q) => {
1001
+ const rows: Row[] = await q`SELECT * FROM repo_streams WHERE repo=${repo}`
1002
+ recordRepositoryExistence(this, repo, !!rows[0])
1003
+ return rows[0] ? this.repoRecord(rows[0]) : null
1004
+ }),
1005
+ )
904
1006
  }
905
1007
 
906
1008
  private async appendRepoLocked(
@@ -908,12 +1010,12 @@ export class PostgresBuildStore implements BuildStore {
908
1010
  repo: string,
909
1011
  event: RepositoryEventWrite,
910
1012
  alreadyLocked = false,
1013
+ ts = this.now(),
911
1014
  ): Promise<RepositoryEventEnvelope> {
912
1015
  if (!alreadyLocked) await this.lockRepo(q, repo)
913
1016
  const tails: Row[] =
914
1017
  await q`SELECT COALESCE(MAX(seq), 0) AS seq FROM repo_events WHERE repo=${repo}`
915
1018
  const seq = num(tails[0]?.seq) + 1
916
- const ts = this.now()
917
1019
  await q`INSERT INTO repo_events (repo, seq, ts, actor, type, payload) VALUES (${repo},${seq},${ts},${event.actor},${event.type},${event.payload})`
918
1020
  await q`UPDATE repo_streams SET updated_at=${ts} WHERE repo=${repo}`
919
1021
  return { repo, seq, ts, actor: event.actor, type: event.type, payload: event.payload }
@@ -993,8 +1095,8 @@ export class PostgresBuildStore implements BuildStore {
993
1095
  sinceSeq = 0,
994
1096
  opts?: { waitSeconds?: number; signal?: AbortSignal },
995
1097
  ): Promise<RepositoryEvent[]> {
1098
+ if (!(await this.getRepo(repo))) throw new Error(`unknown repo "${repo}"`)
996
1099
  const read = async (): Promise<RepositoryEvent[]> => {
997
- if (!(await this.getRepo(repo))) throw new Error(`unknown repo "${repo}"`)
998
1100
  const rows: Row[] = await this.run(
999
1101
  (q) => q`SELECT * FROM repo_events WHERE repo=${repo} AND seq>${sinceSeq} ORDER BY seq`,
1000
1102
  )
@@ -1032,12 +1134,108 @@ export class PostgresBuildStore implements BuildStore {
1032
1134
  return code === '42P01'
1033
1135
  }
1034
1136
 
1137
+ async getReducerValue(scope: SnapshotScope, request: ReducerValueRequest): Promise<ReducerValue> {
1138
+ // Reject invalid requests before touching storage.
1139
+ resolveReducerValue(scope, request)
1140
+ return readReducerValue(scope, request, {
1141
+ highWater: async () => {
1142
+ const tail = await this.valueTail(scope)
1143
+ if (tail === undefined) throw new Error('unknown reducer scope')
1144
+ return tail
1145
+ },
1146
+ events: async (since, through) => {
1147
+ const key = scope.kind === 'build' ? scope.slug : scope.repo
1148
+ const rows: Row[] =
1149
+ scope.kind === 'build'
1150
+ ? await this.run(
1151
+ (q) =>
1152
+ q`SELECT * FROM events WHERE build=${key} AND seq>${since} AND seq<=${through} ORDER BY seq`,
1153
+ )
1154
+ : await this.run(
1155
+ (q) =>
1156
+ q`SELECT * FROM repo_events WHERE repo=${key} AND seq>${since} AND seq<=${through} ORDER BY seq`,
1157
+ )
1158
+ return rows.map((row) => ({
1159
+ ...(scope.kind === 'build' ? { build: key } : { repo: key }),
1160
+ seq: num(row.seq),
1161
+ ts: iso(row.ts),
1162
+ actor: json(row.actor),
1163
+ type: String(row.type),
1164
+ payload: json(row.payload),
1165
+ })) as (AbEvent | RepositoryEvent)[]
1166
+ },
1167
+ put: (key, snapshot) => this.putSnapshot(scope, key, snapshot),
1168
+ read: async (key) => {
1169
+ const scopeKey = scope.kind === 'build' ? scope.slug : scope.repo
1170
+ const rows: Row[] = await this.run(
1171
+ (q) =>
1172
+ q`SELECT version, cursor, state, state::text AS raw FROM reducer_snapshots WHERE scope_kind=${scope.kind} AND scope_key=${scopeKey} AND reducer=${key}`,
1173
+ )
1174
+ const row = rows[0]
1175
+ if (!row) return null
1176
+ let state: unknown = null
1177
+ try {
1178
+ state = json(row.state)
1179
+ } catch {
1180
+ /* malformed JSON cache string */
1181
+ }
1182
+ return {
1183
+ snapshot: { version: num(row.version), cursor: num(row.cursor), state },
1184
+ token: String(row.raw),
1185
+ }
1186
+ },
1187
+ repair: async (key, observed, snapshot) => {
1188
+ const scopeKey = scope.kind === 'build' ? scope.slug : scope.repo
1189
+ const rows: Row[] = await this.run(
1190
+ (q) =>
1191
+ 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}
1192
+ 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)
1193
+ 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`,
1194
+ )
1195
+ return rows.length > 0
1196
+ },
1197
+ })
1198
+ }
1199
+
1200
+ private async valueTail(scope: SnapshotScope): Promise<number | undefined> {
1201
+ const key = scope.kind === 'build' ? scope.slug : scope.repo
1202
+ if (scope.kind === 'repo' && hasRepositoryReadContext()) {
1203
+ if (knownRepositoryExists(this, key) === false) return undefined
1204
+ const rows: Row[] = await this.run(
1205
+ (q) => q`SELECT COALESCE(MAX(seq),0) AS tail FROM repo_events WHERE repo=${key}`,
1206
+ )
1207
+ const tail = num(rows[0]?.tail ?? 0)
1208
+ // A nonempty journal proves its FK parent exists, without an unlocked
1209
+ // record read before a delegated harvest mutation takes its own lock.
1210
+ if (tail > 0) recordRepositoryExistence(this, key, true)
1211
+ else if (knownRepositoryExists(this, key) === undefined && !(await this.getRepo(key)))
1212
+ return undefined
1213
+ return tail
1214
+ }
1215
+ const rows: Row[] =
1216
+ scope.kind === 'build'
1217
+ ? await this.run(
1218
+ (q) =>
1219
+ q`SELECT (SELECT COALESCE(MAX(seq),0) FROM events WHERE build=${key}) AS tail FROM builds WHERE slug=${key}`,
1220
+ )
1221
+ : await this.run(
1222
+ (q) =>
1223
+ q`SELECT (SELECT COALESCE(MAX(seq),0) FROM repo_events WHERE repo=${key}) AS tail FROM repo_streams WHERE repo=${key}`,
1224
+ )
1225
+ return rows[0] ? num(rows[0].tail) : undefined
1226
+ }
1227
+
1035
1228
  async getReducerSnapshot(
1036
1229
  scope: SnapshotScope,
1037
1230
  reducer: string,
1038
1231
  version: number,
1039
1232
  ): Promise<ReducerSnapshot | null> {
1233
+ assertPublicSnapshotReducer(reducer)
1040
1234
  const key = scope.kind === 'build' ? scope.slug : scope.repo
1235
+ const repoExists =
1236
+ scope.kind === 'repo' && hasRepositoryReadContext()
1237
+ ? (knownRepositoryExists(this, key) ?? (await this.getRepo(key)) !== null)
1238
+ : undefined
1041
1239
  try {
1042
1240
  const rows: Row[] = await this.run(
1043
1241
  (q) => q`SELECT s.version, s.cursor, s.state FROM reducer_snapshots s
@@ -1048,7 +1246,11 @@ export class PostgresBuildStore implements BuildStore {
1048
1246
  ELSE COALESCE((SELECT MAX(seq) FROM repo_events WHERE repo=${key}), 0) END
1049
1247
  AND CASE WHEN ${scope.kind}='build'
1050
1248
  THEN EXISTS (SELECT 1 FROM builds WHERE slug=${key})
1051
- ELSE EXISTS (SELECT 1 FROM repo_streams WHERE repo=${key}) END`,
1249
+ ELSE ${
1250
+ repoExists === undefined
1251
+ ? this.sql`EXISTS (SELECT 1 FROM repo_streams WHERE repo=${key})`
1252
+ : this.sql`${repoExists}::boolean`
1253
+ } END`,
1052
1254
  )
1053
1255
  const row = rows[0]
1054
1256
  if (row === undefined) return null
@@ -1063,12 +1265,26 @@ export class PostgresBuildStore implements BuildStore {
1063
1265
  scope: SnapshotScope,
1064
1266
  reducer: string,
1065
1267
  snapshot: ReducerSnapshot,
1268
+ ): Promise<boolean> {
1269
+ assertPublicSnapshotReducer(reducer)
1270
+ return this.putSnapshot(scope, reducer, snapshot)
1271
+ }
1272
+
1273
+ private async putSnapshot(
1274
+ scope: SnapshotScope,
1275
+ reducer: string,
1276
+ snapshot: ReducerSnapshot,
1066
1277
  ): Promise<boolean> {
1067
1278
  const key = scope.kind === 'build' ? scope.slug : scope.repo
1068
1279
  const state = JSON.stringify(snapshot.state ?? null)
1280
+ const repoExists =
1281
+ scope.kind === 'repo' && hasRepositoryReadContext()
1282
+ ? (knownRepositoryExists(this, key) ?? (await this.getRepo(key)) !== null)
1283
+ : undefined
1069
1284
  try {
1070
- // One statement: the existence and log-tail guards, and the
1071
- // never-decrease rule, evaluate atomically against concurrent writers.
1285
+ // Tail bounds and never-decrease guards remain atomic. Outside a request
1286
+ // the existence guard is atomic too; request-scoped reuse is safe because
1287
+ // repository journals have no deletion API.
1072
1288
  const rows: Row[] = await this.run(
1073
1289
  (q) => q`INSERT INTO reducer_snapshots
1074
1290
  (scope_kind, scope_key, reducer, version, cursor, state, updated_at)
@@ -1079,7 +1295,11 @@ export class PostgresBuildStore implements BuildStore {
1079
1295
  ELSE COALESCE((SELECT MAX(seq) FROM repo_events WHERE repo=${key}), 0) END
1080
1296
  AND CASE WHEN ${scope.kind}='build'
1081
1297
  THEN EXISTS (SELECT 1 FROM builds WHERE slug=${key})
1082
- ELSE EXISTS (SELECT 1 FROM repo_streams WHERE repo=${key}) END
1298
+ ELSE ${
1299
+ repoExists === undefined
1300
+ ? this.sql`EXISTS (SELECT 1 FROM repo_streams WHERE repo=${key})`
1301
+ : this.sql`${repoExists}::boolean`
1302
+ } END
1083
1303
  ON CONFLICT (scope_kind, scope_key, reducer) DO UPDATE SET
1084
1304
  version = EXCLUDED.version, cursor = EXCLUDED.cursor,
1085
1305
  state = EXCLUDED.state, updated_at = EXCLUDED.updated_at
package/src/tickets.ts CHANGED
@@ -79,6 +79,7 @@ export async function openPostgresTicketDatabase(url: string): Promise<PostgresT
79
79
 
80
80
  export class PostgresTicketSource implements TicketSource {
81
81
  readonly name = 'database'
82
+ readonly integrationTree = true
82
83
  private readonly teamKey: string
83
84
  private readonly repo: string
84
85
  private readonly claimedState: string
@@ -178,6 +179,14 @@ export class PostgresTicketSource implements TicketSource {
178
179
  ...(row.creation_key !== null && row.creation_key !== undefined
179
180
  ? { creationKey: String(row.creation_key) }
180
181
  : {}),
182
+ kind: row.kind === 'node' ? 'node' : 'work',
183
+ ...(row.target_id == null
184
+ ? {}
185
+ : {
186
+ target: String(
187
+ (await this.require(executor, String(row.target_id), 'target')).readable_id,
188
+ ),
189
+ }),
181
190
  title,
182
191
  body: String(row.body),
183
192
  state: String(row.state),
@@ -200,6 +209,49 @@ export class PostgresTicketSource implements TicketSource {
200
209
  return row
201
210
  }
202
211
 
212
+ listAll(): Promise<TicketListing> {
213
+ return this.listReady({})
214
+ }
215
+
216
+ // Creates and metadata mutations take this project lock before any ticket row
217
+ // lock, so ancestor validation cannot race reparenting or node demotion.
218
+ private async graphLock(q: Exec): Promise<void> {
219
+ await q`INSERT INTO ab_ticket_counters (repo, last_number) VALUES (${this.repo}, 0) ON CONFLICT DO NOTHING`
220
+ await q`SELECT last_number FROM ab_ticket_counters WHERE repo = ${this.repo} FOR UPDATE`
221
+ }
222
+
223
+ private async tree(
224
+ q: Exec,
225
+ row: Row | undefined,
226
+ patch: TicketDraft | TicketUpdate,
227
+ ): Promise<string | null> {
228
+ if (patch.kind !== undefined && patch.kind !== 'work' && patch.kind !== 'node')
229
+ throw new Error('kind must be work or node')
230
+ if (typeof patch.target === 'string' && !patch.target.trim())
231
+ throw new Error('target must not be blank')
232
+ if (row && row.kind === 'node' && patch.kind === 'work') {
233
+ const children: Row[] =
234
+ await q`SELECT readable_id FROM ab_tickets WHERE repo = ${this.repo} AND target_id = ${String(row.id)} LIMIT 1`
235
+ if (children.length)
236
+ throw new Error(`cannot demote node: ticket ${children[0]!.readable_id} targets it`)
237
+ }
238
+ if (patch.target === undefined) return row?.target_id == null ? null : String(row.target_id)
239
+ if (patch.target === null) return null
240
+ const seen = new Set(row ? [String(row.id)] : [])
241
+ let next: string | null = patch.target
242
+ let target: string | null = null
243
+ while (next !== null) {
244
+ const ancestor = await this.require(q, next, 'target')
245
+ if (ancestor.kind !== 'node') throw new Error(`target ${next} is not a node`)
246
+ const key = String(ancestor.id)
247
+ if (seen.has(key)) throw new Error(`target ${next} would create a cycle`)
248
+ seen.add(key)
249
+ target ??= key
250
+ next = ancestor.target_id == null ? null : String(ancestor.target_id)
251
+ }
252
+ return target
253
+ }
254
+
203
255
  async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
204
256
  const labels = criteria.labels ?? []
205
257
  return this.retry.tx(this.sql, async (q) => {
@@ -280,13 +332,13 @@ export class PostgresTicketSource implements TicketSource {
280
332
  throw new Error('database ticket create: body must be nonblank')
281
333
  requiredState(this.teamKey, 'ticket teamKey')
282
334
  return this.retry.tx(this.sql, async (q) => {
283
- await q`INSERT INTO ab_ticket_counters (repo, last_number) VALUES (${this.repo}, 0) ON CONFLICT DO NOTHING`
284
- await q`SELECT last_number FROM ab_ticket_counters WHERE repo = ${this.repo} FOR UPDATE`
335
+ await this.graphLock(q)
285
336
  if (options.idempotencyKey !== undefined) {
286
337
  const adopted: Row[] = await q`SELECT *, revision::text AS revision FROM ab_tickets
287
338
  WHERE repo = ${this.repo} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
288
339
  if (adopted[0]) return this.record(q, adopted[0])
289
340
  }
341
+ const target = await this.tree(q, undefined, draft)
290
342
  const state = await this.state(q, options.state ?? this.createState)
291
343
  const blockers: string[] = []
292
344
  for (const blocker of draft.blockedBy ?? []) {
@@ -310,9 +362,9 @@ export class PostgresTicketSource implements TicketSource {
310
362
  const id = `ticket-${crypto.randomUUID()}`
311
363
  const now = new Date().toISOString()
312
364
  const inserted: Row[] = await q`INSERT INTO ab_tickets
313
- (repo, id, readable_id, ticket_number, creation_key, title, body, state, labels, created_at, updated_at)
365
+ (repo, id, readable_id, ticket_number, creation_key, title, body, state, labels, created_at, updated_at, kind, target_id)
314
366
  VALUES (${this.repo}, ${id}, ${readable}, ${number}::bigint, ${options.idempotencyKey ?? null}, ${draft.title}, ${draft.body},
315
- ${state}, ${q.array(draft.labels ?? [], 'text')}, ${now}, ${now})
367
+ ${state}, ${q.array(draft.labels ?? [], 'text')}, ${now}, ${now}, ${draft.kind ?? 'work'}, ${target})
316
368
  ON CONFLICT (repo, creation_key) DO NOTHING RETURNING *, revision::text AS revision`
317
369
  let row = inserted[0]
318
370
  if (!row && options.idempotencyKey !== undefined) {
@@ -353,6 +405,7 @@ export class PostgresTicketSource implements TicketSource {
353
405
  private async mutate(id: string, mutation: TicketMutation, expected?: string): Promise<void> {
354
406
  const patch = mutation.kind === 'update' ? validateTicketUpdate(mutation.patch) : undefined
355
407
  await this.retry.tx(this.sql, async (q) => {
408
+ if (patch && (patch.kind !== undefined || patch.target !== undefined)) await this.graphLock(q)
356
409
  const state = mutation.kind === 'transition' ? await this.state(q, mutation.state) : undefined
357
410
  const row = await this.require(q, id, 'mutation', true)
358
411
  const storageId = String(row.id)
@@ -361,16 +414,20 @@ export class PostgresTicketSource implements TicketSource {
361
414
  throw new TicketRevisionConflict(id, expected, revision)
362
415
  let changed = false
363
416
  if (patch) {
417
+ const target = await this.tree(q, row, patch)
418
+ const kind = patch.kind ?? String(row.kind)
364
419
  const title = patch.title ?? String(row.title)
365
420
  const body = patch.body ?? String(row.body)
366
421
  const labels = patch.labels ?? strings(row.labels)
367
422
  changed =
423
+ kind !== row.kind ||
424
+ target !== row.target_id ||
368
425
  title !== row.title ||
369
426
  body !== row.body ||
370
427
  JSON.stringify([...new Set(labels)].sort()) !==
371
428
  JSON.stringify([...new Set(strings(row.labels))].sort())
372
429
  if (changed)
373
- await q`UPDATE ab_tickets SET title = ${title}, body = ${body}, labels = ${q.array(labels, 'text')} WHERE repo = ${this.repo} AND id = ${storageId}`
430
+ await q`UPDATE ab_tickets SET kind = ${kind}, target_id = ${target}, title = ${title}, body = ${body}, labels = ${q.array(labels, 'text')} WHERE repo = ${this.repo} AND id = ${storageId}`
374
431
  } else if (mutation.kind === 'transition') {
375
432
  changed = row.state !== state
376
433
  if (changed)