@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/README.md +21 -4
- package/package.json +21 -1
- package/src/bin.ts +2 -2
- package/src/config.ts +29 -21
- package/src/connections.ts +160 -0
- package/src/credentials.ts +157 -0
- package/src/index.ts +5 -0
- package/src/projects.ts +171 -0
- package/src/schema.ts +306 -12
- package/src/store.ts +100 -23
- package/src/ticket-assignments.ts +39 -0
- package/src/ticket-grants.ts +86 -0
- package/src/ticket-migration.ts +208 -0
- package/src/ticket-policy.ts +60 -0
- package/src/ticket-states.ts +15 -0
- package/src/tickets.ts +234 -117
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
110
|
-
this.
|
|
111
|
-
this.
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
190
|
+
private async lookup(executor: Exec, id: string, lock = false): Promise<Row | undefined> {
|
|
151
191
|
const rows: Row[] = lock
|
|
152
|
-
? await executor`SELECT
|
|
153
|
-
: await executor`SELECT
|
|
154
|
-
|
|
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.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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.
|
|
178
|
-
|
|
179
|
-
|
|
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.
|
|
185
|
-
|
|
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
|
|
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
|
|
198
|
-
await q`INSERT INTO ab_ticket_comments (
|
|
199
|
-
VALUES (${this.
|
|
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.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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()
|
|
215
|
-
|
|
216
|
-
this.
|
|
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
|
|
220
|
-
WHERE
|
|
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
|
-
(
|
|
230
|
-
VALUES (${this.
|
|
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 (
|
|
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
|
|
236
|
-
WHERE
|
|
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(
|
|
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 (
|
|
245
|
-
VALUES (${this.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
294
|
-
|
|
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(
|
|
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
|
),
|