@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 +6 -2
- package/src/credentials.ts +19 -5
- package/src/request-context.ts +51 -0
- package/src/schema.ts +83 -5
- package/src/store.ts +118 -11
- 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.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",
|
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
|
|
@@ -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
|
|
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 =
|
|
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
|
|
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(
|
|
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[] =
|
|
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[] =
|
|
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
|
|
927
|
-
|
|
928
|
-
|
|
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
|
|
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
|
-
//
|
|
1185
|
-
//
|
|
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
|
|
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
|
|
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)
|