@defrex/autobuild-postgres-store 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -1
- package/package.json +13 -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 +167 -10
- package/src/ticket-assignments.ts +39 -0
- package/src/ticket-migration.ts +147 -0
- package/src/tickets.ts +41 -59
package/src/tickets.ts
CHANGED
|
@@ -22,7 +22,9 @@ export interface PostgresTicketLifecycle {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export interface PostgresTicketContext {
|
|
25
|
-
|
|
25
|
+
repo: string
|
|
26
|
+
lifecycle: PostgresTicketLifecycle
|
|
27
|
+
proposalState?: string
|
|
26
28
|
claimedState?: string
|
|
27
29
|
createState?: string
|
|
28
30
|
}
|
|
@@ -37,33 +39,14 @@ function strings(value: unknown): string[] {
|
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
/** Shared database handle; request-specific source views never retain another
|
|
40
|
-
* repository's
|
|
42
|
+
* repository's identity or lifecycle policy. */
|
|
41
43
|
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
44
|
private readonly retry = new PlanRetryRunner()
|
|
49
45
|
|
|
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
|
-
}
|
|
46
|
+
constructor(readonly sql: SQL) {}
|
|
64
47
|
|
|
65
48
|
source(context: PostgresTicketContext): PostgresTicketSource {
|
|
66
|
-
return new PostgresTicketSource(this.sql, context,
|
|
49
|
+
return new PostgresTicketSource(this.sql, context, context.lifecycle, this.retry)
|
|
67
50
|
}
|
|
68
51
|
|
|
69
52
|
close(): Promise<void> {
|
|
@@ -71,19 +54,11 @@ export class PostgresTicketDatabase {
|
|
|
71
54
|
}
|
|
72
55
|
}
|
|
73
56
|
|
|
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> {
|
|
57
|
+
export async function openPostgresTicketDatabase(url: string): Promise<PostgresTicketDatabase> {
|
|
83
58
|
const sql = new SQL(url)
|
|
84
59
|
try {
|
|
85
60
|
await assertTicketSchema(sql)
|
|
86
|
-
return new PostgresTicketDatabase(sql
|
|
61
|
+
return new PostgresTicketDatabase(sql)
|
|
87
62
|
} catch (error) {
|
|
88
63
|
await sql.close()
|
|
89
64
|
throw error
|
|
@@ -92,7 +67,7 @@ export async function openPostgresTicketDatabase(
|
|
|
92
67
|
|
|
93
68
|
export class PostgresTicketSource implements TicketSource {
|
|
94
69
|
readonly name = 'database'
|
|
95
|
-
private readonly
|
|
70
|
+
private readonly repo: string
|
|
96
71
|
private readonly claimedState: string
|
|
97
72
|
private readonly createState: string
|
|
98
73
|
private readonly allowed: Set<string>
|
|
@@ -106,9 +81,16 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
106
81
|
* namespace when no pool owner supplies one. */
|
|
107
82
|
private readonly retry: PlanRetryRunner = new PlanRetryRunner(),
|
|
108
83
|
) {
|
|
109
|
-
if (!context.
|
|
110
|
-
this.
|
|
111
|
-
this.allowed = new Set(
|
|
84
|
+
if (!context.repo.trim()) throw new Error('ticket repo must be nonblank')
|
|
85
|
+
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')),
|
|
93
|
+
)
|
|
112
94
|
this.claimedState = context.claimedState ?? lifecycle.doing
|
|
113
95
|
this.createState = context.createState ?? lifecycle.triage
|
|
114
96
|
this.assertState(this.claimedState)
|
|
@@ -125,7 +107,7 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
125
107
|
|
|
126
108
|
private async blockers(executor: Exec, id: string): Promise<string[]> {
|
|
127
109
|
const rows: Row[] = await executor`SELECT blocker_id FROM ab_ticket_blockers
|
|
128
|
-
WHERE
|
|
110
|
+
WHERE repo = ${this.repo} AND ticket_id = ${id} ORDER BY blocker_id`
|
|
129
111
|
return rows.map((row) => String(row.blocker_id))
|
|
130
112
|
}
|
|
131
113
|
|
|
@@ -149,8 +131,8 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
149
131
|
|
|
150
132
|
private async require(executor: Exec, id: string, operation: string, lock = false): Promise<Row> {
|
|
151
133
|
const rows: Row[] = lock
|
|
152
|
-
? await executor`SELECT * FROM ab_tickets WHERE
|
|
153
|
-
: await executor`SELECT * FROM ab_tickets WHERE
|
|
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}`
|
|
154
136
|
const row = rows[0]
|
|
155
137
|
if (!row) throw new Error(`database ticket source: ${operation} on unknown ticket "${id}"`)
|
|
156
138
|
return row
|
|
@@ -160,7 +142,7 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
160
142
|
const labels = criteria.labels ?? []
|
|
161
143
|
return this.retry.run(this.sql, async (q) => {
|
|
162
144
|
const rows: Row[] = await q`SELECT * FROM ab_tickets
|
|
163
|
-
WHERE
|
|
145
|
+
WHERE repo = ${this.repo}
|
|
164
146
|
AND (${criteria.state ?? null}::text IS NULL OR state = ${criteria.state ?? null})
|
|
165
147
|
AND labels @> ${q.array(labels, 'text')}
|
|
166
148
|
ORDER BY created_at, id`
|
|
@@ -175,7 +157,7 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
175
157
|
|
|
176
158
|
async get(id: string): Promise<Ticket | null> {
|
|
177
159
|
return this.retry.run(this.sql, async (q) => {
|
|
178
|
-
const rows: Row[] = await q`SELECT * FROM ab_tickets WHERE
|
|
160
|
+
const rows: Row[] = await q`SELECT * FROM ab_tickets WHERE repo = ${this.repo} AND id = ${id}`
|
|
179
161
|
return rows[0] ? this.record(q, rows[0]) : null
|
|
180
162
|
})
|
|
181
163
|
}
|
|
@@ -184,7 +166,7 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
184
166
|
return this.retry.run(this.sql, async (q) => {
|
|
185
167
|
const rows: Row[] = await q`UPDATE ab_tickets SET state = ${this.claimedState},
|
|
186
168
|
updated_at = ${new Date().toISOString()}
|
|
187
|
-
WHERE
|
|
169
|
+
WHERE repo = ${this.repo} AND id = ${id}
|
|
188
170
|
AND state <> ${this.claimedState} AND state <> ${this.lifecycle.done} RETURNING id`
|
|
189
171
|
return rows.length === 1
|
|
190
172
|
})
|
|
@@ -194,9 +176,9 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
194
176
|
await this.retry.tx(this.sql, async (q) => {
|
|
195
177
|
await this.require(q, id, 'comment', true)
|
|
196
178
|
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.
|
|
179
|
+
WHERE repo = ${this.repo} AND ticket_id = ${id}`
|
|
180
|
+
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()})`
|
|
200
182
|
})
|
|
201
183
|
}
|
|
202
184
|
|
|
@@ -204,7 +186,7 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
204
186
|
this.assertState(state)
|
|
205
187
|
await this.retry.run(this.sql, async (q) => {
|
|
206
188
|
const rows: Row[] = await q`UPDATE ab_tickets SET state = ${state},
|
|
207
|
-
updated_at = ${new Date().toISOString()} WHERE
|
|
189
|
+
updated_at = ${new Date().toISOString()} WHERE repo = ${this.repo} AND id = ${id} RETURNING id`
|
|
208
190
|
if (!rows[0]) throw new Error(`database ticket source: transition on unknown ticket "${id}"`)
|
|
209
191
|
})
|
|
210
192
|
}
|
|
@@ -217,7 +199,7 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
217
199
|
return this.retry.tx(this.sql, async (q) => {
|
|
218
200
|
if (options.idempotencyKey !== undefined) {
|
|
219
201
|
const adopted: Row[] = await q`SELECT * FROM ab_tickets
|
|
220
|
-
WHERE
|
|
202
|
+
WHERE repo = ${this.repo} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
|
|
221
203
|
if (adopted[0]) return this.record(q, adopted[0])
|
|
222
204
|
}
|
|
223
205
|
for (const blocker of draft.blockedBy ?? []) {
|
|
@@ -226,14 +208,14 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
226
208
|
const id = `ticket-${crypto.randomUUID()}`
|
|
227
209
|
const now = new Date().toISOString()
|
|
228
210
|
const inserted: Row[] = await q`INSERT INTO ab_tickets
|
|
229
|
-
(
|
|
230
|
-
VALUES (${this.
|
|
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},
|
|
231
213
|
${state}, ${q.array(draft.labels ?? [], 'text')}, ${now}, ${now})
|
|
232
|
-
ON CONFLICT (
|
|
214
|
+
ON CONFLICT (repo, creation_key) DO NOTHING RETURNING *`
|
|
233
215
|
let row = inserted[0]
|
|
234
216
|
if (!row && options.idempotencyKey !== undefined) {
|
|
235
217
|
const adopted: Row[] = await q`SELECT * FROM ab_tickets
|
|
236
|
-
WHERE
|
|
218
|
+
WHERE repo = ${this.repo} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
|
|
237
219
|
row = adopted[0]
|
|
238
220
|
}
|
|
239
221
|
if (!row) throw new Error('database ticket create failed')
|
|
@@ -241,8 +223,8 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
241
223
|
for (const blocker of [...new Set(draft.blockedBy ?? [])]) {
|
|
242
224
|
if (blocker === id)
|
|
243
225
|
throw new Error(`database ticket source: ticket "${id}" cannot block itself`)
|
|
244
|
-
await q`INSERT INTO ab_ticket_blockers (
|
|
245
|
-
VALUES (${this.
|
|
226
|
+
await q`INSERT INTO ab_ticket_blockers (repo, ticket_id, blocker_id)
|
|
227
|
+
VALUES (${this.repo}, ${id}, ${blocker}) ON CONFLICT DO NOTHING`
|
|
246
228
|
}
|
|
247
229
|
}
|
|
248
230
|
return this.record(q, row)
|
|
@@ -258,7 +240,7 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
258
240
|
body = ${validated.body ?? String(row.body)},
|
|
259
241
|
labels = ${q.array(validated.labels ?? strings(row.labels), 'text')},
|
|
260
242
|
updated_at = ${new Date().toISOString()}
|
|
261
|
-
WHERE
|
|
243
|
+
WHERE repo = ${this.repo} AND id = ${id}`
|
|
262
244
|
})
|
|
263
245
|
}
|
|
264
246
|
|
|
@@ -268,8 +250,8 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
268
250
|
if (id === blockerId)
|
|
269
251
|
throw new Error(`database ticket source: ticket "${id}" cannot block itself`)
|
|
270
252
|
await this.require(q, blockerId, 'addBlocker')
|
|
271
|
-
await q`INSERT INTO ab_ticket_blockers (
|
|
272
|
-
VALUES (${this.
|
|
253
|
+
await q`INSERT INTO ab_ticket_blockers (repo, ticket_id, blocker_id)
|
|
254
|
+
VALUES (${this.repo}, ${id}, ${blockerId}) ON CONFLICT DO NOTHING`
|
|
273
255
|
})
|
|
274
256
|
}
|
|
275
257
|
|
|
@@ -280,7 +262,7 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
280
262
|
await this.retry.run(this.sql, async (q) => {
|
|
281
263
|
await this.require(q, id, 'removeBlocker')
|
|
282
264
|
await q`DELETE FROM ab_ticket_blockers
|
|
283
|
-
WHERE
|
|
265
|
+
WHERE repo = ${this.repo} AND ticket_id = ${id} AND blocker_id = ${blockerId}`
|
|
284
266
|
})
|
|
285
267
|
}
|
|
286
268
|
|
|
@@ -291,7 +273,7 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
291
273
|
// sequential on one pinned connection; the ids stay concurrent.
|
|
292
274
|
this.retry.run(this.sql, async (q) => {
|
|
293
275
|
const rows: Row[] = await q`SELECT state FROM ab_tickets
|
|
294
|
-
WHERE
|
|
276
|
+
WHERE repo = ${this.repo} AND id = ${id}`
|
|
295
277
|
if (!rows[0]) return { id, exists: false, resolved: false, blockedBy: [] }
|
|
296
278
|
return {
|
|
297
279
|
id,
|