@pikku/kysely 0.12.16 → 0.12.17

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,66 @@
1
+ ## 0.12.17
2
+
3
+ ### Patch Changes
4
+
5
+ - 92cd5b1: feat(workflow): workflow-owned step retries + stable invocationId
6
+
7
+ The workflow — not the queue — now owns step retry policy, and each step
8
+ invocation gets a stable idempotency key.
9
+ - **Default `retries: 5` with exponential backoff.** A step with no `retries`
10
+ previously inherited the queue's bare default (e.g. pg-boss `retry_limit 2`,
11
+ no backoff) so retries fired instantly and couldn't outlast a transient
12
+ outage. Retries now default to 5 with backoff, resolved at the workflow layer.
13
+ - **`retries: 0` is honored.** Dispatch previously passed `undefined` options
14
+ for `retries: 0`, letting the queue re-run a non-idempotent step up to its own
15
+ default. The resolved policy now always sets `attempts` (`retries: 0` →
16
+ `attempts: 1`), so the queue never second-guesses the workflow. The persisted
17
+ step retries and the dispatched `attempts` are resolved together so
18
+ "retries exhausted" and "no more redeliveries" are the same event.
19
+ - **`workflowStep.invocationId`** — a deterministic, dependency-free
20
+ `uuidv5(runId:stepName)` handed to every step. Unlike `stepId` (minted per
21
+ attempt), it is identical across retries, so a step can dedupe on it
22
+ (`ON CONFLICT (invocationId)`, Stripe idempotency keys, etc.).
23
+ - **queue-bullmq**: `mapPikkuJobToBull` now maps `backoff` (previously dropped,
24
+ so a step's backoff silently never applied on Redis), and `registerQueues`
25
+ throws a clear error when no logger is available (matching queue-pg-boss).
26
+ - **Dispatch failures are recoverable, not fatal.** A step is now marked
27
+ `scheduled` only _after_ it is successfully handed to its transport (queue or
28
+ scheduler) — a failed hand-off leaves it `pending` so a replay re-dispatches
29
+ it, instead of stranding it in `scheduled` (replay would pause forever on a
30
+ job that was never enqueued). A transport outage (e.g. pg-boss momentarily
31
+ down) is surfaced as a new `WorkflowDispatchException`, which the orchestrator
32
+ treats as transient: the run is left running and the orchestrator job is
33
+ rethrown for redelivery (it replays idempotently from the snapshot) rather
34
+ than the whole run being marked `failed`. The orchestrator job now also
35
+ carries its own retry policy, so this holds even when the orchestrator queue
36
+ is configured `retry_limit 0`. A genuine step error still fails the run.
37
+ - **Same step name can be invoked multiple times in one run.** Step rows are now
38
+ keyed per _invocation_: the Nth reach of a step name in a replay resolves to a
39
+ physical key (`name` for the first, `name#N` for repeats), so a literal
40
+ duplicate name no longer clobbers the earlier step's state. The first reach
41
+ keeps the bare name, so existing rows, graph-node matching and `invocationId`s
42
+ are unchanged. Ordinals are derived deterministically from DSL execution order
43
+ and reset each replay.
44
+ - **Step provenance (`fromStepName`) + graph cycles.** Every step now records
45
+ the predecessor it was scheduled from (`fromStepName`; entry steps have none),
46
+ persisted on the step row across all stores (in-memory, kysely, redis,
47
+ mongodb, cloudflare DO) and carried in the queued payload. The DSL wire
48
+ exposes the derived `fromInvocationId` (`uuidv5(runId:fromStepName)`) so
49
+ consumers get the stable predecessor key without a second persisted id —
50
+ `fromStepName` is the source of truth (it is replay-deterministic; `stepId`,
51
+ minted per row, is not). This makes the walked path reconstructable even when
52
+ a node is reached more than once: in `a → b → a → c` the second `a` is a
53
+ distinct ordinal instance (`a#1`) whose `fromStepName` is `b`.
54
+ The graph runner now supports **cycles**: a forward edge into an
55
+ already-started node still collapses to a single run (joins/diamonds are
56
+ unchanged), but a _back-edge_ — one whose target can reach its source — fires
57
+ a fresh ordinal instance, so a node can loop back to itself. Termination is
58
+ the graph's responsibility (branch routing must converge); the engine enforces
59
+ no visit cap.
60
+
61
+ - Updated dependencies [92cd5b1]
62
+ - @pikku/core@0.12.38
63
+
1
64
  ## 0.12.16
2
65
 
3
66
  ### Patch Changes
@@ -43,6 +43,7 @@ export interface WorkflowStepTable {
43
43
  branchTaken: string | null;
44
44
  retries: number | null;
45
45
  retryDelay: string | null;
46
+ fromStepName: string | null;
46
47
  createdAt: Generated<Date>;
47
48
  updatedAt: Generated<Date>;
48
49
  }
@@ -1,5 +1,5 @@
1
1
  import type { SerializedError } from '@pikku/core';
2
- import { PikkuWorkflowService, type WorkflowPlannedStep, type WorkflowRun, type WorkflowRunWire, type StepState, type WorkflowStatus, type WorkflowVersionStatus } from '@pikku/core/workflow';
2
+ import { PikkuWorkflowService, type WorkflowPlannedStep, type WorkflowRun, type WorkflowRunWire, type StepState, type StepStatus, type WorkflowStatus, type WorkflowVersionStatus } from '@pikku/core/workflow';
3
3
  import type { Kysely } from 'kysely';
4
4
  import type { KyselyPikkuDB } from './kysely-tables.js';
5
5
  export declare class KyselyWorkflowService extends PikkuWorkflowService {
@@ -17,7 +17,7 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
17
17
  protected insertStepStateImpl(runId: string, stepName: string, rpcName: string | null, data: any, stepOptions?: {
18
18
  retries?: number;
19
19
  retryDelay?: string | number;
20
- }): Promise<StepState>;
20
+ }, fromStepName?: string): Promise<StepState>;
21
21
  getStepState(runId: string, stepName: string): Promise<StepState>;
22
22
  getRunHistory(runId: string): Promise<Array<StepState & {
23
23
  stepName: string;
@@ -38,6 +38,11 @@ export declare class KyselyWorkflowService extends PikkuWorkflowService {
38
38
  branchKeys: Record<string, string>;
39
39
  }>;
40
40
  getNodesWithoutSteps(runId: string, nodeIds: string[]): Promise<string[]>;
41
+ getStepInstances(runId: string): Promise<Array<{
42
+ stepName: string;
43
+ status: StepStatus;
44
+ fromStepName?: string;
45
+ }>>;
41
46
  getNodeResults(runId: string, nodeIds: string[]): Promise<Record<string, any>>;
42
47
  protected setBranchTakenImpl(stepId: string, branchKey: string): Promise<void>;
43
48
  protected updateRunStateImpl(runId: string, name: string, value: unknown): Promise<void>;
@@ -55,6 +55,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
55
55
  .addColumn('branch_taken', 'text')
56
56
  .addColumn('retries', 'integer')
57
57
  .addColumn('retry_delay', 'text')
58
+ .addColumn('from_step_name', 'text')
58
59
  .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
59
60
  .addColumn('updated_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
60
61
  .addUniqueConstraint('workflow_step_run_name_unique', [
@@ -62,6 +63,14 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
62
63
  'step_name',
63
64
  ])
64
65
  .execute();
66
+ // Additive: backfill the column on tables created before from_step_name.
67
+ await this.db.schema
68
+ .alterTable('workflow_step')
69
+ .addColumn('from_step_name', 'text')
70
+ .execute()
71
+ .catch(() => {
72
+ // Column already exists (fresh tables include it above) — nothing to do.
73
+ });
65
74
  await this.db.schema
66
75
  .createTable('workflow_step_history')
67
76
  .ifNotExists()
@@ -132,7 +141,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
132
141
  .where('workflowRunId', '=', id)
133
142
  .execute();
134
143
  }
135
- async insertStepStateImpl(runId, stepName, rpcName, data, stepOptions) {
144
+ async insertStepStateImpl(runId, stepName, rpcName, data, stepOptions, fromStepName) {
136
145
  const stepId = crypto.randomUUID();
137
146
  const now = new Date();
138
147
  await this.db
@@ -146,6 +155,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
146
155
  status: 'pending',
147
156
  retries: stepOptions?.retries ?? null,
148
157
  retryDelay: stepOptions?.retryDelay?.toString() ?? null,
158
+ fromStepName: fromStepName ?? null,
149
159
  createdAt: now,
150
160
  updatedAt: now,
151
161
  })
@@ -159,6 +169,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
159
169
  attemptCount: 1,
160
170
  retries: stepOptions?.retries,
161
171
  retryDelay: stepOptions?.retryDelay?.toString(),
172
+ fromStepName,
162
173
  createdAt: now,
163
174
  updatedAt: now,
164
175
  };
@@ -173,6 +184,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
173
184
  's.error',
174
185
  's.retries',
175
186
  's.retryDelay',
187
+ 's.fromStepName',
176
188
  's.createdAt',
177
189
  's.updatedAt',
178
190
  ])
@@ -195,6 +207,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
195
207
  attemptCount: Number(row.attemptCount),
196
208
  retries: row.retries != null ? Number(row.retries) : undefined,
197
209
  retryDelay: row.retryDelay ?? undefined,
210
+ fromStepName: row.fromStepName ?? undefined,
198
211
  createdAt: new Date(row.createdAt),
199
212
  updatedAt: new Date(row.updatedAt),
200
213
  };
@@ -348,6 +361,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
348
361
  's.error',
349
362
  's.retries',
350
363
  's.retryDelay',
364
+ 's.fromStepName',
351
365
  's.createdAt',
352
366
  's.updatedAt',
353
367
  ])
@@ -366,6 +380,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
366
380
  attemptCount: Number(row.attemptCount),
367
381
  retries: row.retries != null ? Number(row.retries) : undefined,
368
382
  retryDelay: row.retryDelay ?? undefined,
383
+ fromStepName: row.fromStepName ?? undefined,
369
384
  createdAt: new Date(row.createdAt),
370
385
  updatedAt: new Date(row.updatedAt),
371
386
  };
@@ -420,6 +435,18 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
420
435
  const existingStepNames = new Set(result.map((r) => r.stepName));
421
436
  return nodeIds.filter((id) => !existingStepNames.has(id));
422
437
  }
438
+ async getStepInstances(runId) {
439
+ const rows = await this.db
440
+ .selectFrom('workflowStep')
441
+ .select(['stepName', 'status', 'fromStepName'])
442
+ .where('workflowRunId', '=', runId)
443
+ .execute();
444
+ return rows.map((r) => ({
445
+ stepName: r.stepName,
446
+ status: r.status,
447
+ fromStepName: r.fromStepName ?? undefined,
448
+ }));
449
+ }
423
450
  async getNodeResults(runId, nodeIds) {
424
451
  if (nodeIds.length === 0)
425
452
  return {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/kysely",
3
- "version": "0.12.16",
3
+ "version": "0.12.17",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/src/index.js",
@@ -20,7 +20,7 @@
20
20
  "prepublishOnly": "yarn build"
21
21
  },
22
22
  "peerDependencies": {
23
- "@pikku/core": "^0.12.28"
23
+ "@pikku/core": "^0.12.38"
24
24
  },
25
25
  "dependencies": {
26
26
  "kysely": "^0.29.0"
@@ -51,6 +51,7 @@ export interface WorkflowStepTable {
51
51
  branchTaken: string | null
52
52
  retries: number | null
53
53
  retryDelay: string | null
54
+ fromStepName: string | null
54
55
  createdAt: Generated<Date>
55
56
  updatedAt: Generated<Date>
56
57
  }
@@ -5,6 +5,7 @@ import {
5
5
  type WorkflowRun,
6
6
  type WorkflowRunWire,
7
7
  type StepState,
8
+ type StepStatus,
8
9
  type WorkflowStatus,
9
10
  type WorkflowVersionStatus,
10
11
  } from '@pikku/core/workflow'
@@ -79,6 +80,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
79
80
  .addColumn('branch_taken', 'text')
80
81
  .addColumn('retries', 'integer')
81
82
  .addColumn('retry_delay', 'text')
83
+ .addColumn('from_step_name', 'text')
82
84
  .addColumn('created_at', 'timestamp', (col) =>
83
85
  col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
84
86
  )
@@ -91,6 +93,15 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
91
93
  ])
92
94
  .execute()
93
95
 
96
+ // Additive: backfill the column on tables created before from_step_name.
97
+ await this.db.schema
98
+ .alterTable('workflow_step')
99
+ .addColumn('from_step_name', 'text')
100
+ .execute()
101
+ .catch(() => {
102
+ // Column already exists (fresh tables include it above) — nothing to do.
103
+ })
104
+
94
105
  await this.db.schema
95
106
  .createTable('workflow_step_history')
96
107
  .ifNotExists()
@@ -196,7 +207,8 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
196
207
  stepName: string,
197
208
  rpcName: string | null,
198
209
  data: any,
199
- stepOptions?: { retries?: number; retryDelay?: string | number }
210
+ stepOptions?: { retries?: number; retryDelay?: string | number },
211
+ fromStepName?: string
200
212
  ): Promise<StepState> {
201
213
  const stepId = crypto.randomUUID()
202
214
  const now = new Date()
@@ -212,6 +224,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
212
224
  status: 'pending',
213
225
  retries: stepOptions?.retries ?? null,
214
226
  retryDelay: stepOptions?.retryDelay?.toString() ?? null,
227
+ fromStepName: fromStepName ?? null,
215
228
  createdAt: now,
216
229
  updatedAt: now,
217
230
  })
@@ -227,6 +240,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
227
240
  attemptCount: 1,
228
241
  retries: stepOptions?.retries,
229
242
  retryDelay: stepOptions?.retryDelay?.toString(),
243
+ fromStepName,
230
244
  createdAt: now,
231
245
  updatedAt: now,
232
246
  }
@@ -242,6 +256,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
242
256
  's.error',
243
257
  's.retries',
244
258
  's.retryDelay',
259
+ 's.fromStepName',
245
260
  's.createdAt',
246
261
  's.updatedAt',
247
262
  ])
@@ -274,6 +289,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
274
289
  attemptCount: Number(row.attemptCount),
275
290
  retries: row.retries != null ? Number(row.retries) : undefined,
276
291
  retryDelay: row.retryDelay ?? undefined,
292
+ fromStepName: row.fromStepName ?? undefined,
277
293
  createdAt: new Date(row.createdAt),
278
294
  updatedAt: new Date(row.updatedAt),
279
295
  }
@@ -467,6 +483,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
467
483
  's.error',
468
484
  's.retries',
469
485
  's.retryDelay',
486
+ 's.fromStepName',
470
487
  's.createdAt',
471
488
  's.updatedAt',
472
489
  ])
@@ -492,6 +509,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
492
509
  attemptCount: Number(row.attemptCount),
493
510
  retries: row.retries != null ? Number(row.retries) : undefined,
494
511
  retryDelay: row.retryDelay ?? undefined,
512
+ fromStepName: row.fromStepName ?? undefined,
495
513
  createdAt: new Date(row.createdAt),
496
514
  updatedAt: new Date(row.updatedAt),
497
515
  }
@@ -568,6 +586,21 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
568
586
  return nodeIds.filter((id) => !existingStepNames.has(id))
569
587
  }
570
588
 
589
+ async getStepInstances(runId: string): Promise<
590
+ Array<{ stepName: string; status: StepStatus; fromStepName?: string }>
591
+ > {
592
+ const rows = await this.db
593
+ .selectFrom('workflowStep')
594
+ .select(['stepName', 'status', 'fromStepName'])
595
+ .where('workflowRunId', '=', runId)
596
+ .execute()
597
+ return rows.map((r) => ({
598
+ stepName: r.stepName,
599
+ status: r.status as StepStatus,
600
+ fromStepName: r.fromStepName ?? undefined,
601
+ }))
602
+ }
603
+
571
604
  async getNodeResults(
572
605
  runId: string,
573
606
  nodeIds: string[]