@pikku/kysely 0.12.10 → 0.12.12

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
+ }
@@ -30,6 +30,8 @@ export class KyselyWorkflowRunService implements WorkflowRunService {
30
30
  'error',
31
31
  'inline',
32
32
  'graphHash',
33
+ 'deterministic',
34
+ 'plannedSteps',
33
35
  'wire',
34
36
  'createdAt',
35
37
  'updatedAt',
@@ -64,6 +66,8 @@ export class KyselyWorkflowRunService implements WorkflowRunService {
64
66
  'error',
65
67
  'inline',
66
68
  'graphHash',
69
+ 'deterministic',
70
+ 'plannedSteps',
67
71
  'wire',
68
72
  'createdAt',
69
73
  'updatedAt',
@@ -255,6 +259,8 @@ export class KyselyWorkflowRunService implements WorkflowRunService {
255
259
  error: parseJson(row.error),
256
260
  inline: row.inline as boolean | undefined,
257
261
  graphHash: row.graphHash as string | undefined,
262
+ deterministic: row.deterministic as boolean | undefined,
263
+ plannedSteps: parseJson(row.plannedSteps),
258
264
  wire: parseJson(row.wire) ?? { type: 'unknown' },
259
265
  createdAt: new Date(row.createdAt as string),
260
266
  updatedAt: new Date(row.updatedAt as string),
@@ -1,6 +1,7 @@
1
1
  import type { SerializedError } from '@pikku/core'
2
2
  import {
3
3
  PikkuWorkflowService,
4
+ type WorkflowPlannedStep,
4
5
  type WorkflowRun,
5
6
  type WorkflowRunWire,
6
7
  type StepState,
@@ -43,6 +44,8 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
43
44
  .addColumn('state', 'text', (col) => col.defaultTo('{}'))
44
45
  .addColumn('inline', 'boolean', (col) => col.defaultTo(false))
45
46
  .addColumn('graph_hash', 'text')
47
+ .addColumn('deterministic', 'boolean', (col) => col.defaultTo(false))
48
+ .addColumn('planned_steps', 'text')
46
49
  .addColumn('wire', 'text')
47
50
  .addColumn('created_at', 'timestamp', (col) =>
48
51
  col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
@@ -134,12 +137,16 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
134
137
  this.initialized = true
135
138
  }
136
139
 
137
- async createRun(
140
+ protected async createRunImpl(
138
141
  workflowName: string,
139
142
  input: any,
140
143
  inline: boolean,
141
144
  graphHash: string,
142
- wire: WorkflowRunWire
145
+ wire: WorkflowRunWire,
146
+ options?: {
147
+ deterministic?: boolean
148
+ plannedSteps?: WorkflowPlannedStep[]
149
+ }
143
150
  ): Promise<string> {
144
151
  const id = crypto.randomUUID()
145
152
  await this.db
@@ -151,6 +158,10 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
151
158
  input: JSON.stringify(input),
152
159
  inline,
153
160
  graphHash: graphHash ?? null,
161
+ deterministic: options?.deterministic ?? false,
162
+ plannedSteps: options?.plannedSteps
163
+ ? JSON.stringify(options.plannedSteps)
164
+ : null,
154
165
  wire: wire ? JSON.stringify(wire) : null,
155
166
  })
156
167
  .execute()
@@ -162,7 +173,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
162
173
  return this.runService.getRun(id)
163
174
  }
164
175
 
165
- async updateRunStatus(
176
+ protected async updateRunStatusImpl(
166
177
  id: string,
167
178
  status: WorkflowStatus,
168
179
  output?: any,
@@ -180,7 +191,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
180
191
  .execute()
181
192
  }
182
193
 
183
- async insertStepState(
194
+ protected async insertStepStateImpl(
184
195
  runId: string,
185
196
  stepName: string,
186
197
  rpcName: string | null,
@@ -274,7 +285,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
274
285
  return this.runService.getRunHistory(runId)
275
286
  }
276
287
 
277
- async setStepRunning(stepId: string): Promise<void> {
288
+ protected async setStepRunningImpl(stepId: string): Promise<void> {
278
289
  await this.db
279
290
  .updateTable('workflowStep')
280
291
  .set({ status: 'running', updatedAt: new Date() })
@@ -298,7 +309,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
298
309
  }
299
310
  }
300
311
 
301
- async setStepScheduled(stepId: string): Promise<void> {
312
+ protected async setStepScheduledImpl(stepId: string): Promise<void> {
302
313
  await this.db
303
314
  .updateTable('workflowStep')
304
315
  .set({ status: 'scheduled', updatedAt: new Date() })
@@ -348,7 +359,10 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
348
359
  }
349
360
  }
350
361
 
351
- async setStepChildRunId(stepId: string, childRunId: string): Promise<void> {
362
+ protected async setStepChildRunIdImpl(
363
+ stepId: string,
364
+ childRunId: string
365
+ ): Promise<void> {
352
366
  await this.db
353
367
  .updateTable('workflowStep')
354
368
  .set({
@@ -359,7 +373,10 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
359
373
  .execute()
360
374
  }
361
375
 
362
- async setStepResult(stepId: string, result: any): Promise<void> {
376
+ protected async setStepResultImpl(
377
+ stepId: string,
378
+ result: any
379
+ ): Promise<void> {
363
380
  const resultJson = JSON.stringify(result)
364
381
 
365
382
  await this.db
@@ -390,7 +407,10 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
390
407
  }
391
408
  }
392
409
 
393
- async setStepError(stepId: string, error: Error): Promise<void> {
410
+ protected async setStepErrorImpl(
411
+ stepId: string,
412
+ error: Error
413
+ ): Promise<void> {
394
414
  const serializedError: SerializedError = {
395
415
  message: error.message,
396
416
  stack: error.stack,
@@ -426,7 +446,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
426
446
  }
427
447
  }
428
448
 
429
- async createRetryAttempt(
449
+ protected async createRetryAttemptImpl(
430
450
  stepId: string,
431
451
  status: 'pending' | 'running'
432
452
  ): Promise<StepState> {
@@ -569,7 +589,10 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
569
589
  return results
570
590
  }
571
591
 
572
- async setBranchTaken(stepId: string, branchKey: string): Promise<void> {
592
+ protected async setBranchTakenImpl(
593
+ stepId: string,
594
+ branchKey: string
595
+ ): Promise<void> {
573
596
  await this.db
574
597
  .updateTable('workflowStep')
575
598
  .set({ branchTaken: branchKey, updatedAt: new Date() })
@@ -577,7 +600,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
577
600
  .execute()
578
601
  }
579
602
 
580
- async updateRunState(
603
+ protected async updateRunStateImpl(
581
604
  runId: string,
582
605
  name: string,
583
606
  value: unknown
@@ -609,7 +632,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
609
632
  return parseJson(row.state) ?? {}
610
633
  }
611
634
 
612
- async upsertWorkflowVersion(
635
+ protected async upsertWorkflowVersionImpl(
613
636
  name: string,
614
637
  graphHash: string,
615
638
  graph: any,
@@ -629,7 +652,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
629
652
  .execute()
630
653
  }
631
654
 
632
- async updateWorkflowVersionStatus(
655
+ protected async updateWorkflowVersionStatusImpl(
633
656
  name: string,
634
657
  graphHash: string,
635
658
  status: WorkflowVersionStatus