@pikku/kysely 0.13.24 → 0.13.28

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 (80) hide show
  1. package/CHANGELOG.md +271 -0
  2. package/dist/src/index.d.ts +4 -1
  3. package/dist/src/index.js +3 -1
  4. package/dist/src/kysely-analytics-service.d.ts +30 -0
  5. package/dist/src/kysely-analytics-service.js +80 -0
  6. package/dist/src/kysely-feature-flag-store.d.ts +37 -0
  7. package/dist/src/kysely-feature-flag-store.js +239 -0
  8. package/dist/src/kysely-scope-service.d.ts +1 -0
  9. package/dist/src/kysely-scope-service.js +29 -0
  10. package/dist/src/kysely-tables.d.ts +45 -0
  11. package/dist/src/schema/analytics.schema.d.ts +17 -0
  12. package/dist/src/schema/analytics.schema.js +48 -0
  13. package/dist/src/schema/flag.schema.d.ts +16 -0
  14. package/dist/src/schema/flag.schema.js +62 -0
  15. package/dist/src/schema/index.d.ts +2 -0
  16. package/dist/src/schema/index.js +6 -0
  17. package/package.json +11 -6
  18. package/run-tests.sh +9 -1
  19. package/bin/pikku-kysely-pure.ts +0 -59
  20. package/dist/src/kysely-lock-vault.d.ts +0 -34
  21. package/dist/src/kysely-lock-vault.js +0 -77
  22. package/dist/src/schema/data-lock.schema.d.ts +0 -15
  23. package/dist/src/schema/data-lock.schema.js +0 -27
  24. package/dist/tsconfig.tsbuildinfo +0 -1
  25. package/src/classification-crypto.test.ts +0 -143
  26. package/src/classification-crypto.ts +0 -163
  27. package/src/classification-plugin.pglite.test.ts +0 -226
  28. package/src/classification-plugin.test.ts +0 -265
  29. package/src/classification-plugin.ts +0 -249
  30. package/src/coercion-plugin.test.ts +0 -160
  31. package/src/coercion-plugin.ts +0 -187
  32. package/src/create-audited-kysely.test.ts +0 -560
  33. package/src/create-audited-kysely.ts +0 -254
  34. package/src/index.ts +0 -81
  35. package/src/kysely-agent-run-scores.ts +0 -47
  36. package/src/kysely-agent-run-service.ts +0 -244
  37. package/src/kysely-agent-run-state-service.ts +0 -200
  38. package/src/kysely-agent-storage-service.ts +0 -632
  39. package/src/kysely-audit-service.test.ts +0 -352
  40. package/src/kysely-audit-service.ts +0 -237
  41. package/src/kysely-channel-store.ts +0 -107
  42. package/src/kysely-credential-service.ts +0 -286
  43. package/src/kysely-deployment-service.ts +0 -163
  44. package/src/kysely-eventhub-store.ts +0 -53
  45. package/src/kysely-json.ts +0 -5
  46. package/src/kysely-scope-service.test.ts +0 -418
  47. package/src/kysely-scope-service.ts +0 -425
  48. package/src/kysely-secret-service.ts +0 -242
  49. package/src/kysely-services.test.ts +0 -405
  50. package/src/kysely-session-store.ts +0 -57
  51. package/src/kysely-tables.test.ts +0 -110
  52. package/src/kysely-tables.ts +0 -434
  53. package/src/kysely-virtual-user-run-store.test.ts +0 -369
  54. package/src/kysely-virtual-user-run-store.ts +0 -235
  55. package/src/kysely-virtual-user-schedule-store.test.ts +0 -187
  56. package/src/kysely-virtual-user-schedule-store.ts +0 -187
  57. package/src/kysely-webhook-service.test.ts +0 -153
  58. package/src/kysely-webhook-service.ts +0 -139
  59. package/src/kysely-workflow-mirror.test.ts +0 -259
  60. package/src/kysely-workflow-mirror.ts +0 -374
  61. package/src/kysely-workflow-run-service.ts +0 -305
  62. package/src/kysely-workflow-service.test.ts +0 -544
  63. package/src/kysely-workflow-service.ts +0 -820
  64. package/src/schema/agent.schema.ts +0 -162
  65. package/src/schema/audit.schema.ts +0 -57
  66. package/src/schema/channel.schema.ts +0 -38
  67. package/src/schema/credential.schema.ts +0 -58
  68. package/src/schema/deployment.schema.ts +0 -47
  69. package/src/schema/index.ts +0 -441
  70. package/src/schema/pikku-schema.types.ts +0 -169
  71. package/src/schema/schema.test.ts +0 -591
  72. package/src/schema/scope.schema.ts +0 -110
  73. package/src/schema/secret.schema.ts +0 -49
  74. package/src/schema/session.schema.ts +0 -20
  75. package/src/schema/virtual-user-schedule.schema.ts +0 -48
  76. package/src/schema/virtual-user.schema.ts +0 -106
  77. package/src/schema/webhook.schema.ts +0 -58
  78. package/src/schema/workflow.schema.ts +0 -150
  79. package/src/serialize-plugin.ts +0 -233
  80. package/tsconfig.json +0 -17
@@ -1,259 +0,0 @@
1
- import { describe, test, beforeEach, afterEach } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { CamelCasePlugin, Kysely, SqliteDialect, sql } from 'kysely'
4
- import { SerializePlugin } from './serialize-plugin.js'
5
- import Database from 'better-sqlite3'
6
-
7
- import type { KyselyPikkuDB } from './kysely-tables.js'
8
- import { KyselyWorkflowMirror } from './kysely-workflow-mirror.js'
9
- import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js'
10
- import { applyPikkuSchemas, workflowSchema } from './schema/index.js'
11
-
12
- let db: Kysely<KyselyPikkuDB>
13
- let mirror: KyselyWorkflowMirror
14
- let runService: KyselyWorkflowRunService
15
-
16
- const RUN_ID = 'run-aaaa'
17
- const STEP_ID = 'step-bbbb'
18
-
19
- beforeEach(async () => {
20
- db = new Kysely<KyselyPikkuDB>({
21
- dialect: new SqliteDialect({ database: new Database(':memory:') }),
22
- plugins: [new CamelCasePlugin(), new SerializePlugin()],
23
- })
24
- await applyPikkuSchemas(db, [workflowSchema])
25
- mirror = new KyselyWorkflowMirror(db)
26
- await mirror.init()
27
- runService = new KyselyWorkflowRunService(db)
28
- })
29
-
30
- afterEach(async () => {
31
- await db.destroy()
32
- })
33
-
34
- async function seedRun(opts?: { input?: any; inline?: boolean }) {
35
- await mirror.createRun(
36
- RUN_ID,
37
- 'wf',
38
- opts?.input ?? { foo: 1 },
39
- opts?.inline ?? false,
40
- 'hash1',
41
- { type: 'do', id: RUN_ID } as any
42
- )
43
- }
44
-
45
- async function seedStep(stepId = STEP_ID, stepName = 'step1') {
46
- await mirror.insertStepState(RUN_ID, {
47
- stepId,
48
- stepName,
49
- rpcName: 'rpc.fn',
50
- data: { x: 1 },
51
- status: 'pending',
52
- attemptCount: 1,
53
- createdAt: new Date(),
54
- updatedAt: new Date(),
55
- } as any)
56
- }
57
-
58
- describe('KyselyWorkflowMirror — runs', () => {
59
- test('createRun is visible to KyselyWorkflowRunService.getRun', async () => {
60
- await seedRun({ input: { hello: 'world' } })
61
- const run = await runService.getRun(RUN_ID)
62
- assert.ok(run)
63
- assert.equal(run.workflow, 'wf')
64
- assert.equal(run.status, 'running')
65
- assert.deepEqual(run.input, { hello: 'world' })
66
- assert.equal(run.graphHash, 'hash1')
67
- })
68
-
69
- test('updateRunStatus reflects in getRun', async () => {
70
- await seedRun()
71
- await mirror.updateRunStatus(RUN_ID, 'completed', { ok: true })
72
- const run = await runService.getRun(RUN_ID)
73
- assert.equal(run!.status, 'completed')
74
- assert.deepEqual(run!.output, { ok: true })
75
- })
76
-
77
- test('listRuns surfaces mirrored runs', async () => {
78
- await seedRun()
79
- const runs = await runService.listRuns()
80
- assert.equal(runs.length, 1)
81
- assert.equal(runs[0]!.id, RUN_ID)
82
- })
83
-
84
- test('getDistinctWorkflowNames returns mirrored workflow names', async () => {
85
- await seedRun()
86
- const names = await runService.getDistinctWorkflowNames()
87
- assert.deepEqual(names, ['wf'])
88
- })
89
- })
90
-
91
- describe('KyselyWorkflowMirror — step lifecycle', () => {
92
- beforeEach(async () => {
93
- await seedRun()
94
- })
95
-
96
- test('insertStepState creates step + initial pending history', async () => {
97
- await seedStep()
98
- const steps = await runService.getRunSteps(RUN_ID)
99
- assert.equal(steps.length, 1)
100
- assert.equal(steps[0]!.stepName, 'step1')
101
- assert.equal(steps[0]!.status, 'pending')
102
-
103
- const history = await runService.getRunHistory(RUN_ID)
104
- assert.equal(history.length, 1)
105
- assert.equal(history[0]!.status, 'pending')
106
- })
107
-
108
- test('getRunHistory surfaces step provenance (fromStepName)', async () => {
109
- // The mirror persists fromStepName and getRunHistory reads it back, so the
110
- // run history reconstructs the walked path (entry has none, the next step
111
- // records its predecessor).
112
- await seedStep(STEP_ID, 'begin')
113
- await mirror.insertStepState(RUN_ID, {
114
- stepId: `${STEP_ID}-2`,
115
- stepName: 'next',
116
- rpcName: 'rpc.fn',
117
- data: { x: 2 },
118
- status: 'pending',
119
- attemptCount: 1,
120
- fromStepName: 'begin',
121
- createdAt: new Date(),
122
- updatedAt: new Date(),
123
- } as any)
124
-
125
- const history = await runService.getRunHistory(RUN_ID)
126
- const from = new Map(history.map((h) => [h.stepName, h.fromStepName]))
127
- assert.equal(from.get('begin'), undefined, 'entry step has no predecessor')
128
- assert.equal(from.get('next'), 'begin', 'next records its predecessor')
129
- })
130
-
131
- test('running → succeeded updates the in-place history row', async () => {
132
- await seedStep()
133
- await mirror.setStepRunning(STEP_ID)
134
- await mirror.setStepResult(STEP_ID, { value: 42 })
135
-
136
- const steps = await runService.getRunSteps(RUN_ID)
137
- assert.equal(steps[0]!.status, 'succeeded')
138
- assert.deepEqual(steps[0]!.result, { value: 42 })
139
-
140
- const history = await runService.getRunHistory(RUN_ID)
141
- assert.equal(history.length, 1, 'updates collapse onto latest history row')
142
- assert.equal(history[0]!.status, 'succeeded')
143
- assert.deepEqual(history[0]!.result, { value: 42 })
144
- })
145
-
146
- test('setStepError stores serialized error', async () => {
147
- await seedStep()
148
- await mirror.setStepError(STEP_ID, {
149
- message: 'boom',
150
- stack: 'stack',
151
- code: 'EBOOM',
152
- })
153
-
154
- const steps = await runService.getRunSteps(RUN_ID)
155
- assert.equal(steps[0]!.status, 'failed')
156
- assert.equal(steps[0]!.error?.message, 'boom')
157
- assert.equal(steps[0]!.error?.code, 'EBOOM')
158
- })
159
-
160
- test('createRetryAttempt appends a new history row + clears result/error', async () => {
161
- await seedStep()
162
- await mirror.setStepError(STEP_ID, { message: 'first try', code: 'X' })
163
-
164
- await mirror.createRetryAttempt(STEP_ID, {
165
- stepId: STEP_ID,
166
- stepName: 'step1',
167
- status: 'pending',
168
- attemptCount: 2,
169
- createdAt: new Date(),
170
- updatedAt: new Date(),
171
- } as any)
172
-
173
- const steps = await runService.getRunSteps(RUN_ID)
174
- assert.equal(steps[0]!.status, 'pending')
175
- assert.equal(steps[0]!.error, undefined)
176
- assert.equal(steps[0]!.result, undefined)
177
-
178
- const history = await runService.getRunHistory(RUN_ID)
179
- // pending (initial — collapsed onto failed) + pending (retry append)
180
- assert.equal(history.length, 2)
181
- assert.equal(history[0]!.status, 'failed')
182
- assert.equal(history[1]!.status, 'pending')
183
- })
184
-
185
- test('setStepChildRunId persists', async () => {
186
- await seedStep()
187
- await mirror.setStepChildRunId(STEP_ID, 'child-run-1')
188
- const steps = await runService.getRunSteps(RUN_ID)
189
- assert.equal((steps[0] as any).childRunId, 'child-run-1')
190
- })
191
-
192
- test('setBranchTaken persists the branch key', async () => {
193
- await seedStep()
194
- await mirror.setBranchTaken(STEP_ID, 'left')
195
- const row = await db
196
- .selectFrom('workflowStep')
197
- .select('branchTaken')
198
- .where('workflowStepId', '=', STEP_ID)
199
- .executeTakeFirst()
200
- assert.equal(row?.branchTaken, 'left')
201
- })
202
- })
203
-
204
- describe('KyselyWorkflowMirror — run state', () => {
205
- beforeEach(async () => {
206
- await seedRun()
207
- })
208
-
209
- test('updateRunState merges values', async () => {
210
- await mirror.updateRunState(RUN_ID, 'counter', 1)
211
- await mirror.updateRunState(RUN_ID, 'flag', true)
212
- const row = await db
213
- .selectFrom('workflowRuns')
214
- .select('state')
215
- .where('workflowRunId', '=', RUN_ID)
216
- .executeTakeFirst()
217
- const state =
218
- typeof row?.state === 'string' ? JSON.parse(row.state) : row?.state
219
- assert.deepEqual(state, { counter: 1, flag: true })
220
- })
221
- })
222
-
223
- describe('KyselyWorkflowMirror — workflow versions', () => {
224
- test('upsertWorkflowVersion is idempotent on (name, hash)', async () => {
225
- await mirror.upsertWorkflowVersion('wf', 'h1', { v: 1 }, 'graph')
226
- await mirror.upsertWorkflowVersion('wf', 'h1', { v: 2 }, 'graph')
227
- const v = await runService.getWorkflowVersion('wf', 'h1')
228
- assert.deepEqual(v?.graph, { v: 1 }, 'first write wins (doNothing)')
229
- })
230
-
231
- test('updateWorkflowVersionStatus mutates status', async () => {
232
- await mirror.upsertWorkflowVersion('wf', 'h1', {}, 'graph', 'active')
233
- await mirror.updateWorkflowVersionStatus('wf', 'h1', 'archived')
234
- const row = await db
235
- .selectFrom('workflowVersions')
236
- .select('status')
237
- .where('workflowName', '=', 'wf')
238
- .where('graphHash', '=', 'h1')
239
- .executeTakeFirst()
240
- assert.equal(row?.status, 'archived')
241
- })
242
- })
243
-
244
- describe('KyselyWorkflowMirror — init', () => {
245
- test('init is idempotent', async () => {
246
- await mirror.init()
247
- await mirror.init()
248
- // No-op on second call; just shouldn't throw.
249
- })
250
-
251
- test('cohabits with KyselyWorkflowService, which declares the same tables', async () => {
252
- // Both services own the workflow schema, so both call init() against the
253
- // same database. The second one to run must find the tables and stop, not
254
- // try to create them again. Importing the other service needs the
255
- // singleton-services bootstrap, so a fresh instance stands in for it.
256
- const second = new KyselyWorkflowMirror(db)
257
- await assert.doesNotReject(second.init())
258
- })
259
- })
@@ -1,374 +0,0 @@
1
- import type { SerializedError } from '@pikku/core/errors'
2
- import type {
3
- StepState,
4
- WorkflowRunMirror,
5
- WorkflowRunWire,
6
- WorkflowStatus,
7
- WorkflowPlannedStep,
8
- WorkflowVersionStatus,
9
- } from '@pikku/core/workflow'
10
- import type { Kysely } from 'kysely'
11
- import type { KyselyPikkuDB } from './kysely-tables.js'
12
- import { requirePikkuSchema } from './schema/index.js'
13
- import { workflowSchema } from './schema/workflow.schema.js'
14
-
15
- /**
16
- * Kysely-backed `WorkflowRunMirror`.
17
- *
18
- * Forwards executor writes to the same `workflow_runs` / `workflow_step` /
19
- * `workflow_step_history` / `workflow_versions` tables that
20
- * `KyselyWorkflowService` uses, so a `KyselyWorkflowRunService` reading
21
- * those tables will see runs driven by any executor (Cloudflare Durable
22
- * Object, Redis, MongoDB, in-memory) — not just kysely-driven runs.
23
- *
24
- * Errors thrown here are caught by the executor's `safeMirror` wrapper
25
- * and logged, so a mirror outage cannot break a running workflow.
26
- */
27
- export class KyselyWorkflowMirror implements WorkflowRunMirror {
28
- private initialized = false
29
-
30
- constructor(protected db: Kysely<KyselyPikkuDB>) {}
31
-
32
- /**
33
- * Create the underlying tables if none of them exist yet.
34
- *
35
- * Safe to call from either the mirror or `KyselyWorkflowService.init()` —
36
- * both apply the same `workflowSchema`, and `requirePikkuSchema` is a no-op
37
- * once the tables are there. A database holding only *part* of the schema
38
- * throws rather than filling in the rest: something else owns those tables,
39
- * and boot is not where that gets reconciled.
40
- */
41
- public async init(): Promise<void> {
42
- if (this.initialized) return
43
- await requirePikkuSchema(this.db, workflowSchema)
44
- this.initialized = true
45
- }
46
-
47
- async createRun(
48
- runId: string,
49
- workflowName: string,
50
- input: any,
51
- inline: boolean,
52
- graphHash: string,
53
- wire: WorkflowRunWire,
54
- options?: {
55
- deterministic?: boolean
56
- plannedSteps?: WorkflowPlannedStep[]
57
- }
58
- ): Promise<void> {
59
- await this.db
60
- .insertInto('workflowRuns')
61
- .values({
62
- workflowRunId: runId,
63
- workflow: workflowName,
64
- status: 'running',
65
- input: JSON.stringify(input),
66
- inline,
67
- graphHash: graphHash ?? null,
68
- deterministic: options?.deterministic ?? false,
69
- plannedSteps: options?.plannedSteps
70
- ? JSON.stringify(options.plannedSteps)
71
- : null,
72
- wire: wire ? JSON.stringify(wire) : null,
73
- })
74
- .execute()
75
- }
76
-
77
- async updateRunStatus(
78
- id: string,
79
- status: WorkflowStatus,
80
- output?: any,
81
- error?: SerializedError
82
- ): Promise<void> {
83
- await this.db
84
- .updateTable('workflowRuns')
85
- .set({
86
- status,
87
- output: output !== undefined ? JSON.stringify(output) : null,
88
- error: error !== undefined ? JSON.stringify(error) : null,
89
- updatedAt: new Date(),
90
- })
91
- .where('workflowRunId', '=', id)
92
- .execute()
93
- }
94
-
95
- async insertStepState(
96
- runId: string,
97
- step: StepState & {
98
- stepName: string
99
- rpcName: string | null
100
- data: any
101
- }
102
- ): Promise<void> {
103
- const now = step.createdAt ?? new Date()
104
-
105
- await this.db
106
- .insertInto('workflowStep')
107
- .values({
108
- workflowStepId: step.stepId,
109
- workflowRunId: runId,
110
- stepName: step.stepName,
111
- rpcName: step.rpcName,
112
- data: step.data != null ? JSON.stringify(step.data) : null,
113
- status: step.status,
114
- retries: step.retries ?? null,
115
- retryDelay: step.retryDelay?.toString() ?? null,
116
- fromStepName: step.fromStepName ?? null,
117
- currentAttempt: 1,
118
- createdAt: now,
119
- updatedAt: now,
120
- })
121
- .execute()
122
-
123
- await this.insertHistoryRecord(step.stepId, step.status, 1)
124
- }
125
-
126
- async setStepRunning(stepId: string): Promise<void> {
127
- await this.updateStepStatus(stepId, 'running')
128
- await this.appendOrUpdateLatestHistory(stepId, 'running')
129
- }
130
-
131
- async setStepScheduled(stepId: string): Promise<void> {
132
- await this.updateStepStatus(stepId, 'scheduled')
133
- }
134
-
135
- async setStepResult(stepId: string, result: any): Promise<void> {
136
- const resultJson = JSON.stringify(result)
137
- await this.db
138
- .updateTable('workflowStep')
139
- .set({
140
- status: 'succeeded',
141
- result: resultJson,
142
- error: null,
143
- updatedAt: new Date(),
144
- })
145
- .where('workflowStepId', '=', stepId)
146
- .execute()
147
- await this.appendOrUpdateLatestHistory(stepId, 'succeeded', resultJson)
148
- }
149
-
150
- async setStepChildRunId(stepId: string, childRunId: string): Promise<void> {
151
- await this.db
152
- .updateTable('workflowStep')
153
- .set({ childRunId, updatedAt: new Date() })
154
- .where('workflowStepId', '=', stepId)
155
- .execute()
156
- }
157
-
158
- async setStepError(stepId: string, error: SerializedError): Promise<void> {
159
- const errorJson = JSON.stringify(error)
160
- await this.db
161
- .updateTable('workflowStep')
162
- .set({
163
- status: 'failed',
164
- error: errorJson,
165
- result: null,
166
- updatedAt: new Date(),
167
- })
168
- .where('workflowStepId', '=', stepId)
169
- .execute()
170
- await this.appendOrUpdateLatestHistory(
171
- stepId,
172
- 'failed',
173
- undefined,
174
- errorJson
175
- )
176
- }
177
-
178
- async createRetryAttempt(
179
- failedStepId: string,
180
- newStep: StepState & { stepName: string }
181
- ): Promise<void> {
182
- const previous = await this.db
183
- .selectFrom('workflowStep')
184
- .select('currentAttempt')
185
- .where('workflowStepId', '=', failedStepId)
186
- .executeTakeFirst()
187
- const attempt = (previous?.currentAttempt ?? 0) + 1
188
-
189
- await this.db
190
- .updateTable('workflowStep')
191
- .set({
192
- status: newStep.status,
193
- result: null,
194
- error: null,
195
- currentAttempt: attempt,
196
- updatedAt: new Date(),
197
- })
198
- .where('workflowStepId', '=', failedStepId)
199
- .execute()
200
- await this.insertHistoryRecord(failedStepId, newStep.status, attempt)
201
- }
202
-
203
- async setBranchTaken(stepId: string, branchKey: string): Promise<void> {
204
- await this.db
205
- .updateTable('workflowStep')
206
- .set({ branchTaken: branchKey, updatedAt: new Date() })
207
- .where('workflowStepId', '=', stepId)
208
- .execute()
209
- }
210
-
211
- async updateRunState(
212
- runId: string,
213
- name: string,
214
- value: unknown
215
- ): Promise<void> {
216
- const row = await this.db
217
- .selectFrom('workflowRuns')
218
- .select('state')
219
- .where('workflowRunId', '=', runId)
220
- .executeTakeFirst()
221
-
222
- const state: Record<string, unknown> =
223
- row?.state != null
224
- ? typeof row.state === 'string'
225
- ? JSON.parse(row.state)
226
- : row.state
227
- : {}
228
- state[name] = value
229
-
230
- await this.db
231
- .updateTable('workflowRuns')
232
- .set({ state: JSON.stringify(state), updatedAt: new Date() })
233
- .where('workflowRunId', '=', runId)
234
- .execute()
235
- }
236
-
237
- async upsertWorkflowVersion(
238
- name: string,
239
- graphHash: string,
240
- graph: any,
241
- source: string,
242
- status: WorkflowVersionStatus = 'active'
243
- ): Promise<void> {
244
- await this.db
245
- .insertInto('workflowVersions')
246
- .values({
247
- workflowName: name,
248
- graphHash,
249
- graph: JSON.stringify(graph),
250
- source,
251
- status,
252
- })
253
- .onConflict((oc) => oc.columns(['workflowName', 'graphHash']).doNothing())
254
- .execute()
255
- }
256
-
257
- async updateWorkflowVersionStatus(
258
- name: string,
259
- graphHash: string,
260
- status: WorkflowVersionStatus
261
- ): Promise<void> {
262
- await this.db
263
- .updateTable('workflowVersions')
264
- .set({ status })
265
- .where('workflowName', '=', name)
266
- .where('graphHash', '=', graphHash)
267
- .execute()
268
- }
269
-
270
- // ─── internals ────────────────────────────────────────────────────
271
-
272
- private async updateStepStatus(
273
- stepId: string,
274
- status: StepState['status']
275
- ): Promise<void> {
276
- await this.db
277
- .updateTable('workflowStep')
278
- .set({ status, updatedAt: new Date() })
279
- .where('workflowStepId', '=', stepId)
280
- .execute()
281
- }
282
-
283
- private async insertHistoryRecord(
284
- stepId: string,
285
- status: string,
286
- attempt: number,
287
- resultJson?: string | null,
288
- errorJson?: string | null
289
- ): Promise<void> {
290
- const now = new Date()
291
- const values: Record<string, any> = {
292
- historyId: crypto.randomUUID(),
293
- workflowStepId: stepId,
294
- status,
295
- attempt,
296
- result: resultJson ?? null,
297
- error: errorJson ?? null,
298
- createdAt: now,
299
- }
300
- const tsField = timestampFieldFor(status)
301
- if (tsField) values[tsField] = now
302
-
303
- await this.db
304
- .insertInto('workflowStepHistory')
305
- .values(values as any)
306
- .execute()
307
- }
308
-
309
- /**
310
- * The executor pattern is: `insertStepState` writes a 'pending' history
311
- * row, then later transitions UPDATE that latest row in place rather
312
- * than appending a new one — except for retry attempts which append.
313
- *
314
- * We mirror the same shape so `KyselyWorkflowRunService.getRunHistory`
315
- * returns the same sequence the executor would have produced.
316
- */
317
- private async appendOrUpdateLatestHistory(
318
- stepId: string,
319
- status: string,
320
- resultJson?: string,
321
- errorJson?: string
322
- ): Promise<void> {
323
- const update: Record<string, any> = { status }
324
- if (resultJson !== undefined) update.result = resultJson
325
- if (errorJson !== undefined) update.error = errorJson
326
- const tsField = timestampFieldFor(status)
327
- if (tsField) update[tsField] = new Date()
328
-
329
- const result = await this.db
330
- .updateTable('workflowStepHistory')
331
- .set(update)
332
- .where('workflowStepId', '=', stepId)
333
- // Correlate through the step row rather than sorting this table by
334
- // createdAt: two attempts can land in the same millisecond, and the
335
- // step row already knows which attempt is current.
336
- .where('attempt', '=', (eb) =>
337
- eb
338
- .selectFrom('workflowStep')
339
- .select('currentAttempt')
340
- .where('workflowStepId', '=', stepId)
341
- )
342
- .executeTakeFirst()
343
-
344
- if (Number(result?.numUpdatedRows ?? 0n) === 0) {
345
- const step = await this.db
346
- .selectFrom('workflowStep')
347
- .select('currentAttempt')
348
- .where('workflowStepId', '=', stepId)
349
- .executeTakeFirst()
350
- await this.insertHistoryRecord(
351
- stepId,
352
- status,
353
- step?.currentAttempt ?? 1,
354
- resultJson,
355
- errorJson
356
- )
357
- }
358
- }
359
- }
360
-
361
- function timestampFieldFor(status: string): string | null {
362
- switch (status) {
363
- case 'running':
364
- return 'runningAt'
365
- case 'scheduled':
366
- return 'scheduledAt'
367
- case 'succeeded':
368
- return 'succeededAt'
369
- case 'failed':
370
- return 'failedAt'
371
- default:
372
- return null
373
- }
374
- }