@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 +6 -2
- package/src/credentials.ts +19 -5
- package/src/request-context.ts +51 -0
- package/src/schema.ts +90 -5
- package/src/store.ts +280 -60
- package/src/tickets.ts +62 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defrex/autobuild-postgres-store",
|
|
3
|
-
"version": "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",
|
package/src/credentials.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
125
|
-
|
|
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
|
|
128
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
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[] =
|
|
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[] =
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
|
901
|
-
|
|
902
|
-
|
|
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
|
|
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
|
-
//
|
|
1071
|
-
//
|
|
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
|
|
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
|
|
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)
|