@defrex/autobuild-postgres-store 0.11.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 +21 -4
- package/package.json +21 -1
- package/src/bin.ts +2 -2
- package/src/config.ts +29 -21
- package/src/connections.ts +160 -0
- package/src/credentials.ts +157 -0
- package/src/index.ts +5 -0
- package/src/projects.ts +171 -0
- package/src/schema.ts +306 -12
- package/src/store.ts +100 -23
- package/src/ticket-assignments.ts +39 -0
- package/src/ticket-grants.ts +86 -0
- package/src/ticket-migration.ts +208 -0
- package/src/ticket-policy.ts +60 -0
- package/src/ticket-states.ts +15 -0
- package/src/tickets.ts +234 -117
package/src/schema.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import { PostgresProjectRegistry } from './projects'
|
|
2
|
+
import type { PostgresEnv } from './env'
|
|
3
|
+
import type { BlobStore } from '@defrex/autobuild/plugin-sdk'
|
|
4
|
+
import {
|
|
5
|
+
migrateLegacyTickets,
|
|
6
|
+
migrateReadableTickets,
|
|
7
|
+
READABLE_TICKET_CONSTRAINTS,
|
|
8
|
+
} from './ticket-migration'
|
|
1
9
|
import { SQL } from 'bun'
|
|
2
10
|
import {
|
|
3
11
|
AUTH_SCHEMA_CHECKSUM,
|
|
@@ -8,7 +16,7 @@ import {
|
|
|
8
16
|
assertAuthSchema,
|
|
9
17
|
} from './auth-schema'
|
|
10
18
|
|
|
11
|
-
export const SCHEMA_VERSION =
|
|
19
|
+
export const SCHEMA_VERSION = 13
|
|
12
20
|
export const MIGRATE_COMMAND = 'bun run postgres:migrate (from a pinned Autobuild release checkout)'
|
|
13
21
|
|
|
14
22
|
/** The frozen v3 DDL, kept verbatim so a deployed v3 marker's checksum can be
|
|
@@ -666,7 +674,7 @@ CREATE INDEX IF NOT EXISTS events_type_build_seq ON events (type, build, seq);
|
|
|
666
674
|
CREATE INDEX IF NOT EXISTS repo_events_type_repo_seq ON repo_events (repo, type, seq);`.trim()
|
|
667
675
|
export const SCHEMA_V8_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_V8_DDL).digest('hex')
|
|
668
676
|
|
|
669
|
-
export const
|
|
677
|
+
export const SCHEMA_V9_DDL = `
|
|
670
678
|
CREATE TABLE IF NOT EXISTS ab_schema_migrations (
|
|
671
679
|
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
672
680
|
version integer NOT NULL,
|
|
@@ -773,12 +781,68 @@ CREATE TABLE IF NOT EXISTS ticket_assets (
|
|
|
773
781
|
PRIMARY KEY (repo, ticket_id, kind, name, revision)
|
|
774
782
|
);`.trim()
|
|
775
783
|
|
|
784
|
+
export const SCHEMA_V9_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_V9_DDL).digest('hex')
|
|
785
|
+
|
|
786
|
+
export const PROJECT_SCHEMA_DDL = `
|
|
787
|
+
CREATE SEQUENCE IF NOT EXISTS project_journal_seq;
|
|
788
|
+
CREATE TABLE IF NOT EXISTS project_journal (
|
|
789
|
+
seq bigint PRIMARY KEY, repo text NOT NULL,
|
|
790
|
+
action text NOT NULL CHECK (action IN ('added','removed')),
|
|
791
|
+
actor text NOT NULL CHECK (length(btrim(actor)) > 0),
|
|
792
|
+
ts timestamptz NOT NULL
|
|
793
|
+
);
|
|
794
|
+
CREATE TABLE IF NOT EXISTS project_registry_state (
|
|
795
|
+
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
796
|
+
legacy_imported boolean NOT NULL
|
|
797
|
+
);`.trim()
|
|
798
|
+
export const SCHEMA_V10_DDL = `${SCHEMA_V9_DDL}\n${PROJECT_SCHEMA_DDL}`
|
|
799
|
+
export const SCHEMA_V10_CHECKSUM = new Bun.CryptoHasher('sha256')
|
|
800
|
+
.update(SCHEMA_V10_DDL)
|
|
801
|
+
.digest('hex')
|
|
802
|
+
export const CREDENTIAL_SCHEMA_DDL = `
|
|
803
|
+
CREATE TABLE IF NOT EXISTS project_credentials (
|
|
804
|
+
id text PRIMARY KEY, digest text NOT NULL, repo text NOT NULL,
|
|
805
|
+
created_at timestamptz NOT NULL, created_by text NOT NULL,
|
|
806
|
+
last_used_at timestamptz, revoked_at timestamptz, expires_at timestamptz,
|
|
807
|
+
purpose text NOT NULL CHECK (purpose IN ('project-owner','dispatcher'))
|
|
808
|
+
);`.trim()
|
|
809
|
+
export const SCHEMA_V11_DDL = `${SCHEMA_V10_DDL}\n${CREDENTIAL_SCHEMA_DDL}`
|
|
810
|
+
export const SCHEMA_V11_CHECKSUM = new Bun.CryptoHasher('sha256')
|
|
811
|
+
.update(SCHEMA_V11_DDL)
|
|
812
|
+
.digest('hex')
|
|
813
|
+
export const CONNECTION_SCHEMA_DDL = `
|
|
814
|
+
CREATE TABLE IF NOT EXISTS project_connections (
|
|
815
|
+
id text PRIMARY KEY, repo text NOT NULL, credential_id text NOT NULL,
|
|
816
|
+
digest text NOT NULL, capability_hash text NOT NULL, code text NOT NULL UNIQUE,
|
|
817
|
+
created_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
|
|
818
|
+
status text NOT NULL CHECK (status IN ('pending','approved','denied','expired')),
|
|
819
|
+
approved_by text, polled_at timestamptz, poll_attempts integer NOT NULL DEFAULT 0
|
|
820
|
+
);
|
|
821
|
+
CREATE TABLE IF NOT EXISTS connection_lookup_limits (
|
|
822
|
+
actor text PRIMARY KEY, started_at timestamptz NOT NULL, attempts integer NOT NULL
|
|
823
|
+
);`.trim()
|
|
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}`
|
|
776
840
|
export const SCHEMA_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_DDL).digest('hex')
|
|
777
841
|
|
|
778
842
|
/** Ticket persistence evolves independently so deployed v1 BuildStore markers
|
|
779
843
|
* remain valid while the hosted ticket tables are added. */
|
|
780
|
-
export const TICKET_SCHEMA_VERSION =
|
|
781
|
-
export const
|
|
844
|
+
export const TICKET_SCHEMA_VERSION = 5
|
|
845
|
+
export const TICKET_SCHEMA_V1_DDL = `
|
|
782
846
|
CREATE TABLE IF NOT EXISTS ab_ticket_schema_migrations (
|
|
783
847
|
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
784
848
|
version integer NOT NULL, checksum text NOT NULL, applied_at timestamptz NOT NULL
|
|
@@ -803,6 +867,38 @@ CREATE TABLE IF NOT EXISTS ab_ticket_blockers (
|
|
|
803
867
|
FOREIGN KEY (team, blocker_id) REFERENCES ab_tickets(team, id) ON DELETE CASCADE,
|
|
804
868
|
CHECK (ticket_id <> blocker_id)
|
|
805
869
|
);`.trim()
|
|
870
|
+
export const TICKET_SCHEMA_V1_CHECKSUM = new Bun.CryptoHasher('sha256')
|
|
871
|
+
.update(TICKET_SCHEMA_V1_DDL)
|
|
872
|
+
.digest('hex')
|
|
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()
|
|
806
902
|
export const TICKET_SCHEMA_CHECKSUM = new Bun.CryptoHasher('sha256')
|
|
807
903
|
.update(TICKET_SCHEMA_DDL)
|
|
808
904
|
.digest('hex')
|
|
@@ -825,6 +921,65 @@ CREATE TABLE IF NOT EXISTS reducer_snapshots (
|
|
|
825
921
|
type ExpectedColumn = readonly [name: string, type: string, notNull: boolean, defaultValue?: string]
|
|
826
922
|
|
|
827
923
|
const EXPECTED_COLUMNS: Record<string, readonly ExpectedColumn[]> = {
|
|
924
|
+
project_connections: [
|
|
925
|
+
['id', 'text', true],
|
|
926
|
+
['repo', 'text', true],
|
|
927
|
+
['credential_id', 'text', true],
|
|
928
|
+
['digest', 'text', true],
|
|
929
|
+
['capability_hash', 'text', true],
|
|
930
|
+
['code', 'text', true],
|
|
931
|
+
['created_at', 'timestamp with time zone', true],
|
|
932
|
+
['expires_at', 'timestamp with time zone', true],
|
|
933
|
+
['status', 'text', true],
|
|
934
|
+
['approved_by', 'text', false],
|
|
935
|
+
['polled_at', 'timestamp with time zone', false],
|
|
936
|
+
['poll_attempts', 'integer', true, '0'],
|
|
937
|
+
],
|
|
938
|
+
connection_lookup_limits: [
|
|
939
|
+
['actor', 'text', true],
|
|
940
|
+
['started_at', 'timestamp with time zone', true],
|
|
941
|
+
['attempts', 'integer', true],
|
|
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
|
+
],
|
|
961
|
+
project_credentials: [
|
|
962
|
+
['id', 'text', true],
|
|
963
|
+
['digest', 'text', true],
|
|
964
|
+
['repo', 'text', true],
|
|
965
|
+
['created_at', 'timestamp with time zone', true],
|
|
966
|
+
['created_by', 'text', true],
|
|
967
|
+
['last_used_at', 'timestamp with time zone', false],
|
|
968
|
+
['revoked_at', 'timestamp with time zone', false],
|
|
969
|
+
['expires_at', 'timestamp with time zone', false],
|
|
970
|
+
['purpose', 'text', true],
|
|
971
|
+
],
|
|
972
|
+
project_journal: [
|
|
973
|
+
['seq', 'bigint', true],
|
|
974
|
+
['repo', 'text', true],
|
|
975
|
+
['action', 'text', true],
|
|
976
|
+
['actor', 'text', true],
|
|
977
|
+
['ts', 'timestamp with time zone', true],
|
|
978
|
+
],
|
|
979
|
+
project_registry_state: [
|
|
980
|
+
['singleton', 'boolean', true, 'true'],
|
|
981
|
+
['legacy_imported', 'boolean', true],
|
|
982
|
+
],
|
|
828
983
|
ab_schema_migrations: [
|
|
829
984
|
['singleton', 'boolean', true, 'true'],
|
|
830
985
|
['version', 'integer', true],
|
|
@@ -958,6 +1113,20 @@ const EXPECTED_COLUMNS: Record<string, readonly ExpectedColumn[]> = {
|
|
|
958
1113
|
}
|
|
959
1114
|
|
|
960
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|',
|
|
1120
|
+
'project_connections|p|id||||',
|
|
1121
|
+
'project_connections|c|||||',
|
|
1122
|
+
'connection_lookup_limits|p|actor||||',
|
|
1123
|
+
'project_credentials|p|id||||',
|
|
1124
|
+
'project_credentials|c|||||',
|
|
1125
|
+
'project_journal|p|seq||||',
|
|
1126
|
+
'project_journal|c|||||',
|
|
1127
|
+
'project_journal|c|||||',
|
|
1128
|
+
'project_registry_state|p|singleton||||',
|
|
1129
|
+
'project_registry_state|c|||||',
|
|
961
1130
|
// CHECK constraints are compared by presence only (their pg_get_expr text
|
|
962
1131
|
// is version-sensitive); PK/FK rows compare fully.
|
|
963
1132
|
'ab_schema_migrations|c|||||',
|
|
@@ -1112,7 +1281,7 @@ async function assertCatalogShape(sql: SQL): Promise<void> {
|
|
|
1112
1281
|
].join('|'),
|
|
1113
1282
|
)
|
|
1114
1283
|
.sort()
|
|
1115
|
-
if (JSON.stringify(actualConstraints) !== JSON.stringify(EXPECTED_CONSTRAINTS)) {
|
|
1284
|
+
if (JSON.stringify(actualConstraints) !== JSON.stringify([...EXPECTED_CONSTRAINTS].sort())) {
|
|
1116
1285
|
throw schemaError('constraints are missing or mismatched')
|
|
1117
1286
|
}
|
|
1118
1287
|
}
|
|
@@ -1170,7 +1339,7 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
|
|
|
1170
1339
|
['applied_at', 'timestamp with time zone', true],
|
|
1171
1340
|
],
|
|
1172
1341
|
ab_tickets: [
|
|
1173
|
-
['
|
|
1342
|
+
['repo', 'text', true],
|
|
1174
1343
|
['id', 'text', true],
|
|
1175
1344
|
['creation_key', 'text', false],
|
|
1176
1345
|
['title', 'text', true],
|
|
@@ -1179,16 +1348,28 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
|
|
|
1179
1348
|
['labels', 'text[]', true],
|
|
1180
1349
|
['created_at', 'timestamp with time zone', true],
|
|
1181
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],
|
|
1182
1363
|
],
|
|
1183
1364
|
ab_ticket_comments: [
|
|
1184
|
-
['
|
|
1365
|
+
['repo', 'text', true],
|
|
1185
1366
|
['ticket_id', 'text', true],
|
|
1186
1367
|
['seq', 'bigint', true],
|
|
1187
1368
|
['body', 'text', true],
|
|
1188
1369
|
['created_at', 'timestamp with time zone', true],
|
|
1189
1370
|
],
|
|
1190
1371
|
ab_ticket_blockers: [
|
|
1191
|
-
['
|
|
1372
|
+
['repo', 'text', true],
|
|
1192
1373
|
['ticket_id', 'text', true],
|
|
1193
1374
|
['blocker_id', 'text', true],
|
|
1194
1375
|
],
|
|
@@ -1203,7 +1384,7 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
|
|
|
1203
1384
|
WHERE n.nspname = current_schema() AND c.relkind IN ('r', 'p')
|
|
1204
1385
|
AND a.attnum > 0 AND NOT a.attisdropped
|
|
1205
1386
|
AND c.relname = ANY(ARRAY['ab_ticket_schema_migrations', 'ab_tickets',
|
|
1206
|
-
'ab_ticket_comments', 'ab_ticket_blockers'])
|
|
1387
|
+
'ab_ticket_comments', 'ab_ticket_blockers', 'ab_ticket_counters', 'ab_ticket_states'])
|
|
1207
1388
|
ORDER BY c.relname, a.attnum`
|
|
1208
1389
|
for (const [table, shape] of Object.entries(expected)) {
|
|
1209
1390
|
const actual = columns
|
|
@@ -1213,6 +1394,51 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
|
|
|
1213
1394
|
throw schemaError(`ticket table ${table} is missing or mismatched`)
|
|
1214
1395
|
}
|
|
1215
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')
|
|
1405
|
+
const constraints: { table_name: string; definition: string }[] = await sql`
|
|
1406
|
+
SELECT c.relname AS table_name, pg_get_constraintdef(k.oid) AS definition
|
|
1407
|
+
FROM pg_constraint k JOIN pg_class c ON c.oid = k.conrelid
|
|
1408
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
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')`
|
|
1410
|
+
const expectedConstraints: Record<string, string[]> = {
|
|
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))'],
|
|
1423
|
+
ab_ticket_comments: [
|
|
1424
|
+
'PRIMARY KEY (repo, ticket_id, seq)',
|
|
1425
|
+
'FOREIGN KEY (repo, ticket_id) REFERENCES ab_tickets(repo, id) ON DELETE CASCADE',
|
|
1426
|
+
],
|
|
1427
|
+
ab_ticket_blockers: [
|
|
1428
|
+
'PRIMARY KEY (repo, ticket_id, blocker_id)',
|
|
1429
|
+
'FOREIGN KEY (repo, ticket_id) REFERENCES ab_tickets(repo, id) ON DELETE CASCADE',
|
|
1430
|
+
'FOREIGN KEY (repo, blocker_id) REFERENCES ab_tickets(repo, id) ON DELETE CASCADE',
|
|
1431
|
+
'CHECK ((ticket_id <> blocker_id))',
|
|
1432
|
+
],
|
|
1433
|
+
}
|
|
1434
|
+
for (const [table, definitions] of Object.entries(expectedConstraints)) {
|
|
1435
|
+
const actual = constraints
|
|
1436
|
+
.filter((row) => row.table_name === table)
|
|
1437
|
+
.map((row) => row.definition)
|
|
1438
|
+
.sort()
|
|
1439
|
+
if (JSON.stringify(actual) !== JSON.stringify(definitions.sort()))
|
|
1440
|
+
throw schemaError(`ticket table ${table} constraints are missing or mismatched`)
|
|
1441
|
+
}
|
|
1216
1442
|
}
|
|
1217
1443
|
|
|
1218
1444
|
/** The guarded streams.creation_seq upgrade, shared by every pre-v7 marker
|
|
@@ -1243,13 +1469,20 @@ const STREAMS_CREATION_SEQ_MIGRATION = `
|
|
|
1243
1469
|
END $$;
|
|
1244
1470
|
`
|
|
1245
1471
|
|
|
1246
|
-
export async function migratePostgres(
|
|
1472
|
+
export async function migratePostgres(
|
|
1473
|
+
url: string,
|
|
1474
|
+
options: {
|
|
1475
|
+
openBlobs?: () => BlobStore | Promise<BlobStore>
|
|
1476
|
+
legacyProjectEnv?: PostgresEnv
|
|
1477
|
+
} = {},
|
|
1478
|
+
): Promise<void> {
|
|
1247
1479
|
const sql = new SQL(url)
|
|
1248
1480
|
try {
|
|
1249
1481
|
await sql.begin(async (tx) => {
|
|
1250
1482
|
await tx`SELECT pg_advisory_xact_lock(470281941)`
|
|
1251
1483
|
await tx.unsafe(SCHEMA_DDL)
|
|
1252
|
-
await tx.unsafe(
|
|
1484
|
+
await tx.unsafe(TICKET_SCHEMA_V2_DDL)
|
|
1485
|
+
await tx.unsafe(TICKET_STATES_DDL)
|
|
1253
1486
|
await tx.unsafe(SNAPSHOT_SCHEMA_DDL)
|
|
1254
1487
|
await tx.unsafe(AUTH_SCHEMA_DDL)
|
|
1255
1488
|
const rows: { version: number; checksum: string }[] =
|
|
@@ -1427,6 +1660,21 @@ export async function migratePostgres(url: string): Promise<void> {
|
|
|
1427
1660
|
SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
|
|
1428
1661
|
applied_at = ${new Date().toISOString()}
|
|
1429
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`
|
|
1666
|
+
} else if (version === 11 && marker.checksum === SCHEMA_V11_CHECKSUM) {
|
|
1667
|
+
await tx`UPDATE ab_schema_migrations SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
|
|
1668
|
+
applied_at = ${new Date().toISOString()} WHERE singleton = true`
|
|
1669
|
+
} else if (version === 10 && marker.checksum === SCHEMA_V10_CHECKSUM) {
|
|
1670
|
+
await tx`UPDATE ab_schema_migrations
|
|
1671
|
+
SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
|
|
1672
|
+
applied_at = ${new Date().toISOString()} WHERE singleton = true`
|
|
1673
|
+
} else if (version === 9 && marker.checksum === SCHEMA_V9_CHECKSUM) {
|
|
1674
|
+
await tx`UPDATE ab_schema_migrations
|
|
1675
|
+
SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
|
|
1676
|
+
applied_at = ${new Date().toISOString()}
|
|
1677
|
+
WHERE singleton = true`
|
|
1430
1678
|
} else if (version === 8 && marker.checksum === SCHEMA_V8_CHECKSUM) {
|
|
1431
1679
|
// v8 → v9: the idempotent full DDL above created the ticket_assets
|
|
1432
1680
|
// table, which is the entire delta (no pre-existing table changes).
|
|
@@ -1442,10 +1690,47 @@ export async function migratePostgres(url: string): Promise<void> {
|
|
|
1442
1690
|
await tx`INSERT INTO ab_schema_migrations (singleton, version, checksum, applied_at)
|
|
1443
1691
|
VALUES (true, ${SCHEMA_VERSION}, ${SCHEMA_CHECKSUM}, ${new Date().toISOString()})`
|
|
1444
1692
|
}
|
|
1693
|
+
if (options.legacyProjectEnv !== undefined) {
|
|
1694
|
+
await new PostgresProjectRegistry(tx as SQL).initializeFromEnvInTransaction(
|
|
1695
|
+
options.legacyProjectEnv,
|
|
1696
|
+
)
|
|
1697
|
+
}
|
|
1445
1698
|
const ticketRows: { version: number; checksum: string }[] =
|
|
1446
1699
|
await tx`SELECT version, checksum FROM ab_ticket_schema_migrations WHERE singleton = true FOR UPDATE`
|
|
1447
1700
|
const ticketMarker = ticketRows[0]
|
|
1448
|
-
if (
|
|
1701
|
+
if (
|
|
1702
|
+
ticketMarker &&
|
|
1703
|
+
Number(ticketMarker.version) === 1 &&
|
|
1704
|
+
ticketMarker.checksum === TICKET_SCHEMA_V1_CHECKSUM
|
|
1705
|
+
) {
|
|
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)
|
|
1709
|
+
await tx`UPDATE ab_ticket_schema_migrations SET version = ${TICKET_SCHEMA_VERSION},
|
|
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`
|
|
1733
|
+
} else if (ticketMarker) {
|
|
1449
1734
|
if (Number(ticketMarker.version) !== TICKET_SCHEMA_VERSION) {
|
|
1450
1735
|
throw schemaError(
|
|
1451
1736
|
`ticket marker is incompatible: version ${ticketMarker.version} does not match ` +
|
|
@@ -1458,10 +1743,19 @@ export async function migratePostgres(url: string): Promise<void> {
|
|
|
1458
1743
|
)
|
|
1459
1744
|
}
|
|
1460
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)
|
|
1461
1748
|
await tx`INSERT INTO ab_ticket_schema_migrations
|
|
1462
1749
|
(singleton, version, checksum, applied_at)
|
|
1463
1750
|
VALUES (true, ${TICKET_SCHEMA_VERSION}, ${TICKET_SCHEMA_CHECKSUM}, ${new Date().toISOString()})`
|
|
1464
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`
|
|
1465
1759
|
const authRows: { version: number; checksum: string }[] =
|
|
1466
1760
|
await tx`SELECT version, checksum FROM ab_auth_schema_migrations WHERE singleton = true FOR UPDATE`
|
|
1467
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,39 @@
|
|
|
1
|
+
import { SQL } from 'bun'
|
|
2
|
+
import { normalizeProjectRepository, initializeProjectRegistryForRecovery } from './projects'
|
|
3
|
+
import { resolvePostgresUrl, type PostgresEnv } from './env'
|
|
4
|
+
|
|
5
|
+
export const TICKET_ASSIGNMENTS_DDL = `CREATE TABLE IF NOT EXISTS ticket_team_assignments (
|
|
6
|
+
seq bigserial PRIMARY KEY, team text NOT NULL, repo text NOT NULL,
|
|
7
|
+
actor text NOT NULL, ts timestamptz NOT NULL
|
|
8
|
+
)`
|
|
9
|
+
export interface TicketAssignments {
|
|
10
|
+
assign(team: string, repo: string, actor: string): Promise<void>
|
|
11
|
+
close(): Promise<void>
|
|
12
|
+
}
|
|
13
|
+
/** Deliberately independent of v2 ticket schema assertion: this is the recovery
|
|
14
|
+
* path for a migration whose ownership preflight has stopped. */
|
|
15
|
+
export async function openTicketAssignmentsFromEnv(env: PostgresEnv): Promise<TicketAssignments> {
|
|
16
|
+
const sql = new SQL(resolvePostgresUrl(env))
|
|
17
|
+
try {
|
|
18
|
+
await initializeProjectRegistryForRecovery(sql, env)
|
|
19
|
+
await sql.unsafe(TICKET_ASSIGNMENTS_DDL)
|
|
20
|
+
return {
|
|
21
|
+
async assign(team, rawRepo, actor) {
|
|
22
|
+
const repo = normalizeProjectRepository(rawRepo)
|
|
23
|
+
if (!team.trim() || !actor.trim()) throw new Error('team and actor must be nonblank')
|
|
24
|
+
await sql.begin(async (tx) => {
|
|
25
|
+
await tx`LOCK TABLE project_journal, ticket_team_assignments IN SHARE ROW EXCLUSIVE MODE`
|
|
26
|
+
const [entry] =
|
|
27
|
+
await tx`SELECT action FROM project_journal WHERE repo = ${repo} ORDER BY seq DESC LIMIT 1`
|
|
28
|
+
if (entry?.action !== 'added') throw new Error(`project not registered: ${repo}`)
|
|
29
|
+
await tx`INSERT INTO ticket_team_assignments (team, repo, actor, ts)
|
|
30
|
+
VALUES (${team}, ${repo}, ${actor.trim()}, ${new Date().toISOString()})`
|
|
31
|
+
})
|
|
32
|
+
},
|
|
33
|
+
close: () => sql.close(),
|
|
34
|
+
}
|
|
35
|
+
} catch (error) {
|
|
36
|
+
await sql.close()
|
|
37
|
+
throw error
|
|
38
|
+
}
|
|
39
|
+
}
|