@pikku/mongodb 0.12.6 → 0.12.8

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,5 +1,92 @@
1
1
  # @pikku/mongodb
2
2
 
3
+ ## 0.12.8
4
+
5
+ ### Patch Changes
6
+
7
+ - 72694f6: feat(workflow): expose per-step attempt count + record running/succeeded/failed timestamps
8
+
9
+ `getRunStatus` now returns `attempts` (the latest attempt count) per step, so
10
+ consumers can show retry counts without a second history query. It already
11
+ computed `duration` from `runningAt`/`succeededAt`, but the kysely and mongodb
12
+ workflow stores only stamped those timestamps on the _insert_ path — the
13
+ `running` / `succeeded` / `failed` status transitions updated the history row's
14
+ status without setting `runningAt` / `succeededAt` / `failedAt`, so `duration`
15
+ was always undefined. The transitions now stamp the matching timestamp, so step
16
+ duration is populated for kysely- and mongodb-backed runs. (Redis already
17
+ stamped on transition.) A shared service-suite test guards both behaviours.
18
+
19
+ - Updated dependencies [4be205f]
20
+ - Updated dependencies [061c717]
21
+ - Updated dependencies [2c55e13]
22
+ - Updated dependencies [c745c26]
23
+ - Updated dependencies [57900b5]
24
+ - Updated dependencies [72694f6]
25
+ - @pikku/core@0.12.39
26
+
27
+ ## 0.12.7
28
+
29
+ ### Patch Changes
30
+
31
+ - 92cd5b1: feat(workflow): workflow-owned step retries + stable invocationId
32
+
33
+ The workflow — not the queue — now owns step retry policy, and each step
34
+ invocation gets a stable idempotency key.
35
+ - **Default `retries: 5` with exponential backoff.** A step with no `retries`
36
+ previously inherited the queue's bare default (e.g. pg-boss `retry_limit 2`,
37
+ no backoff) so retries fired instantly and couldn't outlast a transient
38
+ outage. Retries now default to 5 with backoff, resolved at the workflow layer.
39
+ - **`retries: 0` is honored.** Dispatch previously passed `undefined` options
40
+ for `retries: 0`, letting the queue re-run a non-idempotent step up to its own
41
+ default. The resolved policy now always sets `attempts` (`retries: 0` →
42
+ `attempts: 1`), so the queue never second-guesses the workflow. The persisted
43
+ step retries and the dispatched `attempts` are resolved together so
44
+ "retries exhausted" and "no more redeliveries" are the same event.
45
+ - **`workflowStep.invocationId`** — a deterministic, dependency-free
46
+ `uuidv5(runId:stepName)` handed to every step. Unlike `stepId` (minted per
47
+ attempt), it is identical across retries, so a step can dedupe on it
48
+ (`ON CONFLICT (invocationId)`, Stripe idempotency keys, etc.).
49
+ - **queue-bullmq**: `mapPikkuJobToBull` now maps `backoff` (previously dropped,
50
+ so a step's backoff silently never applied on Redis), and `registerQueues`
51
+ throws a clear error when no logger is available (matching queue-pg-boss).
52
+ - **Dispatch failures are recoverable, not fatal.** A step is now marked
53
+ `scheduled` only _after_ it is successfully handed to its transport (queue or
54
+ scheduler) — a failed hand-off leaves it `pending` so a replay re-dispatches
55
+ it, instead of stranding it in `scheduled` (replay would pause forever on a
56
+ job that was never enqueued). A transport outage (e.g. pg-boss momentarily
57
+ down) is surfaced as a new `WorkflowDispatchException`, which the orchestrator
58
+ treats as transient: the run is left running and the orchestrator job is
59
+ rethrown for redelivery (it replays idempotently from the snapshot) rather
60
+ than the whole run being marked `failed`. The orchestrator job now also
61
+ carries its own retry policy, so this holds even when the orchestrator queue
62
+ is configured `retry_limit 0`. A genuine step error still fails the run.
63
+ - **Same step name can be invoked multiple times in one run.** Step rows are now
64
+ keyed per _invocation_: the Nth reach of a step name in a replay resolves to a
65
+ physical key (`name` for the first, `name#N` for repeats), so a literal
66
+ duplicate name no longer clobbers the earlier step's state. The first reach
67
+ keeps the bare name, so existing rows, graph-node matching and `invocationId`s
68
+ are unchanged. Ordinals are derived deterministically from DSL execution order
69
+ and reset each replay.
70
+ - **Step provenance (`fromStepName`) + graph cycles.** Every step now records
71
+ the predecessor it was scheduled from (`fromStepName`; entry steps have none),
72
+ persisted on the step row across all stores (in-memory, kysely, redis,
73
+ mongodb, cloudflare DO) and carried in the queued payload. The DSL wire
74
+ exposes the derived `fromInvocationId` (`uuidv5(runId:fromStepName)`) so
75
+ consumers get the stable predecessor key without a second persisted id —
76
+ `fromStepName` is the source of truth (it is replay-deterministic; `stepId`,
77
+ minted per row, is not). This makes the walked path reconstructable even when
78
+ a node is reached more than once: in `a → b → a → c` the second `a` is a
79
+ distinct ordinal instance (`a#1`) whose `fromStepName` is `b`.
80
+ The graph runner now supports **cycles**: a forward edge into an
81
+ already-started node still collapses to a single run (joins/diamonds are
82
+ unchanged), but a _back-edge_ — one whose target can reach its source — fires
83
+ a fresh ordinal instance, so a node can loop back to itself. Termination is
84
+ the graph's responsibility (branch routing must converge); the engine enforces
85
+ no visit cap.
86
+
87
+ - Updated dependencies [92cd5b1]
88
+ - @pikku/core@0.12.38
89
+
3
90
  ## 0.12.6
4
91
 
5
92
  ### Patch Changes
@@ -25,6 +25,6 @@ export declare class MongoDBSecretService implements SecretService {
25
25
  hasSecret(key: string): Promise<boolean>;
26
26
  setSecret(key: string, value: unknown): Promise<void>;
27
27
  deleteSecret(key: string): Promise<void>;
28
- getSecrets(keys: string[]): Promise<Record<string, unknown>>;
28
+ getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(keys: (keyof T & string)[]): Promise<T>;
29
29
  rotateKEK(): Promise<number>;
30
30
  }
@@ -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 { Db } from 'mongodb';
4
4
  export declare class MongoDBWorkflowService extends PikkuWorkflowService {
5
5
  private initialized;
@@ -19,7 +19,7 @@ export declare class MongoDBWorkflowService extends PikkuWorkflowService {
19
19
  protected insertStepStateImpl(runId: string, stepName: string, rpcName: string | null, data: any, stepOptions?: {
20
20
  retries?: number;
21
21
  retryDelay?: string | number;
22
- }): Promise<StepState>;
22
+ }, fromStepName?: string): Promise<StepState>;
23
23
  getStepState(runId: string, stepName: string): Promise<StepState>;
24
24
  getRunHistory(runId: string): Promise<Array<StepState & {
25
25
  stepName: string;
@@ -40,6 +40,11 @@ export declare class MongoDBWorkflowService extends PikkuWorkflowService {
40
40
  branchKeys: Record<string, string>;
41
41
  }>;
42
42
  getNodesWithoutSteps(runId: string, nodeIds: string[]): Promise<string[]>;
43
+ getStepInstances(runId: string): Promise<Array<{
44
+ stepName: string;
45
+ status: StepStatus;
46
+ fromStepName?: string;
47
+ }>>;
43
48
  getNodeResults(runId: string, nodeIds: string[]): Promise<Record<string, any>>;
44
49
  protected setBranchTakenImpl(stepId: string, branchKey: string): Promise<void>;
45
50
  protected updateRunStateImpl(runId: string, name: string, value: unknown): Promise<void>;
@@ -62,7 +62,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
62
62
  },
63
63
  });
64
64
  }
65
- async insertStepStateImpl(runId, stepName, rpcName, data, stepOptions) {
65
+ async insertStepStateImpl(runId, stepName, rpcName, data, stepOptions, fromStepName) {
66
66
  const stepId = crypto.randomUUID();
67
67
  const now = new Date();
68
68
  await this.steps.insertOne({
@@ -77,6 +77,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
77
77
  branchTaken: null,
78
78
  retries: stepOptions?.retries ?? null,
79
79
  retryDelay: stepOptions?.retryDelay?.toString() ?? null,
80
+ fromStepName: fromStepName ?? null,
80
81
  createdAt: now,
81
82
  updatedAt: now,
82
83
  });
@@ -89,6 +90,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
89
90
  attemptCount: 1,
90
91
  retries: stepOptions?.retries,
91
92
  retryDelay: stepOptions?.retryDelay?.toString(),
93
+ fromStepName,
92
94
  createdAt: now,
93
95
  updatedAt: now,
94
96
  };
@@ -112,6 +114,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
112
114
  attemptCount,
113
115
  retries: row.retries != null ? Number(row.retries) : undefined,
114
116
  retryDelay: row.retryDelay ?? undefined,
117
+ fromStepName: row.fromStepName ?? undefined,
115
118
  createdAt: new Date(row.createdAt),
116
119
  updatedAt: new Date(row.updatedAt),
117
120
  };
@@ -127,7 +130,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
127
130
  .limit(1)
128
131
  .toArray();
129
132
  if (latestHistory.length > 0) {
130
- await this.stepHistory.updateOne({ _id: latestHistory[0]._id }, { $set: { status: 'running' } });
133
+ await this.stepHistory.updateOne({ _id: latestHistory[0]._id }, { $set: { status: 'running', runningAt: new Date() } });
131
134
  }
132
135
  }
133
136
  async setStepScheduledImpl(stepId) {
@@ -190,7 +193,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
190
193
  .limit(1)
191
194
  .toArray();
192
195
  if (latestHistory.length > 0) {
193
- await this.stepHistory.updateOne({ _id: latestHistory[0]._id }, { $set: { status: 'succeeded', result } });
196
+ await this.stepHistory.updateOne({ _id: latestHistory[0]._id }, { $set: { status: 'succeeded', result, succeededAt: new Date() } });
194
197
  }
195
198
  }
196
199
  async setStepErrorImpl(stepId, error) {
@@ -213,7 +216,13 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
213
216
  .limit(1)
214
217
  .toArray();
215
218
  if (latestHistory.length > 0) {
216
- await this.stepHistory.updateOne({ _id: latestHistory[0]._id }, { $set: { status: 'failed', error: serializedError } });
219
+ await this.stepHistory.updateOne({ _id: latestHistory[0]._id }, {
220
+ $set: {
221
+ status: 'failed',
222
+ error: serializedError,
223
+ failedAt: new Date(),
224
+ },
225
+ });
217
226
  }
218
227
  }
219
228
  async createRetryAttemptImpl(stepId, status) {
@@ -240,6 +249,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
240
249
  attemptCount,
241
250
  retries: row.retries != null ? Number(row.retries) : undefined,
242
251
  retryDelay: row.retryDelay ?? undefined,
252
+ fromStepName: row.fromStepName ?? undefined,
243
253
  createdAt: new Date(row.createdAt),
244
254
  updatedAt: new Date(row.updatedAt),
245
255
  };
@@ -293,6 +303,17 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
293
303
  const existingStepNames = new Set(result.map((r) => r.stepName));
294
304
  return nodeIds.filter((id) => !existingStepNames.has(id));
295
305
  }
306
+ async getStepInstances(runId) {
307
+ const rows = await this.steps
308
+ .find({ workflowRunId: runId })
309
+ .project({ stepName: 1, status: 1, fromStepName: 1 })
310
+ .toArray();
311
+ return rows.map((r) => ({
312
+ stepName: r.stepName,
313
+ status: r.status,
314
+ fromStepName: r.fromStepName ?? undefined,
315
+ }));
316
+ }
296
317
  async getNodeResults(runId, nodeIds) {
297
318
  if (nodeIds.length === 0)
298
319
  return {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/mongodb",
3
- "version": "0.12.6",
3
+ "version": "0.12.8",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/src/index.js",
@@ -17,7 +17,7 @@
17
17
  "prepublishOnly": "yarn build"
18
18
  },
19
19
  "peerDependencies": {
20
- "@pikku/core": "^0.12.27"
20
+ "@pikku/core": "^0.12.39"
21
21
  },
22
22
  "dependencies": {
23
23
  "mongodb": "^6.12.0"
@@ -131,7 +131,9 @@ export class MongoDBSecretService implements SecretService {
131
131
  await this.logAudit(key, 'delete')
132
132
  }
133
133
 
134
- async getSecrets(keys: string[]): Promise<Record<string, unknown>> {
134
+ async getSecrets<
135
+ T extends Record<string, unknown> = Record<string, unknown>,
136
+ >(keys: (keyof T & string)[]): Promise<T> {
135
137
  const rows = await this.secrets
136
138
  .find({ _id: { $in: keys } } as any)
137
139
  .toArray()
@@ -148,7 +150,7 @@ export class MongoDBSecretService implements SecretService {
148
150
  // skip secrets that fail to decrypt
149
151
  }
150
152
  }
151
- return out
153
+ return out as T
152
154
  }
153
155
 
154
156
  async rotateKEK(): Promise<number> {
@@ -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'
@@ -41,6 +42,7 @@ interface WorkflowStepDoc {
41
42
  branchTaken: string | null
42
43
  retries: number | null
43
44
  retryDelay: string | null
45
+ fromStepName?: string | null
44
46
  createdAt: Date
45
47
  updatedAt: Date
46
48
  }
@@ -172,7 +174,8 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
172
174
  stepName: string,
173
175
  rpcName: string | null,
174
176
  data: any,
175
- stepOptions?: { retries?: number; retryDelay?: string | number }
177
+ stepOptions?: { retries?: number; retryDelay?: string | number },
178
+ fromStepName?: string
176
179
  ): Promise<StepState> {
177
180
  const stepId = crypto.randomUUID()
178
181
  const now = new Date()
@@ -189,6 +192,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
189
192
  branchTaken: null,
190
193
  retries: stepOptions?.retries ?? null,
191
194
  retryDelay: stepOptions?.retryDelay?.toString() ?? null,
195
+ fromStepName: fromStepName ?? null,
192
196
  createdAt: now,
193
197
  updatedAt: now,
194
198
  })
@@ -203,6 +207,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
203
207
  attemptCount: 1,
204
208
  retries: stepOptions?.retries,
205
209
  retryDelay: stepOptions?.retryDelay?.toString(),
210
+ fromStepName,
206
211
  createdAt: now,
207
212
  updatedAt: now,
208
213
  }
@@ -232,6 +237,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
232
237
  attemptCount,
233
238
  retries: row.retries != null ? Number(row.retries) : undefined,
234
239
  retryDelay: row.retryDelay ?? undefined,
240
+ fromStepName: row.fromStepName ?? undefined,
235
241
  createdAt: new Date(row.createdAt),
236
242
  updatedAt: new Date(row.updatedAt),
237
243
  }
@@ -258,7 +264,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
258
264
  if (latestHistory.length > 0) {
259
265
  await this.stepHistory.updateOne(
260
266
  { _id: latestHistory[0]!._id },
261
- { $set: { status: 'running' } }
267
+ { $set: { status: 'running', runningAt: new Date() } }
262
268
  )
263
269
  }
264
270
  }
@@ -353,7 +359,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
353
359
  if (latestHistory.length > 0) {
354
360
  await this.stepHistory.updateOne(
355
361
  { _id: latestHistory[0]!._id },
356
- { $set: { status: 'succeeded', result } }
362
+ { $set: { status: 'succeeded', result, succeededAt: new Date() } }
357
363
  )
358
364
  }
359
365
  }
@@ -389,7 +395,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
389
395
  if (latestHistory.length > 0) {
390
396
  await this.stepHistory.updateOne(
391
397
  { _id: latestHistory[0]!._id },
392
- { $set: { status: 'failed', error: serializedError } }
398
+ { $set: { status: 'failed', error: serializedError, failedAt: new Date() } }
393
399
  )
394
400
  }
395
401
  }
@@ -427,6 +433,7 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
427
433
  attemptCount,
428
434
  retries: row.retries != null ? Number(row.retries) : undefined,
429
435
  retryDelay: row.retryDelay ?? undefined,
436
+ fromStepName: row.fromStepName ?? undefined,
430
437
  createdAt: new Date(row.createdAt),
431
438
  updatedAt: new Date(row.updatedAt),
432
439
  }
@@ -500,6 +507,20 @@ export class MongoDBWorkflowService extends PikkuWorkflowService {
500
507
  return nodeIds.filter((id) => !existingStepNames.has(id))
501
508
  }
502
509
 
510
+ async getStepInstances(runId: string): Promise<
511
+ Array<{ stepName: string; status: StepStatus; fromStepName?: string }>
512
+ > {
513
+ const rows = await this.steps
514
+ .find({ workflowRunId: runId })
515
+ .project({ stepName: 1, status: 1, fromStepName: 1 })
516
+ .toArray()
517
+ return rows.map((r: any) => ({
518
+ stepName: r.stepName,
519
+ status: r.status as StepStatus,
520
+ fromStepName: r.fromStepName ?? undefined,
521
+ }))
522
+ }
523
+
503
524
  async getNodeResults(
504
525
  runId: string,
505
526
  nodeIds: string[]