@defrex/autobuild-postgres-store 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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'
@@ -22,7 +33,10 @@ export interface PostgresTicketLifecycle {
22
33
  }
23
34
 
24
35
  export interface PostgresTicketContext {
36
+ repo: string
25
37
  teamKey: string
38
+ lifecycle: PostgresTicketLifecycle
39
+ proposalState?: string
26
40
  claimedState?: string
27
41
  createState?: string
28
42
  }
@@ -37,33 +51,14 @@ function strings(value: unknown): string[] {
37
51
  }
38
52
 
39
53
  /** Shared database handle; request-specific source views never retain another
40
- * repository's team or lifecycle overrides. */
54
+ * repository's identity or lifecycle policy. */
41
55
  export class PostgresTicketDatabase {
42
- readonly lifecycle: PostgresTicketLifecycle
43
- /** The shared plan-retry mechanics (`./retry`): one runner per pool owner,
44
- * so every source view this database hands out shares one memo — healing
45
- * done by one source benefits all sources on the same pool, and the
46
- * per-runner marker namespace keeps sources of different databases from
47
- * colliding on prepared-statement names. */
48
56
  private readonly retry = new PlanRetryRunner()
49
57
 
50
- constructor(
51
- readonly sql: SQL,
52
- lifecycle: PostgresTicketLifecycle,
53
- ) {
54
- this.lifecycle = {
55
- triage: requiredState(lifecycle.triage, 'triage state'),
56
- ready: requiredState(lifecycle.ready, 'ready state'),
57
- doing: requiredState(lifecycle.doing, 'doing state'),
58
- done: requiredState(lifecycle.done, 'done state'),
59
- }
60
- if (new Set(Object.values(this.lifecycle)).size !== 4) {
61
- throw new Error('database ticket lifecycle states must be distinct')
62
- }
63
- }
58
+ constructor(readonly sql: SQL) {}
64
59
 
65
60
  source(context: PostgresTicketContext): PostgresTicketSource {
66
- return new PostgresTicketSource(this.sql, context, this.lifecycle, this.retry)
61
+ return new PostgresTicketSource(this.sql, context, context.lifecycle, this.retry)
67
62
  }
68
63
 
69
64
  close(): Promise<void> {
@@ -71,19 +66,11 @@ export class PostgresTicketDatabase {
71
66
  }
72
67
  }
73
68
 
74
- export async function openPostgresTicketDatabase(
75
- url: string,
76
- lifecycle: PostgresTicketLifecycle = {
77
- triage: 'Triage',
78
- ready: 'Ready',
79
- doing: 'Doing',
80
- done: 'Done',
81
- },
82
- ): Promise<PostgresTicketDatabase> {
69
+ export async function openPostgresTicketDatabase(url: string): Promise<PostgresTicketDatabase> {
83
70
  const sql = new SQL(url)
84
71
  try {
85
72
  await assertTicketSchema(sql)
86
- return new PostgresTicketDatabase(sql, lifecycle)
73
+ return new PostgresTicketDatabase(sql)
87
74
  } catch (error) {
88
75
  await sql.close()
89
76
  throw error
@@ -92,10 +79,11 @@ export async function openPostgresTicketDatabase(
92
79
 
93
80
  export class PostgresTicketSource implements TicketSource {
94
81
  readonly name = 'database'
95
- private readonly team: string
82
+ private readonly teamKey: string
83
+ private readonly repo: string
96
84
  private readonly claimedState: string
97
85
  private readonly createState: string
98
- private readonly allowed: Set<string>
86
+ private readonly configured: string[]
99
87
 
100
88
  constructor(
101
89
  private readonly sql: SQL,
@@ -106,35 +94,87 @@ export class PostgresTicketSource implements TicketSource {
106
94
  * namespace when no pool owner supplies one. */
107
95
  private readonly retry: PlanRetryRunner = new PlanRetryRunner(),
108
96
  ) {
109
- if (!context.teamKey.trim()) throw new Error('ticket teamKey must be nonblank')
110
- this.team = context.teamKey
111
- this.allowed = new Set(Object.values(lifecycle))
97
+ if (!context.repo.trim()) throw new Error('ticket repo must be nonblank')
98
+ this.teamKey = context.teamKey
99
+ this.repo = context.repo
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
+ ),
109
+ )
112
110
  this.claimedState = context.claimedState ?? lifecycle.doing
113
111
  this.createState = context.createState ?? lifecycle.triage
114
- this.assertState(this.claimedState)
115
- this.assertState(this.createState)
116
112
  }
117
113
 
118
- private assertState(state: string): void {
119
- if (!this.allowed.has(state)) {
120
- throw new Error(
121
- `database ticket state "${state}" is not configured; allowed states: ${[...this.allowed].join(', ')}`,
122
- )
123
- }
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
+ )
124
161
  }
125
162
 
126
163
  private async blockers(executor: Exec, id: string): Promise<string[]> {
127
- const rows: Row[] = await executor`SELECT blocker_id FROM ab_ticket_blockers
128
- WHERE team = ${this.team} 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`
129
167
  return rows.map((row) => String(row.blocker_id))
130
168
  }
131
169
 
132
170
  private async record(executor: Exec, row: Row): Promise<Ticket> {
133
171
  const id = String(row.id)
134
172
  const title = String(row.title)
135
- const blockedBy = await this.blockers(executor, id)
173
+ const blockedBy =
174
+ row.blockers === undefined ? await this.blockers(executor, id) : strings(row.blockers)
136
175
  return {
137
- ref: { source: this.name, id, title },
176
+ ref: { source: this.name, id: String(row.readable_id), title },
177
+ revision: String(row.revision),
138
178
  ...(row.creation_key !== null && row.creation_key !== undefined
139
179
  ? { creationKey: String(row.creation_key) }
140
180
  : {}),
@@ -147,21 +187,28 @@ export class PostgresTicketSource implements TicketSource {
147
187
  }
148
188
  }
149
189
 
150
- 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> {
151
191
  const rows: Row[] = lock
152
- ? await executor`SELECT * FROM ab_tickets WHERE team = ${this.team} AND id = ${id} FOR UPDATE`
153
- : await executor`SELECT * FROM ab_tickets WHERE team = ${this.team} AND id = ${id}`
154
- 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)
155
199
  if (!row) throw new Error(`database ticket source: ${operation} on unknown ticket "${id}"`)
156
200
  return row
157
201
  }
158
202
 
159
203
  async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
160
204
  const labels = criteria.labels ?? []
161
- return this.retry.run(this.sql, async (q) => {
162
- const rows: Row[] = await q`SELECT * FROM ab_tickets
163
- WHERE team = ${this.team}
164
- AND (${criteria.state ?? null}::text IS NULL OR state = ${criteria.state ?? null})
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
210
+ WHERE repo = ${this.repo}
211
+ AND (${state}::text IS NULL OR state = ${state})
165
212
  AND labels @> ${q.array(labels, 'text')}
166
213
  ORDER BY created_at, id`
167
214
  // Sequential, not `Promise.all`: every statement of a body runs on one
@@ -174,17 +221,36 @@ export class PostgresTicketSource implements TicketSource {
174
221
  }
175
222
 
176
223
  async get(id: string): Promise<Ticket | null> {
177
- return this.retry.run(this.sql, async (q) => {
178
- const rows: Row[] = await q`SELECT * FROM ab_tickets WHERE team = ${this.team} AND id = ${id}`
179
- 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
+ }
180
241
  })
181
242
  }
182
243
 
183
244
  async claim(id: string): Promise<boolean> {
184
- return this.retry.run(this.sql, async (q) => {
185
- 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,
186
252
  updated_at = ${new Date().toISOString()}
187
- WHERE team = ${this.team} AND id = ${id}
253
+ WHERE repo = ${this.repo} AND id = ${storageId}
188
254
  AND state <> ${this.claimedState} AND state <> ${this.lifecycle.done} RETURNING id`
189
255
  return rows.length === 1
190
256
  })
@@ -192,57 +258,75 @@ export class PostgresTicketSource implements TicketSource {
192
258
 
193
259
  async comment(id: string, body: string): Promise<void> {
194
260
  await this.retry.tx(this.sql, async (q) => {
195
- await this.require(q, id, 'comment', true)
261
+ const storageId = String((await this.require(q, id, 'comment', true)).id)
196
262
  const tails: Row[] = await q`SELECT COALESCE(MAX(seq), 0) AS seq FROM ab_ticket_comments
197
- WHERE team = ${this.team} AND ticket_id = ${id}`
198
- await q`INSERT INTO ab_ticket_comments (team, ticket_id, seq, body, created_at)
199
- VALUES (${this.team}, ${id}, ${Number(tails[0]?.seq ?? 0) + 1}, ${body}, ${new Date().toISOString()})`
263
+ WHERE repo = ${this.repo} AND ticket_id = ${storageId}`
264
+ await q`INSERT INTO ab_ticket_comments (repo, ticket_id, seq, body, created_at)
265
+ VALUES (${this.repo}, ${storageId}, ${Number(tails[0]?.seq ?? 0) + 1}, ${body}, ${new Date().toISOString()})`
200
266
  })
201
267
  }
202
268
 
203
269
  async transition(id: string, state: string): Promise<void> {
204
- this.assertState(state)
205
- await this.retry.run(this.sql, async (q) => {
206
- const rows: Row[] = await q`UPDATE ab_tickets SET state = ${state},
207
- updated_at = ${new Date().toISOString()} WHERE team = ${this.team} AND id = ${id} RETURNING id`
208
- if (!rows[0]) throw new Error(`database ticket source: transition on unknown ticket "${id}"`)
209
- })
270
+ await this.mutate(id, { kind: 'transition', state })
271
+ }
272
+
273
+ get requestedIds() {
274
+ return hostedRequestedIds(this.teamKey)
210
275
  }
211
276
 
212
277
  async create(draft: TicketDraft, options: TicketCreateOptions = {}): Promise<Ticket> {
213
278
  if (!draft.title.trim()) throw new Error('database ticket create: title must be nonblank')
214
- if (!draft.body.trim()) throw new Error('database ticket create: body must be nonblank')
215
- const state = options.state ?? this.createState
216
- 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')
217
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`
218
285
  if (options.idempotencyKey !== undefined) {
219
- const adopted: Row[] = await q`SELECT * FROM ab_tickets
220
- WHERE team = ${this.team} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
286
+ const adopted: Row[] = await q`SELECT *, revision::text AS revision FROM ab_tickets
287
+ WHERE repo = ${this.repo} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
221
288
  if (adopted[0]) return this.record(q, adopted[0])
222
289
  }
290
+ const state = await this.state(q, options.state ?? this.createState)
291
+ const blockers: string[] = []
223
292
  for (const blocker of draft.blockedBy ?? []) {
224
- await this.require(q, blocker, 'create blocker')
293
+ blockers.push(String((await this.require(q, blocker, 'create blocker')).id))
225
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}`
226
310
  const id = `ticket-${crypto.randomUUID()}`
227
311
  const now = new Date().toISOString()
228
312
  const inserted: Row[] = await q`INSERT INTO ab_tickets
229
- (team, id, creation_key, title, body, state, labels, created_at, updated_at)
230
- VALUES (${this.team}, ${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},
231
315
  ${state}, ${q.array(draft.labels ?? [], 'text')}, ${now}, ${now})
232
- ON CONFLICT (team, creation_key) DO NOTHING RETURNING *`
316
+ ON CONFLICT (repo, creation_key) DO NOTHING RETURNING *, revision::text AS revision`
233
317
  let row = inserted[0]
234
318
  if (!row && options.idempotencyKey !== undefined) {
235
- const adopted: Row[] = await q`SELECT * FROM ab_tickets
236
- WHERE team = ${this.team} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
319
+ const adopted: Row[] = await q`SELECT *, revision::text AS revision FROM ab_tickets
320
+ WHERE repo = ${this.repo} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
237
321
  row = adopted[0]
238
322
  }
239
323
  if (!row) throw new Error('database ticket create failed')
240
324
  if (String(row.id) === id) {
241
- for (const blocker of [...new Set(draft.blockedBy ?? [])]) {
325
+ for (const blocker of [...new Set(blockers)]) {
242
326
  if (blocker === id)
243
327
  throw new Error(`database ticket source: ticket "${id}" cannot block itself`)
244
- await q`INSERT INTO ab_ticket_blockers (team, ticket_id, blocker_id)
245
- VALUES (${this.team}, ${id}, ${blocker}) ON CONFLICT DO NOTHING`
328
+ await q`INSERT INTO ab_ticket_blockers (repo, ticket_id, blocker_id)
329
+ VALUES (${this.repo}, ${id}, ${blocker}) ON CONFLICT DO NOTHING`
246
330
  }
247
331
  }
248
332
  return this.record(q, row)
@@ -250,37 +334,71 @@ export class PostgresTicketSource implements TicketSource {
250
334
  }
251
335
 
252
336
  async update(id: string, patch: TicketUpdate): Promise<void> {
253
- const validated = validateTicketUpdate(patch)
254
- await this.retry.tx(this.sql, async (q) => {
255
- const row = await this.require(q, id, 'update', true)
256
- await q`UPDATE ab_tickets SET
257
- title = ${validated.title ?? String(row.title)},
258
- body = ${validated.body ?? String(row.body)},
259
- labels = ${q.array(validated.labels ?? strings(row.labels), 'text')},
260
- updated_at = ${new Date().toISOString()}
261
- WHERE team = ${this.team} AND id = ${id}`
262
- })
337
+ await this.mutate(id, { kind: 'update', patch })
263
338
  }
264
339
 
265
340
  async addBlocker(id: string, blockerId: string): Promise<void> {
266
- await this.retry.tx(this.sql, async (q) => {
267
- await this.require(q, id, 'addBlocker', true)
268
- if (id === blockerId)
269
- throw new Error(`database ticket source: ticket "${id}" cannot block itself`)
270
- await this.require(q, blockerId, 'addBlocker')
271
- await q`INSERT INTO ab_ticket_blockers (team, ticket_id, blocker_id)
272
- VALUES (${this.team}, ${id}, ${blockerId}) ON CONFLICT DO NOTHING`
273
- })
341
+ await this.mutate(id, { kind: 'blockers', operation: 'block', ids: [blockerId] })
274
342
  }
275
343
 
276
344
  async removeBlocker(id: string, blockerId: string): Promise<void> {
277
- // Require + DELETE are one coherent body on a pinned connection: the
278
- // require's `SELECT *` is a migration-poisonable plan, and the pair must
279
- // re-run together on the retry.
280
- await this.retry.run(this.sql, async (q) => {
281
- await this.require(q, id, 'removeBlocker')
282
- await q`DELETE FROM ab_ticket_blockers
283
- WHERE team = ${this.team} 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}`
284
402
  })
285
403
  }
286
404
 
@@ -290,14 +408,13 @@ export class PostgresTicketSource implements TicketSource {
290
408
  // One run per id keeps each id's state read + blockers query
291
409
  // sequential on one pinned connection; the ids stay concurrent.
292
410
  this.retry.run(this.sql, async (q) => {
293
- const rows: Row[] = await q`SELECT state FROM ab_tickets
294
- WHERE team = ${this.team} AND id = ${id}`
295
- 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: [] }
296
413
  return {
297
414
  id,
298
415
  exists: true,
299
- resolved: String(rows[0].state) === this.lifecycle.done,
300
- blockedBy: await this.blockers(q, id),
416
+ resolved: String(row.state) === this.lifecycle.done,
417
+ blockedBy: await this.blockers(q, String(row.id)),
301
418
  }
302
419
  }),
303
420
  ),