@defrex/autobuild-postgres-store 0.12.0 → 0.14.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.
@@ -2,6 +2,7 @@ import type { SQL } from 'bun'
2
2
  import type { BlobStore, RepositoryEvent } from '@defrex/autobuild/plugin-sdk'
3
3
  import { findEffectiveConfigRef, decodeEffectiveConfig } from '@defrex/autobuild/operator'
4
4
  import { TICKET_ASSIGNMENTS_DDL } from './ticket-assignments'
5
+ import { PostgresTicketPolicyStore } from './ticket-policy'
5
6
 
6
7
  /** Called only inside the schema migration transaction. Locks preserve the
7
8
  * registry/config/ownership snapshot across immutable blob reads and rekeying. */
@@ -41,41 +42,8 @@ export async function migrateLegacyTickets(
41
42
  )
42
43
  const claimants = new Map<string, string[]>()
43
44
  for (const { repo } of remaining.length > 0 ? projects : []) {
44
- const 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
- }
45
+ const config = await loadTicketConfig(sql, blobs, repo, configFailure)
46
+ if (!config) continue
79
47
  const team = config.tickets.teamKey
80
48
  if (team !== undefined) claimants.set(team, [...(claimants.get(team) ?? []), repo])
81
49
  }
@@ -145,3 +113,96 @@ export async function migrateLegacyTickets(
145
113
  ALTER TABLE ab_ticket_blockers ADD FOREIGN KEY (repo,ticket_id) REFERENCES ab_tickets(repo,id) ON DELETE CASCADE;
146
114
  ALTER TABLE ab_ticket_blockers ADD FOREIGN KEY (repo,blocker_id) REFERENCES ab_tickets(repo,id) ON DELETE CASCADE;`)
147
115
  }
116
+
117
+ /** Load exactly the durable effective config selected by the repository log. */
118
+ async function loadTicketConfig(
119
+ sql: SQL,
120
+ blobs: BlobStore,
121
+ repo: string,
122
+ configFailure: (detail: string) => Error,
123
+ ) {
124
+ const rows: { seq: number; ts: string; actor: unknown; type: string; payload: unknown }[] =
125
+ await sql`SELECT seq, ts, actor, type, payload FROM repo_events WHERE repo = ${repo} ORDER BY seq`
126
+ const events = rows.map((row) => ({
127
+ ...row,
128
+ repo,
129
+ seq: Number(row.seq),
130
+ ts: new Date(row.ts).toISOString(),
131
+ })) as RepositoryEvent[]
132
+ const ref = findEffectiveConfigRef(events)
133
+ // Registration/onboarding alone makes no team claim.
134
+ if (ref === undefined) return undefined
135
+ const [artifact] =
136
+ await sql`SELECT blob_ref FROM repo_artifacts WHERE repo = ${repo} AND kind = ${ref.kind} AND revision = ${ref.rev}`
137
+ if (!artifact)
138
+ throw configFailure(`effective config ${ref.kind}@${ref.rev} unavailable for ${repo}`)
139
+ let bytes: Uint8Array | null
140
+ try {
141
+ bytes = await blobs.get(artifact.blob_ref)
142
+ } catch {
143
+ throw configFailure(
144
+ `cannot read effective configuration blob for ${repo}; check blob access and prefix`,
145
+ )
146
+ }
147
+ if (bytes === null)
148
+ throw configFailure(
149
+ `missing effective configuration blob for ${repo}; check blob backend and prefix`,
150
+ )
151
+ let config: ReturnType<typeof decodeEffectiveConfig>
152
+ try {
153
+ config = decodeEffectiveConfig(bytes, `${repo} ${ref.kind}@${ref.rev}`)
154
+ } catch {
155
+ throw configFailure(`selected effective config ${ref.kind}@${ref.rev} for ${repo} is invalid`)
156
+ }
157
+ return config
158
+ }
159
+
160
+ export const READABLE_TICKET_CONSTRAINTS = `
161
+ ALTER TABLE ab_tickets ALTER COLUMN readable_id SET NOT NULL;
162
+ ALTER TABLE ab_tickets ALTER COLUMN ticket_number SET NOT NULL;
163
+ ALTER TABLE ab_tickets ADD CONSTRAINT ab_tickets_ticket_number_check CHECK (ticket_number > 0);
164
+ ALTER TABLE ab_tickets ADD CONSTRAINT ab_tickets_repo_ticket_number_key UNIQUE (repo, ticket_number);
165
+ CREATE UNIQUE INDEX ab_tickets_readable_id_key ON ab_tickets (repo, lower(readable_id));
166
+ `
167
+
168
+ /** Runs once, under the schema marker transaction; no historical foreign keys move. */
169
+ export async function migrateReadableTickets(
170
+ sql: SQL,
171
+ openBlobs?: () => BlobStore | Promise<BlobStore>,
172
+ ): Promise<void> {
173
+ await sql`LOCK TABLE ab_tickets, ab_ticket_comments, ab_ticket_blockers, ticket_assets,
174
+ repo_events, repo_artifacts, project_ticket_policy, project_journal IN SHARE ROW EXCLUSIVE MODE`
175
+ await sql.unsafe(`ALTER TABLE ab_tickets ADD COLUMN readable_id text;
176
+ ALTER TABLE ab_tickets ADD COLUMN ticket_number bigint;
177
+ CREATE TABLE ab_ticket_counters (repo text PRIMARY KEY, last_number bigint NOT NULL CHECK (last_number >= 0));`)
178
+ const projects: { repo: string }[] = await sql`SELECT DISTINCT repo FROM ab_tickets ORDER BY repo`
179
+ const fail = (detail: string) =>
180
+ new Error(
181
+ `Readable ticket migration stopped unchanged: ${detail}. Restore the project's effective configuration and blob access, then rerun migration.`,
182
+ )
183
+ let blobs: BlobStore | undefined
184
+ for (const { repo } of projects) {
185
+ // Independent hosted policy is authoritative; projects using local build
186
+ // storage need not have an uploaded config or any configuration blobs.
187
+ const policy = await new PostgresTicketPolicyStore(sql).get(repo)
188
+ let prefix = policy?.tickets.teamKey
189
+ if (!policy) {
190
+ if (!blobs) {
191
+ if (!openBlobs) throw fail(`project ${repo}: populated projects require blob access`)
192
+ try {
193
+ blobs = await openBlobs()
194
+ } catch {
195
+ throw fail(`project ${repo}: cannot open blob storage`)
196
+ }
197
+ }
198
+ const config = await loadTicketConfig(sql, blobs, repo, fail)
199
+ prefix = config?.tickets.teamKey
200
+ }
201
+ if (!prefix?.trim()) throw fail(`project ${repo} has no nonblank effective tickets.teamKey`)
202
+ await sql`WITH numbered AS (SELECT id, row_number() OVER (ORDER BY created_at, id) AS n FROM ab_tickets WHERE repo = ${repo})
203
+ UPDATE ab_tickets t SET ticket_number = numbered.n, readable_id = ${prefix} || '-' || numbered.n::text
204
+ FROM numbered WHERE t.repo = ${repo} AND t.id = numbered.id`
205
+ await sql`INSERT INTO ab_ticket_counters (repo, last_number) SELECT ${repo}, max(ticket_number) FROM ab_tickets WHERE repo = ${repo}`
206
+ }
207
+ await sql.unsafe(READABLE_TICKET_CONSTRAINTS)
208
+ }
@@ -0,0 +1,60 @@
1
+ import { seedTicketStates, policyStateNames } from './ticket-states'
2
+ import type { Exec } from './retry'
3
+ import type { SQL } from 'bun'
4
+ import { hostedTicketPolicySchema, type HostedTicketPolicy } from '@defrex/autobuild/hosted-tickets'
5
+
6
+ export interface TicketPolicyStore {
7
+ get(repo: string): Promise<HostedTicketPolicy | null>
8
+ put(repo: string, policy: HostedTicketPolicy, writer: string): Promise<void>
9
+ }
10
+
11
+ /** Registration sequence scopes policy to the current project incarnation. */
12
+ export class PostgresTicketPolicyStore implements TicketPolicyStore {
13
+ constructor(private readonly sql: SQL) {}
14
+ async get(repo: string): Promise<HostedTicketPolicy | null> {
15
+ const [row] = await this.sql`SELECT p.policy FROM project_ticket_policy p JOIN LATERAL
16
+ (SELECT seq, action FROM project_journal WHERE repo = ${repo} ORDER BY seq DESC LIMIT 1) r
17
+ ON r.seq = p.registration AND r.action = 'added' WHERE p.repo = ${repo}`
18
+ return row
19
+ ? hostedTicketPolicySchema.parse(
20
+ typeof row.policy === 'string' ? JSON.parse(row.policy) : row.policy,
21
+ )
22
+ : null
23
+ }
24
+ async put(repo: string, policy: HostedTicketPolicy, writer: string): Promise<void> {
25
+ const parsed = hostedTicketPolicySchema.parse(policy)
26
+ await this.sql.begin(async (tx) => {
27
+ await tx`SELECT singleton FROM project_registry_state WHERE singleton = true FOR UPDATE`
28
+ const [registration] =
29
+ await tx`SELECT seq, action FROM project_journal WHERE repo = ${repo} ORDER BY seq DESC LIMIT 1`
30
+ if (registration?.action !== 'added') throw new Error('project not registered')
31
+ const [previous] =
32
+ await tx`SELECT policy FROM project_ticket_policy WHERE repo = ${repo} AND registration = ${registration.seq}`
33
+ const old = previous
34
+ ? hostedTicketPolicySchema.parse(
35
+ typeof previous.policy === 'string' ? JSON.parse(previous.policy) : previous.policy,
36
+ )
37
+ : undefined
38
+ await seedTicketStates(tx as unknown as Exec, repo, [
39
+ ...(old ? policyStateNames(old) : []),
40
+ ...policyStateNames(parsed),
41
+ ])
42
+ await tx`INSERT INTO project_ticket_policy (repo, registration, policy, writer, updated_at)
43
+ VALUES (${repo}, ${registration.seq}, ${parsed}, ${writer}, ${new Date().toISOString()})
44
+ ON CONFLICT (repo) DO UPDATE SET registration = EXCLUDED.registration, policy = EXCLUDED.policy,
45
+ writer = EXCLUDED.writer, updated_at = EXCLUDED.updated_at
46
+ WHERE project_ticket_policy.registration <> EXCLUDED.registration OR project_ticket_policy.policy <> EXCLUDED.policy`
47
+ })
48
+ }
49
+ }
50
+
51
+ /** Explicit test seam; production always composes the PostgreSQL adapter. */
52
+ export class MemoryTicketPolicyStore implements TicketPolicyStore {
53
+ private readonly policies = new Map<string, HostedTicketPolicy>()
54
+ async get(repo: string) {
55
+ return structuredClone(this.policies.get(repo) ?? null)
56
+ }
57
+ async put(repo: string, policy: HostedTicketPolicy, _writer: string) {
58
+ this.policies.set(repo, hostedTicketPolicySchema.parse(policy))
59
+ }
60
+ }
@@ -0,0 +1,15 @@
1
+ import type { Exec } from './retry'
2
+ import type { HostedTicketPolicy } from '@defrex/autobuild/hosted-tickets'
3
+
4
+ export function policyStateNames(policy: HostedTicketPolicy): string[] {
5
+ const t = policy.tickets
6
+ return [t.triageState, t.readyState, t.claimedState, t.doneState, t.createState, t.proposalState]
7
+ }
8
+
9
+ /** All writers share this project lock, including policy publication. */
10
+ export async function seedTicketStates(q: Exec, repo: string, names: string[]): Promise<void> {
11
+ await q`SELECT pg_advisory_xact_lock(hashtextextended(${repo}, 731926))`
12
+ for (const name of new Set(names)) {
13
+ await q`INSERT INTO ab_ticket_states (repo, name) VALUES (${repo}, ${name}) ON CONFLICT DO NOTHING`
14
+ }
15
+ }
package/src/tickets.ts CHANGED
@@ -1,3 +1,12 @@
1
+ import { hostedRequestedIds, MAX_TICKET_NUMBER } from '@defrex/autobuild/hosted-tickets'
2
+ import {
3
+ orderStates,
4
+ resolveState,
5
+ validateStateName,
6
+ validateStatePurpose,
7
+ } from '@defrex/autobuild/hosted-tickets'
8
+ import { seedTicketStates } from './ticket-states'
9
+ import { TicketRevisionConflict } from '@defrex/autobuild/hosted-tickets'
1
10
  import { SQL } from 'bun'
2
11
  import type {
3
12
  DependencyState,
@@ -7,6 +16,8 @@ import type {
7
16
  TicketListing,
8
17
  TicketSource,
9
18
  TicketUpdate,
19
+ TicketStateInfo,
20
+ TicketMutation,
10
21
  } from '@defrex/autobuild/plugin-sdk'
11
22
  import { validateTicketUpdate } from '@defrex/autobuild/ticket-update'
12
23
  import { assertTicketSchema } from './schema'
@@ -23,6 +34,7 @@ export interface PostgresTicketLifecycle {
23
34
 
24
35
  export interface PostgresTicketContext {
25
36
  repo: string
37
+ teamKey: string
26
38
  lifecycle: PostgresTicketLifecycle
27
39
  proposalState?: string
28
40
  claimedState?: string
@@ -67,10 +79,11 @@ export async function openPostgresTicketDatabase(url: string): Promise<PostgresT
67
79
 
68
80
  export class PostgresTicketSource implements TicketSource {
69
81
  readonly name = 'database'
82
+ private readonly teamKey: string
70
83
  private readonly repo: string
71
84
  private readonly claimedState: string
72
85
  private readonly createState: string
73
- private readonly allowed: Set<string>
86
+ private readonly configured: string[]
74
87
 
75
88
  constructor(
76
89
  private readonly sql: SQL,
@@ -82,41 +95,86 @@ export class PostgresTicketSource implements TicketSource {
82
95
  private readonly retry: PlanRetryRunner = new PlanRetryRunner(),
83
96
  ) {
84
97
  if (!context.repo.trim()) throw new Error('ticket repo must be nonblank')
98
+ this.teamKey = context.teamKey
85
99
  this.repo = context.repo
86
- this.allowed = new Set(
87
- [
88
- ...Object.values(lifecycle),
89
- context.claimedState ?? lifecycle.doing,
90
- context.createState ?? lifecycle.triage,
91
- context.proposalState ?? lifecycle.triage,
92
- ].map((value) => requiredState(value, 'ticket state')),
100
+ this.configured = Array.from(
101
+ new Set(
102
+ [
103
+ ...Object.values(lifecycle),
104
+ context.claimedState ?? lifecycle.doing,
105
+ context.createState ?? lifecycle.triage,
106
+ context.proposalState ?? lifecycle.triage,
107
+ ].map((value) => requiredState(value, 'ticket state')),
108
+ ),
93
109
  )
94
110
  this.claimedState = context.claimedState ?? lifecycle.doing
95
111
  this.createState = context.createState ?? lifecycle.triage
96
- this.assertState(this.claimedState)
97
- this.assertState(this.createState)
98
112
  }
99
113
 
100
- private assertState(state: string): void {
101
- if (!this.allowed.has(state)) {
102
- throw new Error(
103
- `database ticket state "${state}" is not configured; allowed states: ${[...this.allowed].join(', ')}`,
104
- )
105
- }
114
+ private async states(q: Exec): Promise<TicketStateInfo[]> {
115
+ await seedTicketStates(q, this.repo, this.configured)
116
+ const rows: Row[] = await q`SELECT s.name, s.about, count(t.id)::integer AS tickets
117
+ FROM ab_ticket_states s LEFT JOIN ab_tickets t ON t.repo = s.repo AND t.state = s.name
118
+ WHERE s.repo = ${this.repo} GROUP BY s.name, s.about`
119
+ const infos = rows.map((row) => ({
120
+ name: String(row.name),
121
+ tickets: Number(row.tickets),
122
+ ...(row.about === null ? {} : { about: String(row.about) }),
123
+ }))
124
+ return orderStates(
125
+ infos.map((s) => s.name),
126
+ [this.lifecycle.triage, this.lifecycle.ready, this.lifecycle.doing, this.lifecycle.done],
127
+ ).map((name) => infos.find((s) => s.name === name)!)
128
+ }
129
+
130
+ async listStates(): Promise<TicketStateInfo[]> {
131
+ return this.retry.tx(this.sql, (q) => this.states(q))
132
+ }
133
+
134
+ async addState(
135
+ name: string,
136
+ opts: { about?: string } = {},
137
+ ): Promise<{ state: TicketStateInfo; created: boolean }> {
138
+ validateStateName(name, 'database ticket source')
139
+ const about = validateStatePurpose(opts.about, 'database ticket source')
140
+ return this.retry.tx(this.sql, async (q) => {
141
+ const states = await this.states(q)
142
+ const clash = states.find((s) => s.name.toLowerCase() === name.toLowerCase())
143
+ if (clash)
144
+ throw new Error(
145
+ `database ticket source: state "${name}" already exists as "${clash.name}"; state names are compared case-insensitively`,
146
+ )
147
+ await q`INSERT INTO ab_ticket_states (repo, name, about) VALUES (${this.repo}, ${name}, ${about ?? null})`
148
+ return {
149
+ state: { name, tickets: 0, ...(about === undefined ? {} : { about }) },
150
+ created: true,
151
+ }
152
+ })
153
+ }
154
+
155
+ private async state(q: Exec, name: string): Promise<string> {
156
+ return resolveState(
157
+ name,
158
+ (await this.states(q)).map((s) => s.name),
159
+ 'database ticket source',
160
+ )
106
161
  }
107
162
 
108
163
  private async blockers(executor: Exec, id: string): Promise<string[]> {
109
- const rows: Row[] = await executor`SELECT blocker_id FROM ab_ticket_blockers
110
- WHERE repo = ${this.repo} AND ticket_id = ${id} ORDER BY blocker_id`
164
+ const rows: Row[] = await executor`SELECT t.readable_id AS blocker_id FROM ab_ticket_blockers b
165
+ JOIN ab_tickets t ON t.repo = b.repo AND t.id = b.blocker_id
166
+ WHERE b.repo = ${this.repo} AND b.ticket_id = ${id} ORDER BY t.readable_id`
111
167
  return rows.map((row) => String(row.blocker_id))
112
168
  }
113
169
 
114
170
  private async record(executor: Exec, row: Row): Promise<Ticket> {
115
171
  const id = String(row.id)
116
172
  const title = String(row.title)
117
- const blockedBy = await this.blockers(executor, id)
173
+ const blockedBy =
174
+ row.blockers === undefined ? await this.blockers(executor, id) : strings(row.blockers)
118
175
  return {
119
- ref: { source: this.name, id, title },
176
+ ref: { source: this.name, id: String(row.readable_id), title },
177
+ revision: String(row.revision),
120
178
  ...(row.creation_key !== null && row.creation_key !== undefined
121
179
  ? { creationKey: String(row.creation_key) }
122
180
  : {}),
@@ -129,21 +187,28 @@ export class PostgresTicketSource implements TicketSource {
129
187
  }
130
188
  }
131
189
 
132
- private async require(executor: Exec, id: string, operation: string, lock = false): Promise<Row> {
190
+ private async lookup(executor: Exec, id: string, lock = false): Promise<Row | undefined> {
133
191
  const rows: Row[] = lock
134
- ? await executor`SELECT * FROM ab_tickets WHERE repo = ${this.repo} AND id = ${id} FOR UPDATE`
135
- : await executor`SELECT * FROM ab_tickets WHERE repo = ${this.repo} AND id = ${id}`
136
- const row = rows[0]
192
+ ? await executor`SELECT *, revision::text AS revision FROM ab_tickets WHERE repo = ${this.repo} AND (id = ${id} OR lower(readable_id) = lower(${id})) FOR UPDATE`
193
+ : await executor`SELECT *, revision::text AS revision FROM ab_tickets WHERE repo = ${this.repo} AND (id = ${id} OR lower(readable_id) = lower(${id}))`
194
+ return rows[0]
195
+ }
196
+
197
+ private async require(executor: Exec, id: string, operation: string, lock = false): Promise<Row> {
198
+ const row = await this.lookup(executor, id, lock)
137
199
  if (!row) throw new Error(`database ticket source: ${operation} on unknown ticket "${id}"`)
138
200
  return row
139
201
  }
140
202
 
141
203
  async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
142
204
  const labels = criteria.labels ?? []
143
- return this.retry.run(this.sql, async (q) => {
144
- const rows: Row[] = await q`SELECT * FROM ab_tickets
205
+ return this.retry.tx(this.sql, async (q) => {
206
+ await seedTicketStates(q, this.repo, this.configured)
207
+ const state = criteria.state === undefined ? null : await this.state(q, criteria.state)
208
+ const rows: Row[] =
209
+ await q`SELECT *, revision::text AS revision, ARRAY(SELECT t.readable_id FROM ab_ticket_blockers b JOIN ab_tickets t ON t.repo = b.repo AND t.id = b.blocker_id WHERE b.repo = ab_tickets.repo AND b.ticket_id = ab_tickets.id ORDER BY t.readable_id) AS blockers FROM ab_tickets
145
210
  WHERE repo = ${this.repo}
146
- AND (${criteria.state ?? null}::text IS NULL OR state = ${criteria.state ?? null})
211
+ AND (${state}::text IS NULL OR state = ${state})
147
212
  AND labels @> ${q.array(labels, 'text')}
148
213
  ORDER BY created_at, id`
149
214
  // Sequential, not `Promise.all`: every statement of a body runs on one
@@ -156,17 +221,36 @@ export class PostgresTicketSource implements TicketSource {
156
221
  }
157
222
 
158
223
  async get(id: string): Promise<Ticket | null> {
159
- return this.retry.run(this.sql, async (q) => {
160
- const rows: Row[] = await q`SELECT * FROM ab_tickets WHERE repo = ${this.repo} AND id = ${id}`
161
- return rows[0] ? this.record(q, rows[0]) : null
224
+ return this.retry.tx(this.sql, async (q) => {
225
+ await seedTicketStates(q, this.repo, this.configured)
226
+ const rows: Row[] =
227
+ await q`SELECT *, revision::text AS revision, ARRAY(SELECT t.readable_id FROM ab_ticket_blockers b JOIN ab_tickets t ON t.repo = b.repo AND t.id = b.blocker_id WHERE b.repo = ab_tickets.repo AND b.ticket_id = ab_tickets.id ORDER BY t.readable_id) AS blockers FROM ab_tickets WHERE repo = ${this.repo} AND (id = ${id} OR lower(readable_id) = lower(${id}))`
228
+ if (!rows[0]) return null
229
+ const ticket = await this.record(q, rows[0])
230
+ const storageId = String(rows[0].id)
231
+ const comments: Row[] = await q`SELECT body, created_at FROM ab_ticket_comments
232
+ WHERE repo = ${this.repo} AND ticket_id = ${storageId}
233
+ ORDER BY created_at ASC, seq ASC`
234
+ return {
235
+ ...ticket,
236
+ comments: comments.map((row) => ({
237
+ body: String(row.body),
238
+ createdAt: new Date(row.created_at as string | Date).toISOString(),
239
+ })),
240
+ }
162
241
  })
163
242
  }
164
243
 
165
244
  async claim(id: string): Promise<boolean> {
166
- return this.retry.run(this.sql, async (q) => {
167
- const rows: Row[] = await q`UPDATE ab_tickets SET state = ${this.claimedState},
245
+ return this.retry.tx(this.sql, async (q) => {
246
+ await seedTicketStates(q, this.repo, this.configured)
247
+ const row = await this.lookup(q, id)
248
+ if (!row) return false
249
+ const storageId = String(row.id)
250
+ const rows: Row[] =
251
+ await q`UPDATE ab_tickets SET state = ${this.claimedState}, revision = revision + 1,
168
252
  updated_at = ${new Date().toISOString()}
169
- WHERE repo = ${this.repo} AND id = ${id}
253
+ WHERE repo = ${this.repo} AND id = ${storageId}
170
254
  AND state <> ${this.claimedState} AND state <> ${this.lifecycle.done} RETURNING id`
171
255
  return rows.length === 1
172
256
  })
@@ -174,53 +258,71 @@ export class PostgresTicketSource implements TicketSource {
174
258
 
175
259
  async comment(id: string, body: string): Promise<void> {
176
260
  await this.retry.tx(this.sql, async (q) => {
177
- await this.require(q, id, 'comment', true)
261
+ const storageId = String((await this.require(q, id, 'comment', true)).id)
178
262
  const tails: Row[] = await q`SELECT COALESCE(MAX(seq), 0) AS seq FROM ab_ticket_comments
179
- WHERE repo = ${this.repo} AND ticket_id = ${id}`
263
+ WHERE repo = ${this.repo} AND ticket_id = ${storageId}`
180
264
  await q`INSERT INTO ab_ticket_comments (repo, ticket_id, seq, body, created_at)
181
- VALUES (${this.repo}, ${id}, ${Number(tails[0]?.seq ?? 0) + 1}, ${body}, ${new Date().toISOString()})`
265
+ VALUES (${this.repo}, ${storageId}, ${Number(tails[0]?.seq ?? 0) + 1}, ${body}, ${new Date().toISOString()})`
182
266
  })
183
267
  }
184
268
 
185
269
  async transition(id: string, state: string): Promise<void> {
186
- this.assertState(state)
187
- await this.retry.run(this.sql, async (q) => {
188
- const rows: Row[] = await q`UPDATE ab_tickets SET state = ${state},
189
- updated_at = ${new Date().toISOString()} WHERE repo = ${this.repo} AND id = ${id} RETURNING id`
190
- if (!rows[0]) throw new Error(`database ticket source: transition on unknown ticket "${id}"`)
191
- })
270
+ await this.mutate(id, { kind: 'transition', state })
271
+ }
272
+
273
+ get requestedIds() {
274
+ return hostedRequestedIds(this.teamKey)
192
275
  }
193
276
 
194
277
  async create(draft: TicketDraft, options: TicketCreateOptions = {}): Promise<Ticket> {
195
278
  if (!draft.title.trim()) throw new Error('database ticket create: title must be nonblank')
196
- if (!draft.body.trim()) throw new Error('database ticket create: body must be nonblank')
197
- const state = options.state ?? this.createState
198
- this.assertState(state)
279
+ if (!draft.body.trim() && options.requestedId === undefined)
280
+ throw new Error('database ticket create: body must be nonblank')
281
+ requiredState(this.teamKey, 'ticket teamKey')
199
282
  return this.retry.tx(this.sql, async (q) => {
283
+ await q`INSERT INTO ab_ticket_counters (repo, last_number) VALUES (${this.repo}, 0) ON CONFLICT DO NOTHING`
284
+ await q`SELECT last_number FROM ab_ticket_counters WHERE repo = ${this.repo} FOR UPDATE`
200
285
  if (options.idempotencyKey !== undefined) {
201
- const adopted: Row[] = await q`SELECT * FROM ab_tickets
286
+ const adopted: Row[] = await q`SELECT *, revision::text AS revision FROM ab_tickets
202
287
  WHERE repo = ${this.repo} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
203
288
  if (adopted[0]) return this.record(q, adopted[0])
204
289
  }
290
+ const state = await this.state(q, options.state ?? this.createState)
291
+ const blockers: string[] = []
205
292
  for (const blocker of draft.blockedBy ?? []) {
206
- await this.require(q, blocker, 'create blocker')
293
+ blockers.push(String((await this.require(q, blocker, 'create blocker')).id))
207
294
  }
295
+ const policy = hostedRequestedIds(this.teamKey)
296
+ const kept =
297
+ options.requestedId === undefined ? undefined : policy.preserve(options.requestedId)
298
+ const rows: Row[] = await q`SELECT GREATEST(
299
+ (SELECT last_number FROM ab_ticket_counters WHERE repo = ${this.repo}),
300
+ COALESCE(MAX(ticket_number), 0))::text AS high FROM ab_tickets WHERE repo = ${this.repo}`
301
+ const high = BigInt(String(rows[0]!.high))
302
+ const chosen = kept === undefined ? high + 1n : BigInt(kept.slice(this.teamKey.length + 1))
303
+ if (chosen > MAX_TICKET_NUMBER) throw new Error('ticket numbers exhausted')
304
+ const clashes: Row[] =
305
+ await q`SELECT id FROM ab_tickets WHERE repo = ${this.repo} AND ticket_number = ${String(chosen)}::bigint`
306
+ if (clashes.length) throw new Error(`requested ticket id already occupied: ${kept}`)
307
+ const number = String(chosen)
308
+ await q`UPDATE ab_ticket_counters SET last_number = ${String(chosen > high ? chosen : high)}::bigint WHERE repo = ${this.repo}`
309
+ const readable = `${this.teamKey}-${number}`
208
310
  const id = `ticket-${crypto.randomUUID()}`
209
311
  const now = new Date().toISOString()
210
312
  const inserted: Row[] = await q`INSERT INTO ab_tickets
211
- (repo, id, creation_key, title, body, state, labels, created_at, updated_at)
212
- VALUES (${this.repo}, ${id}, ${options.idempotencyKey ?? null}, ${draft.title}, ${draft.body},
313
+ (repo, id, readable_id, ticket_number, creation_key, title, body, state, labels, created_at, updated_at)
314
+ VALUES (${this.repo}, ${id}, ${readable}, ${number}::bigint, ${options.idempotencyKey ?? null}, ${draft.title}, ${draft.body},
213
315
  ${state}, ${q.array(draft.labels ?? [], 'text')}, ${now}, ${now})
214
- ON CONFLICT (repo, creation_key) DO NOTHING RETURNING *`
316
+ ON CONFLICT (repo, creation_key) DO NOTHING RETURNING *, revision::text AS revision`
215
317
  let row = inserted[0]
216
318
  if (!row && options.idempotencyKey !== undefined) {
217
- const adopted: Row[] = await q`SELECT * FROM ab_tickets
319
+ const adopted: Row[] = await q`SELECT *, revision::text AS revision FROM ab_tickets
218
320
  WHERE repo = ${this.repo} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
219
321
  row = adopted[0]
220
322
  }
221
323
  if (!row) throw new Error('database ticket create failed')
222
324
  if (String(row.id) === id) {
223
- for (const blocker of [...new Set(draft.blockedBy ?? [])]) {
325
+ for (const blocker of [...new Set(blockers)]) {
224
326
  if (blocker === id)
225
327
  throw new Error(`database ticket source: ticket "${id}" cannot block itself`)
226
328
  await q`INSERT INTO ab_ticket_blockers (repo, ticket_id, blocker_id)
@@ -232,37 +334,71 @@ export class PostgresTicketSource implements TicketSource {
232
334
  }
233
335
 
234
336
  async update(id: string, patch: TicketUpdate): Promise<void> {
235
- const validated = validateTicketUpdate(patch)
236
- await this.retry.tx(this.sql, async (q) => {
237
- const row = await this.require(q, id, 'update', true)
238
- await q`UPDATE ab_tickets SET
239
- title = ${validated.title ?? String(row.title)},
240
- body = ${validated.body ?? String(row.body)},
241
- labels = ${q.array(validated.labels ?? strings(row.labels), 'text')},
242
- updated_at = ${new Date().toISOString()}
243
- WHERE repo = ${this.repo} AND id = ${id}`
244
- })
337
+ await this.mutate(id, { kind: 'update', patch })
245
338
  }
246
339
 
247
340
  async addBlocker(id: string, blockerId: string): Promise<void> {
248
- await this.retry.tx(this.sql, async (q) => {
249
- await this.require(q, id, 'addBlocker', true)
250
- if (id === blockerId)
251
- throw new Error(`database ticket source: ticket "${id}" cannot block itself`)
252
- await this.require(q, blockerId, 'addBlocker')
253
- await q`INSERT INTO ab_ticket_blockers (repo, ticket_id, blocker_id)
254
- VALUES (${this.repo}, ${id}, ${blockerId}) ON CONFLICT DO NOTHING`
255
- })
341
+ await this.mutate(id, { kind: 'blockers', operation: 'block', ids: [blockerId] })
256
342
  }
257
343
 
258
344
  async removeBlocker(id: string, blockerId: string): Promise<void> {
259
- // Require + DELETE are one coherent body on a pinned connection: the
260
- // require's `SELECT *` is a migration-poisonable plan, and the pair must
261
- // re-run together on the retry.
262
- await this.retry.run(this.sql, async (q) => {
263
- await this.require(q, id, 'removeBlocker')
264
- await q`DELETE FROM ab_ticket_blockers
265
- WHERE repo = ${this.repo} AND ticket_id = ${id} AND blocker_id = ${blockerId}`
345
+ await this.mutate(id, { kind: 'blockers', operation: 'unblock', ids: [blockerId] })
346
+ }
347
+
348
+ async mutateIfRevision(id: string, expected: string, mutation: TicketMutation): Promise<void> {
349
+ if (!expected.trim()) throw new Error('expected revision must be nonblank')
350
+ await this.mutate(id, mutation, expected)
351
+ }
352
+
353
+ private async mutate(id: string, mutation: TicketMutation, expected?: string): Promise<void> {
354
+ const patch = mutation.kind === 'update' ? validateTicketUpdate(mutation.patch) : undefined
355
+ await this.retry.tx(this.sql, async (q) => {
356
+ const state = mutation.kind === 'transition' ? await this.state(q, mutation.state) : undefined
357
+ const row = await this.require(q, id, 'mutation', true)
358
+ const storageId = String(row.id)
359
+ const revision = String(row.revision)
360
+ if (expected !== undefined && revision !== expected)
361
+ throw new TicketRevisionConflict(id, expected, revision)
362
+ let changed = false
363
+ if (patch) {
364
+ const title = patch.title ?? String(row.title)
365
+ const body = patch.body ?? String(row.body)
366
+ const labels = patch.labels ?? strings(row.labels)
367
+ changed =
368
+ title !== row.title ||
369
+ body !== row.body ||
370
+ JSON.stringify([...new Set(labels)].sort()) !==
371
+ JSON.stringify([...new Set(strings(row.labels))].sort())
372
+ if (changed)
373
+ await q`UPDATE ab_tickets SET title = ${title}, body = ${body}, labels = ${q.array(labels, 'text')} WHERE repo = ${this.repo} AND id = ${storageId}`
374
+ } else if (mutation.kind === 'transition') {
375
+ changed = row.state !== state
376
+ if (changed)
377
+ await q`UPDATE ab_tickets SET state = ${state} WHERE repo = ${this.repo} AND id = ${storageId}`
378
+ } else if (mutation.kind === 'blockers') {
379
+ const keys = new Set<string>()
380
+ for (const blocker of mutation.ids) {
381
+ const target =
382
+ mutation.operation === 'block' || expected !== undefined
383
+ ? await this.require(q, blocker, 'blocker')
384
+ : await this.lookup(q, blocker)
385
+ if (!target) continue
386
+ const key = String(target.id)
387
+ if (mutation.operation === 'block' && key === storageId)
388
+ throw new Error(`ticket "${id}" cannot block itself`)
389
+ keys.add(key)
390
+ }
391
+ const ids = [...keys]
392
+ for (const blocker of ids) {
393
+ const rows: Row[] =
394
+ mutation.operation === 'block'
395
+ ? await q`INSERT INTO ab_ticket_blockers (repo, ticket_id, blocker_id) VALUES (${this.repo}, ${storageId}, ${blocker}) ON CONFLICT DO NOTHING RETURNING blocker_id`
396
+ : await q`DELETE FROM ab_ticket_blockers WHERE repo = ${this.repo} AND ticket_id = ${storageId} AND blocker_id = ${blocker} RETURNING blocker_id`
397
+ changed ||= rows.length > 0
398
+ }
399
+ }
400
+ if (changed)
401
+ await q`UPDATE ab_tickets SET revision = revision + 1, updated_at = ${new Date().toISOString()} WHERE repo = ${this.repo} AND id = ${storageId}`
266
402
  })
267
403
  }
268
404
 
@@ -272,14 +408,13 @@ export class PostgresTicketSource implements TicketSource {
272
408
  // One run per id keeps each id's state read + blockers query
273
409
  // sequential on one pinned connection; the ids stay concurrent.
274
410
  this.retry.run(this.sql, async (q) => {
275
- const rows: Row[] = await q`SELECT state FROM ab_tickets
276
- WHERE repo = ${this.repo} AND id = ${id}`
277
- if (!rows[0]) return { id, exists: false, resolved: false, blockedBy: [] }
411
+ const row = await this.lookup(q, id)
412
+ if (!row) return { id, exists: false, resolved: false, blockedBy: [] }
278
413
  return {
279
414
  id,
280
415
  exists: true,
281
- resolved: String(rows[0].state) === this.lifecycle.done,
282
- blockedBy: await this.blockers(q, id),
416
+ resolved: String(row.state) === this.lifecycle.done,
417
+ blockedBy: await this.blockers(q, String(row.id)),
283
418
  }
284
419
  }),
285
420
  ),