@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.
@@ -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
+ }
@@ -0,0 +1,208 @@
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
+ import { PostgresTicketPolicyStore } from './ticket-policy'
6
+
7
+ /** Called only inside the schema migration transaction. Locks preserve the
8
+ * registry/config/ownership snapshot across immutable blob reads and rekeying. */
9
+ export async function migrateLegacyTickets(
10
+ sql: SQL,
11
+ openBlobs?: () => BlobStore | Promise<BlobStore>,
12
+ ): Promise<void> {
13
+ await sql.unsafe(TICKET_ASSIGNMENTS_DDL)
14
+ await sql`LOCK TABLE project_journal, repo_streams, repo_events, repo_artifacts,
15
+ ticket_team_assignments, ab_tickets, ab_ticket_comments, ab_ticket_blockers,
16
+ ticket_assets IN SHARE ROW EXCLUSIVE MODE`
17
+ const teams: { team: string }[] = await sql`SELECT team FROM ab_tickets UNION
18
+ SELECT team FROM ab_ticket_comments UNION SELECT team FROM ab_ticket_blockers`
19
+ const assignments = new Map<string, string>()
20
+ if (teams.length > 0) {
21
+ if (!openBlobs)
22
+ throw new Error(
23
+ 'Populated v1 ticket migration requires blob access; configure the service blob backend, credentials, access and prefix and rerun the environment-aware migration command.',
24
+ )
25
+ let blobs: BlobStore
26
+ try {
27
+ blobs = await openBlobs()
28
+ } catch {
29
+ throw new Error(
30
+ 'Ticket migration cannot open blob storage; configure the service blob backend, credentials, access and prefix.',
31
+ )
32
+ }
33
+ const projects: { repo: string }[] = await sql`SELECT repo FROM (
34
+ SELECT DISTINCT ON (repo) repo, action FROM project_journal ORDER BY repo, seq DESC
35
+ ) latest WHERE action = 'added' ORDER BY repo`
36
+ const overrides: { team: string; repo: string }[] =
37
+ await sql`SELECT DISTINCT ON (team) team, repo FROM ticket_team_assignments ORDER BY team, seq DESC`
38
+ const remaining = teams.filter(({ team }) => !overrides.some((item) => item.team === team))
39
+ const configFailure = (detail: string) =>
40
+ new Error(
41
+ `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.`,
42
+ )
43
+ const claimants = new Map<string, string[]>()
44
+ for (const { repo } of remaining.length > 0 ? projects : []) {
45
+ const config = await loadTicketConfig(sql, blobs, repo, configFailure)
46
+ if (!config) continue
47
+ const team = config.tickets.teamKey
48
+ if (team !== undefined) claimants.set(team, [...(claimants.get(team) ?? []), repo])
49
+ }
50
+ const errors: string[] = []
51
+ for (const { team } of teams) {
52
+ const override = overrides.find((item) => item.team === team)
53
+ const candidates = claimants.get(team) ?? []
54
+ if (override && projects.some((project) => project.repo === override.repo))
55
+ assignments.set(team, override.repo)
56
+ else if (!override && candidates.length === 1) assignments.set(team, candidates[0]!)
57
+ else
58
+ errors.push(
59
+ `team "${team}": ${override ? `assignment project is not registered: ${override.repo}` : candidates.length ? `conflicting projects: ${candidates.join(', ')}` : 'no project claims this team'}`,
60
+ )
61
+ }
62
+ if (errors.length)
63
+ throw new Error(
64
+ `Ticket migration stopped unchanged: ${errors.join('; ')}. Resolve ownership with ab-hosted-store tickets assign-team TEAM REPOSITORY --user IDENTITY, then rerun migration.`,
65
+ )
66
+ const assets: { repo: string; ticket_id: string; team: string }[] =
67
+ 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`
68
+ for (const asset of assets) {
69
+ if (assignments.get(asset.team) !== asset.repo)
70
+ throw new Error(
71
+ `Ticket migration: asset ownership mismatch for ${asset.ticket_id}, team ${asset.team}, project ${asset.repo}`,
72
+ )
73
+ }
74
+ // Reject collapsing distinct old teams onto a duplicate project id/key.
75
+ const tickets: { team: string; id: string; creation_key: string | null }[] =
76
+ await sql`SELECT team, id, creation_key FROM ab_tickets`
77
+ const ids = new Set<string>(),
78
+ keys = new Set<string>()
79
+ for (const ticket of tickets) {
80
+ const repo = assignments.get(ticket.team)!
81
+ const id = JSON.stringify([repo, ticket.id]),
82
+ key = JSON.stringify([repo, ticket.creation_key])
83
+ if (ids.has(id) || (ticket.creation_key !== null && keys.has(key)))
84
+ throw new Error(`Ticket migration: colliding ticket id or creation key in ${repo}`)
85
+ ids.add(id)
86
+ if (ticket.creation_key !== null) keys.add(key)
87
+ }
88
+ }
89
+ // Drop FKs during the all-at-once partition rewrite; PKs/unique indexes stay
90
+ // live. Temporary values avoid collisions with legacy team strings.
91
+ await sql.unsafe(`ALTER TABLE ab_ticket_comments DROP CONSTRAINT ab_ticket_comments_team_ticket_id_fkey;
92
+ ALTER TABLE ab_ticket_blockers DROP CONSTRAINT ab_ticket_blockers_team_ticket_id_fkey;
93
+ ALTER TABLE ab_ticket_blockers DROP CONSTRAINT ab_ticket_blockers_team_blocker_id_fkey;`)
94
+ const staged = [...assignments].map(([team, repo]) => ({
95
+ team,
96
+ repo,
97
+ temporary: `migration-${crypto.randomUUID()}`,
98
+ }))
99
+ for (const item of staged) {
100
+ await sql`UPDATE ab_tickets SET team = ${item.temporary} WHERE team = ${item.team}`
101
+ await sql`UPDATE ab_ticket_comments SET team = ${item.temporary} WHERE team = ${item.team}`
102
+ await sql`UPDATE ab_ticket_blockers SET team = ${item.temporary} WHERE team = ${item.team}`
103
+ }
104
+ for (const item of staged) {
105
+ await sql`UPDATE ab_tickets SET team = ${item.repo} WHERE team = ${item.temporary}`
106
+ await sql`UPDATE ab_ticket_comments SET team = ${item.repo} WHERE team = ${item.temporary}`
107
+ await sql`UPDATE ab_ticket_blockers SET team = ${item.repo} WHERE team = ${item.temporary}`
108
+ }
109
+ await sql.unsafe(`ALTER TABLE ab_tickets RENAME COLUMN team TO repo;
110
+ ALTER TABLE ab_ticket_comments RENAME COLUMN team TO repo;
111
+ ALTER TABLE ab_ticket_blockers RENAME COLUMN team TO repo;
112
+ ALTER TABLE ab_ticket_comments ADD FOREIGN KEY (repo,ticket_id) REFERENCES ab_tickets(repo,id) ON DELETE CASCADE;
113
+ ALTER TABLE ab_ticket_blockers ADD FOREIGN KEY (repo,ticket_id) REFERENCES ab_tickets(repo,id) ON DELETE CASCADE;
114
+ ALTER TABLE ab_ticket_blockers ADD FOREIGN KEY (repo,blocker_id) REFERENCES ab_tickets(repo,id) ON DELETE CASCADE;`)
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
+ }