@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/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
 
@@ -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 team = ${this.team} AND id = ${id} FOR UPDATE`
152
- : 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}`
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 team = ${this.team}
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 team = ${this.team} AND id = ${id}`
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 team = ${this.team} AND id = ${id}
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 team = ${this.team} AND ticket_id = ${id}`
197
- await q`INSERT INTO ab_ticket_comments (team, ticket_id, seq, body, created_at)
198
- 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()})`
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 team = ${this.team} AND id = ${id} RETURNING id`
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 team = ${this.team} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
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
- (team, id, creation_key, title, body, state, labels, created_at, updated_at)
229
- 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},
230
213
  ${state}, ${q.array(draft.labels ?? [], 'text')}, ${now}, ${now})
231
- ON CONFLICT (team, creation_key) DO NOTHING RETURNING *`
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 team = ${this.team} AND creation_key = ${options.idempotencyKey} FOR UPDATE`
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 (team, ticket_id, blocker_id)
244
- 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`
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 team = ${this.team} AND id = ${id}`
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 (team, ticket_id, blocker_id)
271
- 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`
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 team = ${this.team} AND ticket_id = ${id} AND blocker_id = ${blockerId}`
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 team = ${this.team} AND id = ${id}`
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,