@defrex/autobuild-postgres-store 0.10.1 → 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/store.ts +20 -13
- package/src/ticket-assignments.ts +39 -0
- package/src/ticket-migration.ts +147 -0
- package/src/tickets.ts +42 -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
|
|
|
@@ -141,6 +123,7 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
141
123
|
title,
|
|
142
124
|
body: String(row.body),
|
|
143
125
|
state: String(row.state),
|
|
126
|
+
resolved: String(row.state) === this.lifecycle.done,
|
|
144
127
|
labels: strings(row.labels),
|
|
145
128
|
...(blockedBy.length > 0 ? { blockedBy } : {}),
|
|
146
129
|
}
|
|
@@ -148,8 +131,8 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
148
131
|
|
|
149
132
|
private async require(executor: Exec, id: string, operation: string, lock = false): Promise<Row> {
|
|
150
133
|
const rows: Row[] = lock
|
|
151
|
-
? await executor`SELECT * FROM ab_tickets WHERE
|
|
152
|
-
: 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}`
|
|
153
136
|
const row = rows[0]
|
|
154
137
|
if (!row) throw new Error(`database ticket source: ${operation} on unknown ticket "${id}"`)
|
|
155
138
|
return row
|
|
@@ -159,7 +142,7 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
159
142
|
const labels = criteria.labels ?? []
|
|
160
143
|
return this.retry.run(this.sql, async (q) => {
|
|
161
144
|
const rows: Row[] = await q`SELECT * FROM ab_tickets
|
|
162
|
-
WHERE
|
|
145
|
+
WHERE repo = ${this.repo}
|
|
163
146
|
AND (${criteria.state ?? null}::text IS NULL OR state = ${criteria.state ?? null})
|
|
164
147
|
AND labels @> ${q.array(labels, 'text')}
|
|
165
148
|
ORDER BY created_at, id`
|
|
@@ -174,7 +157,7 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
174
157
|
|
|
175
158
|
async get(id: string): Promise<Ticket | null> {
|
|
176
159
|
return this.retry.run(this.sql, async (q) => {
|
|
177
|
-
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}`
|
|
178
161
|
return rows[0] ? this.record(q, rows[0]) : null
|
|
179
162
|
})
|
|
180
163
|
}
|
|
@@ -183,7 +166,7 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
183
166
|
return this.retry.run(this.sql, async (q) => {
|
|
184
167
|
const rows: Row[] = await q`UPDATE ab_tickets SET state = ${this.claimedState},
|
|
185
168
|
updated_at = ${new Date().toISOString()}
|
|
186
|
-
WHERE
|
|
169
|
+
WHERE repo = ${this.repo} AND id = ${id}
|
|
187
170
|
AND state <> ${this.claimedState} AND state <> ${this.lifecycle.done} RETURNING id`
|
|
188
171
|
return rows.length === 1
|
|
189
172
|
})
|
|
@@ -193,9 +176,9 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
193
176
|
await this.retry.tx(this.sql, async (q) => {
|
|
194
177
|
await this.require(q, id, 'comment', true)
|
|
195
178
|
const tails: Row[] = await q`SELECT COALESCE(MAX(seq), 0) AS seq FROM ab_ticket_comments
|
|
196
|
-
WHERE
|
|
197
|
-
await q`INSERT INTO ab_ticket_comments (
|
|
198
|
-
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()})`
|
|
199
182
|
})
|
|
200
183
|
}
|
|
201
184
|
|
|
@@ -203,7 +186,7 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
203
186
|
this.assertState(state)
|
|
204
187
|
await this.retry.run(this.sql, async (q) => {
|
|
205
188
|
const rows: Row[] = await q`UPDATE ab_tickets SET state = ${state},
|
|
206
|
-
updated_at = ${new Date().toISOString()} WHERE
|
|
189
|
+
updated_at = ${new Date().toISOString()} WHERE repo = ${this.repo} AND id = ${id} RETURNING id`
|
|
207
190
|
if (!rows[0]) throw new Error(`database ticket source: transition on unknown ticket "${id}"`)
|
|
208
191
|
})
|
|
209
192
|
}
|
|
@@ -216,7 +199,7 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
216
199
|
return this.retry.tx(this.sql, async (q) => {
|
|
217
200
|
if (options.idempotencyKey !== undefined) {
|
|
218
201
|
const adopted: Row[] = await q`SELECT * FROM ab_tickets
|
|
219
|
-
WHERE
|
|
202
|
+
WHERE repo = ${this.repo} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
|
|
220
203
|
if (adopted[0]) return this.record(q, adopted[0])
|
|
221
204
|
}
|
|
222
205
|
for (const blocker of draft.blockedBy ?? []) {
|
|
@@ -225,14 +208,14 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
225
208
|
const id = `ticket-${crypto.randomUUID()}`
|
|
226
209
|
const now = new Date().toISOString()
|
|
227
210
|
const inserted: Row[] = await q`INSERT INTO ab_tickets
|
|
228
|
-
(
|
|
229
|
-
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},
|
|
230
213
|
${state}, ${q.array(draft.labels ?? [], 'text')}, ${now}, ${now})
|
|
231
|
-
ON CONFLICT (
|
|
214
|
+
ON CONFLICT (repo, creation_key) DO NOTHING RETURNING *`
|
|
232
215
|
let row = inserted[0]
|
|
233
216
|
if (!row && options.idempotencyKey !== undefined) {
|
|
234
217
|
const adopted: Row[] = await q`SELECT * FROM ab_tickets
|
|
235
|
-
WHERE
|
|
218
|
+
WHERE repo = ${this.repo} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
|
|
236
219
|
row = adopted[0]
|
|
237
220
|
}
|
|
238
221
|
if (!row) throw new Error('database ticket create failed')
|
|
@@ -240,8 +223,8 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
240
223
|
for (const blocker of [...new Set(draft.blockedBy ?? [])]) {
|
|
241
224
|
if (blocker === id)
|
|
242
225
|
throw new Error(`database ticket source: ticket "${id}" cannot block itself`)
|
|
243
|
-
await q`INSERT INTO ab_ticket_blockers (
|
|
244
|
-
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`
|
|
245
228
|
}
|
|
246
229
|
}
|
|
247
230
|
return this.record(q, row)
|
|
@@ -257,7 +240,7 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
257
240
|
body = ${validated.body ?? String(row.body)},
|
|
258
241
|
labels = ${q.array(validated.labels ?? strings(row.labels), 'text')},
|
|
259
242
|
updated_at = ${new Date().toISOString()}
|
|
260
|
-
WHERE
|
|
243
|
+
WHERE repo = ${this.repo} AND id = ${id}`
|
|
261
244
|
})
|
|
262
245
|
}
|
|
263
246
|
|
|
@@ -267,8 +250,8 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
267
250
|
if (id === blockerId)
|
|
268
251
|
throw new Error(`database ticket source: ticket "${id}" cannot block itself`)
|
|
269
252
|
await this.require(q, blockerId, 'addBlocker')
|
|
270
|
-
await q`INSERT INTO ab_ticket_blockers (
|
|
271
|
-
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`
|
|
272
255
|
})
|
|
273
256
|
}
|
|
274
257
|
|
|
@@ -279,7 +262,7 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
279
262
|
await this.retry.run(this.sql, async (q) => {
|
|
280
263
|
await this.require(q, id, 'removeBlocker')
|
|
281
264
|
await q`DELETE FROM ab_ticket_blockers
|
|
282
|
-
WHERE
|
|
265
|
+
WHERE repo = ${this.repo} AND ticket_id = ${id} AND blocker_id = ${blockerId}`
|
|
283
266
|
})
|
|
284
267
|
}
|
|
285
268
|
|
|
@@ -290,7 +273,7 @@ export class PostgresTicketSource implements TicketSource {
|
|
|
290
273
|
// sequential on one pinned connection; the ids stay concurrent.
|
|
291
274
|
this.retry.run(this.sql, async (q) => {
|
|
292
275
|
const rows: Row[] = await q`SELECT state FROM ab_tickets
|
|
293
|
-
WHERE
|
|
276
|
+
WHERE repo = ${this.repo} AND id = ${id}`
|
|
294
277
|
if (!rows[0]) return { id, exists: false, resolved: false, blockedBy: [] }
|
|
295
278
|
return {
|
|
296
279
|
id,
|