@defrex/autobuild-postgres-store 0.11.0 → 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/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 = 9
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 SCHEMA_DDL = `
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 = 1
781
- export const TICKET_SCHEMA_DDL = `
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
- ['team', 'text', true],
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
- ['team', 'text', true],
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
- ['team', 'text', true],
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(url: string): Promise<void> {
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 (ticketMarker) {
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 ` +
@@ -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
+ }