@defrex/autobuild-postgres-store 0.10.1 → 0.12.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 +18 -1
- package/package.json +13 -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 +167 -10
- package/src/store.ts +20 -13
- package/src/ticket-assignments.ts +39 -0
- package/src/ticket-migration.ts +147 -0
- package/src/tickets.ts +42 -59
package/src/schema.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { PostgresProjectRegistry } from './projects'
|
|
2
|
+
import type { PostgresEnv } from './env'
|
|
3
|
+
import type { BlobStore } from '@defrex/autobuild/plugin-sdk'
|
|
4
|
+
import { migrateLegacyTickets } from './ticket-migration'
|
|
1
5
|
import { SQL } from 'bun'
|
|
2
6
|
import {
|
|
3
7
|
AUTH_SCHEMA_CHECKSUM,
|
|
@@ -8,7 +12,7 @@ import {
|
|
|
8
12
|
assertAuthSchema,
|
|
9
13
|
} from './auth-schema'
|
|
10
14
|
|
|
11
|
-
export const SCHEMA_VERSION =
|
|
15
|
+
export const SCHEMA_VERSION = 12
|
|
12
16
|
export const MIGRATE_COMMAND = 'bun run postgres:migrate (from a pinned Autobuild release checkout)'
|
|
13
17
|
|
|
14
18
|
/** The frozen v3 DDL, kept verbatim so a deployed v3 marker's checksum can be
|
|
@@ -666,7 +670,7 @@ CREATE INDEX IF NOT EXISTS events_type_build_seq ON events (type, build, seq);
|
|
|
666
670
|
CREATE INDEX IF NOT EXISTS repo_events_type_repo_seq ON repo_events (repo, type, seq);`.trim()
|
|
667
671
|
export const SCHEMA_V8_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_V8_DDL).digest('hex')
|
|
668
672
|
|
|
669
|
-
export const
|
|
673
|
+
export const SCHEMA_V9_DDL = `
|
|
670
674
|
CREATE TABLE IF NOT EXISTS ab_schema_migrations (
|
|
671
675
|
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
672
676
|
version integer NOT NULL,
|
|
@@ -773,12 +777,53 @@ CREATE TABLE IF NOT EXISTS ticket_assets (
|
|
|
773
777
|
PRIMARY KEY (repo, ticket_id, kind, name, revision)
|
|
774
778
|
);`.trim()
|
|
775
779
|
|
|
780
|
+
export const SCHEMA_V9_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_V9_DDL).digest('hex')
|
|
781
|
+
|
|
782
|
+
export const PROJECT_SCHEMA_DDL = `
|
|
783
|
+
CREATE SEQUENCE IF NOT EXISTS project_journal_seq;
|
|
784
|
+
CREATE TABLE IF NOT EXISTS project_journal (
|
|
785
|
+
seq bigint PRIMARY KEY, repo text NOT NULL,
|
|
786
|
+
action text NOT NULL CHECK (action IN ('added','removed')),
|
|
787
|
+
actor text NOT NULL CHECK (length(btrim(actor)) > 0),
|
|
788
|
+
ts timestamptz NOT NULL
|
|
789
|
+
);
|
|
790
|
+
CREATE TABLE IF NOT EXISTS project_registry_state (
|
|
791
|
+
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
792
|
+
legacy_imported boolean NOT NULL
|
|
793
|
+
);`.trim()
|
|
794
|
+
export const SCHEMA_V10_DDL = `${SCHEMA_V9_DDL}\n${PROJECT_SCHEMA_DDL}`
|
|
795
|
+
export const SCHEMA_V10_CHECKSUM = new Bun.CryptoHasher('sha256')
|
|
796
|
+
.update(SCHEMA_V10_DDL)
|
|
797
|
+
.digest('hex')
|
|
798
|
+
export const CREDENTIAL_SCHEMA_DDL = `
|
|
799
|
+
CREATE TABLE IF NOT EXISTS project_credentials (
|
|
800
|
+
id text PRIMARY KEY, digest text NOT NULL, repo text NOT NULL,
|
|
801
|
+
created_at timestamptz NOT NULL, created_by text NOT NULL,
|
|
802
|
+
last_used_at timestamptz, revoked_at timestamptz, expires_at timestamptz,
|
|
803
|
+
purpose text NOT NULL CHECK (purpose IN ('project-owner','dispatcher'))
|
|
804
|
+
);`.trim()
|
|
805
|
+
export const SCHEMA_V11_DDL = `${SCHEMA_V10_DDL}\n${CREDENTIAL_SCHEMA_DDL}`
|
|
806
|
+
export const SCHEMA_V11_CHECKSUM = new Bun.CryptoHasher('sha256')
|
|
807
|
+
.update(SCHEMA_V11_DDL)
|
|
808
|
+
.digest('hex')
|
|
809
|
+
export const CONNECTION_SCHEMA_DDL = `
|
|
810
|
+
CREATE TABLE IF NOT EXISTS project_connections (
|
|
811
|
+
id text PRIMARY KEY, repo text NOT NULL, credential_id text NOT NULL,
|
|
812
|
+
digest text NOT NULL, capability_hash text NOT NULL, code text NOT NULL UNIQUE,
|
|
813
|
+
created_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
|
|
814
|
+
status text NOT NULL CHECK (status IN ('pending','approved','denied','expired')),
|
|
815
|
+
approved_by text, polled_at timestamptz, poll_attempts integer NOT NULL DEFAULT 0
|
|
816
|
+
);
|
|
817
|
+
CREATE TABLE IF NOT EXISTS connection_lookup_limits (
|
|
818
|
+
actor text PRIMARY KEY, started_at timestamptz NOT NULL, attempts integer NOT NULL
|
|
819
|
+
);`.trim()
|
|
820
|
+
export const SCHEMA_DDL = `${SCHEMA_V11_DDL}\n${CONNECTION_SCHEMA_DDL}`
|
|
776
821
|
export const SCHEMA_CHECKSUM = new Bun.CryptoHasher('sha256').update(SCHEMA_DDL).digest('hex')
|
|
777
822
|
|
|
778
823
|
/** Ticket persistence evolves independently so deployed v1 BuildStore markers
|
|
779
824
|
* remain valid while the hosted ticket tables are added. */
|
|
780
|
-
export const TICKET_SCHEMA_VERSION =
|
|
781
|
-
export const
|
|
825
|
+
export const TICKET_SCHEMA_VERSION = 2
|
|
826
|
+
export const TICKET_SCHEMA_V1_DDL = `
|
|
782
827
|
CREATE TABLE IF NOT EXISTS ab_ticket_schema_migrations (
|
|
783
828
|
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
|
784
829
|
version integer NOT NULL, checksum text NOT NULL, applied_at timestamptz NOT NULL
|
|
@@ -803,6 +848,10 @@ CREATE TABLE IF NOT EXISTS ab_ticket_blockers (
|
|
|
803
848
|
FOREIGN KEY (team, blocker_id) REFERENCES ab_tickets(team, id) ON DELETE CASCADE,
|
|
804
849
|
CHECK (ticket_id <> blocker_id)
|
|
805
850
|
);`.trim()
|
|
851
|
+
export const TICKET_SCHEMA_V1_CHECKSUM = new Bun.CryptoHasher('sha256')
|
|
852
|
+
.update(TICKET_SCHEMA_V1_DDL)
|
|
853
|
+
.digest('hex')
|
|
854
|
+
export const TICKET_SCHEMA_DDL = TICKET_SCHEMA_V1_DDL.replaceAll('team', 'repo')
|
|
806
855
|
export const TICKET_SCHEMA_CHECKSUM = new Bun.CryptoHasher('sha256')
|
|
807
856
|
.update(TICKET_SCHEMA_DDL)
|
|
808
857
|
.digest('hex')
|
|
@@ -825,6 +874,47 @@ CREATE TABLE IF NOT EXISTS reducer_snapshots (
|
|
|
825
874
|
type ExpectedColumn = readonly [name: string, type: string, notNull: boolean, defaultValue?: string]
|
|
826
875
|
|
|
827
876
|
const EXPECTED_COLUMNS: Record<string, readonly ExpectedColumn[]> = {
|
|
877
|
+
project_connections: [
|
|
878
|
+
['id', 'text', true],
|
|
879
|
+
['repo', 'text', true],
|
|
880
|
+
['credential_id', 'text', true],
|
|
881
|
+
['digest', 'text', true],
|
|
882
|
+
['capability_hash', 'text', true],
|
|
883
|
+
['code', 'text', true],
|
|
884
|
+
['created_at', 'timestamp with time zone', true],
|
|
885
|
+
['expires_at', 'timestamp with time zone', true],
|
|
886
|
+
['status', 'text', true],
|
|
887
|
+
['approved_by', 'text', false],
|
|
888
|
+
['polled_at', 'timestamp with time zone', false],
|
|
889
|
+
['poll_attempts', 'integer', true, '0'],
|
|
890
|
+
],
|
|
891
|
+
connection_lookup_limits: [
|
|
892
|
+
['actor', 'text', true],
|
|
893
|
+
['started_at', 'timestamp with time zone', true],
|
|
894
|
+
['attempts', 'integer', true],
|
|
895
|
+
],
|
|
896
|
+
project_credentials: [
|
|
897
|
+
['id', 'text', true],
|
|
898
|
+
['digest', 'text', true],
|
|
899
|
+
['repo', 'text', true],
|
|
900
|
+
['created_at', 'timestamp with time zone', true],
|
|
901
|
+
['created_by', 'text', true],
|
|
902
|
+
['last_used_at', 'timestamp with time zone', false],
|
|
903
|
+
['revoked_at', 'timestamp with time zone', false],
|
|
904
|
+
['expires_at', 'timestamp with time zone', false],
|
|
905
|
+
['purpose', 'text', true],
|
|
906
|
+
],
|
|
907
|
+
project_journal: [
|
|
908
|
+
['seq', 'bigint', true],
|
|
909
|
+
['repo', 'text', true],
|
|
910
|
+
['action', 'text', true],
|
|
911
|
+
['actor', 'text', true],
|
|
912
|
+
['ts', 'timestamp with time zone', true],
|
|
913
|
+
],
|
|
914
|
+
project_registry_state: [
|
|
915
|
+
['singleton', 'boolean', true, 'true'],
|
|
916
|
+
['legacy_imported', 'boolean', true],
|
|
917
|
+
],
|
|
828
918
|
ab_schema_migrations: [
|
|
829
919
|
['singleton', 'boolean', true, 'true'],
|
|
830
920
|
['version', 'integer', true],
|
|
@@ -958,6 +1048,16 @@ const EXPECTED_COLUMNS: Record<string, readonly ExpectedColumn[]> = {
|
|
|
958
1048
|
}
|
|
959
1049
|
|
|
960
1050
|
const EXPECTED_CONSTRAINTS = [
|
|
1051
|
+
'project_connections|p|id||||',
|
|
1052
|
+
'project_connections|c|||||',
|
|
1053
|
+
'connection_lookup_limits|p|actor||||',
|
|
1054
|
+
'project_credentials|p|id||||',
|
|
1055
|
+
'project_credentials|c|||||',
|
|
1056
|
+
'project_journal|p|seq||||',
|
|
1057
|
+
'project_journal|c|||||',
|
|
1058
|
+
'project_journal|c|||||',
|
|
1059
|
+
'project_registry_state|p|singleton||||',
|
|
1060
|
+
'project_registry_state|c|||||',
|
|
961
1061
|
// CHECK constraints are compared by presence only (their pg_get_expr text
|
|
962
1062
|
// is version-sensitive); PK/FK rows compare fully.
|
|
963
1063
|
'ab_schema_migrations|c|||||',
|
|
@@ -1112,7 +1212,7 @@ async function assertCatalogShape(sql: SQL): Promise<void> {
|
|
|
1112
1212
|
].join('|'),
|
|
1113
1213
|
)
|
|
1114
1214
|
.sort()
|
|
1115
|
-
if (JSON.stringify(actualConstraints) !== JSON.stringify(EXPECTED_CONSTRAINTS)) {
|
|
1215
|
+
if (JSON.stringify(actualConstraints) !== JSON.stringify([...EXPECTED_CONSTRAINTS].sort())) {
|
|
1116
1216
|
throw schemaError('constraints are missing or mismatched')
|
|
1117
1217
|
}
|
|
1118
1218
|
}
|
|
@@ -1170,7 +1270,7 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
|
|
|
1170
1270
|
['applied_at', 'timestamp with time zone', true],
|
|
1171
1271
|
],
|
|
1172
1272
|
ab_tickets: [
|
|
1173
|
-
['
|
|
1273
|
+
['repo', 'text', true],
|
|
1174
1274
|
['id', 'text', true],
|
|
1175
1275
|
['creation_key', 'text', false],
|
|
1176
1276
|
['title', 'text', true],
|
|
@@ -1181,14 +1281,14 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
|
|
|
1181
1281
|
['updated_at', 'timestamp with time zone', true],
|
|
1182
1282
|
],
|
|
1183
1283
|
ab_ticket_comments: [
|
|
1184
|
-
['
|
|
1284
|
+
['repo', 'text', true],
|
|
1185
1285
|
['ticket_id', 'text', true],
|
|
1186
1286
|
['seq', 'bigint', true],
|
|
1187
1287
|
['body', 'text', true],
|
|
1188
1288
|
['created_at', 'timestamp with time zone', true],
|
|
1189
1289
|
],
|
|
1190
1290
|
ab_ticket_blockers: [
|
|
1191
|
-
['
|
|
1291
|
+
['repo', 'text', true],
|
|
1192
1292
|
['ticket_id', 'text', true],
|
|
1193
1293
|
['blocker_id', 'text', true],
|
|
1194
1294
|
],
|
|
@@ -1213,6 +1313,32 @@ export async function assertTicketSchema(sql: SQL): Promise<void> {
|
|
|
1213
1313
|
throw schemaError(`ticket table ${table} is missing or mismatched`)
|
|
1214
1314
|
}
|
|
1215
1315
|
}
|
|
1316
|
+
const constraints: { table_name: string; definition: string }[] = await sql`
|
|
1317
|
+
SELECT c.relname AS table_name, pg_get_constraintdef(k.oid) AS definition
|
|
1318
|
+
FROM pg_constraint k JOIN pg_class c ON c.oid = k.conrelid
|
|
1319
|
+
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')`
|
|
1321
|
+
const expectedConstraints: Record<string, string[]> = {
|
|
1322
|
+
ab_tickets: ['PRIMARY KEY (repo, id)', 'UNIQUE (repo, creation_key)'],
|
|
1323
|
+
ab_ticket_comments: [
|
|
1324
|
+
'PRIMARY KEY (repo, ticket_id, seq)',
|
|
1325
|
+
'FOREIGN KEY (repo, ticket_id) REFERENCES ab_tickets(repo, id) ON DELETE CASCADE',
|
|
1326
|
+
],
|
|
1327
|
+
ab_ticket_blockers: [
|
|
1328
|
+
'PRIMARY KEY (repo, ticket_id, blocker_id)',
|
|
1329
|
+
'FOREIGN KEY (repo, ticket_id) REFERENCES ab_tickets(repo, id) ON DELETE CASCADE',
|
|
1330
|
+
'FOREIGN KEY (repo, blocker_id) REFERENCES ab_tickets(repo, id) ON DELETE CASCADE',
|
|
1331
|
+
'CHECK ((ticket_id <> blocker_id))',
|
|
1332
|
+
],
|
|
1333
|
+
}
|
|
1334
|
+
for (const [table, definitions] of Object.entries(expectedConstraints)) {
|
|
1335
|
+
const actual = constraints
|
|
1336
|
+
.filter((row) => row.table_name === table)
|
|
1337
|
+
.map((row) => row.definition)
|
|
1338
|
+
.sort()
|
|
1339
|
+
if (JSON.stringify(actual) !== JSON.stringify(definitions.sort()))
|
|
1340
|
+
throw schemaError(`ticket table ${table} constraints are missing or mismatched`)
|
|
1341
|
+
}
|
|
1216
1342
|
}
|
|
1217
1343
|
|
|
1218
1344
|
/** The guarded streams.creation_seq upgrade, shared by every pre-v7 marker
|
|
@@ -1243,7 +1369,13 @@ const STREAMS_CREATION_SEQ_MIGRATION = `
|
|
|
1243
1369
|
END $$;
|
|
1244
1370
|
`
|
|
1245
1371
|
|
|
1246
|
-
export async function migratePostgres(
|
|
1372
|
+
export async function migratePostgres(
|
|
1373
|
+
url: string,
|
|
1374
|
+
options: {
|
|
1375
|
+
openBlobs?: () => BlobStore | Promise<BlobStore>
|
|
1376
|
+
legacyProjectEnv?: PostgresEnv
|
|
1377
|
+
} = {},
|
|
1378
|
+
): Promise<void> {
|
|
1247
1379
|
const sql = new SQL(url)
|
|
1248
1380
|
try {
|
|
1249
1381
|
await sql.begin(async (tx) => {
|
|
@@ -1427,6 +1559,18 @@ export async function migratePostgres(url: string): Promise<void> {
|
|
|
1427
1559
|
SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
|
|
1428
1560
|
applied_at = ${new Date().toISOString()}
|
|
1429
1561
|
WHERE singleton = true`
|
|
1562
|
+
} else if (version === 11 && marker.checksum === SCHEMA_V11_CHECKSUM) {
|
|
1563
|
+
await tx`UPDATE ab_schema_migrations SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
|
|
1564
|
+
applied_at = ${new Date().toISOString()} WHERE singleton = true`
|
|
1565
|
+
} else if (version === 10 && marker.checksum === SCHEMA_V10_CHECKSUM) {
|
|
1566
|
+
await tx`UPDATE ab_schema_migrations
|
|
1567
|
+
SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
|
|
1568
|
+
applied_at = ${new Date().toISOString()} WHERE singleton = true`
|
|
1569
|
+
} else if (version === 9 && marker.checksum === SCHEMA_V9_CHECKSUM) {
|
|
1570
|
+
await tx`UPDATE ab_schema_migrations
|
|
1571
|
+
SET version = ${SCHEMA_VERSION}, checksum = ${SCHEMA_CHECKSUM},
|
|
1572
|
+
applied_at = ${new Date().toISOString()}
|
|
1573
|
+
WHERE singleton = true`
|
|
1430
1574
|
} else if (version === 8 && marker.checksum === SCHEMA_V8_CHECKSUM) {
|
|
1431
1575
|
// v8 → v9: the idempotent full DDL above created the ticket_assets
|
|
1432
1576
|
// table, which is the entire delta (no pre-existing table changes).
|
|
@@ -1442,10 +1586,23 @@ export async function migratePostgres(url: string): Promise<void> {
|
|
|
1442
1586
|
await tx`INSERT INTO ab_schema_migrations (singleton, version, checksum, applied_at)
|
|
1443
1587
|
VALUES (true, ${SCHEMA_VERSION}, ${SCHEMA_CHECKSUM}, ${new Date().toISOString()})`
|
|
1444
1588
|
}
|
|
1589
|
+
if (options.legacyProjectEnv !== undefined) {
|
|
1590
|
+
await new PostgresProjectRegistry(tx as SQL).initializeFromEnvInTransaction(
|
|
1591
|
+
options.legacyProjectEnv,
|
|
1592
|
+
)
|
|
1593
|
+
}
|
|
1445
1594
|
const ticketRows: { version: number; checksum: string }[] =
|
|
1446
1595
|
await tx`SELECT version, checksum FROM ab_ticket_schema_migrations WHERE singleton = true FOR UPDATE`
|
|
1447
1596
|
const ticketMarker = ticketRows[0]
|
|
1448
|
-
if (
|
|
1597
|
+
if (
|
|
1598
|
+
ticketMarker &&
|
|
1599
|
+
Number(ticketMarker.version) === 1 &&
|
|
1600
|
+
ticketMarker.checksum === TICKET_SCHEMA_V1_CHECKSUM
|
|
1601
|
+
) {
|
|
1602
|
+
await migrateLegacyTickets(tx as SQL, options.openBlobs)
|
|
1603
|
+
await tx`UPDATE ab_ticket_schema_migrations SET version = ${TICKET_SCHEMA_VERSION},
|
|
1604
|
+
checksum = ${TICKET_SCHEMA_CHECKSUM}, applied_at = ${new Date().toISOString()} WHERE singleton = true`
|
|
1605
|
+
} else if (ticketMarker) {
|
|
1449
1606
|
if (Number(ticketMarker.version) !== TICKET_SCHEMA_VERSION) {
|
|
1450
1607
|
throw schemaError(
|
|
1451
1608
|
`ticket marker is incompatible: version ${ticketMarker.version} does not match ` +
|
package/src/store.ts
CHANGED
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
DIGEST_EVENT_TYPES,
|
|
78
78
|
type DigestEventRow,
|
|
79
79
|
REPOSITORY_STATE_EVENT_TYPES,
|
|
80
|
+
REPOSITORY_LATEST_EVENT_TYPES,
|
|
80
81
|
readRepoStateEventsWithAnchorRecheck,
|
|
81
82
|
reduceBuildDigest,
|
|
82
83
|
} from '@defrex/autobuild/store-adapter'
|
|
@@ -1091,10 +1092,6 @@ export class PostgresBuildStore implements BuildStore {
|
|
|
1091
1092
|
// select; an append after the final re-probe is an ordinary post-read
|
|
1092
1093
|
// concurrent append, not this race.
|
|
1093
1094
|
const durableList = [...REPOSITORY_STATE_EVENT_TYPES]
|
|
1094
|
-
// `repo` is $1; the anchored select also spends $2 on the anchor, so the
|
|
1095
|
-
// type list's placeholders start at the right offset per branch.
|
|
1096
|
-
const placeholders = (start: number) =>
|
|
1097
|
-
durableList.map((_, index) => `$${index + start}`).join(', ')
|
|
1098
1095
|
return readRepoStateEventsWithAnchorRecheck(
|
|
1099
1096
|
async () => {
|
|
1100
1097
|
// The probe below is an aggregate: MAX() always returns exactly one row —
|
|
@@ -1115,16 +1112,26 @@ export class PostgresBuildStore implements BuildStore {
|
|
|
1115
1112
|
return probe?.no_anchor ? undefined : num(probe?.seq)
|
|
1116
1113
|
},
|
|
1117
1114
|
async (anchor) => {
|
|
1118
|
-
const
|
|
1115
|
+
const bindings: (string | number)[] = [repo]
|
|
1116
|
+
const bind = (value: string | number) => {
|
|
1117
|
+
bindings.push(value)
|
|
1118
|
+
return `$${bindings.length}`
|
|
1119
|
+
}
|
|
1120
|
+
const durable = `type IN (${durableList.map(bind).join(', ')})`
|
|
1121
|
+
const latestTypes = REPOSITORY_LATEST_EVENT_TYPES.map(bind)
|
|
1122
|
+
const latest = latestTypes.map(
|
|
1123
|
+
(type) =>
|
|
1124
|
+
`(type = ${type} AND seq = (SELECT seq FROM repo_events WHERE repo = $1 AND type = ${type} ORDER BY seq DESC LIMIT 1))`,
|
|
1125
|
+
)
|
|
1126
|
+
const tail =
|
|
1119
1127
|
anchor === undefined
|
|
1120
|
-
?
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
),
|
|
1128
|
+
? []
|
|
1129
|
+
: [`(seq >= ${bind(anchor)} AND type NOT IN (${latestTypes.join(', ')}))`]
|
|
1130
|
+
const rows: Row[] = await this.run((q) =>
|
|
1131
|
+
q.unsafe(
|
|
1132
|
+
`SELECT * FROM repo_events WHERE repo = $1 AND (${[durable, ...tail, ...latest].join(' OR ')}) ORDER BY seq`,
|
|
1133
|
+
bindings,
|
|
1134
|
+
),
|
|
1128
1135
|
)
|
|
1129
1136
|
return rows.map((row) => ({
|
|
1130
1137
|
repo: String(row.repo),
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { SQL } from 'bun'
|
|
2
|
+
import type { BlobStore, RepositoryEvent } from '@defrex/autobuild/plugin-sdk'
|
|
3
|
+
import { findEffectiveConfigRef, decodeEffectiveConfig } from '@defrex/autobuild/operator'
|
|
4
|
+
import { TICKET_ASSIGNMENTS_DDL } from './ticket-assignments'
|
|
5
|
+
|
|
6
|
+
/** Called only inside the schema migration transaction. Locks preserve the
|
|
7
|
+
* registry/config/ownership snapshot across immutable blob reads and rekeying. */
|
|
8
|
+
export async function migrateLegacyTickets(
|
|
9
|
+
sql: SQL,
|
|
10
|
+
openBlobs?: () => BlobStore | Promise<BlobStore>,
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
await sql.unsafe(TICKET_ASSIGNMENTS_DDL)
|
|
13
|
+
await sql`LOCK TABLE project_journal, repo_streams, repo_events, repo_artifacts,
|
|
14
|
+
ticket_team_assignments, ab_tickets, ab_ticket_comments, ab_ticket_blockers,
|
|
15
|
+
ticket_assets IN SHARE ROW EXCLUSIVE MODE`
|
|
16
|
+
const teams: { team: string }[] = await sql`SELECT team FROM ab_tickets UNION
|
|
17
|
+
SELECT team FROM ab_ticket_comments UNION SELECT team FROM ab_ticket_blockers`
|
|
18
|
+
const assignments = new Map<string, string>()
|
|
19
|
+
if (teams.length > 0) {
|
|
20
|
+
if (!openBlobs)
|
|
21
|
+
throw new Error(
|
|
22
|
+
'Populated v1 ticket migration requires blob access; configure the service blob backend, credentials, access and prefix and rerun the environment-aware migration command.',
|
|
23
|
+
)
|
|
24
|
+
let blobs: BlobStore
|
|
25
|
+
try {
|
|
26
|
+
blobs = await openBlobs()
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'Ticket migration cannot open blob storage; configure the service blob backend, credentials, access and prefix.',
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
const projects: { repo: string }[] = await sql`SELECT repo FROM (
|
|
33
|
+
SELECT DISTINCT ON (repo) repo, action FROM project_journal ORDER BY repo, seq DESC
|
|
34
|
+
) latest WHERE action = 'added' ORDER BY repo`
|
|
35
|
+
const overrides: { team: string; repo: string }[] =
|
|
36
|
+
await sql`SELECT DISTINCT ON (team) team, repo FROM ticket_team_assignments ORDER BY team, seq DESC`
|
|
37
|
+
const remaining = teams.filter(({ team }) => !overrides.some((item) => item.team === team))
|
|
38
|
+
const configFailure = (detail: string) =>
|
|
39
|
+
new Error(
|
|
40
|
+
`Ticket migration stopped unchanged: ${detail}. Restore the selected effective configuration/blob, or explicitly assign every remaining legacy team (${remaining.map(({ team }) => team).join(', ')}) with ab-hosted-store tickets assign-team TEAM REPOSITORY --user IDENTITY, then rerun migration.`,
|
|
41
|
+
)
|
|
42
|
+
const claimants = new Map<string, string[]>()
|
|
43
|
+
for (const { repo } of remaining.length > 0 ? projects : []) {
|
|
44
|
+
const rows: { seq: number; ts: string; actor: unknown; type: string; payload: unknown }[] =
|
|
45
|
+
await sql`SELECT seq, ts, actor, type, payload FROM repo_events WHERE repo = ${repo} ORDER BY seq`
|
|
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
|
+
}
|
|
79
|
+
const team = config.tickets.teamKey
|
|
80
|
+
if (team !== undefined) claimants.set(team, [...(claimants.get(team) ?? []), repo])
|
|
81
|
+
}
|
|
82
|
+
const errors: string[] = []
|
|
83
|
+
for (const { team } of teams) {
|
|
84
|
+
const override = overrides.find((item) => item.team === team)
|
|
85
|
+
const candidates = claimants.get(team) ?? []
|
|
86
|
+
if (override && projects.some((project) => project.repo === override.repo))
|
|
87
|
+
assignments.set(team, override.repo)
|
|
88
|
+
else if (!override && candidates.length === 1) assignments.set(team, candidates[0]!)
|
|
89
|
+
else
|
|
90
|
+
errors.push(
|
|
91
|
+
`team "${team}": ${override ? `assignment project is not registered: ${override.repo}` : candidates.length ? `conflicting projects: ${candidates.join(', ')}` : 'no project claims this team'}`,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
if (errors.length)
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Ticket migration stopped unchanged: ${errors.join('; ')}. Resolve ownership with ab-hosted-store tickets assign-team TEAM REPOSITORY --user IDENTITY, then rerun migration.`,
|
|
97
|
+
)
|
|
98
|
+
const assets: { repo: string; ticket_id: string; team: string }[] =
|
|
99
|
+
await sql`SELECT DISTINCT a.repo, a.ticket_id, t.team FROM ticket_assets a JOIN ab_tickets t ON t.id = a.ticket_id`
|
|
100
|
+
for (const asset of assets) {
|
|
101
|
+
if (assignments.get(asset.team) !== asset.repo)
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Ticket migration: asset ownership mismatch for ${asset.ticket_id}, team ${asset.team}, project ${asset.repo}`,
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
// Reject collapsing distinct old teams onto a duplicate project id/key.
|
|
107
|
+
const tickets: { team: string; id: string; creation_key: string | null }[] =
|
|
108
|
+
await sql`SELECT team, id, creation_key FROM ab_tickets`
|
|
109
|
+
const ids = new Set<string>(),
|
|
110
|
+
keys = new Set<string>()
|
|
111
|
+
for (const ticket of tickets) {
|
|
112
|
+
const repo = assignments.get(ticket.team)!
|
|
113
|
+
const id = JSON.stringify([repo, ticket.id]),
|
|
114
|
+
key = JSON.stringify([repo, ticket.creation_key])
|
|
115
|
+
if (ids.has(id) || (ticket.creation_key !== null && keys.has(key)))
|
|
116
|
+
throw new Error(`Ticket migration: colliding ticket id or creation key in ${repo}`)
|
|
117
|
+
ids.add(id)
|
|
118
|
+
if (ticket.creation_key !== null) keys.add(key)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Drop FKs during the all-at-once partition rewrite; PKs/unique indexes stay
|
|
122
|
+
// live. Temporary values avoid collisions with legacy team strings.
|
|
123
|
+
await sql.unsafe(`ALTER TABLE ab_ticket_comments DROP CONSTRAINT ab_ticket_comments_team_ticket_id_fkey;
|
|
124
|
+
ALTER TABLE ab_ticket_blockers DROP CONSTRAINT ab_ticket_blockers_team_ticket_id_fkey;
|
|
125
|
+
ALTER TABLE ab_ticket_blockers DROP CONSTRAINT ab_ticket_blockers_team_blocker_id_fkey;`)
|
|
126
|
+
const staged = [...assignments].map(([team, repo]) => ({
|
|
127
|
+
team,
|
|
128
|
+
repo,
|
|
129
|
+
temporary: `migration-${crypto.randomUUID()}`,
|
|
130
|
+
}))
|
|
131
|
+
for (const item of staged) {
|
|
132
|
+
await sql`UPDATE ab_tickets SET team = ${item.temporary} WHERE team = ${item.team}`
|
|
133
|
+
await sql`UPDATE ab_ticket_comments SET team = ${item.temporary} WHERE team = ${item.team}`
|
|
134
|
+
await sql`UPDATE ab_ticket_blockers SET team = ${item.temporary} WHERE team = ${item.team}`
|
|
135
|
+
}
|
|
136
|
+
for (const item of staged) {
|
|
137
|
+
await sql`UPDATE ab_tickets SET team = ${item.repo} WHERE team = ${item.temporary}`
|
|
138
|
+
await sql`UPDATE ab_ticket_comments SET team = ${item.repo} WHERE team = ${item.temporary}`
|
|
139
|
+
await sql`UPDATE ab_ticket_blockers SET team = ${item.repo} WHERE team = ${item.temporary}`
|
|
140
|
+
}
|
|
141
|
+
await sql.unsafe(`ALTER TABLE ab_tickets RENAME COLUMN team TO repo;
|
|
142
|
+
ALTER TABLE ab_ticket_comments RENAME COLUMN team TO repo;
|
|
143
|
+
ALTER TABLE ab_ticket_blockers RENAME COLUMN team TO repo;
|
|
144
|
+
ALTER TABLE ab_ticket_comments ADD FOREIGN KEY (repo,ticket_id) REFERENCES ab_tickets(repo,id) ON DELETE CASCADE;
|
|
145
|
+
ALTER TABLE ab_ticket_blockers ADD FOREIGN KEY (repo,ticket_id) REFERENCES ab_tickets(repo,id) ON DELETE CASCADE;
|
|
146
|
+
ALTER TABLE ab_ticket_blockers ADD FOREIGN KEY (repo,blocker_id) REFERENCES ab_tickets(repo,id) ON DELETE CASCADE;`)
|
|
147
|
+
}
|