@sixb/pg 0.1.18 → 0.1.19

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.
@@ -4,33 +4,23 @@ import {
4
4
  serializeActionRunFailure,
5
5
  } from "@sixb/core/internal/action-run-storage"
6
6
  import { assertActionRunExecution } from "@sixb/core/internal/action-run-storage-provider"
7
- import {
8
- actionRunPhaseRecordsEqual,
9
- canRequeueActionRunAfterEnqueueFailure,
10
- finishActionRunPhase,
11
- } from "@sixb/core/internal/storage"
7
+ import { normalizeRecordActionRunInput, resolveActionRunEffects } from "@sixb/core/internal/storage"
12
8
  import type {
13
9
  ActionRunEffectsRecord,
14
- ActionRunFailure,
15
10
  ActionRunParams,
16
11
  ActionRunPhase,
17
12
  ActionRunRecord,
13
+ ActionRunStatus,
18
14
  ActionRunStorage,
19
15
  ActionRunWritebackRecord,
20
- EnterActionRunPhaseInput,
21
16
  ExecutionStorage,
22
- FinishActionRunInput,
23
17
  ListActionRunsInput,
24
18
  ListActionRunsResult,
25
- LockActionMaterializationRunInput,
26
- QueueActionRunInput,
27
19
  RecordActionEffectsInput,
28
- RecordActionWritebackInput,
29
- StartActionRunInput,
20
+ RecordActionRunInput,
30
21
  } from "@sixb/core/storage"
31
- import { ActionRunError, isTerminalActionRun } from "@sixb/core/storage"
32
- import type { SQLClient, SqlParameter } from "./pg-client"
33
- import { isUniqueViolation } from "./storage-errors"
22
+ import { ActionRunError } from "@sixb/core/storage"
23
+ import type { SqlParameter } from "./pg-client"
34
24
  import { type PgStoreClient, runPgTransaction } from "./transactions"
35
25
 
36
26
  export class PgActionRunStorage implements ActionRunStorage {
@@ -39,289 +29,100 @@ export class PgActionRunStorage implements ActionRunStorage {
39
29
  private readonly executions: ExecutionStorage
40
30
  ) {}
41
31
 
42
- async lockForMaterialization(input: LockActionMaterializationRunInput): Promise<ActionRunRecord> {
43
- const [row] = await this.sql<DatabaseRow[]>`
44
- SELECT *
45
- FROM action_runs
46
- WHERE project_id = ${input.projectId} AND id = ${input.runId}
47
- FOR UPDATE
48
- `
49
- if (!row) {
50
- throw new ActionRunError(
51
- `[SixbPg] Action run '${input.runId}' not found for project '${input.projectId}'.`
52
- )
53
- }
54
- if (row.action_id !== input.actionId) {
55
- throw new ActionRunError(
56
- `[SixbPg] Action run '${input.runId}' does not belong to action '${input.actionId}'.`
57
- )
58
- }
59
- if (row.status !== "running") {
60
- throw new ActionRunError(
61
- `[SixbPg] Action run '${input.runId}' cannot materialize from status '${row.status}'.`
62
- )
63
- }
64
- return rowToActionRunRecord(row)
65
- }
66
-
67
- async queue(input: QueueActionRunInput): Promise<ActionRunRecord> {
32
+ async record(input: RecordActionRunInput): Promise<ActionRunRecord> {
33
+ const record = normalizeRecordActionRunInput(input)
68
34
  await assertActionRunExecution({
69
35
  executions: this.executions,
70
- projectId: input.projectId,
71
- executionId: input.executionId,
72
- runId: input.id,
73
- actionId: input.actionId,
36
+ projectId: record.projectId,
37
+ executionId: record.executionId,
38
+ runId: record.id,
39
+ actionId: record.actionId,
74
40
  })
75
41
 
76
- try {
77
- const [row] = await this.sql<DatabaseRow[]>`
78
- INSERT INTO action_runs (
79
- project_id,
80
- id,
81
- execution_id,
82
- action_id,
83
- subject_kind,
84
- object_type_id,
85
- primary_id,
86
- status,
87
- phase,
88
- queued_at,
89
- started_at,
90
- finished_at,
91
- params,
92
- idempotency_key,
93
- writeback_status,
94
- writeback_completed_at,
95
- writeback_result,
96
- writeback_error,
97
- effects_status,
98
- effects_completed_at,
99
- effects_error,
100
- error
101
- ) VALUES (
102
- ${input.projectId},
103
- ${input.id},
104
- ${input.executionId},
105
- ${input.actionId},
106
- ${input.subject.kind},
107
- ${input.subject.kind === "object" ? input.subject.objectTypeId : null},
108
- ${input.subject.kind === "object" ? input.subject.primaryId : null},
109
- ${"queued"},
110
- ${"request"},
111
- ${input.queuedAt ?? new Date()},
112
- ${null},
113
- ${null},
114
- ${JSON.stringify(input.params)}::text::jsonb,
115
- ${input.idempotencyKey},
116
- ${null},
117
- ${null},
118
- ${null},
119
- ${null},
120
- ${null},
121
- ${null},
122
- ${null},
123
- ${null}
124
- )
125
- RETURNING *
126
- `
127
-
128
- return rowToActionRunRecord(row)
129
- } catch (error) {
130
- if (isUniqueViolation(error)) {
131
- return this.requeueAfterEnqueueFailure(input)
132
- }
133
-
134
- throw error
135
- }
136
- }
137
-
138
- private async requeueAfterEnqueueFailure(input: QueueActionRunInput): Promise<ActionRunRecord> {
139
- return runPgTransaction(this.sql, async (tx) => {
140
- const [existing] = await tx<DatabaseRow[]>`
141
- SELECT * FROM action_runs
142
- WHERE project_id = ${input.projectId} AND id = ${input.id}
143
- FOR UPDATE
144
- `
145
-
146
- if (
147
- !existing ||
148
- !canRequeueActionRunAfterEnqueueFailure(rowToActionRunRecord(existing), input)
149
- ) {
150
- throw new ActionRunError(
151
- `[SixbPg] Action run '${input.id}' already exists for project '${input.projectId}'.`
152
- )
153
- }
154
-
155
- const [updated] = await tx<DatabaseRow[]>`
156
- UPDATE action_runs
157
- SET
158
- status = ${"queued"},
159
- phase = ${"request"},
160
- queued_at = ${input.queuedAt ?? new Date()},
161
- started_at = ${null},
162
- finished_at = ${null},
163
- writeback_status = ${null},
164
- writeback_completed_at = ${null},
165
- writeback_result = ${null},
166
- writeback_error = ${null},
167
- effects_status = ${null},
168
- effects_completed_at = ${null},
169
- effects_error = ${null},
170
- error = ${null}
171
- WHERE project_id = ${input.projectId} AND id = ${input.id}
172
- RETURNING *
173
- `
174
-
175
- return rowToActionRunRecord(updated)
176
- })
177
- }
178
-
179
- async start(input: StartActionRunInput): Promise<ActionRunRecord> {
180
- const [updated] = await this.sql<DatabaseRow[]>`
181
- UPDATE action_runs
182
- SET
183
- status = ${"running"},
184
- phase = ${input.phase ?? "validation"},
185
- started_at = ${input.startedAt ?? new Date()},
186
- error = ${null}
187
- WHERE project_id = ${input.projectId}
188
- AND id = ${input.id}
189
- AND status = ${"queued"}
42
+ // A conflict with a committed record is answered as a refusal rather than raised: a raised
43
+ // unique violation would abort the caller's transaction before the error could say what
44
+ // conflicted. A conflict with a concurrent transaction's insert waits for that transaction;
45
+ // under the serializable isolation of an Action commit, it then fails with a serialization
46
+ // error, which the Materializer retries into a replay of the commit that won.
47
+ const [row] = await this.sql<DatabaseRow[]>`
48
+ INSERT INTO action_runs (
49
+ project_id,
50
+ id,
51
+ execution_id,
52
+ action_id,
53
+ subject_kind,
54
+ object_type_id,
55
+ primary_id,
56
+ status,
57
+ phase,
58
+ started_at,
59
+ finished_at,
60
+ params,
61
+ idempotency_key,
62
+ writeback_status,
63
+ writeback_completed_at,
64
+ writeback_result,
65
+ writeback_error,
66
+ error
67
+ ) VALUES (
68
+ ${record.projectId},
69
+ ${record.id},
70
+ ${record.executionId},
71
+ ${record.actionId},
72
+ ${record.subject.kind},
73
+ ${record.subject.kind === "object" ? record.subject.objectTypeId : null},
74
+ ${record.subject.kind === "object" ? record.subject.primaryId : null},
75
+ ${record.status},
76
+ ${record.phase},
77
+ ${record.startedAt},
78
+ ${record.finishedAt},
79
+ ${JSON.stringify(record.params)}::text::jsonb,
80
+ ${record.idempotencyKey},
81
+ ${record.writeback?.status ?? null},
82
+ ${record.writeback?.completedAt ?? null},
83
+ ${record.writeback?.status === "succeeded" ? JSON.stringify(record.writeback.result) : null}::text::jsonb,
84
+ ${record.writeback?.status === "failed" ? serializeActionRunFailure(record.writeback.error, "writeback") : null}::text::jsonb,
85
+ ${record.status === "failed" ? serializeActionRunFailure(record.error) : null}::text::jsonb
86
+ )
87
+ ON CONFLICT DO NOTHING
190
88
  RETURNING *
191
89
  `
90
+ if (row) return rowToActionRunRecord(row)
192
91
 
193
- if (updated) {
194
- return rowToActionRunRecord(updated)
195
- }
196
-
197
- const existing = await this.getById({ projectId: input.projectId, id: input.id })
198
- if (!existing) {
199
- throw new ActionRunError(
200
- `[SixbPg] Action run '${input.id}' not found for project '${input.projectId}'.`
201
- )
202
- }
203
-
92
+ const [conflict] = await this.sql<{ id: string }[]>`
93
+ SELECT id FROM action_runs
94
+ WHERE project_id = ${record.projectId} AND id = ${record.id}
95
+ `
204
96
  throw new ActionRunError(
205
- `[SixbPg] Action run '${input.id}' cannot start from status '${existing.status}'.`
97
+ conflict
98
+ ? `[SixbPg] Action run '${record.id}' is already recorded for project '${record.projectId}'.`
99
+ : `[SixbPg] Execution '${record.executionId}' already belongs to another Action run.`
206
100
  )
207
101
  }
208
102
 
209
- async enterPhase(input: EnterActionRunPhaseInput): Promise<ActionRunRecord> {
210
- return runPgTransaction(this.sql, async (tx) => {
211
- await this.requireRunningRun(tx, input.projectId, input.id, "transition phase")
212
-
213
- const [updated] = await tx<DatabaseRow[]>`
214
- UPDATE action_runs
215
- SET phase = ${input.phase}
216
- WHERE project_id = ${input.projectId} AND id = ${input.id}
217
- RETURNING *
218
- `
219
-
220
- return rowToActionRunRecord(updated)
221
- })
222
- }
223
-
224
- async recordWriteback(input: RecordActionWritebackInput): Promise<ActionRunRecord> {
225
- return runPgTransaction(this.sql, async (tx) => {
226
- const existing = await this.requireRunningRun(
227
- tx,
228
- input.projectId,
229
- input.id,
230
- "record writeback"
231
- )
232
- const nextWriteback = toWritebackRecord(input, new Date(input.completedAt ?? new Date()))
233
- const currentWriteback = toActionRunWritebackRecord(existing)
234
-
235
- if (currentWriteback) {
236
- if (actionRunPhaseRecordsEqual(currentWriteback, nextWriteback)) {
237
- return rowToActionRunRecord(existing)
238
- }
239
-
240
- throw new ActionRunError(
241
- `[SixbPg] Action run '${input.id}' already has a different writeback record.`
242
- )
243
- }
244
-
245
- const [updated] = await tx<DatabaseRow[]>`
246
- UPDATE action_runs
247
- SET
248
- phase = ${"writeback"},
249
- writeback_status = ${input.status},
250
- writeback_completed_at = ${nextWriteback.completedAt},
251
- writeback_result = ${input.status === "succeeded" ? JSON.stringify(input.result) : null}::text::jsonb,
252
- writeback_error = ${input.status === "failed" ? serializeActionRunFailure(input.error, "writeback") : null}::text::jsonb
253
- WHERE project_id = ${input.projectId} AND id = ${input.id}
254
- RETURNING *
255
- `
256
-
257
- return rowToActionRunRecord(updated)
258
- })
259
- }
260
-
261
103
  async recordEffects(input: RecordActionEffectsInput): Promise<ActionRunRecord> {
262
- return runPgTransaction(this.sql, async (tx) => {
263
- const existing = await this.requireRunningRun(tx, input.projectId, input.id, "record effects")
264
- const nextEffects = toEffectsRecord(input, new Date(input.completedAt ?? new Date()))
265
- const currentEffects = toActionRunEffectsRecord(existing)
266
-
267
- if (currentEffects) {
268
- if (actionRunPhaseRecordsEqual(currentEffects, nextEffects)) {
269
- return rowToActionRunRecord(existing)
270
- }
271
-
272
- throw new ActionRunError(
273
- `[SixbPg] Action run '${input.id}' already has a different effects record.`
274
- )
275
- }
276
-
277
- const [updated] = await tx<DatabaseRow[]>`
278
- UPDATE action_runs
279
- SET
280
- phase = ${"effects"},
281
- effects_status = ${input.status},
282
- effects_completed_at = ${nextEffects.completedAt},
283
- effects_error = ${input.status === "failed" ? serializeActionRunFailure(input.error, "effects") : null}::text::jsonb
284
- WHERE project_id = ${input.projectId} AND id = ${input.id}
285
- RETURNING *
286
- `
287
-
288
- return rowToActionRunRecord(updated)
289
- })
290
- }
291
-
292
- async finish(input: FinishActionRunInput): Promise<ActionRunRecord> {
293
104
  return runPgTransaction(this.sql, async (tx) => {
294
105
  const [existing] = await tx<DatabaseRow[]>`
295
106
  SELECT * FROM action_runs
296
107
  WHERE project_id = ${input.projectId} AND id = ${input.id}
297
108
  FOR UPDATE
298
109
  `
299
-
300
- if (!existing) {
301
- throw new ActionRunError(
302
- `[SixbPg] Action run '${input.id}' not found for project '${input.projectId}'.`
303
- )
304
- }
305
-
306
- if (isTerminalActionRun({ status: existing.status })) {
307
- throw new ActionRunError(
308
- `[SixbPg] Action run '${input.id}' cannot finish from terminal status '${existing.status}'.`
309
- )
310
- }
311
-
312
- const phase = finishActionRunPhase(input, existing.phase)
110
+ const { run, effects } = resolveActionRunEffects(
111
+ existing ? rowToActionRunRecord(existing) : null,
112
+ input
113
+ )
114
+ if (!effects) return run
313
115
 
314
116
  const [updated] = await tx<DatabaseRow[]>`
315
117
  UPDATE action_runs
316
118
  SET
317
- status = ${input.status},
318
- phase = ${phase},
319
- finished_at = ${input.finishedAt ?? new Date()},
320
- error = ${input.status === "succeeded" ? null : serializeActionRunFailure(input.error)}::text::jsonb
119
+ phase = ${"effects"},
120
+ effects_status = ${effects.status},
121
+ effects_completed_at = ${effects.completedAt},
122
+ effects_error = ${effects.status === "failed" ? serializeActionRunFailure(effects.error, "effects") : null}::text::jsonb
321
123
  WHERE project_id = ${input.projectId} AND id = ${input.id}
322
124
  RETURNING *
323
125
  `
324
-
325
126
  return rowToActionRunRecord(updated)
326
127
  })
327
128
  }
@@ -405,12 +206,12 @@ export class PgActionRunStorage implements ActionRunStorage {
405
206
  }
406
207
 
407
208
  if (input.startedAfter) {
408
- whereClauses.push(`COALESCE(started_at, queued_at) >= $${index++}`)
209
+ whereClauses.push(`started_at >= $${index++}`)
409
210
  params.push(input.startedAfter)
410
211
  }
411
212
 
412
213
  if (input.startedBefore) {
413
- whereClauses.push(`COALESCE(started_at, queued_at) <= $${index++}`)
214
+ whereClauses.push(`started_at <= $${index++}`)
414
215
  params.push(input.startedBefore)
415
216
  }
416
217
 
@@ -427,7 +228,7 @@ export class PgActionRunStorage implements ActionRunStorage {
427
228
  let query = `
428
229
  SELECT * FROM action_runs
429
230
  ${where}
430
- ORDER BY COALESCE(started_at, queued_at) ${order}, id ${order}
231
+ ORDER BY started_at ${order}, id ${order}
431
232
  `
432
233
 
433
234
  if (input.limit !== undefined) {
@@ -448,42 +249,6 @@ export class PgActionRunStorage implements ActionRunStorage {
448
249
  total,
449
250
  }
450
251
  }
451
-
452
- private async requireRunningRun(
453
- runner: SQLClient,
454
- projectId: string,
455
- id: string,
456
- operation: string
457
- ): Promise<DatabaseRow> {
458
- const [existing] = await runner<DatabaseRow[]>`
459
- SELECT * FROM action_runs
460
- WHERE project_id = ${projectId} AND id = ${id}
461
- FOR UPDATE
462
- `
463
-
464
- if (!existing) {
465
- throw new ActionRunError(`[SixbPg] Action run '${id}' not found for project '${projectId}'.`)
466
- }
467
-
468
- if (existing.status !== "running") {
469
- throw new ActionRunError(
470
- `[SixbPg] Action run '${id}' cannot ${operation} from status '${existing.status}'.`
471
- )
472
- }
473
-
474
- return existing
475
- }
476
- }
477
-
478
- function toActionRunFailure(row: DatabaseRow): ActionRunFailure | undefined {
479
- return row.error === null ? undefined : parseActionRunFailure(row.error)
480
- }
481
-
482
- function parsePhaseFailure<TPhase extends Extract<ActionRunPhase, "writeback" | "effects">>(
483
- value: unknown,
484
- expectedPhase: TPhase
485
- ): ActionRunFailure<TPhase> {
486
- return parseActionRunFailure(value, expectedPhase)
487
252
  }
488
253
 
489
254
  function toActionRunWritebackRecord(row: DatabaseRow): ActionRunWritebackRecord | undefined {
@@ -491,22 +256,19 @@ function toActionRunWritebackRecord(row: DatabaseRow): ActionRunWritebackRecord
491
256
  return undefined
492
257
  }
493
258
 
494
- const completedAt = row.writeback_completed_at
495
- ? new Date(row.writeback_completed_at)
496
- : new Date(row.queued_at)
497
-
259
+ const completedAt = new Date(row.writeback_completed_at ?? row.finished_at)
498
260
  if (row.writeback_status === "succeeded") {
499
261
  return {
500
262
  status: "succeeded",
501
263
  completedAt,
502
- result: (row.writeback_result ?? null) as JsonValue,
264
+ result: row.writeback_result ?? null,
503
265
  }
504
266
  }
505
267
 
506
268
  return {
507
269
  status: "failed",
508
270
  completedAt,
509
- error: parsePhaseFailure(row.writeback_error, "writeback"),
271
+ error: parseActionRunFailure(row.writeback_error, "writeback"),
510
272
  }
511
273
  }
512
274
 
@@ -515,10 +277,7 @@ function toActionRunEffectsRecord(row: DatabaseRow): ActionRunEffectsRecord | un
515
277
  return undefined
516
278
  }
517
279
 
518
- const completedAt = row.effects_completed_at
519
- ? new Date(row.effects_completed_at)
520
- : new Date(row.queued_at)
521
-
280
+ const completedAt = new Date(row.effects_completed_at ?? row.finished_at)
522
281
  if (row.effects_status === "succeeded") {
523
282
  return {
524
283
  status: "succeeded",
@@ -529,65 +288,30 @@ function toActionRunEffectsRecord(row: DatabaseRow): ActionRunEffectsRecord | un
529
288
  return {
530
289
  status: "failed",
531
290
  completedAt,
532
- error: parsePhaseFailure(row.effects_error, "effects"),
533
- }
534
- }
535
-
536
- function toWritebackRecord(
537
- input: RecordActionWritebackInput,
538
- completedAt: Date
539
- ): ActionRunWritebackRecord {
540
- if (input.status === "succeeded") {
541
- return {
542
- status: "succeeded",
543
- completedAt,
544
- result: input.result,
545
- }
546
- }
547
-
548
- return {
549
- status: "failed",
550
- completedAt,
551
- error: input.error,
552
- }
553
- }
554
-
555
- function toEffectsRecord(
556
- input: RecordActionEffectsInput,
557
- completedAt: Date
558
- ): ActionRunEffectsRecord {
559
- if (input.status === "succeeded") {
560
- return {
561
- status: "succeeded",
562
- completedAt,
563
- }
564
- }
565
-
566
- return {
567
- status: "failed",
568
- completedAt,
569
- error: input.error,
291
+ error: parseActionRunFailure(row.effects_error, "effects"),
570
292
  }
571
293
  }
572
294
 
573
295
  function rowToActionRunRecord(row: DatabaseRow): ActionRunRecord {
574
- return {
296
+ const writeback = toActionRunWritebackRecord(row)
297
+ const effects = toActionRunEffectsRecord(row)
298
+ const fields = {
575
299
  id: row.id,
576
300
  projectId: row.project_id,
577
301
  executionId: row.execution_id,
578
302
  actionId: row.action_id,
579
303
  subject: rowToActionSubject(row),
580
- status: row.status,
581
- phase: row.phase ?? undefined,
582
- queuedAt: new Date(row.queued_at),
583
- startedAt: row.started_at ? new Date(row.started_at) : undefined,
584
- finishedAt: row.finished_at ? new Date(row.finished_at) : undefined,
304
+ phase: row.phase,
305
+ startedAt: new Date(row.started_at),
306
+ finishedAt: new Date(row.finished_at),
585
307
  params: row.params,
586
308
  idempotencyKey: row.idempotency_key,
587
- writeback: toActionRunWritebackRecord(row),
588
- effects: toActionRunEffectsRecord(row),
589
- error: toActionRunFailure(row),
309
+ ...(writeback === undefined ? {} : { writeback }),
310
+ ...(effects === undefined ? {} : { effects }),
590
311
  }
312
+
313
+ if (row.status === "succeeded") return { ...fields, status: "succeeded" }
314
+ return { ...fields, status: "failed", error: parseActionRunFailure(row.error) }
591
315
  }
592
316
 
593
317
  function rowToActionSubject(row: DatabaseRow): ActionSubject {
@@ -614,11 +338,10 @@ interface DatabaseRow {
614
338
  subject_kind: ActionSubject["kind"]
615
339
  object_type_id: string | null
616
340
  primary_id: string | null
617
- status: ActionRunRecord["status"]
618
- phase: ActionRunPhase | null
619
- queued_at: Date | string
620
- started_at: Date | string | null
621
- finished_at: Date | string | null
341
+ status: ActionRunStatus
342
+ phase: ActionRunPhase
343
+ started_at: Date | string
344
+ finished_at: Date | string
622
345
  params: ActionRunParams
623
346
  idempotency_key: string
624
347
  writeback_status: ActionRunWritebackRecord["status"] | null