@pikku/kysely 0.13.24 → 0.13.27

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 (78) hide show
  1. package/CHANGELOG.md +235 -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-tables.d.ts +45 -0
  9. package/dist/src/schema/analytics.schema.d.ts +17 -0
  10. package/dist/src/schema/analytics.schema.js +48 -0
  11. package/dist/src/schema/flag.schema.d.ts +16 -0
  12. package/dist/src/schema/flag.schema.js +62 -0
  13. package/dist/src/schema/index.d.ts +2 -0
  14. package/dist/src/schema/index.js +6 -0
  15. package/package.json +11 -6
  16. package/run-tests.sh +9 -1
  17. package/bin/pikku-kysely-pure.ts +0 -59
  18. package/dist/src/kysely-lock-vault.d.ts +0 -34
  19. package/dist/src/kysely-lock-vault.js +0 -77
  20. package/dist/src/schema/data-lock.schema.d.ts +0 -15
  21. package/dist/src/schema/data-lock.schema.js +0 -27
  22. package/dist/tsconfig.tsbuildinfo +0 -1
  23. package/src/classification-crypto.test.ts +0 -143
  24. package/src/classification-crypto.ts +0 -163
  25. package/src/classification-plugin.pglite.test.ts +0 -226
  26. package/src/classification-plugin.test.ts +0 -265
  27. package/src/classification-plugin.ts +0 -249
  28. package/src/coercion-plugin.test.ts +0 -160
  29. package/src/coercion-plugin.ts +0 -187
  30. package/src/create-audited-kysely.test.ts +0 -560
  31. package/src/create-audited-kysely.ts +0 -254
  32. package/src/index.ts +0 -81
  33. package/src/kysely-agent-run-scores.ts +0 -47
  34. package/src/kysely-agent-run-service.ts +0 -244
  35. package/src/kysely-agent-run-state-service.ts +0 -200
  36. package/src/kysely-agent-storage-service.ts +0 -632
  37. package/src/kysely-audit-service.test.ts +0 -352
  38. package/src/kysely-audit-service.ts +0 -237
  39. package/src/kysely-channel-store.ts +0 -107
  40. package/src/kysely-credential-service.ts +0 -286
  41. package/src/kysely-deployment-service.ts +0 -163
  42. package/src/kysely-eventhub-store.ts +0 -53
  43. package/src/kysely-json.ts +0 -5
  44. package/src/kysely-scope-service.test.ts +0 -418
  45. package/src/kysely-scope-service.ts +0 -425
  46. package/src/kysely-secret-service.ts +0 -242
  47. package/src/kysely-services.test.ts +0 -405
  48. package/src/kysely-session-store.ts +0 -57
  49. package/src/kysely-tables.test.ts +0 -110
  50. package/src/kysely-tables.ts +0 -434
  51. package/src/kysely-virtual-user-run-store.test.ts +0 -369
  52. package/src/kysely-virtual-user-run-store.ts +0 -235
  53. package/src/kysely-virtual-user-schedule-store.test.ts +0 -187
  54. package/src/kysely-virtual-user-schedule-store.ts +0 -187
  55. package/src/kysely-webhook-service.test.ts +0 -153
  56. package/src/kysely-webhook-service.ts +0 -139
  57. package/src/kysely-workflow-mirror.test.ts +0 -259
  58. package/src/kysely-workflow-mirror.ts +0 -374
  59. package/src/kysely-workflow-run-service.ts +0 -305
  60. package/src/kysely-workflow-service.test.ts +0 -544
  61. package/src/kysely-workflow-service.ts +0 -820
  62. package/src/schema/agent.schema.ts +0 -162
  63. package/src/schema/audit.schema.ts +0 -57
  64. package/src/schema/channel.schema.ts +0 -38
  65. package/src/schema/credential.schema.ts +0 -58
  66. package/src/schema/deployment.schema.ts +0 -47
  67. package/src/schema/index.ts +0 -441
  68. package/src/schema/pikku-schema.types.ts +0 -169
  69. package/src/schema/schema.test.ts +0 -591
  70. package/src/schema/scope.schema.ts +0 -110
  71. package/src/schema/secret.schema.ts +0 -49
  72. package/src/schema/session.schema.ts +0 -20
  73. package/src/schema/virtual-user-schedule.schema.ts +0 -48
  74. package/src/schema/virtual-user.schema.ts +0 -106
  75. package/src/schema/webhook.schema.ts +0 -58
  76. package/src/schema/workflow.schema.ts +0 -150
  77. package/src/serialize-plugin.ts +0 -233
  78. 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
- }