@pikku/kysely 0.12.11 → 0.12.13

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.
@@ -0,0 +1,438 @@
1
+ import type { SerializedError } from '@pikku/core'
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 { sql } from 'kysely'
12
+ import type { KyselyPikkuDB } from './kysely-tables.js'
13
+
14
+ /**
15
+ * Kysely-backed `WorkflowRunMirror`.
16
+ *
17
+ * Forwards executor writes to the same `workflow_runs` / `workflow_step` /
18
+ * `workflow_step_history` / `workflow_versions` tables that
19
+ * `KyselyWorkflowService` uses, so a `KyselyWorkflowRunService` reading
20
+ * those tables will see runs driven by any executor (Cloudflare Durable
21
+ * Object, Redis, MongoDB, in-memory) — not just kysely-driven runs.
22
+ *
23
+ * Errors thrown here are caught by the executor's `safeMirror` wrapper
24
+ * and logged, so a mirror outage cannot break a running workflow.
25
+ */
26
+ export class KyselyWorkflowMirror implements WorkflowRunMirror {
27
+ private initialized = false
28
+
29
+ constructor(protected db: Kysely<KyselyPikkuDB>) {}
30
+
31
+ /**
32
+ * Create the underlying tables if they don't exist. Safe to call from
33
+ * either the mirror or `KyselyWorkflowService.init()` — schema is the
34
+ * same and uses `ifNotExists`.
35
+ */
36
+ public async init(): Promise<void> {
37
+ if (this.initialized) return
38
+
39
+ await this.db.schema
40
+ .createTable('workflow_runs')
41
+ .ifNotExists()
42
+ .addColumn('workflow_run_id', 'text', (col) => col.primaryKey())
43
+ .addColumn('workflow', 'text', (col) => col.notNull())
44
+ .addColumn('status', 'text', (col) => col.notNull())
45
+ .addColumn('input', 'text', (col) => col.notNull())
46
+ .addColumn('output', 'text')
47
+ .addColumn('error', 'text')
48
+ .addColumn('state', 'text', (col) => col.defaultTo('{}'))
49
+ .addColumn('inline', 'boolean', (col) => col.defaultTo(false))
50
+ .addColumn('graph_hash', 'text')
51
+ .addColumn('deterministic', 'boolean', (col) => col.defaultTo(false))
52
+ .addColumn('planned_steps', 'text')
53
+ .addColumn('wire', 'text')
54
+ .addColumn('created_at', 'timestamp', (col) =>
55
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
56
+ )
57
+ .addColumn('updated_at', 'timestamp', (col) =>
58
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
59
+ )
60
+ .execute()
61
+
62
+ await this.db.schema
63
+ .createTable('workflow_step')
64
+ .ifNotExists()
65
+ .addColumn('workflow_step_id', 'text', (col) => col.primaryKey())
66
+ .addColumn('workflow_run_id', 'text', (col) =>
67
+ col
68
+ .notNull()
69
+ .references('workflow_runs.workflow_run_id')
70
+ .onDelete('cascade')
71
+ )
72
+ .addColumn('step_name', 'text', (col) => col.notNull())
73
+ .addColumn('rpc_name', 'text')
74
+ .addColumn('data', 'text')
75
+ .addColumn('status', 'text', (col) => col.notNull().defaultTo('pending'))
76
+ .addColumn('result', 'text')
77
+ .addColumn('error', 'text')
78
+ .addColumn('child_run_id', 'text')
79
+ .addColumn('branch_taken', 'text')
80
+ .addColumn('retries', 'integer')
81
+ .addColumn('retry_delay', 'text')
82
+ .addColumn('created_at', 'timestamp', (col) =>
83
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
84
+ )
85
+ .addColumn('updated_at', 'timestamp', (col) =>
86
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
87
+ )
88
+ .addUniqueConstraint('workflow_step_run_name_unique', [
89
+ 'workflow_run_id',
90
+ 'step_name',
91
+ ])
92
+ .execute()
93
+
94
+ await this.db.schema
95
+ .createTable('workflow_step_history')
96
+ .ifNotExists()
97
+ .addColumn('history_id', 'text', (col) => col.primaryKey())
98
+ .addColumn('workflow_step_id', 'text', (col) =>
99
+ col
100
+ .notNull()
101
+ .references('workflow_step.workflow_step_id')
102
+ .onDelete('cascade')
103
+ )
104
+ .addColumn('status', 'text', (col) => col.notNull())
105
+ .addColumn('result', 'text')
106
+ .addColumn('error', 'text')
107
+ .addColumn('created_at', 'timestamp', (col) =>
108
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
109
+ )
110
+ .addColumn('running_at', 'timestamp')
111
+ .addColumn('scheduled_at', 'timestamp')
112
+ .addColumn('succeeded_at', 'timestamp')
113
+ .addColumn('failed_at', 'timestamp')
114
+ .execute()
115
+
116
+ await this.db.schema
117
+ .createTable('workflow_versions')
118
+ .ifNotExists()
119
+ .addColumn('workflow_name', 'text', (col) => col.notNull())
120
+ .addColumn('graph_hash', 'text', (col) => col.notNull())
121
+ .addColumn('graph', 'text', (col) => col.notNull())
122
+ .addColumn('source', 'text', (col) => col.notNull())
123
+ .addColumn('status', 'text', (col) => col.notNull().defaultTo('active'))
124
+ .addColumn('created_at', 'timestamp', (col) =>
125
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
126
+ )
127
+ .addPrimaryKeyConstraint('workflow_versions_pk', [
128
+ 'workflow_name',
129
+ 'graph_hash',
130
+ ])
131
+ .execute()
132
+
133
+ this.initialized = true
134
+ }
135
+
136
+ async createRun(
137
+ runId: string,
138
+ workflowName: string,
139
+ input: any,
140
+ inline: boolean,
141
+ graphHash: string,
142
+ wire: WorkflowRunWire,
143
+ options?: {
144
+ deterministic?: boolean
145
+ plannedSteps?: WorkflowPlannedStep[]
146
+ }
147
+ ): Promise<void> {
148
+ await this.db
149
+ .insertInto('workflowRuns')
150
+ .values({
151
+ workflowRunId: runId,
152
+ workflow: workflowName,
153
+ status: 'running',
154
+ input: JSON.stringify(input),
155
+ inline,
156
+ graphHash: graphHash ?? null,
157
+ deterministic: options?.deterministic ?? false,
158
+ plannedSteps: options?.plannedSteps
159
+ ? JSON.stringify(options.plannedSteps)
160
+ : null,
161
+ wire: wire ? JSON.stringify(wire) : null,
162
+ })
163
+ .execute()
164
+ }
165
+
166
+ async updateRunStatus(
167
+ id: string,
168
+ status: WorkflowStatus,
169
+ output?: any,
170
+ error?: SerializedError
171
+ ): Promise<void> {
172
+ await this.db
173
+ .updateTable('workflowRuns')
174
+ .set({
175
+ status,
176
+ output: output !== undefined ? JSON.stringify(output) : null,
177
+ error: error !== undefined ? JSON.stringify(error) : null,
178
+ updatedAt: new Date(),
179
+ })
180
+ .where('workflowRunId', '=', id)
181
+ .execute()
182
+ }
183
+
184
+ async insertStepState(
185
+ runId: string,
186
+ step: StepState & {
187
+ stepName: string
188
+ rpcName: string | null
189
+ data: any
190
+ }
191
+ ): Promise<void> {
192
+ const now = step.createdAt ?? new Date()
193
+
194
+ await this.db
195
+ .insertInto('workflowStep')
196
+ .values({
197
+ workflowStepId: step.stepId,
198
+ workflowRunId: runId,
199
+ stepName: step.stepName,
200
+ rpcName: step.rpcName,
201
+ data: step.data != null ? JSON.stringify(step.data) : null,
202
+ status: step.status,
203
+ retries: step.retries ?? null,
204
+ retryDelay: step.retryDelay?.toString() ?? null,
205
+ createdAt: now,
206
+ updatedAt: now,
207
+ })
208
+ .execute()
209
+
210
+ await this.insertHistoryRecord(step.stepId, step.status)
211
+ }
212
+
213
+ async setStepRunning(stepId: string): Promise<void> {
214
+ await this.updateStepStatus(stepId, 'running')
215
+ await this.appendOrUpdateLatestHistory(stepId, 'running')
216
+ }
217
+
218
+ async setStepScheduled(stepId: string): Promise<void> {
219
+ await this.updateStepStatus(stepId, 'scheduled')
220
+ }
221
+
222
+ async setStepResult(stepId: string, result: any): Promise<void> {
223
+ const resultJson = JSON.stringify(result)
224
+ await this.db
225
+ .updateTable('workflowStep')
226
+ .set({
227
+ status: 'succeeded',
228
+ result: resultJson,
229
+ error: null,
230
+ updatedAt: new Date(),
231
+ })
232
+ .where('workflowStepId', '=', stepId)
233
+ .execute()
234
+ await this.appendOrUpdateLatestHistory(stepId, 'succeeded', resultJson)
235
+ }
236
+
237
+ async setStepChildRunId(stepId: string, childRunId: string): Promise<void> {
238
+ await this.db
239
+ .updateTable('workflowStep')
240
+ .set({ childRunId, updatedAt: new Date() })
241
+ .where('workflowStepId', '=', stepId)
242
+ .execute()
243
+ }
244
+
245
+ async setStepError(stepId: string, error: SerializedError): Promise<void> {
246
+ const errorJson = JSON.stringify(error)
247
+ await this.db
248
+ .updateTable('workflowStep')
249
+ .set({
250
+ status: 'failed',
251
+ error: errorJson,
252
+ result: null,
253
+ updatedAt: new Date(),
254
+ })
255
+ .where('workflowStepId', '=', stepId)
256
+ .execute()
257
+ await this.appendOrUpdateLatestHistory(
258
+ stepId,
259
+ 'failed',
260
+ undefined,
261
+ errorJson
262
+ )
263
+ }
264
+
265
+ async createRetryAttempt(
266
+ failedStepId: string,
267
+ newStep: StepState & { stepName: string }
268
+ ): Promise<void> {
269
+ await this.db
270
+ .updateTable('workflowStep')
271
+ .set({
272
+ status: newStep.status,
273
+ result: null,
274
+ error: null,
275
+ updatedAt: new Date(),
276
+ })
277
+ .where('workflowStepId', '=', failedStepId)
278
+ .execute()
279
+ await this.insertHistoryRecord(failedStepId, newStep.status)
280
+ }
281
+
282
+ async setBranchTaken(stepId: string, branchKey: string): Promise<void> {
283
+ await this.db
284
+ .updateTable('workflowStep')
285
+ .set({ branchTaken: branchKey, updatedAt: new Date() })
286
+ .where('workflowStepId', '=', stepId)
287
+ .execute()
288
+ }
289
+
290
+ async updateRunState(
291
+ runId: string,
292
+ name: string,
293
+ value: unknown
294
+ ): Promise<void> {
295
+ const row = await this.db
296
+ .selectFrom('workflowRuns')
297
+ .select('state')
298
+ .where('workflowRunId', '=', runId)
299
+ .executeTakeFirst()
300
+
301
+ const state: Record<string, unknown> =
302
+ row?.state != null
303
+ ? typeof row.state === 'string'
304
+ ? JSON.parse(row.state)
305
+ : row.state
306
+ : {}
307
+ state[name] = value
308
+
309
+ await this.db
310
+ .updateTable('workflowRuns')
311
+ .set({ state: JSON.stringify(state), updatedAt: new Date() })
312
+ .where('workflowRunId', '=', runId)
313
+ .execute()
314
+ }
315
+
316
+ async upsertWorkflowVersion(
317
+ name: string,
318
+ graphHash: string,
319
+ graph: any,
320
+ source: string,
321
+ status: WorkflowVersionStatus = 'active'
322
+ ): Promise<void> {
323
+ await this.db
324
+ .insertInto('workflowVersions')
325
+ .values({
326
+ workflowName: name,
327
+ graphHash,
328
+ graph: JSON.stringify(graph),
329
+ source,
330
+ status,
331
+ })
332
+ .onConflict((oc) => oc.columns(['workflowName', 'graphHash']).doNothing())
333
+ .execute()
334
+ }
335
+
336
+ async updateWorkflowVersionStatus(
337
+ name: string,
338
+ graphHash: string,
339
+ status: WorkflowVersionStatus
340
+ ): Promise<void> {
341
+ await this.db
342
+ .updateTable('workflowVersions')
343
+ .set({ status })
344
+ .where('workflowName', '=', name)
345
+ .where('graphHash', '=', graphHash)
346
+ .execute()
347
+ }
348
+
349
+ // ─── internals ────────────────────────────────────────────────────
350
+
351
+ private async updateStepStatus(
352
+ stepId: string,
353
+ status: StepState['status']
354
+ ): Promise<void> {
355
+ await this.db
356
+ .updateTable('workflowStep')
357
+ .set({ status, updatedAt: new Date() })
358
+ .where('workflowStepId', '=', stepId)
359
+ .execute()
360
+ }
361
+
362
+ private async insertHistoryRecord(
363
+ stepId: string,
364
+ status: string,
365
+ resultJson?: string | null,
366
+ errorJson?: string | null
367
+ ): Promise<void> {
368
+ const now = new Date()
369
+ const values: Record<string, any> = {
370
+ historyId: crypto.randomUUID(),
371
+ workflowStepId: stepId,
372
+ status,
373
+ result: resultJson ?? null,
374
+ error: errorJson ?? null,
375
+ createdAt: now,
376
+ }
377
+ const tsField = timestampFieldFor(status)
378
+ if (tsField) values[tsField] = now
379
+
380
+ await this.db
381
+ .insertInto('workflowStepHistory')
382
+ .values(values as any)
383
+ .execute()
384
+ }
385
+
386
+ /**
387
+ * The executor pattern is: `insertStepState` writes a 'pending' history
388
+ * row, then later transitions UPDATE that latest row in place rather
389
+ * than appending a new one — except for retry attempts which append.
390
+ *
391
+ * We mirror the same shape so `KyselyWorkflowRunService.getRunHistory`
392
+ * returns the same sequence the executor would have produced.
393
+ */
394
+ private async appendOrUpdateLatestHistory(
395
+ stepId: string,
396
+ status: string,
397
+ resultJson?: string,
398
+ errorJson?: string
399
+ ): Promise<void> {
400
+ const latest = await this.db
401
+ .selectFrom('workflowStepHistory')
402
+ .select('historyId')
403
+ .where('workflowStepId', '=', stepId)
404
+ .orderBy('createdAt', 'desc')
405
+ .limit(1)
406
+ .executeTakeFirst()
407
+
408
+ if (latest) {
409
+ const update: Record<string, any> = { status }
410
+ if (resultJson !== undefined) update.result = resultJson
411
+ if (errorJson !== undefined) update.error = errorJson
412
+ const tsField = timestampFieldFor(status)
413
+ if (tsField) update[tsField] = new Date()
414
+ await this.db
415
+ .updateTable('workflowStepHistory')
416
+ .set(update)
417
+ .where('historyId', '=', latest.historyId)
418
+ .execute()
419
+ } else {
420
+ await this.insertHistoryRecord(stepId, status, resultJson, errorJson)
421
+ }
422
+ }
423
+ }
424
+
425
+ function timestampFieldFor(status: string): string | null {
426
+ switch (status) {
427
+ case 'running':
428
+ return 'runningAt'
429
+ case 'scheduled':
430
+ return 'scheduledAt'
431
+ case 'succeeded':
432
+ return 'succeededAt'
433
+ case 'failed':
434
+ return 'failedAt'
435
+ default:
436
+ return null
437
+ }
438
+ }
@@ -137,7 +137,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
137
137
  this.initialized = true
138
138
  }
139
139
 
140
- async createRun(
140
+ protected async createRunImpl(
141
141
  workflowName: string,
142
142
  input: any,
143
143
  inline: boolean,
@@ -173,7 +173,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
173
173
  return this.runService.getRun(id)
174
174
  }
175
175
 
176
- async updateRunStatus(
176
+ protected async updateRunStatusImpl(
177
177
  id: string,
178
178
  status: WorkflowStatus,
179
179
  output?: any,
@@ -191,7 +191,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
191
191
  .execute()
192
192
  }
193
193
 
194
- async insertStepState(
194
+ protected async insertStepStateImpl(
195
195
  runId: string,
196
196
  stepName: string,
197
197
  rpcName: string | null,
@@ -285,7 +285,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
285
285
  return this.runService.getRunHistory(runId)
286
286
  }
287
287
 
288
- async setStepRunning(stepId: string): Promise<void> {
288
+ protected async setStepRunningImpl(stepId: string): Promise<void> {
289
289
  await this.db
290
290
  .updateTable('workflowStep')
291
291
  .set({ status: 'running', updatedAt: new Date() })
@@ -309,7 +309,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
309
309
  }
310
310
  }
311
311
 
312
- async setStepScheduled(stepId: string): Promise<void> {
312
+ protected async setStepScheduledImpl(stepId: string): Promise<void> {
313
313
  await this.db
314
314
  .updateTable('workflowStep')
315
315
  .set({ status: 'scheduled', updatedAt: new Date() })
@@ -359,7 +359,10 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
359
359
  }
360
360
  }
361
361
 
362
- async setStepChildRunId(stepId: string, childRunId: string): Promise<void> {
362
+ protected async setStepChildRunIdImpl(
363
+ stepId: string,
364
+ childRunId: string
365
+ ): Promise<void> {
363
366
  await this.db
364
367
  .updateTable('workflowStep')
365
368
  .set({
@@ -370,7 +373,10 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
370
373
  .execute()
371
374
  }
372
375
 
373
- async setStepResult(stepId: string, result: any): Promise<void> {
376
+ protected async setStepResultImpl(
377
+ stepId: string,
378
+ result: any
379
+ ): Promise<void> {
374
380
  const resultJson = JSON.stringify(result)
375
381
 
376
382
  await this.db
@@ -401,7 +407,10 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
401
407
  }
402
408
  }
403
409
 
404
- async setStepError(stepId: string, error: Error): Promise<void> {
410
+ protected async setStepErrorImpl(
411
+ stepId: string,
412
+ error: Error
413
+ ): Promise<void> {
405
414
  const serializedError: SerializedError = {
406
415
  message: error.message,
407
416
  stack: error.stack,
@@ -437,7 +446,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
437
446
  }
438
447
  }
439
448
 
440
- async createRetryAttempt(
449
+ protected async createRetryAttemptImpl(
441
450
  stepId: string,
442
451
  status: 'pending' | 'running'
443
452
  ): Promise<StepState> {
@@ -580,7 +589,10 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
580
589
  return results
581
590
  }
582
591
 
583
- async setBranchTaken(stepId: string, branchKey: string): Promise<void> {
592
+ protected async setBranchTakenImpl(
593
+ stepId: string,
594
+ branchKey: string
595
+ ): Promise<void> {
584
596
  await this.db
585
597
  .updateTable('workflowStep')
586
598
  .set({ branchTaken: branchKey, updatedAt: new Date() })
@@ -588,7 +600,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
588
600
  .execute()
589
601
  }
590
602
 
591
- async updateRunState(
603
+ protected async updateRunStateImpl(
592
604
  runId: string,
593
605
  name: string,
594
606
  value: unknown
@@ -620,7 +632,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
620
632
  return parseJson(row.state) ?? {}
621
633
  }
622
634
 
623
- async upsertWorkflowVersion(
635
+ protected async upsertWorkflowVersionImpl(
624
636
  name: string,
625
637
  graphHash: string,
626
638
  graph: any,
@@ -640,7 +652,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
640
652
  .execute()
641
653
  }
642
654
 
643
- async updateWorkflowVersionStatus(
655
+ protected async updateWorkflowVersionStatusImpl(
644
656
  name: string,
645
657
  graphHash: string,
646
658
  status: WorkflowVersionStatus