@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/src/tickets.ts CHANGED
@@ -22,7 +22,9 @@ export interface PostgresTicketLifecycle {
22
22
  }
23
23
 
24
24
  export interface PostgresTicketContext {
25
- teamKey: string
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 team or lifecycle overrides. */
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, this.lifecycle, this.retry)
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, lifecycle)
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 team: string
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.teamKey.trim()) throw new Error('ticket teamKey must be nonblank')
110
- this.team = context.teamKey
111
- this.allowed = new Set(Object.values(lifecycle))
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 team = ${this.team} AND ticket_id = ${id} ORDER BY blocker_id`
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 team = ${this.team} AND id = ${id} FOR UPDATE`
153
- : await executor`SELECT * FROM ab_tickets WHERE team = ${this.team} AND id = ${id}`
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 team = ${this.team}
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 team = ${this.team} AND id = ${id}`
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 team = ${this.team} AND id = ${id}
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 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()})`
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 team = ${this.team} AND id = ${id} RETURNING id`
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 team = ${this.team} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
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
- (team, id, creation_key, title, body, state, labels, created_at, updated_at)
230
- VALUES (${this.team}, ${id}, ${options.idempotencyKey ?? null}, ${draft.title}, ${draft.body},
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 (team, creation_key) DO NOTHING RETURNING *`
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 team = ${this.team} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
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 (team, ticket_id, blocker_id)
245
- VALUES (${this.team}, ${id}, ${blocker}) ON CONFLICT DO NOTHING`
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 team = ${this.team} AND id = ${id}`
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 (team, ticket_id, blocker_id)
272
- VALUES (${this.team}, ${id}, ${blockerId}) ON CONFLICT DO NOTHING`
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 team = ${this.team} AND ticket_id = ${id} AND blocker_id = ${blockerId}`
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 team = ${this.team} AND id = ${id}`
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,