@defrex/autobuild-postgres-store 0.14.0 → 1.0.0-beta.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defrex/autobuild-postgres-store",
3
- "version": "0.14.0",
3
+ "version": "1.0.0-beta.1",
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 = 14
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
@@ -840,12 +840,19 @@ export const SCHEMA_V13_DDL = `${SCHEMA_V12_DDL}\n${TICKET_POLICY_SCHEMA_DDL}\n$
840
840
  export const SCHEMA_V13_CHECKSUM = new Bun.CryptoHasher('sha256')
841
841
  .update(SCHEMA_V13_DDL)
842
842
  .digest('hex')
843
- export const SCHEMA_DDL = `${SCHEMA_V13_DDL}\nALTER TABLE repo_streams ADD COLUMN IF NOT EXISTS tick jsonb;`
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}`
844
851
  export const SCHEMA_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_DDL).digest('hex')
845
852
 
846
853
  /** Ticket persistence evolves independently so deployed v1 BuildStore markers
847
854
  * remain valid while the hosted ticket tables are added. */
848
- export const TICKET_SCHEMA_VERSION = 5
855
+ export const TICKET_SCHEMA_VERSION = 6
849
856
  export const TICKET_SCHEMA_V1_DDL = `
850
857
  CREATE TABLE IF NOT EXISTS ab_ticket_schema_migrations (
851
858
  singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
@@ -895,7 +902,7 @@ export const TICKET_SCHEMA_V4_DDL = `${TICKET_SCHEMA_V3_DDL}\n${TICKET_STATES_DD
895
902
  export const TICKET_SCHEMA_V4_CHECKSUM = new Bun.CryptoHasher('sha256')
896
903
  .update(TICKET_SCHEMA_V4_DDL)
897
904
  .digest('hex')
898
- export const TICKET_SCHEMA_DDL =
905
+ export const TICKET_SCHEMA_V5_DDL =
899
906
  TICKET_SCHEMA_V4_DDL +
900
907
  `
901
908
  ALTER TABLE ab_tickets ADD COLUMN IF NOT EXISTS readable_id text;
@@ -903,6 +910,19 @@ ALTER TABLE ab_tickets ADD COLUMN IF NOT EXISTS ticket_number bigint;
903
910
  CREATE TABLE IF NOT EXISTS ab_ticket_counters (repo text PRIMARY KEY, last_number bigint NOT NULL CHECK (last_number >= 0));
904
911
  ` +
905
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
906
926
  export const TICKET_SCHEMA_CHECKSUM = new Bun.CryptoHasher('sha256')
907
927
  .update(TICKET_SCHEMA_DDL)
908
928
  .digest('hex')
@@ -1005,6 +1025,9 @@ const EXPECTED_COLUMNS: Record<string, readonly ExpectedColumn[]> = {
1005
1025
  // the end, so migrated and fresh databases assert identically (the
1006
1026
  // sessions.creation_seq and streams.session comments above).
1007
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],
1008
1031
  ],
1009
1032
  events: [
1010
1033
  ['build', 'text', true],
@@ -1356,6 +1379,8 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
1356
1379
  ['revision', 'bigint', true],
1357
1380
  ['readable_id', 'text', true],
1358
1381
  ['ticket_number', 'bigint', true],
1382
+ ['kind', 'text', true],
1383
+ ['target_id', 'text', false],
1359
1384
  ],
1360
1385
  ab_ticket_counters: [
1361
1386
  ['repo', 'text', true],
@@ -1407,6 +1432,10 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
1407
1432
  !String(indexes[0].indexdef).endsWith('(repo, lower(readable_id))')
1408
1433
  )
1409
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')
1410
1439
  const constraints: { table_name: string; definition: string }[] = await sql`
1411
1440
  SELECT c.relname AS table_name, pg_get_constraintdef(k.oid) AS definition
1412
1441
  FROM pg_constraint k JOIN pg_class c ON c.oid = k.conrelid
@@ -1423,6 +1452,9 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
1423
1452
  'UNIQUE (repo, creation_key)',
1424
1453
  'UNIQUE (repo, ticket_number)',
1425
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))',
1426
1458
  ],
1427
1459
  ab_ticket_counters: ['PRIMARY KEY (repo)', 'CHECK ((last_number >= 0))'],
1428
1460
  ab_ticket_comments: [
@@ -1485,10 +1517,11 @@ export async function migratePostgres(
1485
1517
  try {
1486
1518
  await sql.begin(async (tx) => {
1487
1519
  await tx`SELECT pg_advisory_xact_lock(470281941)`
1488
- await tx.unsafe(SCHEMA_DDL)
1520
+ await tx.unsafe(SCHEMA_V14_DDL)
1489
1521
  await tx.unsafe(TICKET_SCHEMA_V2_DDL)
1490
1522
  await tx.unsafe(TICKET_STATES_DDL)
1491
1523
  await tx.unsafe(SNAPSHOT_SCHEMA_DDL)
1524
+ await tx.unsafe(REPO_CHANGE_DDL)
1492
1525
  await tx.unsafe(AUTH_SCHEMA_DDL)
1493
1526
  const rows: { version: number; checksum: string }[] =
1494
1527
  await tx`SELECT version, checksum FROM ab_schema_migrations WHERE singleton = true FOR UPDATE`
@@ -1665,6 +1698,8 @@ export async function migratePostgres(
1665
1698
  SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
1666
1699
  applied_at = ${new Date().toISOString()}
1667
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`
1668
1703
  } else if (version === 13 && marker.checksum === SCHEMA_V13_CHECKSUM) {
1669
1704
  await tx`UPDATE ab_schema_migrations SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM}, applied_at = ${new Date().toISOString()} WHERE singleton = true`
1670
1705
  } else if (version === 12 && marker.checksum === SCHEMA_V12_CHECKSUM) {
@@ -1697,6 +1732,9 @@ export async function migratePostgres(
1697
1732
  await tx`INSERT INTO ab_schema_migrations (singleton, version, checksum, applied_at)
1698
1733
  VALUES (true, ${SCHEMA_VERSION}, ${SCHEMA_CHECKSUM}, ${new Date().toISOString()})`
1699
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)
1700
1738
  if (options.legacyProjectEnv !== undefined) {
1701
1739
  await new PostgresProjectRegistry(tx as SQL).initializeFromEnvInTransaction(
1702
1740
  options.legacyProjectEnv,
@@ -1713,6 +1751,7 @@ export async function migratePostgres(
1713
1751
  await migrateLegacyTickets(tx as SQL, options.openBlobs)
1714
1752
  await tx`ALTER TABLE ab_tickets ADD COLUMN IF NOT EXISTS revision bigint NOT NULL DEFAULT 1`
1715
1753
  await migrateReadableTickets(tx as SQL, options.openBlobs)
1754
+ await tx.unsafe(TICKET_TREE_DELTA)
1716
1755
  await tx`UPDATE ab_ticket_schema_migrations SET version = ${TICKET_SCHEMA_VERSION},
1717
1756
  checksum = ${TICKET_SCHEMA_CHECKSUM}, applied_at = ${new Date().toISOString()} WHERE singleton = true`
1718
1757
  } else if (
@@ -1722,6 +1761,7 @@ export async function migratePostgres(
1722
1761
  ) {
1723
1762
  await tx`ALTER TABLE ab_tickets ADD COLUMN revision bigint NOT NULL DEFAULT 1`
1724
1763
  await migrateReadableTickets(tx as SQL, options.openBlobs)
1764
+ await tx.unsafe(TICKET_TREE_DELTA)
1725
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`
1726
1766
  } else if (
1727
1767
  ticketMarker &&
@@ -1729,6 +1769,7 @@ export async function migratePostgres(
1729
1769
  ticketMarker.checksum === TICKET_SCHEMA_V3_CHECKSUM
1730
1770
  ) {
1731
1771
  await migrateReadableTickets(tx as SQL, options.openBlobs)
1772
+ await tx.unsafe(TICKET_TREE_DELTA)
1732
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`
1733
1774
  } else if (
1734
1775
  ticketMarker &&
@@ -1736,6 +1777,14 @@ export async function migratePostgres(
1736
1777
  ticketMarker.checksum === TICKET_SCHEMA_V4_CHECKSUM
1737
1778
  ) {
1738
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)
1739
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`
1740
1789
  } else if (ticketMarker) {
1741
1790
  if (Number(ticketMarker.version) !== TICKET_SCHEMA_VERSION) {
@@ -1752,6 +1801,7 @@ export async function migratePostgres(
1752
1801
  } else {
1753
1802
  await tx`ALTER TABLE ab_tickets ADD COLUMN IF NOT EXISTS revision bigint NOT NULL DEFAULT 1`
1754
1803
  await migrateReadableTickets(tx as SQL, options.openBlobs)
1804
+ await tx.unsafe(TICKET_TREE_DELTA)
1755
1805
  await tx`INSERT INTO ab_ticket_schema_migrations
1756
1806
  (singleton, version, checksum, applied_at)
1757
1807
  VALUES (true, ${TICKET_SCHEMA_VERSION}, ${TICKET_SCHEMA_CHECKSUM}, ${new Date().toISOString()})`
@@ -1820,3 +1870,31 @@ export async function migratePostgres(
1820
1870
  await sql.close()
1821
1871
  }
1822
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,9 @@
1
+ import {
2
+ repositoryRead,
3
+ hasRepositoryReadContext,
4
+ knownRepositoryExists,
5
+ recordRepositoryExistence,
6
+ } from './request-context'
1
7
  import { readPersistedBuildDigests } from '@defrex/autobuild/store-adapter'
2
8
  import { assertPublicSnapshotReducer } from '@defrex/autobuild/store-adapter'
3
9
  import {
@@ -159,6 +165,7 @@ export interface PostgresBuildStoreOptions {
159
165
  }
160
166
 
161
167
  export class PostgresBuildStore implements BuildStore {
168
+ readonly networkReads = true
162
169
  readonly blobs: BlobStore
163
170
  private readonly clock: Clock
164
171
  private readonly maxRevisions: number
@@ -283,12 +290,42 @@ export class PostgresBuildStore implements BuildStore {
283
290
  }
284
291
 
285
292
  async listBuilds(): Promise<BuildRecord[]> {
293
+ return this.listBuildRecords()
294
+ }
295
+
296
+ async getRepoChangePosition(repo: string): Promise<string> {
297
+ return this.run(async (q) => {
298
+ const rows: Row[] =
299
+ await q`SELECT position::text AS position FROM repo_change_positions WHERE repo = ${repo}`
300
+ return rows[0] ? String(rows[0].position) : '0'
301
+ })
302
+ }
303
+
304
+ async listBuildsForRepo(repo: string): Promise<BuildRecord[]> {
305
+ return this.listBuildRecords(repo)
306
+ }
307
+
308
+ private async listBuildRecords(repo?: string): Promise<BuildRecord[]> {
286
309
  return this.run(async (q) => {
287
310
  if (!(await this.hasReadableTicketSchema(q))) {
288
- const rows: Row[] = await q`SELECT * FROM builds ORDER BY created_at, slug`
311
+ const rows: Row[] =
312
+ repo === undefined
313
+ ? await q`SELECT * FROM builds ORDER BY created_at, slug`
314
+ : await q`SELECT * FROM builds WHERE repo = ${repo} ORDER BY created_at, slug`
289
315
  return rows.map((row) => this.record(row))
290
316
  }
291
- const rows: Row[] = await q`SELECT b.*, t.readable_id AS projected_ticket_id FROM builds b
317
+ const rows: Row[] =
318
+ repo === undefined
319
+ ? await q`SELECT b.*, t.readable_id AS projected_ticket_id FROM builds b
320
+ LEFT JOIN LATERAL (
321
+ SELECT readable_id FROM ab_tickets
322
+ WHERE b.ticket->>'source' = 'hosted' AND repo = b.repo
323
+ AND (id = b.ticket->>'id' OR lower(readable_id) = lower(b.ticket->>'id'))
324
+ LIMIT 1
325
+ ) t ON true
326
+ ORDER BY b.created_at, b.slug`
327
+ : await q`SELECT b.*, t.readable_id AS projected_ticket_id
328
+ FROM (SELECT * FROM builds WHERE repo = ${repo}) b
292
329
  LEFT JOIN LATERAL (
293
330
  SELECT readable_id FROM ab_tickets
294
331
  WHERE b.ticket->>'source' = 'hosted' AND repo = b.repo
@@ -556,6 +593,43 @@ export class PostgresBuildStore implements BuildStore {
556
593
  })
557
594
  }
558
595
 
596
+ async claimNodeWriter(slug: string, holder: string, ttlMs: number): Promise<boolean> {
597
+ return this.tx(async (q) => {
598
+ const row = await this.lockBuild(q, slug)
599
+ const now = this.clock().getTime()
600
+ if (
601
+ row.node_writer_holder &&
602
+ row.node_writer_holder !== holder &&
603
+ row.node_writer_expires_at &&
604
+ Date.parse(iso(row.node_writer_expires_at)) > now
605
+ )
606
+ return false
607
+ const expires = new Date(now + ttlMs).toISOString()
608
+ 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}`
609
+ return true
610
+ })
611
+ }
612
+ async renewNodeWriter(slug: string, holder: string): Promise<boolean> {
613
+ return this.tx(async (q) => {
614
+ const row = await this.lockBuild(q, slug)
615
+ const now = this.clock().getTime()
616
+ if (
617
+ row.node_writer_holder !== holder ||
618
+ !row.node_writer_expires_at ||
619
+ Date.parse(iso(row.node_writer_expires_at)) <= now
620
+ )
621
+ return false
622
+ const expires = new Date(now + num(row.node_writer_ttl_ms)).toISOString()
623
+ await q`UPDATE builds SET node_writer_expires_at=${expires}, updated_at=${new Date(now).toISOString()} WHERE slug=${slug}`
624
+ return true
625
+ })
626
+ }
627
+ async releaseNodeWriter(slug: string, holder: string): Promise<void> {
628
+ await this.tx(async (q) => {
629
+ await this.lockBuild(q, slug)
630
+ 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}`
631
+ })
632
+ }
559
633
  async claimLease(slug: string, holder: string, ttlMs: number): Promise<boolean> {
560
634
  return this.claim('builds', slug, holder, ttlMs)
561
635
  }
@@ -923,10 +997,13 @@ export class PostgresBuildStore implements BuildStore {
923
997
  }
924
998
 
925
999
  async getRepo(repo: string): Promise<RepositoryRecord | null> {
926
- return this.run(async (q) => {
927
- const rows: Row[] = await q`SELECT * FROM repo_streams WHERE repo=${repo}`
928
- return rows[0] ? this.repoRecord(rows[0]) : null
929
- })
1000
+ return repositoryRead(this, repo, () =>
1001
+ this.run(async (q) => {
1002
+ const rows: Row[] = await q`SELECT * FROM repo_streams WHERE repo=${repo}`
1003
+ recordRepositoryExistence(this, repo, !!rows[0])
1004
+ return rows[0] ? this.repoRecord(rows[0]) : null
1005
+ }),
1006
+ )
930
1007
  }
931
1008
 
932
1009
  private async appendRepoLocked(
@@ -1019,8 +1096,8 @@ export class PostgresBuildStore implements BuildStore {
1019
1096
  sinceSeq = 0,
1020
1097
  opts?: { waitSeconds?: number; signal?: AbortSignal },
1021
1098
  ): Promise<RepositoryEvent[]> {
1099
+ if (!(await this.getRepo(repo))) throw new Error(`unknown repo "${repo}"`)
1022
1100
  const read = async (): Promise<RepositoryEvent[]> => {
1023
- if (!(await this.getRepo(repo))) throw new Error(`unknown repo "${repo}"`)
1024
1101
  const rows: Row[] = await this.run(
1025
1102
  (q) => q`SELECT * FROM repo_events WHERE repo=${repo} AND seq>${sinceSeq} ORDER BY seq`,
1026
1103
  )
@@ -1123,6 +1200,19 @@ export class PostgresBuildStore implements BuildStore {
1123
1200
 
1124
1201
  private async valueTail(scope: SnapshotScope): Promise<number | undefined> {
1125
1202
  const key = scope.kind === 'build' ? scope.slug : scope.repo
1203
+ if (scope.kind === 'repo' && hasRepositoryReadContext()) {
1204
+ if (knownRepositoryExists(this, key) === false) return undefined
1205
+ const rows: Row[] = await this.run(
1206
+ (q) => q`SELECT COALESCE(MAX(seq),0) AS tail FROM repo_events WHERE repo=${key}`,
1207
+ )
1208
+ const tail = num(rows[0]?.tail ?? 0)
1209
+ // A nonempty journal proves its FK parent exists, without an unlocked
1210
+ // record read before a delegated harvest mutation takes its own lock.
1211
+ if (tail > 0) recordRepositoryExistence(this, key, true)
1212
+ else if (knownRepositoryExists(this, key) === undefined && !(await this.getRepo(key)))
1213
+ return undefined
1214
+ return tail
1215
+ }
1126
1216
  const rows: Row[] =
1127
1217
  scope.kind === 'build'
1128
1218
  ? await this.run(
@@ -1143,6 +1233,10 @@ export class PostgresBuildStore implements BuildStore {
1143
1233
  ): Promise<ReducerSnapshot | null> {
1144
1234
  assertPublicSnapshotReducer(reducer)
1145
1235
  const key = scope.kind === 'build' ? scope.slug : scope.repo
1236
+ const repoExists =
1237
+ scope.kind === 'repo' && hasRepositoryReadContext()
1238
+ ? (knownRepositoryExists(this, key) ?? (await this.getRepo(key)) !== null)
1239
+ : undefined
1146
1240
  try {
1147
1241
  const rows: Row[] = await this.run(
1148
1242
  (q) => q`SELECT s.version, s.cursor, s.state FROM reducer_snapshots s
@@ -1153,7 +1247,11 @@ export class PostgresBuildStore implements BuildStore {
1153
1247
  ELSE COALESCE((SELECT MAX(seq) FROM repo_events WHERE repo=${key}), 0) END
1154
1248
  AND CASE WHEN ${scope.kind}='build'
1155
1249
  THEN EXISTS (SELECT 1 FROM builds WHERE slug=${key})
1156
- ELSE EXISTS (SELECT 1 FROM repo_streams WHERE repo=${key}) END`,
1250
+ ELSE ${
1251
+ repoExists === undefined
1252
+ ? this.sql`EXISTS (SELECT 1 FROM repo_streams WHERE repo=${key})`
1253
+ : this.sql`${repoExists}::boolean`
1254
+ } END`,
1157
1255
  )
1158
1256
  const row = rows[0]
1159
1257
  if (row === undefined) return null
@@ -1180,9 +1278,14 @@ export class PostgresBuildStore implements BuildStore {
1180
1278
  ): Promise<boolean> {
1181
1279
  const key = scope.kind === 'build' ? scope.slug : scope.repo
1182
1280
  const state = JSON.stringify(snapshot.state ?? null)
1281
+ const repoExists =
1282
+ scope.kind === 'repo' && hasRepositoryReadContext()
1283
+ ? (knownRepositoryExists(this, key) ?? (await this.getRepo(key)) !== null)
1284
+ : undefined
1183
1285
  try {
1184
- // One statement: the existence and log-tail guards, and the
1185
- // never-decrease rule, evaluate atomically against concurrent writers.
1286
+ // Tail bounds and never-decrease guards remain atomic. Outside a request
1287
+ // the existence guard is atomic too; request-scoped reuse is safe because
1288
+ // repository journals have no deletion API.
1186
1289
  const rows: Row[] = await this.run(
1187
1290
  (q) => q`INSERT INTO reducer_snapshots
1188
1291
  (scope_kind, scope_key, reducer, version, cursor, state, updated_at)
@@ -1193,7 +1296,11 @@ export class PostgresBuildStore implements BuildStore {
1193
1296
  ELSE COALESCE((SELECT MAX(seq) FROM repo_events WHERE repo=${key}), 0) END
1194
1297
  AND CASE WHEN ${scope.kind}='build'
1195
1298
  THEN EXISTS (SELECT 1 FROM builds WHERE slug=${key})
1196
- ELSE EXISTS (SELECT 1 FROM repo_streams WHERE repo=${key}) END
1299
+ ELSE ${
1300
+ repoExists === undefined
1301
+ ? this.sql`EXISTS (SELECT 1 FROM repo_streams WHERE repo=${key})`
1302
+ : this.sql`${repoExists}::boolean`
1303
+ } END
1197
1304
  ON CONFLICT (scope_kind, scope_key, reducer) DO UPDATE SET
1198
1305
  version = EXCLUDED.version, cursor = EXCLUDED.cursor,
1199
1306
  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)