@defrex/autobuild-postgres-store 0.12.0 → 0.13.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/README.md +3 -3
- package/package.json +9 -1
- package/src/schema.ts +146 -9
- package/src/store.ts +100 -23
- package/src/ticket-grants.ts +86 -0
- package/src/ticket-migration.ts +96 -35
- package/src/ticket-policy.ts +60 -0
- package/src/ticket-states.ts +15 -0
- package/src/tickets.ts +217 -82
package/README.md
CHANGED
|
@@ -119,9 +119,9 @@ Only an object-store 404 is treated as absent; authorization and service errors
|
|
|
119
119
|
are propagated. Vercel supports either a Blob read-write token or a Vercel OIDC
|
|
120
120
|
token paired with its Blob store ID.
|
|
121
121
|
|
|
122
|
-
`PostgresTicketDatabase.source({ teamKey, claimedState?, createState? })`
|
|
123
|
-
a request-bound TicketSource view. The deployment supplies
|
|
124
|
-
|
|
122
|
+
`PostgresTicketDatabase.source({ repo, teamKey, lifecycle, claimedState?, createState? })`
|
|
123
|
+
creates a request-bound TicketSource view. The deployment supplies the project
|
|
124
|
+
identity, readable-id prefix, and lifecycle names. Ticket bodies
|
|
125
125
|
are stored as PostgreSQL `text` and returned unchanged.
|
|
126
126
|
|
|
127
127
|
For an authenticated HTTP deployment of this adapter, see the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defrex/autobuild-postgres-store",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
@@ -34,6 +34,14 @@
|
|
|
34
34
|
"./connections": {
|
|
35
35
|
"types": "./src/connections.ts",
|
|
36
36
|
"import": "./src/connections.ts"
|
|
37
|
+
},
|
|
38
|
+
"./ticket-policy": {
|
|
39
|
+
"types": "./src/ticket-policy.ts",
|
|
40
|
+
"import": "./src/ticket-policy.ts"
|
|
41
|
+
},
|
|
42
|
+
"./ticket-grants": {
|
|
43
|
+
"types": "./src/ticket-grants.ts",
|
|
44
|
+
"import": "./src/ticket-grants.ts"
|
|
37
45
|
}
|
|
38
46
|
},
|
|
39
47
|
"bin": {
|
package/src/schema.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { PostgresProjectRegistry } from './projects'
|
|
2
2
|
import type { PostgresEnv } from './env'
|
|
3
3
|
import type { BlobStore } from '@defrex/autobuild/plugin-sdk'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
migrateLegacyTickets,
|
|
6
|
+
migrateReadableTickets,
|
|
7
|
+
READABLE_TICKET_CONSTRAINTS,
|
|
8
|
+
} from './ticket-migration'
|
|
5
9
|
import { SQL } from 'bun'
|
|
6
10
|
import {
|
|
7
11
|
AUTH_SCHEMA_CHECKSUM,
|
|
@@ -12,7 +16,7 @@ import {
|
|
|
12
16
|
assertAuthSchema,
|
|
13
17
|
} from './auth-schema'
|
|
14
18
|
|
|
15
|
-
export const SCHEMA_VERSION =
|
|
19
|
+
export const SCHEMA_VERSION = 13
|
|
16
20
|
export const MIGRATE_COMMAND = 'bun run postgres:migrate (from a pinned Autobuild release checkout)'
|
|
17
21
|
|
|
18
22
|
/** The frozen v3 DDL, kept verbatim so a deployed v3 marker's checksum can be
|
|
@@ -817,12 +821,27 @@ CREATE TABLE IF NOT EXISTS project_connections (
|
|
|
817
821
|
CREATE TABLE IF NOT EXISTS connection_lookup_limits (
|
|
818
822
|
actor text PRIMARY KEY, started_at timestamptz NOT NULL, attempts integer NOT NULL
|
|
819
823
|
);`.trim()
|
|
820
|
-
export const
|
|
824
|
+
export const SCHEMA_V12_DDL = `${SCHEMA_V11_DDL}\n${CONNECTION_SCHEMA_DDL}`
|
|
825
|
+
export const SCHEMA_V12_CHECKSUM = new Bun.CryptoHasher('sha256')
|
|
826
|
+
.update(SCHEMA_V12_DDL)
|
|
827
|
+
.digest('hex')
|
|
828
|
+
export const TICKET_POLICY_SCHEMA_DDL = `
|
|
829
|
+
CREATE TABLE IF NOT EXISTS project_ticket_policy (
|
|
830
|
+
repo text PRIMARY KEY, registration bigint NOT NULL, policy jsonb NOT NULL,
|
|
831
|
+
writer text NOT NULL, updated_at timestamptz NOT NULL
|
|
832
|
+
);`
|
|
833
|
+
export const TICKET_GRANT_SCHEMA_DDL = `
|
|
834
|
+
CREATE TABLE IF NOT EXISTS ticket_grants (
|
|
835
|
+
id text PRIMARY KEY, digest text NOT NULL UNIQUE, repo text NOT NULL, registration bigint NOT NULL,
|
|
836
|
+
execution text NOT NULL, kind text NOT NULL CHECK (kind IN ('runner','phase')),
|
|
837
|
+
parent text REFERENCES ticket_grants(id), expires_at timestamptz NOT NULL, revoked_at timestamptz
|
|
838
|
+
);`
|
|
839
|
+
export const SCHEMA_DDL = `${SCHEMA_V12_DDL}\n${TICKET_POLICY_SCHEMA_DDL}\n${TICKET_GRANT_SCHEMA_DDL}`
|
|
821
840
|
export const SCHEMA_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_DDL).digest('hex')
|
|
822
841
|
|
|
823
842
|
/** Ticket persistence evolves independently so deployed v1 BuildStore markers
|
|
824
843
|
* remain valid while the hosted ticket tables are added. */
|
|
825
|
-
export const TICKET_SCHEMA_VERSION =
|
|
844
|
+
export const TICKET_SCHEMA_VERSION = 5
|
|
826
845
|
export const TICKET_SCHEMA_V1_DDL = `
|
|
827
846
|
CREATE TABLE IF NOT EXISTS ab_ticket_schema_migrations (
|
|
828
847
|
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
@@ -851,7 +870,35 @@ CREATE TABLE IF NOT EXISTS ab_ticket_blockers (
|
|
|
851
870
|
export const TICKET_SCHEMA_V1_CHECKSUM = new Bun.CryptoHasher('sha256')
|
|
852
871
|
.update(TICKET_SCHEMA_V1_DDL)
|
|
853
872
|
.digest('hex')
|
|
854
|
-
export const
|
|
873
|
+
export const TICKET_SCHEMA_V2_DDL = TICKET_SCHEMA_V1_DDL.replaceAll('team', 'repo')
|
|
874
|
+
export const TICKET_SCHEMA_V2_CHECKSUM = new Bun.CryptoHasher('sha256')
|
|
875
|
+
.update(TICKET_SCHEMA_V2_DDL)
|
|
876
|
+
.digest('hex')
|
|
877
|
+
export const TICKET_SCHEMA_V3_DDL = TICKET_SCHEMA_V2_DDL.replace(
|
|
878
|
+
'updated_at timestamptz NOT NULL,',
|
|
879
|
+
'updated_at timestamptz NOT NULL, revision bigint NOT NULL DEFAULT 1,',
|
|
880
|
+
)
|
|
881
|
+
export const TICKET_SCHEMA_V3_CHECKSUM = new Bun.CryptoHasher('sha256')
|
|
882
|
+
.update(TICKET_SCHEMA_V3_DDL)
|
|
883
|
+
.digest('hex')
|
|
884
|
+
export const TICKET_STATES_DDL = `CREATE TABLE IF NOT EXISTS ab_ticket_states (
|
|
885
|
+
repo text NOT NULL, name text NOT NULL, about text,
|
|
886
|
+
PRIMARY KEY (repo, name),
|
|
887
|
+
CHECK (length(btrim(name)) > 0),
|
|
888
|
+
CHECK (about IS NULL OR (length(btrim(about)) > 0 AND position(chr(10) in about) = 0 AND position(chr(13) in about) = 0))
|
|
889
|
+
);`
|
|
890
|
+
export const TICKET_SCHEMA_V4_DDL = `${TICKET_SCHEMA_V3_DDL}\n${TICKET_STATES_DDL}`
|
|
891
|
+
export const TICKET_SCHEMA_V4_CHECKSUM = new Bun.CryptoHasher('sha256')
|
|
892
|
+
.update(TICKET_SCHEMA_V4_DDL)
|
|
893
|
+
.digest('hex')
|
|
894
|
+
export const TICKET_SCHEMA_DDL =
|
|
895
|
+
TICKET_SCHEMA_V4_DDL +
|
|
896
|
+
`
|
|
897
|
+
ALTER TABLE ab_tickets ADD COLUMN IF NOT EXISTS readable_id text;
|
|
898
|
+
ALTER TABLE ab_tickets ADD COLUMN IF NOT EXISTS ticket_number bigint;
|
|
899
|
+
CREATE TABLE IF NOT EXISTS ab_ticket_counters (repo text PRIMARY KEY, last_number bigint NOT NULL CHECK (last_number >= 0));
|
|
900
|
+
` +
|
|
901
|
+
READABLE_TICKET_CONSTRAINTS.trim()
|
|
855
902
|
export const TICKET_SCHEMA_CHECKSUM = new Bun.CryptoHasher('sha256')
|
|
856
903
|
.update(TICKET_SCHEMA_DDL)
|
|
857
904
|
.digest('hex')
|
|
@@ -893,6 +940,24 @@ const EXPECTED_COLUMNS: Record<string, readonly ExpectedColumn[]> = {
|
|
|
893
940
|
['started_at', 'timestamp with time zone', true],
|
|
894
941
|
['attempts', 'integer', true],
|
|
895
942
|
],
|
|
943
|
+
project_ticket_policy: [
|
|
944
|
+
['repo', 'text', true],
|
|
945
|
+
['registration', 'bigint', true],
|
|
946
|
+
['policy', 'jsonb', true],
|
|
947
|
+
['writer', 'text', true],
|
|
948
|
+
['updated_at', 'timestamp with time zone', true],
|
|
949
|
+
],
|
|
950
|
+
ticket_grants: [
|
|
951
|
+
['id', 'text', true],
|
|
952
|
+
['digest', 'text', true],
|
|
953
|
+
['repo', 'text', true],
|
|
954
|
+
['registration', 'bigint', true],
|
|
955
|
+
['execution', 'text', true],
|
|
956
|
+
['kind', 'text', true],
|
|
957
|
+
['parent', 'text', false],
|
|
958
|
+
['expires_at', 'timestamp with time zone', true],
|
|
959
|
+
['revoked_at', 'timestamp with time zone', false],
|
|
960
|
+
],
|
|
896
961
|
project_credentials: [
|
|
897
962
|
['id', 'text', true],
|
|
898
963
|
['digest', 'text', true],
|
|
@@ -1048,6 +1113,10 @@ const EXPECTED_COLUMNS: Record<string, readonly ExpectedColumn[]> = {
|
|
|
1048
1113
|
}
|
|
1049
1114
|
|
|
1050
1115
|
const EXPECTED_CONSTRAINTS = [
|
|
1116
|
+
'project_ticket_policy|p|repo||||',
|
|
1117
|
+
'ticket_grants|p|id||||',
|
|
1118
|
+
'ticket_grants|c|||||',
|
|
1119
|
+
'ticket_grants|f|parent|ticket_grants|id|a|',
|
|
1051
1120
|
'project_connections|p|id||||',
|
|
1052
1121
|
'project_connections|c|||||',
|
|
1053
1122
|
'connection_lookup_limits|p|actor||||',
|
|
@@ -1279,6 +1348,18 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
|
|
|
1279
1348
|
['labels', 'text[]', true],
|
|
1280
1349
|
['created_at', 'timestamp with time zone', true],
|
|
1281
1350
|
['updated_at', 'timestamp with time zone', true],
|
|
1351
|
+
['revision', 'bigint', true],
|
|
1352
|
+
['readable_id', 'text', true],
|
|
1353
|
+
['ticket_number', 'bigint', true],
|
|
1354
|
+
],
|
|
1355
|
+
ab_ticket_counters: [
|
|
1356
|
+
['repo', 'text', true],
|
|
1357
|
+
['last_number', 'bigint', true],
|
|
1358
|
+
],
|
|
1359
|
+
ab_ticket_states: [
|
|
1360
|
+
['repo', 'text', true],
|
|
1361
|
+
['name', 'text', true],
|
|
1362
|
+
['about', 'text', false],
|
|
1282
1363
|
],
|
|
1283
1364
|
ab_ticket_comments: [
|
|
1284
1365
|
['repo', 'text', true],
|
|
@@ -1303,7 +1384,7 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
|
|
|
1303
1384
|
WHERE n.nspname = current_schema() AND c.relkind IN ('r', 'p')
|
|
1304
1385
|
AND a.attnum > 0 AND NOT a.attisdropped
|
|
1305
1386
|
AND c.relname = ANY(ARRAY['ab_ticket_schema_migrations', 'ab_tickets',
|
|
1306
|
-
'ab_ticket_comments', 'ab_ticket_blockers'])
|
|
1387
|
+
'ab_ticket_comments', 'ab_ticket_blockers', 'ab_ticket_counters', 'ab_ticket_states'])
|
|
1307
1388
|
ORDER BY c.relname, a.attnum`
|
|
1308
1389
|
for (const [table, shape] of Object.entries(expected)) {
|
|
1309
1390
|
const actual = columns
|
|
@@ -1313,13 +1394,32 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
|
|
|
1313
1394
|
throw schemaError(`ticket table ${table} is missing or mismatched`)
|
|
1314
1395
|
}
|
|
1315
1396
|
}
|
|
1397
|
+
const indexes =
|
|
1398
|
+
await sql`SELECT indexdef FROM pg_indexes WHERE schemaname = current_schema() AND tablename = 'ab_tickets' AND indexname = 'ab_tickets_readable_id_key'`
|
|
1399
|
+
if (
|
|
1400
|
+
!indexes[0] ||
|
|
1401
|
+
!String(indexes[0].indexdef).includes('UNIQUE INDEX') ||
|
|
1402
|
+
!String(indexes[0].indexdef).endsWith('(repo, lower(readable_id))')
|
|
1403
|
+
)
|
|
1404
|
+
throw schemaError('ticket readable id unique index is missing or mismatched')
|
|
1316
1405
|
const constraints: { table_name: string; definition: string }[] = await sql`
|
|
1317
1406
|
SELECT c.relname AS table_name, pg_get_constraintdef(k.oid) AS definition
|
|
1318
1407
|
FROM pg_constraint k JOIN pg_class c ON c.oid = k.conrelid
|
|
1319
1408
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
1320
|
-
WHERE n.nspname = current_schema() AND k.contype IN ('p', 'u', 'f', 'c') AND c.relname IN ('ab_tickets', 'ab_ticket_comments', 'ab_ticket_blockers')`
|
|
1409
|
+
WHERE n.nspname = current_schema() AND k.contype IN ('p', 'u', 'f', 'c') AND c.relname IN ('ab_tickets', 'ab_ticket_comments', 'ab_ticket_blockers', 'ab_ticket_counters', 'ab_ticket_states')`
|
|
1321
1410
|
const expectedConstraints: Record<string, string[]> = {
|
|
1322
|
-
|
|
1411
|
+
ab_ticket_states: [
|
|
1412
|
+
'PRIMARY KEY (repo, name)',
|
|
1413
|
+
'CHECK ((length(btrim(name)) > 0))',
|
|
1414
|
+
'CHECK (((about IS NULL) OR ((length(btrim(about)) > 0) AND (POSITION((chr(10)) IN (about)) = 0) AND (POSITION((chr(13)) IN (about)) = 0))))',
|
|
1415
|
+
],
|
|
1416
|
+
ab_tickets: [
|
|
1417
|
+
'PRIMARY KEY (repo, id)',
|
|
1418
|
+
'UNIQUE (repo, creation_key)',
|
|
1419
|
+
'UNIQUE (repo, ticket_number)',
|
|
1420
|
+
'CHECK ((ticket_number > 0))',
|
|
1421
|
+
],
|
|
1422
|
+
ab_ticket_counters: ['PRIMARY KEY (repo)', 'CHECK ((last_number >= 0))'],
|
|
1323
1423
|
ab_ticket_comments: [
|
|
1324
1424
|
'PRIMARY KEY (repo, ticket_id, seq)',
|
|
1325
1425
|
'FOREIGN KEY (repo, ticket_id) REFERENCES ab_tickets(repo, id) ON DELETE CASCADE',
|
|
@@ -1381,7 +1481,8 @@ export async function migratePostgres(
|
|
|
1381
1481
|
await sql.begin(async (tx) => {
|
|
1382
1482
|
await tx`SELECT pg_advisory_xact_lock(470281941)`
|
|
1383
1483
|
await tx.unsafe(SCHEMA_DDL)
|
|
1384
|
-
await tx.unsafe(
|
|
1484
|
+
await tx.unsafe(TICKET_SCHEMA_V2_DDL)
|
|
1485
|
+
await tx.unsafe(TICKET_STATES_DDL)
|
|
1385
1486
|
await tx.unsafe(SNAPSHOT_SCHEMA_DDL)
|
|
1386
1487
|
await tx.unsafe(AUTH_SCHEMA_DDL)
|
|
1387
1488
|
const rows: { version: number; checksum: string }[] =
|
|
@@ -1559,6 +1660,9 @@ export async function migratePostgres(
|
|
|
1559
1660
|
SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
|
|
1560
1661
|
applied_at = ${new Date().toISOString()}
|
|
1561
1662
|
WHERE singleton = true`
|
|
1663
|
+
} else if (version === 12 && marker.checksum === SCHEMA_V12_CHECKSUM) {
|
|
1664
|
+
await tx`UPDATE ab_schema_migrations SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
|
|
1665
|
+
applied_at = ${new Date().toISOString()} WHERE singleton = true`
|
|
1562
1666
|
} else if (version === 11 && marker.checksum === SCHEMA_V11_CHECKSUM) {
|
|
1563
1667
|
await tx`UPDATE ab_schema_migrations SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
|
|
1564
1668
|
applied_at = ${new Date().toISOString()} WHERE singleton = true`
|
|
@@ -1600,8 +1704,32 @@ export async function migratePostgres(
|
|
|
1600
1704
|
ticketMarker.checksum === TICKET_SCHEMA_V1_CHECKSUM
|
|
1601
1705
|
) {
|
|
1602
1706
|
await migrateLegacyTickets(tx as SQL, options.openBlobs)
|
|
1707
|
+
await tx`ALTER TABLE ab_tickets ADD COLUMN IF NOT EXISTS revision bigint NOT NULL DEFAULT 1`
|
|
1708
|
+
await migrateReadableTickets(tx as SQL, options.openBlobs)
|
|
1603
1709
|
await tx`UPDATE ab_ticket_schema_migrations SET version = ${TICKET_SCHEMA_VERSION},
|
|
1604
1710
|
checksum = ${TICKET_SCHEMA_CHECKSUM}, applied_at = ${new Date().toISOString()} WHERE singleton = true`
|
|
1711
|
+
} else if (
|
|
1712
|
+
ticketMarker &&
|
|
1713
|
+
Number(ticketMarker.version) === 2 &&
|
|
1714
|
+
ticketMarker.checksum === TICKET_SCHEMA_V2_CHECKSUM
|
|
1715
|
+
) {
|
|
1716
|
+
await tx`ALTER TABLE ab_tickets ADD COLUMN revision bigint NOT NULL DEFAULT 1`
|
|
1717
|
+
await migrateReadableTickets(tx as SQL, options.openBlobs)
|
|
1718
|
+
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
|
+
} else if (
|
|
1720
|
+
ticketMarker &&
|
|
1721
|
+
Number(ticketMarker.version) === 3 &&
|
|
1722
|
+
ticketMarker.checksum === TICKET_SCHEMA_V3_CHECKSUM
|
|
1723
|
+
) {
|
|
1724
|
+
await migrateReadableTickets(tx as SQL, options.openBlobs)
|
|
1725
|
+
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
|
+
} else if (
|
|
1727
|
+
ticketMarker &&
|
|
1728
|
+
Number(ticketMarker.version) === 4 &&
|
|
1729
|
+
ticketMarker.checksum === TICKET_SCHEMA_V4_CHECKSUM
|
|
1730
|
+
) {
|
|
1731
|
+
await migrateReadableTickets(tx as SQL, options.openBlobs)
|
|
1732
|
+
await tx`UPDATE ab_ticket_schema_migrations SET version = ${TICKET_SCHEMA_VERSION}, checksum = ${TICKET_SCHEMA_CHECKSUM}, applied_at = ${new Date().toISOString()} WHERE singleton = true`
|
|
1605
1733
|
} else if (ticketMarker) {
|
|
1606
1734
|
if (Number(ticketMarker.version) !== TICKET_SCHEMA_VERSION) {
|
|
1607
1735
|
throw schemaError(
|
|
@@ -1615,10 +1743,19 @@ export async function migratePostgres(
|
|
|
1615
1743
|
)
|
|
1616
1744
|
}
|
|
1617
1745
|
} else {
|
|
1746
|
+
await tx`ALTER TABLE ab_tickets ADD COLUMN IF NOT EXISTS revision bigint NOT NULL DEFAULT 1`
|
|
1747
|
+
await migrateReadableTickets(tx as SQL, options.openBlobs)
|
|
1618
1748
|
await tx`INSERT INTO ab_ticket_schema_migrations
|
|
1619
1749
|
(singleton, version, checksum, applied_at)
|
|
1620
1750
|
VALUES (true, ${TICKET_SCHEMA_VERSION}, ${TICKET_SCHEMA_CHECKSUM}, ${new Date().toISOString()})`
|
|
1621
1751
|
}
|
|
1752
|
+
await tx`INSERT INTO ab_ticket_states (repo, name)
|
|
1753
|
+
SELECT DISTINCT repo, state FROM ab_tickets ON CONFLICT DO NOTHING`
|
|
1754
|
+
await tx`INSERT INTO ab_ticket_states (repo, name)
|
|
1755
|
+
SELECT p.repo, value FROM project_ticket_policy p,
|
|
1756
|
+
LATERAL jsonb_each_text(p.policy->'tickets') AS field(key, value)
|
|
1757
|
+
WHERE key IN ('triageState', 'readyState', 'claimedState', 'doneState', 'createState', 'proposalState')
|
|
1758
|
+
ON CONFLICT DO NOTHING`
|
|
1622
1759
|
const authRows: { version: number; checksum: string }[] =
|
|
1623
1760
|
await tx`SELECT version, checksum FROM ab_auth_schema_migrations WHERE singleton = true FOR UPDATE`
|
|
1624
1761
|
const authMarker = authRows[0]
|
package/src/store.ts
CHANGED
|
@@ -248,23 +248,48 @@ export class PostgresBuildStore implements BuildStore {
|
|
|
248
248
|
ON CONFLICT (slug) DO NOTHING RETURNING slug`
|
|
249
249
|
const row = await this.lockBuild(q, input.slug)
|
|
250
250
|
if (!inserted[0]) throw new Error(`build "${input.slug}" already exists`)
|
|
251
|
-
if (created === undefined) return this.
|
|
251
|
+
if (created === undefined) return this.currentRecord(q, row)
|
|
252
252
|
await this.appendLocked(q, input.slug, created, true)
|
|
253
|
-
return this.
|
|
253
|
+
return this.currentRecord(q, await this.lockBuild(q, input.slug))
|
|
254
254
|
})
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
/** Current ticket identity is a read projection; event/artifact bytes remain historical. */
|
|
258
|
+
private async currentRecord(q: Exec, row: Row): Promise<BuildRecord> {
|
|
259
|
+
const record = this.record(row)
|
|
260
|
+
if (record.ticket?.source !== 'hosted') return record
|
|
261
|
+
const ticket = await this.hostedTicketRow(q, record.repo, record.ticket.id)
|
|
262
|
+
const id = ticket?.readable_id
|
|
263
|
+
return id ? { ...record, ticket: { ...record.ticket, id: String(id) } } : record
|
|
264
|
+
}
|
|
265
|
+
|
|
257
266
|
async getBuild(slug: string): Promise<BuildRecord | null> {
|
|
258
267
|
return this.run(async (q) => {
|
|
259
268
|
const rows: Row[] = await q`SELECT * FROM builds WHERE slug = ${slug}`
|
|
260
|
-
return rows[0] ? this.
|
|
269
|
+
return rows[0] ? this.currentRecord(q, rows[0]) : null
|
|
261
270
|
})
|
|
262
271
|
}
|
|
263
272
|
|
|
264
273
|
async listBuilds(): Promise<BuildRecord[]> {
|
|
265
274
|
return this.run(async (q) => {
|
|
266
|
-
|
|
267
|
-
|
|
275
|
+
if (!(await this.hasReadableTicketSchema(q))) {
|
|
276
|
+
const rows: Row[] = await q`SELECT * FROM builds ORDER BY created_at, slug`
|
|
277
|
+
return rows.map((row) => this.record(row))
|
|
278
|
+
}
|
|
279
|
+
const rows: Row[] = await q`SELECT b.*, t.readable_id AS projected_ticket_id FROM builds b
|
|
280
|
+
LEFT JOIN LATERAL (
|
|
281
|
+
SELECT readable_id FROM ab_tickets
|
|
282
|
+
WHERE b.ticket->>'source' = 'hosted' AND repo = b.repo
|
|
283
|
+
AND (id = b.ticket->>'id' OR lower(readable_id) = lower(b.ticket->>'id'))
|
|
284
|
+
LIMIT 1
|
|
285
|
+
) t ON true
|
|
286
|
+
ORDER BY b.created_at, b.slug`
|
|
287
|
+
return rows.map((row) => {
|
|
288
|
+
const record = this.record(row)
|
|
289
|
+
return record.ticket && row.projected_ticket_id
|
|
290
|
+
? { ...record, ticket: { ...record.ticket, id: String(row.projected_ticket_id) } }
|
|
291
|
+
: record
|
|
292
|
+
})
|
|
268
293
|
})
|
|
269
294
|
}
|
|
270
295
|
|
|
@@ -1209,6 +1234,39 @@ export class PostgresBuildStore implements BuildStore {
|
|
|
1209
1234
|
}
|
|
1210
1235
|
}
|
|
1211
1236
|
|
|
1237
|
+
private readableTicketSchema = false
|
|
1238
|
+
|
|
1239
|
+
/** Ticket tables are an independently migrated, optional store extension. */
|
|
1240
|
+
private async hasReadableTicketSchema(q: Exec): Promise<boolean> {
|
|
1241
|
+
if (!this.readableTicketSchema) {
|
|
1242
|
+
const tables: Row[] = await q`SELECT to_regclass('ab_ticket_schema_migrations') AS tickets`
|
|
1243
|
+
if (!tables[0]?.tickets) return false
|
|
1244
|
+
const markers: Row[] =
|
|
1245
|
+
await q`SELECT version FROM ab_ticket_schema_migrations WHERE singleton = true`
|
|
1246
|
+
if (Number(markers[0]?.version ?? 0) < 3) return false
|
|
1247
|
+
// Cache only presence: a store opened before migration must discover a later upgrade.
|
|
1248
|
+
this.readableTicketSchema = true
|
|
1249
|
+
}
|
|
1250
|
+
return true
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
private async hostedTicketRow(q: Exec, repo: string, id: string): Promise<Row | undefined> {
|
|
1254
|
+
if (!(await this.hasReadableTicketSchema(q))) return undefined
|
|
1255
|
+
const rows: Row[] = await q`SELECT id, readable_id FROM ab_tickets
|
|
1256
|
+
WHERE repo = ${repo} AND (id = ${id} OR lower(readable_id) = lower(${id}))`
|
|
1257
|
+
return rows[0]
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
private async currentAssetMeta(q: Exec, row: Row): Promise<TicketAssetMeta> {
|
|
1261
|
+
const meta = this.ticketAssetMeta(row)
|
|
1262
|
+
const ticket = await this.hostedTicketRow(q, meta.repo, meta.ticketId)
|
|
1263
|
+
return ticket?.readable_id ? { ...meta, ticketId: String(ticket.readable_id) } : meta
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
private async ticketStorageKey(q: Exec, repo: string, id: string): Promise<string> {
|
|
1267
|
+
return String((await this.hostedTicketRow(q, repo, id))?.id ?? id)
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1212
1270
|
async putTicketAsset(
|
|
1213
1271
|
repo: string,
|
|
1214
1272
|
ticketId: string,
|
|
@@ -1218,16 +1276,28 @@ export class PostgresBuildStore implements BuildStore {
|
|
|
1218
1276
|
const entries = await storeTicketAssetBlobs(this.blobs, asset.entries)
|
|
1219
1277
|
const manifest = JSON.stringify(entries)
|
|
1220
1278
|
return this.tx(async (q) => {
|
|
1279
|
+
const key = await this.ticketStorageKey(q, repo, ticketId)
|
|
1221
1280
|
// Serialize revision assignment per (repo, ticket, kind, name).
|
|
1222
|
-
await q`SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([repo,
|
|
1281
|
+
await q`SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([repo, key, asset.kind, asset.name])}, 0))`
|
|
1223
1282
|
const tails: Row[] =
|
|
1224
|
-
await q`SELECT COALESCE(MAX(revision), -1) AS revision FROM ticket_assets WHERE repo=${repo} AND ticket_id=${
|
|
1283
|
+
await q`SELECT COALESCE(MAX(revision), -1) AS revision FROM ticket_assets WHERE repo=${repo} AND ticket_id=${key} AND kind=${asset.kind} AND name=${asset.name}`
|
|
1225
1284
|
const revision = num(tails[0]?.revision) + 1
|
|
1226
1285
|
const createdAt = this.now()
|
|
1227
|
-
await q`INSERT INTO ticket_assets (repo,ticket_id,kind,name,revision,layout,manifest,size,removed,created_at) VALUES (${repo},${
|
|
1286
|
+
await q`INSERT INTO ticket_assets (repo,ticket_id,kind,name,revision,layout,manifest,size,removed,created_at) VALUES (${repo},${key},${asset.kind},${asset.name},${revision},${asset.layout},${manifest}::jsonb,${size},false,${createdAt})`
|
|
1287
|
+
const display = await this.currentAssetMeta(q, {
|
|
1288
|
+
repo,
|
|
1289
|
+
ticket_id: key,
|
|
1290
|
+
kind: asset.kind,
|
|
1291
|
+
name: asset.name,
|
|
1292
|
+
revision,
|
|
1293
|
+
layout: asset.layout,
|
|
1294
|
+
size,
|
|
1295
|
+
manifest: entries,
|
|
1296
|
+
created_at: createdAt,
|
|
1297
|
+
})
|
|
1228
1298
|
return {
|
|
1229
1299
|
repo,
|
|
1230
|
-
ticketId,
|
|
1300
|
+
ticketId: display.ticketId,
|
|
1231
1301
|
kind: asset.kind,
|
|
1232
1302
|
name: asset.name,
|
|
1233
1303
|
revision,
|
|
@@ -1246,14 +1316,15 @@ export class PostgresBuildStore implements BuildStore {
|
|
|
1246
1316
|
name: string,
|
|
1247
1317
|
rev?: number,
|
|
1248
1318
|
): Promise<TicketAsset | null> {
|
|
1249
|
-
const rows: Row[] = await this.run((q) =>
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1319
|
+
const rows: Row[] = await this.run(async (q) => {
|
|
1320
|
+
const key = await this.ticketStorageKey(q, repo, ticketId)
|
|
1321
|
+
return rev === undefined
|
|
1322
|
+
? q`SELECT * FROM ticket_assets WHERE repo=${repo} AND ticket_id=${key} AND kind=${kind} AND name=${name} ORDER BY revision DESC LIMIT 1`
|
|
1323
|
+
: q`SELECT * FROM ticket_assets WHERE repo=${repo} AND ticket_id=${key} AND kind=${kind} AND name=${name} AND revision=${rev}`
|
|
1324
|
+
})
|
|
1254
1325
|
const row = rows[0]
|
|
1255
1326
|
if (!row || row.removed === true) return null
|
|
1256
|
-
return loadTicketAsset(this.blobs, this.
|
|
1327
|
+
return loadTicketAsset(this.blobs, await this.run((q) => this.currentAssetMeta(q, row)))
|
|
1257
1328
|
}
|
|
1258
1329
|
|
|
1259
1330
|
async getPinnedTicketAsset(
|
|
@@ -1270,10 +1341,15 @@ export class PostgresBuildStore implements BuildStore {
|
|
|
1270
1341
|
ticketId: string,
|
|
1271
1342
|
opts?: { revisions?: boolean },
|
|
1272
1343
|
): Promise<TicketAssetSummary[]> {
|
|
1273
|
-
const rows: Row[] = await this.run(
|
|
1274
|
-
(q)
|
|
1275
|
-
|
|
1276
|
-
|
|
1344
|
+
const rows: Row[] = await this.run(async (q) => {
|
|
1345
|
+
const key = await this.ticketStorageKey(q, repo, ticketId)
|
|
1346
|
+
const rows: Row[] =
|
|
1347
|
+
await q`SELECT * FROM ticket_assets WHERE repo=${repo} AND ticket_id=${key} ORDER BY kind, name, revision`
|
|
1348
|
+
const projected: Row[] = []
|
|
1349
|
+
for (const row of rows)
|
|
1350
|
+
projected.push({ ...row, ticket_id: (await this.currentAssetMeta(q, row)).ticketId })
|
|
1351
|
+
return projected
|
|
1352
|
+
})
|
|
1277
1353
|
if (opts?.revisions) {
|
|
1278
1354
|
return rows
|
|
1279
1355
|
.filter((row) => row.removed !== true)
|
|
@@ -1293,13 +1369,14 @@ export class PostgresBuildStore implements BuildStore {
|
|
|
1293
1369
|
name: string,
|
|
1294
1370
|
): Promise<TicketAssetMeta | null> {
|
|
1295
1371
|
return this.tx(async (q) => {
|
|
1296
|
-
await q
|
|
1372
|
+
const key = await this.ticketStorageKey(q, repo, ticketId)
|
|
1373
|
+
await q`SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([repo, key, kind, name])}, 0))`
|
|
1297
1374
|
const rows: Row[] =
|
|
1298
|
-
await q`SELECT * FROM ticket_assets WHERE repo=${repo} AND ticket_id=${
|
|
1375
|
+
await q`SELECT * FROM ticket_assets WHERE repo=${repo} AND ticket_id=${key} AND kind=${kind} AND name=${name} ORDER BY revision DESC LIMIT 1`
|
|
1299
1376
|
const latest = rows[0]
|
|
1300
1377
|
if (!latest || latest.removed === true) return null
|
|
1301
|
-
await q`INSERT INTO ticket_assets (repo,ticket_id,kind,name,revision,layout,manifest,size,removed,created_at) VALUES (${repo},${
|
|
1302
|
-
return this.
|
|
1378
|
+
await q`INSERT INTO ticket_assets (repo,ticket_id,kind,name,revision,layout,manifest,size,removed,created_at) VALUES (${repo},${key},${kind},${name},${num(latest.revision) + 1},${String(latest.layout)},${'[]'}::jsonb,0,true,${this.now()})`
|
|
1379
|
+
return this.currentAssetMeta(q, latest)
|
|
1303
1380
|
})
|
|
1304
1381
|
}
|
|
1305
1382
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
2
|
+
import type { SQL } from 'bun'
|
|
3
|
+
|
|
4
|
+
export interface TicketGrant {
|
|
5
|
+
id: string
|
|
6
|
+
repo: string
|
|
7
|
+
execution: string
|
|
8
|
+
kind: 'runner' | 'phase'
|
|
9
|
+
parent: string | null
|
|
10
|
+
expiresAt: string
|
|
11
|
+
}
|
|
12
|
+
export interface TicketGrantStore {
|
|
13
|
+
authenticate(token: string, now: Date): Promise<TicketGrant | null>
|
|
14
|
+
issue(
|
|
15
|
+
repo: string,
|
|
16
|
+
execution: string,
|
|
17
|
+
kind: 'runner' | 'phase',
|
|
18
|
+
parent: TicketGrant | null,
|
|
19
|
+
now: Date,
|
|
20
|
+
): Promise<{ token: string; grant: TicketGrant }>
|
|
21
|
+
revoke(id: string, repo: string): Promise<void>
|
|
22
|
+
renew(id: string, repo: string, now: Date): Promise<void>
|
|
23
|
+
}
|
|
24
|
+
const digest = (token: string) => createHash('sha256').update(token).digest('hex')
|
|
25
|
+
const ttl = 5 * 60_000
|
|
26
|
+
export class PostgresTicketGrantStore implements TicketGrantStore {
|
|
27
|
+
constructor(private readonly sql: SQL) {}
|
|
28
|
+
async authenticate(token: string, now: Date): Promise<TicketGrant | null> {
|
|
29
|
+
const [row] = await this.sql`SELECT g.* FROM ticket_grants g JOIN LATERAL
|
|
30
|
+
(SELECT seq, action FROM project_journal WHERE repo = g.repo ORDER BY seq DESC LIMIT 1) r
|
|
31
|
+
ON r.seq = g.registration AND r.action = 'added'
|
|
32
|
+
WHERE g.digest = ${digest(token)} AND g.revoked_at IS NULL AND g.expires_at > ${now.toISOString()}
|
|
33
|
+
AND (g.parent IS NULL OR EXISTS (SELECT 1 FROM ticket_grants p WHERE p.id = g.parent
|
|
34
|
+
AND p.revoked_at IS NULL AND p.expires_at > ${now.toISOString()} AND p.registration = g.registration))`
|
|
35
|
+
return row
|
|
36
|
+
? {
|
|
37
|
+
id: row.id,
|
|
38
|
+
repo: row.repo,
|
|
39
|
+
execution: row.execution,
|
|
40
|
+
kind: row.kind,
|
|
41
|
+
parent: row.parent,
|
|
42
|
+
expiresAt: new Date(row.expires_at).toISOString(),
|
|
43
|
+
}
|
|
44
|
+
: null
|
|
45
|
+
}
|
|
46
|
+
async issue(
|
|
47
|
+
repo: string,
|
|
48
|
+
execution: string,
|
|
49
|
+
kind: 'runner' | 'phase',
|
|
50
|
+
parent: TicketGrant | null,
|
|
51
|
+
now: Date,
|
|
52
|
+
) {
|
|
53
|
+
if (
|
|
54
|
+
(kind === 'phase' &&
|
|
55
|
+
(parent?.kind !== 'runner' || parent.repo !== repo || parent.execution !== execution)) ||
|
|
56
|
+
(kind === 'runner' && parent)
|
|
57
|
+
)
|
|
58
|
+
throw new Error('invalid ticket grant parent')
|
|
59
|
+
const id = randomBytes(16).toString('hex')
|
|
60
|
+
const token = `abt_${randomBytes(32).toString('hex')}`
|
|
61
|
+
const expiresAt = new Date(
|
|
62
|
+
Math.min(now.getTime() + ttl, parent ? Date.parse(parent.expiresAt) : Infinity),
|
|
63
|
+
).toISOString()
|
|
64
|
+
const rows = await this
|
|
65
|
+
.sql`INSERT INTO ticket_grants (id, digest, repo, registration, execution, kind, parent, expires_at)
|
|
66
|
+
SELECT ${id}, ${digest(token)}, ${repo}, r.seq, ${execution}, ${kind}, ${parent?.id ?? null}, ${expiresAt}
|
|
67
|
+
FROM (SELECT seq, action FROM project_journal WHERE repo = ${repo} ORDER BY seq DESC LIMIT 1) r
|
|
68
|
+
WHERE r.action = 'added' AND (${parent?.id ?? null}::text IS NULL OR EXISTS
|
|
69
|
+
(SELECT 1 FROM ticket_grants p WHERE p.id = ${parent?.id ?? null} AND p.registration = r.seq
|
|
70
|
+
AND p.revoked_at IS NULL AND p.expires_at > ${now.toISOString()})) RETURNING id`
|
|
71
|
+
if (!rows.length) throw new Error('project or parent grant is no longer active')
|
|
72
|
+
return { token, grant: { id, repo, execution, kind, parent: parent?.id ?? null, expiresAt } }
|
|
73
|
+
}
|
|
74
|
+
async revoke(id: string, repo: string) {
|
|
75
|
+
await this
|
|
76
|
+
.sql`UPDATE ticket_grants SET revoked_at = COALESCE(revoked_at, ${new Date().toISOString()}) WHERE id = ${id} AND repo = ${repo}`
|
|
77
|
+
}
|
|
78
|
+
async renew(id: string, repo: string, now: Date) {
|
|
79
|
+
await this.sql`WITH renewed AS (
|
|
80
|
+
UPDATE ticket_grants SET expires_at = ${new Date(now.getTime() + ttl).toISOString()}
|
|
81
|
+
WHERE id = ${id} AND repo = ${repo} AND kind = 'runner' AND revoked_at IS NULL AND expires_at > ${now.toISOString()}
|
|
82
|
+
RETURNING id, expires_at)
|
|
83
|
+
UPDATE ticket_grants SET expires_at = renewed.expires_at FROM renewed
|
|
84
|
+
WHERE ticket_grants.parent = renewed.id AND ticket_grants.revoked_at IS NULL`
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/ticket-migration.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { SQL } from 'bun'
|
|
|
2
2
|
import type { BlobStore, RepositoryEvent } from '@defrex/autobuild/plugin-sdk'
|
|
3
3
|
import { findEffectiveConfigRef, decodeEffectiveConfig } from '@defrex/autobuild/operator'
|
|
4
4
|
import { TICKET_ASSIGNMENTS_DDL } from './ticket-assignments'
|
|
5
|
+
import { PostgresTicketPolicyStore } from './ticket-policy'
|
|
5
6
|
|
|
6
7
|
/** Called only inside the schema migration transaction. Locks preserve the
|
|
7
8
|
* registry/config/ownership snapshot across immutable blob reads and rekeying. */
|
|
@@ -41,41 +42,8 @@ export async function migrateLegacyTickets(
|
|
|
41
42
|
)
|
|
42
43
|
const claimants = new Map<string, string[]>()
|
|
43
44
|
for (const { repo } of remaining.length > 0 ? projects : []) {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
const events = rows.map((row) => ({
|
|
47
|
-
...row,
|
|
48
|
-
repo,
|
|
49
|
-
seq: Number(row.seq),
|
|
50
|
-
ts: new Date(row.ts).toISOString(),
|
|
51
|
-
})) as RepositoryEvent[]
|
|
52
|
-
const ref = findEffectiveConfigRef(events)
|
|
53
|
-
// Registration/onboarding alone makes no team claim.
|
|
54
|
-
if (ref === undefined) continue
|
|
55
|
-
const [artifact] =
|
|
56
|
-
await sql`SELECT blob_ref FROM repo_artifacts WHERE repo = ${repo} AND kind = ${ref.kind} AND revision = ${ref.rev}`
|
|
57
|
-
if (!artifact)
|
|
58
|
-
throw configFailure(`effective config ${ref.kind}@${ref.rev} unavailable for ${repo}`)
|
|
59
|
-
let bytes: Uint8Array | null
|
|
60
|
-
try {
|
|
61
|
-
bytes = await blobs.get(artifact.blob_ref)
|
|
62
|
-
} catch {
|
|
63
|
-
throw configFailure(
|
|
64
|
-
`cannot read effective configuration blob for ${repo}; check blob access and prefix`,
|
|
65
|
-
)
|
|
66
|
-
}
|
|
67
|
-
if (bytes === null)
|
|
68
|
-
throw configFailure(
|
|
69
|
-
`missing effective configuration blob for ${repo}; check blob backend and prefix`,
|
|
70
|
-
)
|
|
71
|
-
let config: ReturnType<typeof decodeEffectiveConfig>
|
|
72
|
-
try {
|
|
73
|
-
config = decodeEffectiveConfig(bytes, `${repo} ${ref.kind}@${ref.rev}`)
|
|
74
|
-
} catch {
|
|
75
|
-
throw configFailure(
|
|
76
|
-
`selected effective config ${ref.kind}@${ref.rev} for ${repo} is invalid`,
|
|
77
|
-
)
|
|
78
|
-
}
|
|
45
|
+
const config = await loadTicketConfig(sql, blobs, repo, configFailure)
|
|
46
|
+
if (!config) continue
|
|
79
47
|
const team = config.tickets.teamKey
|
|
80
48
|
if (team !== undefined) claimants.set(team, [...(claimants.get(team) ?? []), repo])
|
|
81
49
|
}
|
|
@@ -145,3 +113,96 @@ export async function migrateLegacyTickets(
|
|
|
145
113
|
ALTER TABLE ab_ticket_blockers ADD FOREIGN KEY (repo,ticket_id) REFERENCES ab_tickets(repo,id) ON DELETE CASCADE;
|
|
146
114
|
ALTER TABLE ab_ticket_blockers ADD FOREIGN KEY (repo,blocker_id) REFERENCES ab_tickets(repo,id) ON DELETE CASCADE;`)
|
|
147
115
|
}
|
|
116
|
+
|
|
117
|
+
/** Load exactly the durable effective config selected by the repository log. */
|
|
118
|
+
async function loadTicketConfig(
|
|
119
|
+
sql: SQL,
|
|
120
|
+
blobs: BlobStore,
|
|
121
|
+
repo: string,
|
|
122
|
+
configFailure: (detail: string) => Error,
|
|
123
|
+
) {
|
|
124
|
+
const rows: { seq: number; ts: string; actor: unknown; type: string; payload: unknown }[] =
|
|
125
|
+
await sql`SELECT seq, ts, actor, type, payload FROM repo_events WHERE repo = ${repo} ORDER BY seq`
|
|
126
|
+
const events = rows.map((row) => ({
|
|
127
|
+
...row,
|
|
128
|
+
repo,
|
|
129
|
+
seq: Number(row.seq),
|
|
130
|
+
ts: new Date(row.ts).toISOString(),
|
|
131
|
+
})) as RepositoryEvent[]
|
|
132
|
+
const ref = findEffectiveConfigRef(events)
|
|
133
|
+
// Registration/onboarding alone makes no team claim.
|
|
134
|
+
if (ref === undefined) return undefined
|
|
135
|
+
const [artifact] =
|
|
136
|
+
await sql`SELECT blob_ref FROM repo_artifacts WHERE repo = ${repo} AND kind = ${ref.kind} AND revision = ${ref.rev}`
|
|
137
|
+
if (!artifact)
|
|
138
|
+
throw configFailure(`effective config ${ref.kind}@${ref.rev} unavailable for ${repo}`)
|
|
139
|
+
let bytes: Uint8Array | null
|
|
140
|
+
try {
|
|
141
|
+
bytes = await blobs.get(artifact.blob_ref)
|
|
142
|
+
} catch {
|
|
143
|
+
throw configFailure(
|
|
144
|
+
`cannot read effective configuration blob for ${repo}; check blob access and prefix`,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
if (bytes === null)
|
|
148
|
+
throw configFailure(
|
|
149
|
+
`missing effective configuration blob for ${repo}; check blob backend and prefix`,
|
|
150
|
+
)
|
|
151
|
+
let config: ReturnType<typeof decodeEffectiveConfig>
|
|
152
|
+
try {
|
|
153
|
+
config = decodeEffectiveConfig(bytes, `${repo} ${ref.kind}@${ref.rev}`)
|
|
154
|
+
} catch {
|
|
155
|
+
throw configFailure(`selected effective config ${ref.kind}@${ref.rev} for ${repo} is invalid`)
|
|
156
|
+
}
|
|
157
|
+
return config
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const READABLE_TICKET_CONSTRAINTS = `
|
|
161
|
+
ALTER TABLE ab_tickets ALTER COLUMN readable_id SET NOT NULL;
|
|
162
|
+
ALTER TABLE ab_tickets ALTER COLUMN ticket_number SET NOT NULL;
|
|
163
|
+
ALTER TABLE ab_tickets ADD CONSTRAINT ab_tickets_ticket_number_check CHECK (ticket_number > 0);
|
|
164
|
+
ALTER TABLE ab_tickets ADD CONSTRAINT ab_tickets_repo_ticket_number_key UNIQUE (repo, ticket_number);
|
|
165
|
+
CREATE UNIQUE INDEX ab_tickets_readable_id_key ON ab_tickets (repo, lower(readable_id));
|
|
166
|
+
`
|
|
167
|
+
|
|
168
|
+
/** Runs once, under the schema marker transaction; no historical foreign keys move. */
|
|
169
|
+
export async function migrateReadableTickets(
|
|
170
|
+
sql: SQL,
|
|
171
|
+
openBlobs?: () => BlobStore | Promise<BlobStore>,
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
await sql`LOCK TABLE ab_tickets, ab_ticket_comments, ab_ticket_blockers, ticket_assets,
|
|
174
|
+
repo_events, repo_artifacts, project_ticket_policy, project_journal IN SHARE ROW EXCLUSIVE MODE`
|
|
175
|
+
await sql.unsafe(`ALTER TABLE ab_tickets ADD COLUMN readable_id text;
|
|
176
|
+
ALTER TABLE ab_tickets ADD COLUMN ticket_number bigint;
|
|
177
|
+
CREATE TABLE ab_ticket_counters (repo text PRIMARY KEY, last_number bigint NOT NULL CHECK (last_number >= 0));`)
|
|
178
|
+
const projects: { repo: string }[] = await sql`SELECT DISTINCT repo FROM ab_tickets ORDER BY repo`
|
|
179
|
+
const fail = (detail: string) =>
|
|
180
|
+
new Error(
|
|
181
|
+
`Readable ticket migration stopped unchanged: ${detail}. Restore the project's effective configuration and blob access, then rerun migration.`,
|
|
182
|
+
)
|
|
183
|
+
let blobs: BlobStore | undefined
|
|
184
|
+
for (const { repo } of projects) {
|
|
185
|
+
// Independent hosted policy is authoritative; projects using local build
|
|
186
|
+
// storage need not have an uploaded config or any configuration blobs.
|
|
187
|
+
const policy = await new PostgresTicketPolicyStore(sql).get(repo)
|
|
188
|
+
let prefix = policy?.tickets.teamKey
|
|
189
|
+
if (!policy) {
|
|
190
|
+
if (!blobs) {
|
|
191
|
+
if (!openBlobs) throw fail(`project ${repo}: populated projects require blob access`)
|
|
192
|
+
try {
|
|
193
|
+
blobs = await openBlobs()
|
|
194
|
+
} catch {
|
|
195
|
+
throw fail(`project ${repo}: cannot open blob storage`)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const config = await loadTicketConfig(sql, blobs, repo, fail)
|
|
199
|
+
prefix = config?.tickets.teamKey
|
|
200
|
+
}
|
|
201
|
+
if (!prefix?.trim()) throw fail(`project ${repo} has no nonblank effective tickets.teamKey`)
|
|
202
|
+
await sql`WITH numbered AS (SELECT id, row_number() OVER (ORDER BY created_at, id) AS n FROM ab_tickets WHERE repo = ${repo})
|
|
203
|
+
UPDATE ab_tickets t SET ticket_number = numbered.n, readable_id = ${prefix} || '-' || numbered.n::text
|
|
204
|
+
FROM numbered WHERE t.repo = ${repo} AND t.id = numbered.id`
|
|
205
|
+
await sql`INSERT INTO ab_ticket_counters (repo, last_number) SELECT ${repo}, max(ticket_number) FROM ab_tickets WHERE repo = ${repo}`
|
|
206
|
+
}
|
|
207
|
+
await sql.unsafe(READABLE_TICKET_CONSTRAINTS)
|
|
208
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { seedTicketStates, policyStateNames } from './ticket-states'
|
|
2
|
+
import type { Exec } from './retry'
|
|
3
|
+
import type { SQL } from 'bun'
|
|
4
|
+
import { hostedTicketPolicySchema, type HostedTicketPolicy } from '@defrex/autobuild/hosted-tickets'
|
|
5
|
+
|
|
6
|
+
export interface TicketPolicyStore {
|
|
7
|
+
get(repo: string): Promise<HostedTicketPolicy | null>
|
|
8
|
+
put(repo: string, policy: HostedTicketPolicy, writer: string): Promise<void>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Registration sequence scopes policy to the current project incarnation. */
|
|
12
|
+
export class PostgresTicketPolicyStore implements TicketPolicyStore {
|
|
13
|
+
constructor(private readonly sql: SQL) {}
|
|
14
|
+
async get(repo: string): Promise<HostedTicketPolicy | null> {
|
|
15
|
+
const [row] = await this.sql`SELECT p.policy FROM project_ticket_policy p JOIN LATERAL
|
|
16
|
+
(SELECT seq, action FROM project_journal WHERE repo = ${repo} ORDER BY seq DESC LIMIT 1) r
|
|
17
|
+
ON r.seq = p.registration AND r.action = 'added' WHERE p.repo = ${repo}`
|
|
18
|
+
return row
|
|
19
|
+
? hostedTicketPolicySchema.parse(
|
|
20
|
+
typeof row.policy === 'string' ? JSON.parse(row.policy) : row.policy,
|
|
21
|
+
)
|
|
22
|
+
: null
|
|
23
|
+
}
|
|
24
|
+
async put(repo: string, policy: HostedTicketPolicy, writer: string): Promise<void> {
|
|
25
|
+
const parsed = hostedTicketPolicySchema.parse(policy)
|
|
26
|
+
await this.sql.begin(async (tx) => {
|
|
27
|
+
await tx`SELECT singleton FROM project_registry_state WHERE singleton = true FOR UPDATE`
|
|
28
|
+
const [registration] =
|
|
29
|
+
await tx`SELECT seq, action FROM project_journal WHERE repo = ${repo} ORDER BY seq DESC LIMIT 1`
|
|
30
|
+
if (registration?.action !== 'added') throw new Error('project not registered')
|
|
31
|
+
const [previous] =
|
|
32
|
+
await tx`SELECT policy FROM project_ticket_policy WHERE repo = ${repo} AND registration = ${registration.seq}`
|
|
33
|
+
const old = previous
|
|
34
|
+
? hostedTicketPolicySchema.parse(
|
|
35
|
+
typeof previous.policy === 'string' ? JSON.parse(previous.policy) : previous.policy,
|
|
36
|
+
)
|
|
37
|
+
: undefined
|
|
38
|
+
await seedTicketStates(tx as unknown as Exec, repo, [
|
|
39
|
+
...(old ? policyStateNames(old) : []),
|
|
40
|
+
...policyStateNames(parsed),
|
|
41
|
+
])
|
|
42
|
+
await tx`INSERT INTO project_ticket_policy (repo, registration, policy, writer, updated_at)
|
|
43
|
+
VALUES (${repo}, ${registration.seq}, ${parsed}, ${writer}, ${new Date().toISOString()})
|
|
44
|
+
ON CONFLICT (repo) DO UPDATE SET registration = EXCLUDED.registration, policy = EXCLUDED.policy,
|
|
45
|
+
writer = EXCLUDED.writer, updated_at = EXCLUDED.updated_at
|
|
46
|
+
WHERE project_ticket_policy.registration <> EXCLUDED.registration OR project_ticket_policy.policy <> EXCLUDED.policy`
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Explicit test seam; production always composes the PostgreSQL adapter. */
|
|
52
|
+
export class MemoryTicketPolicyStore implements TicketPolicyStore {
|
|
53
|
+
private readonly policies = new Map<string, HostedTicketPolicy>()
|
|
54
|
+
async get(repo: string) {
|
|
55
|
+
return structuredClone(this.policies.get(repo) ?? null)
|
|
56
|
+
}
|
|
57
|
+
async put(repo: string, policy: HostedTicketPolicy, _writer: string) {
|
|
58
|
+
this.policies.set(repo, hostedTicketPolicySchema.parse(policy))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Exec } from './retry'
|
|
2
|
+
import type { HostedTicketPolicy } from '@defrex/autobuild/hosted-tickets'
|
|
3
|
+
|
|
4
|
+
export function policyStateNames(policy: HostedTicketPolicy): string[] {
|
|
5
|
+
const t = policy.tickets
|
|
6
|
+
return [t.triageState, t.readyState, t.claimedState, t.doneState, t.createState, t.proposalState]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** All writers share this project lock, including policy publication. */
|
|
10
|
+
export async function seedTicketStates(q: Exec, repo: string, names: string[]): Promise<void> {
|
|
11
|
+
await q`SELECT pg_advisory_xact_lock(hashtextextended(${repo}, 731926))`
|
|
12
|
+
for (const name of new Set(names)) {
|
|
13
|
+
await q`INSERT INTO ab_ticket_states (repo, name) VALUES (${repo}, ${name}) ON CONFLICT DO NOTHING`
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/tickets.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import { hostedRequestedIds, MAX_TICKET_NUMBER } from '@defrex/autobuild/hosted-tickets'
|
|
2
|
+
import {
|
|
3
|
+
orderStates,
|
|
4
|
+
resolveState,
|
|
5
|
+
validateStateName,
|
|
6
|
+
validateStatePurpose,
|
|
7
|
+
} from '@defrex/autobuild/hosted-tickets'
|
|
8
|
+
import { seedTicketStates } from './ticket-states'
|
|
9
|
+
import { TicketRevisionConflict } from '@defrex/autobuild/hosted-tickets'
|
|
1
10
|
import { SQL } from 'bun'
|
|
2
11
|
import type {
|
|
3
12
|
DependencyState,
|
|
@@ -7,6 +16,8 @@ import type {
|
|
|
7
16
|
TicketListing,
|
|
8
17
|
TicketSource,
|
|
9
18
|
TicketUpdate,
|
|
19
|
+
TicketStateInfo,
|
|
20
|
+
TicketMutation,
|
|
10
21
|
} from '@defrex/autobuild/plugin-sdk'
|
|
11
22
|
import { validateTicketUpdate } from '@defrex/autobuild/ticket-update'
|
|
12
23
|
import { assertTicketSchema } from './schema'
|
|
@@ -23,6 +34,7 @@ export interface PostgresTicketLifecycle {
|
|
|
23
34
|
|
|
24
35
|
export interface PostgresTicketContext {
|
|
25
36
|
repo: string
|
|
37
|
+
teamKey: string
|
|
26
38
|
lifecycle: PostgresTicketLifecycle
|
|
27
39
|
proposalState?: string
|
|
28
40
|
claimedState?: string
|
|
@@ -67,10 +79,11 @@ export async function openPostgresTicketDatabase(url: string): Promise<PostgresT
|
|
|
67
79
|
|
|
68
80
|
export class PostgresTicketSource implements TicketSource {
|
|
69
81
|
readonly name = 'database'
|
|
82
|
+
private readonly teamKey: string
|
|
70
83
|
private readonly repo: string
|
|
71
84
|
private readonly claimedState: string
|
|
72
85
|
private readonly createState: string
|
|
73
|
-
private readonly
|
|
86
|
+
private readonly configured: string[]
|
|
74
87
|
|
|
75
88
|
constructor(
|
|
76
89
|
private readonly sql: SQL,
|
|
@@ -82,41 +95,86 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
82
95
|
private readonly retry: PlanRetryRunner = new PlanRetryRunner(),
|
|
83
96
|
) {
|
|
84
97
|
if (!context.repo.trim()) throw new Error('ticket repo must be nonblank')
|
|
98
|
+
this.teamKey = context.teamKey
|
|
85
99
|
this.repo = context.repo
|
|
86
|
-
this.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
100
|
+
this.configured = Array.from(
|
|
101
|
+
new Set(
|
|
102
|
+
[
|
|
103
|
+
...Object.values(lifecycle),
|
|
104
|
+
context.claimedState ?? lifecycle.doing,
|
|
105
|
+
context.createState ?? lifecycle.triage,
|
|
106
|
+
context.proposalState ?? lifecycle.triage,
|
|
107
|
+
].map((value) => requiredState(value, 'ticket state')),
|
|
108
|
+
),
|
|
93
109
|
)
|
|
94
110
|
this.claimedState = context.claimedState ?? lifecycle.doing
|
|
95
111
|
this.createState = context.createState ?? lifecycle.triage
|
|
96
|
-
this.assertState(this.claimedState)
|
|
97
|
-
this.assertState(this.createState)
|
|
98
112
|
}
|
|
99
113
|
|
|
100
|
-
private
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
114
|
+
private async states(q: Exec): Promise<TicketStateInfo[]> {
|
|
115
|
+
await seedTicketStates(q, this.repo, this.configured)
|
|
116
|
+
const rows: Row[] = await q`SELECT s.name, s.about, count(t.id)::integer AS tickets
|
|
117
|
+
FROM ab_ticket_states s LEFT JOIN ab_tickets t ON t.repo = s.repo AND t.state = s.name
|
|
118
|
+
WHERE s.repo = ${this.repo} GROUP BY s.name, s.about`
|
|
119
|
+
const infos = rows.map((row) => ({
|
|
120
|
+
name: String(row.name),
|
|
121
|
+
tickets: Number(row.tickets),
|
|
122
|
+
...(row.about === null ? {} : { about: String(row.about) }),
|
|
123
|
+
}))
|
|
124
|
+
return orderStates(
|
|
125
|
+
infos.map((s) => s.name),
|
|
126
|
+
[this.lifecycle.triage, this.lifecycle.ready, this.lifecycle.doing, this.lifecycle.done],
|
|
127
|
+
).map((name) => infos.find((s) => s.name === name)!)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async listStates(): Promise<TicketStateInfo[]> {
|
|
131
|
+
return this.retry.tx(this.sql, (q) => this.states(q))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async addState(
|
|
135
|
+
name: string,
|
|
136
|
+
opts: { about?: string } = {},
|
|
137
|
+
): Promise<{ state: TicketStateInfo; created: boolean }> {
|
|
138
|
+
validateStateName(name, 'database ticket source')
|
|
139
|
+
const about = validateStatePurpose(opts.about, 'database ticket source')
|
|
140
|
+
return this.retry.tx(this.sql, async (q) => {
|
|
141
|
+
const states = await this.states(q)
|
|
142
|
+
const clash = states.find((s) => s.name.toLowerCase() === name.toLowerCase())
|
|
143
|
+
if (clash)
|
|
144
|
+
throw new Error(
|
|
145
|
+
`database ticket source: state "${name}" already exists as "${clash.name}"; state names are compared case-insensitively`,
|
|
146
|
+
)
|
|
147
|
+
await q`INSERT INTO ab_ticket_states (repo, name, about) VALUES (${this.repo}, ${name}, ${about ?? null})`
|
|
148
|
+
return {
|
|
149
|
+
state: { name, tickets: 0, ...(about === undefined ? {} : { about }) },
|
|
150
|
+
created: true,
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async state(q: Exec, name: string): Promise<string> {
|
|
156
|
+
return resolveState(
|
|
157
|
+
name,
|
|
158
|
+
(await this.states(q)).map((s) => s.name),
|
|
159
|
+
'database ticket source',
|
|
160
|
+
)
|
|
106
161
|
}
|
|
107
162
|
|
|
108
163
|
private async blockers(executor: Exec, id: string): Promise<string[]> {
|
|
109
|
-
const rows: Row[] = await executor`SELECT blocker_id FROM ab_ticket_blockers
|
|
110
|
-
|
|
164
|
+
const rows: Row[] = await executor`SELECT t.readable_id AS blocker_id FROM ab_ticket_blockers b
|
|
165
|
+
JOIN ab_tickets t ON t.repo = b.repo AND t.id = b.blocker_id
|
|
166
|
+
WHERE b.repo = ${this.repo} AND b.ticket_id = ${id} ORDER BY t.readable_id`
|
|
111
167
|
return rows.map((row) => String(row.blocker_id))
|
|
112
168
|
}
|
|
113
169
|
|
|
114
170
|
private async record(executor: Exec, row: Row): Promise<Ticket> {
|
|
115
171
|
const id = String(row.id)
|
|
116
172
|
const title = String(row.title)
|
|
117
|
-
const blockedBy =
|
|
173
|
+
const blockedBy =
|
|
174
|
+
row.blockers === undefined ? await this.blockers(executor, id) : strings(row.blockers)
|
|
118
175
|
return {
|
|
119
|
-
ref: { source: this.name, id, title },
|
|
176
|
+
ref: { source: this.name, id: String(row.readable_id), title },
|
|
177
|
+
revision: String(row.revision),
|
|
120
178
|
...(row.creation_key !== null && row.creation_key !== undefined
|
|
121
179
|
? { creationKey: String(row.creation_key) }
|
|
122
180
|
: {}),
|
|
@@ -129,21 +187,28 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
129
187
|
}
|
|
130
188
|
}
|
|
131
189
|
|
|
132
|
-
private async
|
|
190
|
+
private async lookup(executor: Exec, id: string, lock = false): Promise<Row | undefined> {
|
|
133
191
|
const rows: Row[] = lock
|
|
134
|
-
? await executor`SELECT
|
|
135
|
-
: await executor`SELECT
|
|
136
|
-
|
|
192
|
+
? await executor`SELECT *, revision::text AS revision FROM ab_tickets WHERE repo = ${this.repo} AND (id = ${id} OR lower(readable_id) = lower(${id})) FOR UPDATE`
|
|
193
|
+
: await executor`SELECT *, revision::text AS revision FROM ab_tickets WHERE repo = ${this.repo} AND (id = ${id} OR lower(readable_id) = lower(${id}))`
|
|
194
|
+
return rows[0]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private async require(executor: Exec, id: string, operation: string, lock = false): Promise<Row> {
|
|
198
|
+
const row = await this.lookup(executor, id, lock)
|
|
137
199
|
if (!row) throw new Error(`database ticket source: ${operation} on unknown ticket "${id}"`)
|
|
138
200
|
return row
|
|
139
201
|
}
|
|
140
202
|
|
|
141
203
|
async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
|
|
142
204
|
const labels = criteria.labels ?? []
|
|
143
|
-
return this.retry.
|
|
144
|
-
|
|
205
|
+
return this.retry.tx(this.sql, async (q) => {
|
|
206
|
+
await seedTicketStates(q, this.repo, this.configured)
|
|
207
|
+
const state = criteria.state === undefined ? null : await this.state(q, criteria.state)
|
|
208
|
+
const rows: Row[] =
|
|
209
|
+
await q`SELECT *, revision::text AS revision, ARRAY(SELECT t.readable_id FROM ab_ticket_blockers b JOIN ab_tickets t ON t.repo = b.repo AND t.id = b.blocker_id WHERE b.repo = ab_tickets.repo AND b.ticket_id = ab_tickets.id ORDER BY t.readable_id) AS blockers FROM ab_tickets
|
|
145
210
|
WHERE repo = ${this.repo}
|
|
146
|
-
AND (${
|
|
211
|
+
AND (${state}::text IS NULL OR state = ${state})
|
|
147
212
|
AND labels @> ${q.array(labels, 'text')}
|
|
148
213
|
ORDER BY created_at, id`
|
|
149
214
|
// Sequential, not `Promise.all`: every statement of a body runs on one
|
|
@@ -156,17 +221,36 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
156
221
|
}
|
|
157
222
|
|
|
158
223
|
async get(id: string): Promise<Ticket | null> {
|
|
159
|
-
return this.retry.
|
|
160
|
-
|
|
161
|
-
|
|
224
|
+
return this.retry.tx(this.sql, async (q) => {
|
|
225
|
+
await seedTicketStates(q, this.repo, this.configured)
|
|
226
|
+
const rows: Row[] =
|
|
227
|
+
await q`SELECT *, revision::text AS revision, ARRAY(SELECT t.readable_id FROM ab_ticket_blockers b JOIN ab_tickets t ON t.repo = b.repo AND t.id = b.blocker_id WHERE b.repo = ab_tickets.repo AND b.ticket_id = ab_tickets.id ORDER BY t.readable_id) AS blockers FROM ab_tickets WHERE repo = ${this.repo} AND (id = ${id} OR lower(readable_id) = lower(${id}))`
|
|
228
|
+
if (!rows[0]) return null
|
|
229
|
+
const ticket = await this.record(q, rows[0])
|
|
230
|
+
const storageId = String(rows[0].id)
|
|
231
|
+
const comments: Row[] = await q`SELECT body, created_at FROM ab_ticket_comments
|
|
232
|
+
WHERE repo = ${this.repo} AND ticket_id = ${storageId}
|
|
233
|
+
ORDER BY created_at ASC, seq ASC`
|
|
234
|
+
return {
|
|
235
|
+
...ticket,
|
|
236
|
+
comments: comments.map((row) => ({
|
|
237
|
+
body: String(row.body),
|
|
238
|
+
createdAt: new Date(row.created_at as string | Date).toISOString(),
|
|
239
|
+
})),
|
|
240
|
+
}
|
|
162
241
|
})
|
|
163
242
|
}
|
|
164
243
|
|
|
165
244
|
async claim(id: string): Promise<boolean> {
|
|
166
|
-
return this.retry.
|
|
167
|
-
|
|
245
|
+
return this.retry.tx(this.sql, async (q) => {
|
|
246
|
+
await seedTicketStates(q, this.repo, this.configured)
|
|
247
|
+
const row = await this.lookup(q, id)
|
|
248
|
+
if (!row) return false
|
|
249
|
+
const storageId = String(row.id)
|
|
250
|
+
const rows: Row[] =
|
|
251
|
+
await q`UPDATE ab_tickets SET state = ${this.claimedState}, revision = revision + 1,
|
|
168
252
|
updated_at = ${new Date().toISOString()}
|
|
169
|
-
WHERE repo = ${this.repo} AND id = ${
|
|
253
|
+
WHERE repo = ${this.repo} AND id = ${storageId}
|
|
170
254
|
AND state <> ${this.claimedState} AND state <> ${this.lifecycle.done} RETURNING id`
|
|
171
255
|
return rows.length === 1
|
|
172
256
|
})
|
|
@@ -174,53 +258,71 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
174
258
|
|
|
175
259
|
async comment(id: string, body: string): Promise<void> {
|
|
176
260
|
await this.retry.tx(this.sql, async (q) => {
|
|
177
|
-
await this.require(q, id, 'comment', true)
|
|
261
|
+
const storageId = String((await this.require(q, id, 'comment', true)).id)
|
|
178
262
|
const tails: Row[] = await q`SELECT COALESCE(MAX(seq), 0) AS seq FROM ab_ticket_comments
|
|
179
|
-
WHERE repo = ${this.repo} AND ticket_id = ${
|
|
263
|
+
WHERE repo = ${this.repo} AND ticket_id = ${storageId}`
|
|
180
264
|
await q`INSERT INTO ab_ticket_comments (repo, ticket_id, seq, body, created_at)
|
|
181
|
-
VALUES (${this.repo}, ${
|
|
265
|
+
VALUES (${this.repo}, ${storageId}, ${Number(tails[0]?.seq ?? 0) + 1}, ${body}, ${new Date().toISOString()})`
|
|
182
266
|
})
|
|
183
267
|
}
|
|
184
268
|
|
|
185
269
|
async transition(id: string, state: string): Promise<void> {
|
|
186
|
-
this.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
})
|
|
270
|
+
await this.mutate(id, { kind: 'transition', state })
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
get requestedIds() {
|
|
274
|
+
return hostedRequestedIds(this.teamKey)
|
|
192
275
|
}
|
|
193
276
|
|
|
194
277
|
async create(draft: TicketDraft, options: TicketCreateOptions = {}): Promise<Ticket> {
|
|
195
278
|
if (!draft.title.trim()) throw new Error('database ticket create: title must be nonblank')
|
|
196
|
-
if (!draft.body.trim()
|
|
197
|
-
|
|
198
|
-
this.
|
|
279
|
+
if (!draft.body.trim() && options.requestedId === undefined)
|
|
280
|
+
throw new Error('database ticket create: body must be nonblank')
|
|
281
|
+
requiredState(this.teamKey, 'ticket teamKey')
|
|
199
282
|
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`
|
|
200
285
|
if (options.idempotencyKey !== undefined) {
|
|
201
|
-
const adopted: Row[] = await q`SELECT
|
|
286
|
+
const adopted: Row[] = await q`SELECT *, revision::text AS revision FROM ab_tickets
|
|
202
287
|
WHERE repo = ${this.repo} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
|
|
203
288
|
if (adopted[0]) return this.record(q, adopted[0])
|
|
204
289
|
}
|
|
290
|
+
const state = await this.state(q, options.state ?? this.createState)
|
|
291
|
+
const blockers: string[] = []
|
|
205
292
|
for (const blocker of draft.blockedBy ?? []) {
|
|
206
|
-
await this.require(q, blocker, 'create blocker')
|
|
293
|
+
blockers.push(String((await this.require(q, blocker, 'create blocker')).id))
|
|
207
294
|
}
|
|
295
|
+
const policy = hostedRequestedIds(this.teamKey)
|
|
296
|
+
const kept =
|
|
297
|
+
options.requestedId === undefined ? undefined : policy.preserve(options.requestedId)
|
|
298
|
+
const rows: Row[] = await q`SELECT GREATEST(
|
|
299
|
+
(SELECT last_number FROM ab_ticket_counters WHERE repo = ${this.repo}),
|
|
300
|
+
COALESCE(MAX(ticket_number), 0))::text AS high FROM ab_tickets WHERE repo = ${this.repo}`
|
|
301
|
+
const high = BigInt(String(rows[0]!.high))
|
|
302
|
+
const chosen = kept === undefined ? high + 1n : BigInt(kept.slice(this.teamKey.length + 1))
|
|
303
|
+
if (chosen > MAX_TICKET_NUMBER) throw new Error('ticket numbers exhausted')
|
|
304
|
+
const clashes: Row[] =
|
|
305
|
+
await q`SELECT id FROM ab_tickets WHERE repo = ${this.repo} AND ticket_number = ${String(chosen)}::bigint`
|
|
306
|
+
if (clashes.length) throw new Error(`requested ticket id already occupied: ${kept}`)
|
|
307
|
+
const number = String(chosen)
|
|
308
|
+
await q`UPDATE ab_ticket_counters SET last_number = ${String(chosen > high ? chosen : high)}::bigint WHERE repo = ${this.repo}`
|
|
309
|
+
const readable = `${this.teamKey}-${number}`
|
|
208
310
|
const id = `ticket-${crypto.randomUUID()}`
|
|
209
311
|
const now = new Date().toISOString()
|
|
210
312
|
const inserted: Row[] = await q`INSERT INTO ab_tickets
|
|
211
|
-
(repo, id, creation_key, title, body, state, labels, created_at, updated_at)
|
|
212
|
-
VALUES (${this.repo}, ${id}, ${options.idempotencyKey ?? null}, ${draft.title}, ${draft.body},
|
|
313
|
+
(repo, id, readable_id, ticket_number, creation_key, title, body, state, labels, created_at, updated_at)
|
|
314
|
+
VALUES (${this.repo}, ${id}, ${readable}, ${number}::bigint, ${options.idempotencyKey ?? null}, ${draft.title}, ${draft.body},
|
|
213
315
|
${state}, ${q.array(draft.labels ?? [], 'text')}, ${now}, ${now})
|
|
214
|
-
ON CONFLICT (repo, creation_key) DO NOTHING RETURNING
|
|
316
|
+
ON CONFLICT (repo, creation_key) DO NOTHING RETURNING *, revision::text AS revision`
|
|
215
317
|
let row = inserted[0]
|
|
216
318
|
if (!row && options.idempotencyKey !== undefined) {
|
|
217
|
-
const adopted: Row[] = await q`SELECT
|
|
319
|
+
const adopted: Row[] = await q`SELECT *, revision::text AS revision FROM ab_tickets
|
|
218
320
|
WHERE repo = ${this.repo} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
|
|
219
321
|
row = adopted[0]
|
|
220
322
|
}
|
|
221
323
|
if (!row) throw new Error('database ticket create failed')
|
|
222
324
|
if (String(row.id) === id) {
|
|
223
|
-
for (const blocker of [...new Set(
|
|
325
|
+
for (const blocker of [...new Set(blockers)]) {
|
|
224
326
|
if (blocker === id)
|
|
225
327
|
throw new Error(`database ticket source: ticket "${id}" cannot block itself`)
|
|
226
328
|
await q`INSERT INTO ab_ticket_blockers (repo, ticket_id, blocker_id)
|
|
@@ -232,37 +334,71 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
232
334
|
}
|
|
233
335
|
|
|
234
336
|
async update(id: string, patch: TicketUpdate): Promise<void> {
|
|
235
|
-
|
|
236
|
-
await this.retry.tx(this.sql, async (q) => {
|
|
237
|
-
const row = await this.require(q, id, 'update', true)
|
|
238
|
-
await q`UPDATE ab_tickets SET
|
|
239
|
-
title = ${validated.title ?? String(row.title)},
|
|
240
|
-
body = ${validated.body ?? String(row.body)},
|
|
241
|
-
labels = ${q.array(validated.labels ?? strings(row.labels), 'text')},
|
|
242
|
-
updated_at = ${new Date().toISOString()}
|
|
243
|
-
WHERE repo = ${this.repo} AND id = ${id}`
|
|
244
|
-
})
|
|
337
|
+
await this.mutate(id, { kind: 'update', patch })
|
|
245
338
|
}
|
|
246
339
|
|
|
247
340
|
async addBlocker(id: string, blockerId: string): Promise<void> {
|
|
248
|
-
await this.
|
|
249
|
-
await this.require(q, id, 'addBlocker', true)
|
|
250
|
-
if (id === blockerId)
|
|
251
|
-
throw new Error(`database ticket source: ticket "${id}" cannot block itself`)
|
|
252
|
-
await this.require(q, blockerId, 'addBlocker')
|
|
253
|
-
await q`INSERT INTO ab_ticket_blockers (repo, ticket_id, blocker_id)
|
|
254
|
-
VALUES (${this.repo}, ${id}, ${blockerId}) ON CONFLICT DO NOTHING`
|
|
255
|
-
})
|
|
341
|
+
await this.mutate(id, { kind: 'blockers', operation: 'block', ids: [blockerId] })
|
|
256
342
|
}
|
|
257
343
|
|
|
258
344
|
async removeBlocker(id: string, blockerId: string): Promise<void> {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
345
|
+
await this.mutate(id, { kind: 'blockers', operation: 'unblock', ids: [blockerId] })
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async mutateIfRevision(id: string, expected: string, mutation: TicketMutation): Promise<void> {
|
|
349
|
+
if (!expected.trim()) throw new Error('expected revision must be nonblank')
|
|
350
|
+
await this.mutate(id, mutation, expected)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private async mutate(id: string, mutation: TicketMutation, expected?: string): Promise<void> {
|
|
354
|
+
const patch = mutation.kind === 'update' ? validateTicketUpdate(mutation.patch) : undefined
|
|
355
|
+
await this.retry.tx(this.sql, async (q) => {
|
|
356
|
+
const state = mutation.kind === 'transition' ? await this.state(q, mutation.state) : undefined
|
|
357
|
+
const row = await this.require(q, id, 'mutation', true)
|
|
358
|
+
const storageId = String(row.id)
|
|
359
|
+
const revision = String(row.revision)
|
|
360
|
+
if (expected !== undefined && revision !== expected)
|
|
361
|
+
throw new TicketRevisionConflict(id, expected, revision)
|
|
362
|
+
let changed = false
|
|
363
|
+
if (patch) {
|
|
364
|
+
const title = patch.title ?? String(row.title)
|
|
365
|
+
const body = patch.body ?? String(row.body)
|
|
366
|
+
const labels = patch.labels ?? strings(row.labels)
|
|
367
|
+
changed =
|
|
368
|
+
title !== row.title ||
|
|
369
|
+
body !== row.body ||
|
|
370
|
+
JSON.stringify([...new Set(labels)].sort()) !==
|
|
371
|
+
JSON.stringify([...new Set(strings(row.labels))].sort())
|
|
372
|
+
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}`
|
|
374
|
+
} else if (mutation.kind === 'transition') {
|
|
375
|
+
changed = row.state !== state
|
|
376
|
+
if (changed)
|
|
377
|
+
await q`UPDATE ab_tickets SET state = ${state} WHERE repo = ${this.repo} AND id = ${storageId}`
|
|
378
|
+
} else if (mutation.kind === 'blockers') {
|
|
379
|
+
const keys = new Set<string>()
|
|
380
|
+
for (const blocker of mutation.ids) {
|
|
381
|
+
const target =
|
|
382
|
+
mutation.operation === 'block' || expected !== undefined
|
|
383
|
+
? await this.require(q, blocker, 'blocker')
|
|
384
|
+
: await this.lookup(q, blocker)
|
|
385
|
+
if (!target) continue
|
|
386
|
+
const key = String(target.id)
|
|
387
|
+
if (mutation.operation === 'block' && key === storageId)
|
|
388
|
+
throw new Error(`ticket "${id}" cannot block itself`)
|
|
389
|
+
keys.add(key)
|
|
390
|
+
}
|
|
391
|
+
const ids = [...keys]
|
|
392
|
+
for (const blocker of ids) {
|
|
393
|
+
const rows: Row[] =
|
|
394
|
+
mutation.operation === 'block'
|
|
395
|
+
? await q`INSERT INTO ab_ticket_blockers (repo, ticket_id, blocker_id) VALUES (${this.repo}, ${storageId}, ${blocker}) ON CONFLICT DO NOTHING RETURNING blocker_id`
|
|
396
|
+
: await q`DELETE FROM ab_ticket_blockers WHERE repo = ${this.repo} AND ticket_id = ${storageId} AND blocker_id = ${blocker} RETURNING blocker_id`
|
|
397
|
+
changed ||= rows.length > 0
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (changed)
|
|
401
|
+
await q`UPDATE ab_tickets SET revision = revision + 1, updated_at = ${new Date().toISOString()} WHERE repo = ${this.repo} AND id = ${storageId}`
|
|
266
402
|
})
|
|
267
403
|
}
|
|
268
404
|
|
|
@@ -272,14 +408,13 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
272
408
|
// One run per id keeps each id's state read + blockers query
|
|
273
409
|
// sequential on one pinned connection; the ids stay concurrent.
|
|
274
410
|
this.retry.run(this.sql, async (q) => {
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
if (!rows[0]) return { id, exists: false, resolved: false, blockedBy: [] }
|
|
411
|
+
const row = await this.lookup(q, id)
|
|
412
|
+
if (!row) return { id, exists: false, resolved: false, blockedBy: [] }
|
|
278
413
|
return {
|
|
279
414
|
id,
|
|
280
415
|
exists: true,
|
|
281
|
-
resolved: String(
|
|
282
|
-
blockedBy: await this.blockers(q, id),
|
|
416
|
+
resolved: String(row.state) === this.lifecycle.done,
|
|
417
|
+
blockedBy: await this.blockers(q, String(row.id)),
|
|
283
418
|
}
|
|
284
419
|
}),
|
|
285
420
|
),
|