@sixb/pg 0.1.0 → 0.1.2

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.
Files changed (99) hide show
  1. package/README.md +7 -6
  2. package/dist/agents/agent-storage.d.ts +2 -1
  3. package/dist/agents/agent-storage.d.ts.map +1 -1
  4. package/dist/agents/rows.d.ts +4 -11
  5. package/dist/agents/rows.d.ts.map +1 -1
  6. package/dist/agents/runs.d.ts +3 -2
  7. package/dist/agents/runs.d.ts.map +1 -1
  8. package/dist/auth-storage/service-accounts.d.ts.map +1 -1
  9. package/dist/auth-storage/shared.d.ts +0 -4
  10. package/dist/auth-storage/shared.d.ts.map +1 -1
  11. package/dist/connector-connection-storage.d.ts +9 -0
  12. package/dist/connector-connection-storage.d.ts.map +1 -0
  13. package/dist/index.d.ts +8 -5
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3131 -732
  16. package/dist/index.js.map +28 -25
  17. package/dist/latest-run-query.d.ts.map +1 -1
  18. package/dist/migrations.d.ts.map +1 -1
  19. package/dist/ontology-storage/materialization-session.d.ts.map +1 -1
  20. package/dist/ontology-storage/materialization-state.d.ts +0 -2
  21. package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
  22. package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
  23. package/dist/ontology-storage/materializations.d.ts.map +1 -1
  24. package/dist/ontology-storage/outbox.d.ts.map +1 -1
  25. package/dist/ontology-storage/shared.d.ts +3 -6
  26. package/dist/ontology-storage/shared.d.ts.map +1 -1
  27. package/dist/pg-action-run-storage.d.ts +3 -2
  28. package/dist/pg-action-run-storage.d.ts.map +1 -1
  29. package/dist/pg-ai-usage-storage.d.ts +16 -0
  30. package/dist/pg-ai-usage-storage.d.ts.map +1 -0
  31. package/dist/pg-execution-storage.d.ts +14 -0
  32. package/dist/pg-execution-storage.d.ts.map +1 -0
  33. package/dist/pg-pipeline-run-storage.d.ts +5 -2
  34. package/dist/pg-pipeline-run-storage.d.ts.map +1 -1
  35. package/dist/pg-projection-run-storage.d.ts +5 -2
  36. package/dist/pg-projection-run-storage.d.ts.map +1 -1
  37. package/dist/pg-sync-run-storage.d.ts +5 -2
  38. package/dist/pg-sync-run-storage.d.ts.map +1 -1
  39. package/dist/pg-webhook-run-storage.d.ts +5 -2
  40. package/dist/pg-webhook-run-storage.d.ts.map +1 -1
  41. package/dist/pg-workflow-run-storage.d.ts +5 -3
  42. package/dist/pg-workflow-run-storage.d.ts.map +1 -1
  43. package/dist/run-list-query.d.ts +1 -1
  44. package/dist/run-list-query.d.ts.map +1 -1
  45. package/dist/testing.d.ts +6 -0
  46. package/dist/testing.d.ts.map +1 -0
  47. package/package.json +5 -2
  48. package/src/agents/agent-storage.ts +3 -2
  49. package/src/agents/rows.ts +12 -43
  50. package/src/agents/runs.ts +29 -15
  51. package/src/auth-storage/service-accounts.ts +14 -15
  52. package/src/auth-storage/shared.ts +0 -8
  53. package/src/connector-connection-storage.ts +760 -0
  54. package/src/index.ts +32 -15
  55. package/src/latest-run-query.ts +7 -1
  56. package/src/migrations/002-workflow-run-output.sql +27 -0
  57. package/src/migrations/003-merge-sync-runs.sql +3 -0
  58. package/src/migrations/004-executions.sql +140 -0
  59. package/src/migrations/005-workflow-executions.sql +10 -0
  60. package/src/migrations/006-narrow-ontology-source-root-index.sql +4 -0
  61. package/src/migrations/007-split-overrides.sql +153 -0
  62. package/src/migrations/008-action-executions.sql +7 -0
  63. package/src/migrations/009-agent-executions.sql +21 -0
  64. package/src/migrations/010-ai-usage-accounting-foundation.sql +76 -0
  65. package/src/migrations/011-sync-failure-record.sql +23 -0
  66. package/src/migrations/012-pipeline-failure-record.sql +47 -0
  67. package/src/migrations/013-workflow-failure-record.sql +45 -0
  68. package/src/migrations/014-agent-failure-record.sql +56 -0
  69. package/src/migrations/015-projection-failure-record.sql +20 -0
  70. package/src/migrations/016-webhook-run-failure-record.sql +19 -0
  71. package/src/migrations/017-action-failure-record.sql +65 -0
  72. package/src/migrations/018-ontology-outbox-failure-record.sql +29 -0
  73. package/src/migrations/019-webhook-delivery-failure-record.sql +27 -0
  74. package/src/migrations/020-drop-run-usage-projections.sql +8 -0
  75. package/src/migrations/021-sync-pipeline-executions.sql +50 -0
  76. package/src/migrations/022-projection-executions.sql +115 -0
  77. package/src/migrations/023-webhook-executions.sql +30 -0
  78. package/src/migrations/024-ontology-commit-executions.sql +12 -0
  79. package/src/migrations/025-connector-connections.sql +231 -0
  80. package/src/migrations.ts +79 -1
  81. package/src/ontology-storage/materialization-session.ts +25 -28
  82. package/src/ontology-storage/materialization-state.ts +159 -43
  83. package/src/ontology-storage/materialization-writer.ts +146 -75
  84. package/src/ontology-storage/materializations.ts +3 -1
  85. package/src/ontology-storage/outbox.ts +9 -2
  86. package/src/ontology-storage/shared.ts +10 -20
  87. package/src/pg-action-run-storage.ts +43 -67
  88. package/src/pg-ai-usage-storage.ts +290 -0
  89. package/src/pg-execution-storage.ts +147 -0
  90. package/src/pg-pipeline-run-storage.ts +107 -41
  91. package/src/pg-projection-run-storage.ts +159 -91
  92. package/src/pg-sync-run-storage.ts +102 -30
  93. package/src/pg-webhook-run-storage.ts +89 -57
  94. package/src/pg-workflow-run-storage.ts +101 -130
  95. package/src/run-list-query.ts +9 -4
  96. package/src/testing.ts +18 -0
  97. package/dist/pg-webhook-delivery-storage.d.ts +0 -17
  98. package/dist/pg-webhook-delivery-storage.d.ts.map +0 -1
  99. package/src/pg-webhook-delivery-storage.ts +0 -180
@@ -1,14 +1,21 @@
1
1
  import { randomUUID } from "node:crypto"
2
+ import type { JsonValue } from "@sixb/core"
3
+ import { serializeSixbFailure } from "@sixb/core/internal/errors"
2
4
  import {
3
5
  advanceProjectionTelemetry,
4
6
  assertGenericProgressDoesNotAdvanceTelemetry,
5
7
  assertProjectionMissingTarget,
6
- assertProjectionRunExecution,
8
+ assertProjectionRunAttempt,
9
+ assertProjectionRunDurableExecution,
7
10
  assertProjectionRunListWindow,
8
11
  assertProjectionRunNonEmpty,
12
+ assertProjectionRunQueueInput,
9
13
  assertProjectionRunRunning,
10
14
  assertProjectionRunStartInput,
15
+ canRequeueProjectionRunAfterEnqueueFailure,
11
16
  createProjectionRunClaim,
17
+ createProjectionRunRecord,
18
+ failProjectionRunEnqueue,
12
19
  immutableDatasetVersionConflict,
13
20
  mergeProjectionRunProgress,
14
21
  type PersistedProjectionRunRecord,
@@ -16,7 +23,6 @@ import {
16
23
  planProjectionRunReclaim,
17
24
  projectionRunNotFound,
18
25
  publicProjectionRunRecord,
19
- requireProjectionRunExecutionToken,
20
26
  requireTelemetryProjectionRun,
21
27
  restoreProjectionRun,
22
28
  type StoredProjectionRunRecord,
@@ -24,6 +30,8 @@ import {
24
30
  } from "@sixb/core/internal/projection-run-storage-provider"
25
31
  import type {
26
32
  AdvanceProjectionTelemetryCheckpointInput,
33
+ ExecutionStorage,
34
+ FailProjectionRunEnqueueInput,
27
35
  FinishProjectionRunInput,
28
36
  ListLatestProjectionRunsInput,
29
37
  ListLatestProjectionRunsResult,
@@ -35,21 +43,34 @@ import type {
35
43
  ProjectionRunRecord,
36
44
  ProjectionRunStatus,
37
45
  ProjectionRunStorage,
46
+ QueueProjectionRunInput,
38
47
  RecordProjectionMissingTargetInput,
39
48
  StartOrReclaimProjectionRunInput,
40
49
  TelemetryProjectionRunRecord,
41
50
  UpdateProjectionRunInput,
42
51
  } from "@sixb/core/storage"
43
- import { ProjectionRunError } from "@sixb/core/storage"
52
+ import { PROJECTION_RUN_FAILURE_CODES, ProjectionRunError } from "@sixb/core/storage"
44
53
  import { queryLatestRunsByOwnerId } from "./latest-run-query"
45
54
  import type { SQLClient, SqlParameter } from "./pg-client"
46
55
  import { lockAdvisoryKeys, type PgStoreClient, runPgTransaction } from "./transactions"
47
56
 
48
57
  export class PgProjectionRunStorage implements ProjectionRunStorage {
49
- constructor(private readonly sql: PgStoreClient) {}
50
-
51
- async startOrReclaim(input: StartOrReclaimProjectionRunInput): Promise<ProjectionRunClaim> {
52
- assertProjectionRunStartInput(input)
58
+ constructor(
59
+ private readonly sql: PgStoreClient,
60
+ private readonly executions: ExecutionStorage
61
+ ) {}
62
+
63
+ async queue(input: QueueProjectionRunInput): Promise<ProjectionRunRecord> {
64
+ assertProjectionRunQueueInput(input)
65
+ await assertProjectionRunDurableExecution({
66
+ executions: this.executions,
67
+ projectId: input.projectId,
68
+ executionId: input.executionId,
69
+ runId: input.id,
70
+ projectionId: input.identity.projectionId,
71
+ datasetId: input.identity.datasetVersion.datasetId,
72
+ datasetVersionId: input.identity.datasetVersion.versionId,
73
+ })
53
74
 
54
75
  return runPgTransaction(this.sql, async (tx) => {
55
76
  await lockAdvisoryKeys(tx, [
@@ -60,98 +81,119 @@ export class PgProjectionRunStorage implements ProjectionRunStorage {
60
81
  input.identity.datasetVersion.versionId
61
82
  ),
62
83
  ])
63
- const [metadataConflict] = await tx<{ readonly id: string }[]>`
64
- SELECT id FROM projection_runs
65
- WHERE project_id = ${input.projectId}
66
- AND id <> ${input.id}
67
- AND dataset_id = ${input.identity.datasetVersion.datasetId}
68
- AND dataset_version_id = ${input.identity.datasetVersion.versionId}
69
- AND dataset_version_created_at IS NOT NULL
70
- AND dataset_version_created_at <> ${input.identity.datasetVersion.createdAt}
71
- LIMIT 1
72
- `
73
- if (metadataConflict) {
74
- throw immutableDatasetVersionConflict(input.identity)
75
- }
76
-
84
+ await assertDatasetVersionIsImmutable(tx, input)
77
85
  const [existingRow] = await tx<DatabaseRow[]>`
78
86
  SELECT * FROM projection_runs
79
87
  WHERE project_id = ${input.projectId} AND id = ${input.id}
80
88
  FOR UPDATE
81
89
  `
82
-
83
- const existing = existingRow ? restoreProjectionRunRow(existingRow) : undefined
84
- const executionToken = createFreshExecutionToken(existing?.executionToken)
85
-
86
- if (existingRow && existing) {
87
- const { attempt } = planProjectionRunReclaim(existing, input)
88
- const previousExecutionToken = requireProjectionRunExecutionToken(existing)
89
-
90
- const [updated] = await tx<DatabaseRow[]>`
90
+ if (existingRow) {
91
+ if (
92
+ !canRequeueProjectionRunAfterEnqueueFailure(rowToProjectionRunRecord(existingRow), input)
93
+ ) {
94
+ throw duplicateProjectionRun(input)
95
+ }
96
+ const [requeued] = await tx<DatabaseRow[]>`
91
97
  UPDATE projection_runs
92
- SET attempt = ${attempt}, execution_token = ${executionToken}
93
- WHERE project_id = ${input.projectId}
94
- AND id = ${input.id}
95
- AND status = ${"running"}
96
- AND execution_token = ${previousExecutionToken}
98
+ SET status = ${"queued"}, queued_at = ${input.queuedAt ?? new Date()},
99
+ started_at = ${null}, finished_at = ${null}, attempt = ${0}, execution_token = ${null},
100
+ next_batch_ordinal = ${input.fixedBatchSize === undefined ? null : 0},
101
+ next_row_offset = ${input.fixedBatchSize === undefined ? null : 0},
102
+ input_exhausted = ${input.fixedBatchSize === undefined ? null : false},
103
+ missing_target_object_type_id = ${null}, missing_target_object_id = ${null},
104
+ missing_target_batch_ordinal = ${null}, missing_target_first_seen_at = ${null},
105
+ source_rows_read = ${0}, source_rows_skipped = ${0}, error = ${null}
106
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
107
+ AND status = ${"failed"} AND error->>'code' = ${"queue.enqueue_failed"}
97
108
  RETURNING *
98
109
  `
99
- if (!updated) throw staleProjectionRunExecution(input.id)
100
- return projectionRunClaim(updated)
110
+ if (!requeued) throw duplicateProjectionRun(input)
111
+ return rowToProjectionRunRecord(requeued)
101
112
  }
102
113
 
114
+ const record = createProjectionRunRecord(input)
115
+ const checkpoint = record.telemetryCheckpoint
103
116
  const [inserted] = await tx<DatabaseRow[]>`
104
117
  INSERT INTO projection_runs (
105
- project_id,
106
- id,
107
- projection_id,
108
- projection_kind,
109
- dataset_id,
110
- dataset_version_id,
111
- object_type_id,
112
- source_object_type_id,
113
- target_object_type_id,
114
- status,
115
- started_at,
116
- attempt,
117
- execution_token,
118
- materialization_protocol,
119
- dataset_version_created_at,
120
- ontology_revision,
121
- projection_revision,
122
- ownership_hash,
123
- fixed_batch_size,
124
- next_batch_ordinal,
125
- next_row_offset,
126
- input_exhausted
118
+ project_id, id, execution_id, projection_id, projection_kind,
119
+ materialization_protocol, dataset_id, dataset_version_id,
120
+ dataset_version_created_at, ontology_revision, projection_revision, ownership_hash,
121
+ object_type_id, source_object_type_id, target_object_type_id, status, queued_at,
122
+ started_at, finished_at, attempt, execution_token, fixed_batch_size,
123
+ next_batch_ordinal, next_row_offset, input_exhausted, source_rows_read,
124
+ source_rows_skipped
127
125
  ) VALUES (
128
- ${input.projectId},
129
- ${input.id},
130
- ${input.identity.projectionId},
131
- ${input.identity.projectionKind},
132
- ${input.identity.datasetVersion.datasetId},
133
- ${input.identity.datasetVersion.versionId},
134
- ${"objectTypeId" in input.target ? input.target.objectTypeId : null},
135
- ${"sourceObjectTypeId" in input.target ? input.target.sourceObjectTypeId : null},
136
- ${"sourceObjectTypeId" in input.target ? input.target.targetObjectTypeId : null},
137
- ${"running"},
138
- ${input.startedAt ?? new Date()},
139
- ${1},
140
- ${executionToken},
141
- ${input.identity.protocol},
142
- ${input.identity.datasetVersion.createdAt},
143
- ${input.identity.ontologyRevision},
144
- ${input.identity.projectionRevision},
145
- ${input.identity.ownershipHash},
146
- ${input.fixedBatchSize ?? null},
147
- ${input.fixedBatchSize === undefined ? null : 0},
148
- ${input.fixedBatchSize === undefined ? null : 0},
149
- ${input.fixedBatchSize === undefined ? null : false}
126
+ ${record.projectId}, ${record.id}, ${record.executionId},
127
+ ${record.identity.projectionId}, ${record.identity.projectionKind},
128
+ ${record.identity.protocol}, ${record.identity.datasetVersion.datasetId},
129
+ ${record.identity.datasetVersion.versionId}, ${record.identity.datasetVersion.createdAt},
130
+ ${record.identity.ontologyRevision}, ${record.identity.projectionRevision},
131
+ ${record.identity.ownershipHash},
132
+ ${"objectTypeId" in record.target ? record.target.objectTypeId : null},
133
+ ${"sourceObjectTypeId" in record.target ? record.target.sourceObjectTypeId : null},
134
+ ${"sourceObjectTypeId" in record.target ? record.target.targetObjectTypeId : null},
135
+ ${"queued"}, ${record.queuedAt}, ${null}, ${null}, ${0}, ${null},
136
+ ${checkpoint?.fixedBatchSize ?? null}, ${checkpoint?.nextBatchOrdinal ?? null},
137
+ ${checkpoint?.nextRowOffset ?? null}, ${checkpoint?.inputExhausted ?? null}, ${0}, ${0}
150
138
  )
151
139
  RETURNING *
152
140
  `
141
+ return rowToProjectionRunRecord(inserted)
142
+ })
143
+ }
144
+
145
+ async startOrReclaim(input: StartOrReclaimProjectionRunInput): Promise<ProjectionRunClaim> {
146
+ assertProjectionRunStartInput(input)
153
147
 
154
- return projectionRunClaim(inserted)
148
+ return runPgTransaction(this.sql, async (tx) => {
149
+ await lockAdvisoryKeys(tx, [projectionRunLockKey(input.projectId, input.id)])
150
+ const [existingRow] = await tx<DatabaseRow[]>`
151
+ SELECT * FROM projection_runs
152
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
153
+ FOR UPDATE
154
+ `
155
+ if (!existingRow) throw projectionRunNotFound(input.projectId, input.id)
156
+ const existing = restoreProjectionRunRow(existingRow)
157
+ const { attempt } = planProjectionRunReclaim(existing, input)
158
+ const executionToken = createFreshExecutionToken(existing.executionToken)
159
+ const [updated] = await tx<DatabaseRow[]>`
160
+ UPDATE projection_runs
161
+ SET status = ${"running"}, started_at = COALESCE(started_at, ${input.startedAt ?? new Date()}),
162
+ finished_at = ${null}, attempt = ${attempt}, execution_token = ${executionToken},
163
+ error = ${null}
164
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
165
+ AND (
166
+ (status = ${"queued"} AND execution_token IS NULL)
167
+ OR (status = ${"running"} AND execution_token = ${existing.executionToken ?? null})
168
+ )
169
+ RETURNING *
170
+ `
171
+ if (!updated) throw staleProjectionRunExecution(input.id)
172
+ return projectionRunClaim(updated)
173
+ })
174
+ }
175
+
176
+ async failEnqueue(input: FailProjectionRunEnqueueInput): Promise<ProjectionRunRecord> {
177
+ return runPgTransaction(this.sql, async (tx) => {
178
+ const [existingRow] = await tx<DatabaseRow[]>`
179
+ SELECT * FROM projection_runs
180
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
181
+ FOR UPDATE
182
+ `
183
+ if (!existingRow) throw projectionRunNotFound(input.projectId, input.id)
184
+ const failed = failProjectionRunEnqueue(restoreProjectionRunRow(existingRow), input)
185
+ if (!failed.error) {
186
+ throw new ProjectionRunError(`[SixbPg] Projection run '${input.id}' has no failure.`)
187
+ }
188
+ const [updated] = await tx<DatabaseRow[]>`
189
+ UPDATE projection_runs
190
+ SET status = ${"failed"}, finished_at = ${failed.finishedAt ?? new Date()},
191
+ error = ${serializeSixbFailure(failed.error, PROJECTION_RUN_FAILURE_CODES)}::text::jsonb
192
+ WHERE project_id = ${input.projectId} AND id = ${input.id} AND status = ${"queued"}
193
+ RETURNING *
194
+ `
195
+ if (!updated) throw duplicateProjectionRun(input)
196
+ return rowToProjectionRunRecord(updated)
155
197
  })
156
198
  }
157
199
 
@@ -200,7 +242,7 @@ export class PgProjectionRunStorage implements ProjectionRunStorage {
200
242
  source_rows_read = ${plan.progress.sourceRowsRead},
201
243
  source_rows_skipped = ${plan.progress.sourceRowsSkipped},
202
244
  input_exhausted = ${plan.inputExhausted ?? existingRow.input_exhausted},
203
- error_message = ${plan.errorMessage ?? null}
245
+ error = ${plan.error === undefined ? null : serializeSixbFailure(plan.error, PROJECTION_RUN_FAILURE_CODES)}::text::jsonb
204
246
  WHERE project_id = ${input.projectId}
205
247
  AND id = ${input.id}
206
248
  AND status = ${"running"}
@@ -328,11 +370,11 @@ export class PgProjectionRunStorage implements ProjectionRunStorage {
328
370
  params.push(...input.statuses)
329
371
  }
330
372
  if (input.startedAfter) {
331
- whereClauses.push(`started_at >= $${index++}`)
373
+ whereClauses.push(`COALESCE(started_at, queued_at) >= $${index++}`)
332
374
  params.push(input.startedAfter)
333
375
  }
334
376
  if (input.startedBefore) {
335
- whereClauses.push(`started_at <= $${index++}`)
377
+ whereClauses.push(`COALESCE(started_at, queued_at) <= $${index++}`)
336
378
  params.push(input.startedBefore)
337
379
  }
338
380
 
@@ -351,7 +393,7 @@ export class PgProjectionRunStorage implements ProjectionRunStorage {
351
393
  let query = `
352
394
  SELECT * FROM projection_runs
353
395
  ${where}
354
- ORDER BY started_at ${order}, id ${order}
396
+ ORDER BY COALESCE(started_at, queued_at) ${order}, id ${order}
355
397
  `
356
398
  if (input.limit !== undefined) {
357
399
  query += ` LIMIT $${index++} OFFSET $${index++}`
@@ -388,7 +430,7 @@ async function requireMaterializationExecution(
388
430
  input: LockProjectionRunForMaterializationInput
389
431
  ): Promise<DatabaseRow> {
390
432
  const row = await requireRunning(sql, input.projectId, input.id)
391
- assertProjectionRunExecution(restoreProjectionRunRow(row), input)
433
+ assertProjectionRunAttempt(restoreProjectionRunRow(row), input)
392
434
  return row
393
435
  }
394
436
 
@@ -406,6 +448,22 @@ async function requireRunning(sql: SQLClient, projectId: string, id: string): Pr
406
448
  return row
407
449
  }
408
450
 
451
+ async function assertDatasetVersionIsImmutable(
452
+ sql: SQLClient,
453
+ input: QueueProjectionRunInput
454
+ ): Promise<void> {
455
+ const [conflict] = await sql<{ readonly id: string }[]>`
456
+ SELECT id FROM projection_runs
457
+ WHERE project_id = ${input.projectId}
458
+ AND id <> ${input.id}
459
+ AND dataset_id = ${input.identity.datasetVersion.datasetId}
460
+ AND dataset_version_id = ${input.identity.datasetVersion.versionId}
461
+ AND dataset_version_created_at <> ${input.identity.datasetVersion.createdAt}
462
+ LIMIT 1
463
+ `
464
+ if (conflict) throw immutableDatasetVersionConflict(input.identity)
465
+ }
466
+
409
467
  function createFreshExecutionToken(previous: string | undefined): string {
410
468
  let token = randomUUID()
411
469
  while (token === previous) token = randomUUID()
@@ -436,6 +494,7 @@ function restoreProjectionRunRow(row: DatabaseRow): StoredProjectionRunRecord {
436
494
  const persisted: PersistedProjectionRunRecord = {
437
495
  id: row.id,
438
496
  projectId: row.project_id,
497
+ executionId: row.execution_id,
439
498
  projectionId: row.projection_id,
440
499
  projectionKind: row.projection_kind,
441
500
  protocol: row.materialization_protocol ?? undefined,
@@ -449,7 +508,8 @@ function restoreProjectionRunRow(row: DatabaseRow): StoredProjectionRunRecord {
449
508
  sourceObjectTypeId: row.source_object_type_id ?? undefined,
450
509
  targetObjectTypeId: row.target_object_type_id ?? undefined,
451
510
  status: row.status,
452
- startedAt: new Date(row.started_at),
511
+ queuedAt: new Date(row.queued_at),
512
+ startedAt: row.started_at ? new Date(row.started_at) : undefined,
453
513
  finishedAt: row.finished_at ? new Date(row.finished_at) : undefined,
454
514
  attempt: databaseSafeInteger(row.attempt, "attempt"),
455
515
  executionToken: row.execution_token ?? undefined,
@@ -470,7 +530,7 @@ function restoreProjectionRunRow(row: DatabaseRow): StoredProjectionRunRecord {
470
530
  sourceRowsRead: databaseSafeInteger(row.source_rows_read, "sourceRowsRead"),
471
531
  sourceRowsSkipped: databaseSafeInteger(row.source_rows_skipped, "sourceRowsSkipped"),
472
532
  },
473
- errorMessage: row.error_message ?? undefined,
533
+ error: row.error ?? undefined,
474
534
  }
475
535
  return restoreProjectionRun(persisted)
476
536
  }
@@ -495,6 +555,7 @@ function databaseSafeInteger(value: number | string | null, fieldName: string):
495
555
  interface DatabaseRow {
496
556
  project_id: string
497
557
  id: string
558
+ execution_id: string
498
559
  projection_id: string
499
560
  projection_kind: ProjectionKind
500
561
  dataset_id: string
@@ -503,7 +564,8 @@ interface DatabaseRow {
503
564
  source_object_type_id: string | null
504
565
  target_object_type_id: string | null
505
566
  status: ProjectionRunStatus
506
- started_at: Date | string
567
+ queued_at: Date | string
568
+ started_at: Date | string | null
507
569
  finished_at: Date | string | null
508
570
  attempt: number | string
509
571
  execution_token: string | null
@@ -522,5 +584,11 @@ interface DatabaseRow {
522
584
  missing_target_first_seen_at: Date | string | null
523
585
  source_rows_read: number | string
524
586
  source_rows_skipped: number | string
525
- error_message: string | null
587
+ error: JsonValue | null
588
+ }
589
+
590
+ function duplicateProjectionRun(input: { readonly projectId: string; readonly id: string }) {
591
+ return new ProjectionRunError(
592
+ `[SixbPg] Projection run '${input.id}' already exists for project '${input.projectId}'.`
593
+ )
526
594
  }
@@ -1,16 +1,23 @@
1
1
  import type { JsonValue } from "@sixb/core"
2
+ import { parseSixbFailure, serializeSixbFailure } from "@sixb/core/internal/errors"
3
+ import { assertSyncRunExecution } from "@sixb/core/internal/sync-run-storage-provider"
2
4
  import type {
5
+ ExecutionStorage,
3
6
  FinishSyncRunInput,
4
7
  ListLatestSyncRunsInput,
5
8
  ListLatestSyncRunsResult,
6
9
  ListSyncRunsInput,
7
10
  ListSyncRunsResult,
11
+ QueueSyncRunInput,
8
12
  StartSyncRunInput,
9
- SyncRunFailure,
10
13
  SyncRunRecord,
11
14
  SyncRunStorage,
12
15
  } from "@sixb/core/storage"
13
- import { SyncRunError } from "@sixb/core/storage"
16
+ import {
17
+ canRequeueSyncRunAfterEnqueueFailure,
18
+ SYNC_RUN_FAILURE_CODES,
19
+ SyncRunError,
20
+ } from "@sixb/core/storage"
14
21
  import { queryLatestRunsByOwnerId } from "./latest-run-query"
15
22
  import type { SqlParameter } from "./pg-client"
16
23
  import { appendRunListFilters, hasEmptyStatuses, queryRunList } from "./run-list-query"
@@ -18,29 +25,43 @@ import { isUniqueViolation } from "./storage-errors"
18
25
  import { type PgStoreClient, runPgTransaction } from "./transactions"
19
26
 
20
27
  export class PgSyncRunStorage implements SyncRunStorage {
21
- constructor(private readonly sql: PgStoreClient) {}
28
+ constructor(
29
+ private readonly sql: PgStoreClient,
30
+ private readonly executions: ExecutionStorage
31
+ ) {}
22
32
 
23
- async start(input: StartSyncRunInput): Promise<SyncRunRecord> {
33
+ async queue(input: QueueSyncRunInput): Promise<SyncRunRecord> {
34
+ await assertSyncRunExecution({
35
+ executions: this.executions,
36
+ projectId: input.projectId,
37
+ executionId: input.executionId,
38
+ runId: input.id,
39
+ syncId: input.syncId,
40
+ })
24
41
  try {
25
42
  const [row] = await this.sql<DatabaseRow[]>`
26
43
  INSERT INTO sync_runs (
27
44
  project_id,
28
45
  id,
46
+ execution_id,
29
47
  sync_id,
30
48
  dataset_id,
31
49
  mode,
32
50
  status,
51
+ queued_at,
33
52
  started_at,
34
53
  expected_latest_version_id,
35
54
  commit_message
36
55
  ) VALUES (
37
56
  ${input.projectId},
38
57
  ${input.id},
58
+ ${input.executionId},
39
59
  ${input.syncId},
40
60
  ${input.datasetId},
41
61
  ${input.mode},
42
- ${"running"},
43
- ${input.startedAt ?? new Date()},
62
+ ${"queued"},
63
+ ${input.queuedAt ?? new Date()},
64
+ ${null},
44
65
  ${input.expectedLatestVersionId ?? null},
45
66
  ${input.commitMessage ?? null}
46
67
  )
@@ -50,13 +71,57 @@ export class PgSyncRunStorage implements SyncRunStorage {
50
71
  return rowToSyncRunRecord(row)
51
72
  } catch (error) {
52
73
  if (isUniqueViolation(error)) {
74
+ return this.requeueAfterEnqueueFailure(input)
75
+ }
76
+
77
+ throw error
78
+ }
79
+ }
80
+
81
+ private async requeueAfterEnqueueFailure(input: QueueSyncRunInput): Promise<SyncRunRecord> {
82
+ return runPgTransaction(this.sql, async (tx) => {
83
+ const [existing] = await tx<DatabaseRow[]>`
84
+ SELECT *, checkpoint IS NOT NULL AS checkpoint_present FROM sync_runs
85
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
86
+ FOR UPDATE
87
+ `
88
+ if (!existing || !canRequeueSyncRunAfterEnqueueFailure(rowToSyncRunRecord(existing), input)) {
53
89
  throw new SyncRunError(
54
90
  `[SixbPg] Sync run '${input.id}' already exists for project '${input.projectId}'.`
55
91
  )
56
92
  }
57
93
 
58
- throw error
94
+ const [updated] = await tx<DatabaseRow[]>`
95
+ UPDATE sync_runs
96
+ SET status = ${"queued"}, queued_at = ${input.queuedAt ?? new Date()},
97
+ started_at = ${null}, finished_at = ${null}, rows_read = ${null},
98
+ output_version_id = ${null}, error = ${null}, checkpoint = ${null}
99
+ WHERE project_id = ${input.projectId} AND id = ${input.id}
100
+ RETURNING *, checkpoint IS NOT NULL AS checkpoint_present
101
+ `
102
+ return rowToSyncRunRecord(updated)
103
+ })
104
+ }
105
+
106
+ async start(input: StartSyncRunInput): Promise<SyncRunRecord> {
107
+ const [updated] = await this.sql<DatabaseRow[]>`
108
+ UPDATE sync_runs
109
+ SET status = ${"running"}, started_at = ${input.startedAt ?? new Date()},
110
+ error = ${null}
111
+ WHERE project_id = ${input.projectId} AND id = ${input.id} AND status = ${"queued"}
112
+ RETURNING *, checkpoint IS NOT NULL AS checkpoint_present
113
+ `
114
+ if (updated) return rowToSyncRunRecord(updated)
115
+
116
+ const existing = await this.getById({ projectId: input.projectId, id: input.id })
117
+ if (!existing) {
118
+ throw new SyncRunError(
119
+ `[SixbPg] Sync run '${input.id}' not found for project '${input.projectId}'.`
120
+ )
59
121
  }
122
+ throw new SyncRunError(
123
+ `[SixbPg] Sync run '${input.id}' cannot start from status '${existing.status}'.`
124
+ )
60
125
  }
61
126
 
62
127
  async finish(input: FinishSyncRunInput): Promise<SyncRunRecord> {
@@ -72,15 +137,26 @@ export class PgSyncRunStorage implements SyncRunStorage {
72
137
  )
73
138
  }
74
139
 
140
+ const enqueueFailure =
141
+ input.status === "failed" && input.error.code === "queue.enqueue_failed"
142
+ if (
143
+ (enqueueFailure && existing.status !== "queued") ||
144
+ (!enqueueFailure && existing.status !== "running")
145
+ ) {
146
+ throw new SyncRunError(
147
+ `[SixbPg] Sync run '${input.id}' cannot finish from status '${existing.status}'.`
148
+ )
149
+ }
150
+
75
151
  if (input.status === "succeeded") {
76
152
  if (input.output && input.output.datasetId !== existing.dataset_id) {
77
153
  throw new SyncRunError(
78
154
  `[SixbPg] Sync run '${input.id}' output dataset '${input.output.datasetId}' does not match '${existing.dataset_id}'.`
79
155
  )
80
156
  }
81
- if (!input.output && (existing.mode !== "append" || input.rowsRead !== 0)) {
157
+ if (!input.output && input.rowsRead !== 0 && existing.mode !== "merge") {
82
158
  throw new SyncRunError(
83
- `[SixbPg] Sync run '${input.id}' may omit its output only for an empty append.`
159
+ `[SixbPg] Sync run '${input.id}' may omit its output with rows read only for an initial merge no-op.`
84
160
  )
85
161
  }
86
162
  }
@@ -94,8 +170,7 @@ export class PgSyncRunStorage implements SyncRunStorage {
94
170
  finished_at = ${input.finishedAt ?? new Date()},
95
171
  rows_read = ${input.rowsRead},
96
172
  output_version_id = ${input.output?.versionId ?? null},
97
- error_name = ${null},
98
- error_message = ${null},
173
+ error = ${null},
99
174
  checkpoint = ${serializeCheckpoint(input.checkpoint)}::text::jsonb
100
175
  WHERE project_id = ${input.projectId} AND id = ${input.id}
101
176
  RETURNING *, checkpoint IS NOT NULL AS checkpoint_present
@@ -107,8 +182,7 @@ export class PgSyncRunStorage implements SyncRunStorage {
107
182
  finished_at = ${input.finishedAt ?? new Date()},
108
183
  rows_read = ${input.rowsRead ?? existing.rows_read ?? null},
109
184
  output_version_id = ${null},
110
- error_name = ${input.error?.name ?? null},
111
- error_message = ${input.error?.message ?? null},
185
+ error = ${input.error === undefined ? null : serializeSixbFailure(input.error, SYNC_RUN_FAILURE_CODES)}::text::jsonb,
112
186
  checkpoint = ${null}
113
187
  WHERE project_id = ${input.projectId} AND id = ${input.id}
114
188
  RETURNING *, checkpoint IS NOT NULL AS checkpoint_present
@@ -156,7 +230,13 @@ export class PgSyncRunStorage implements SyncRunStorage {
156
230
  params.push(input.datasetId)
157
231
  }
158
232
 
159
- index = appendRunListFilters(whereClauses, params, index, input)
233
+ index = appendRunListFilters(
234
+ whereClauses,
235
+ params,
236
+ index,
237
+ input,
238
+ "COALESCE(started_at, queued_at)"
239
+ )
160
240
 
161
241
  const { rows, total, hasMore } = await queryRunList<DatabaseRow>({
162
242
  sql: this.sql,
@@ -198,26 +278,17 @@ function serializeCheckpoint(checkpoint: JsonValue | undefined): string | null {
198
278
  return checkpoint !== undefined ? JSON.stringify(checkpoint) : null
199
279
  }
200
280
 
201
- function toSyncRunFailure(row: DatabaseRow): SyncRunFailure | undefined {
202
- if (!row.error_message) {
203
- return undefined
204
- }
205
-
206
- return {
207
- name: row.error_name ?? undefined,
208
- message: row.error_message,
209
- }
210
- }
211
-
212
281
  function rowToSyncRunRecord(row: DatabaseRow): SyncRunRecord {
213
282
  return {
214
283
  id: row.id,
215
284
  projectId: row.project_id,
285
+ executionId: row.execution_id,
216
286
  syncId: row.sync_id,
217
287
  datasetId: row.dataset_id,
218
288
  mode: row.mode,
219
289
  status: row.status,
220
- startedAt: new Date(row.started_at),
290
+ queuedAt: new Date(row.queued_at),
291
+ startedAt: row.started_at ? new Date(row.started_at) : undefined,
221
292
  finishedAt: row.finished_at ? new Date(row.finished_at) : undefined,
222
293
  rowsRead: row.rows_read != null ? Number(row.rows_read) : undefined,
223
294
  output: row.output_version_id
@@ -228,7 +299,7 @@ function rowToSyncRunRecord(row: DatabaseRow): SyncRunRecord {
228
299
  : undefined,
229
300
  expectedLatestVersionId: row.expected_latest_version_id ?? undefined,
230
301
  commitMessage: row.commit_message ?? undefined,
231
- error: toSyncRunFailure(row),
302
+ error: row.error === null ? undefined : parseSixbFailure(row.error, SYNC_RUN_FAILURE_CODES),
232
303
  checkpoint: hasCheckpoint(row) ? row.checkpoint : undefined,
233
304
  }
234
305
  }
@@ -246,18 +317,19 @@ function hasCheckpoint(row: DatabaseRow): boolean {
246
317
  interface DatabaseRow {
247
318
  project_id: string
248
319
  id: string
320
+ execution_id: string
249
321
  sync_id: string
250
322
  dataset_id: string
251
323
  mode: SyncRunRecord["mode"]
252
324
  status: SyncRunRecord["status"]
253
- started_at: Date | string
325
+ queued_at: Date | string
326
+ started_at: Date | string | null
254
327
  finished_at: Date | string | null
255
328
  rows_read: number | string | null
256
329
  output_version_id: string | null
257
330
  expected_latest_version_id: string | null
258
331
  commit_message: string | null
259
- error_name: string | null
260
- error_message: string | null
332
+ error: JsonValue | null
261
333
  checkpoint: JsonValue | null
262
334
  checkpoint_present: boolean | number | string
263
335
  }