@pikku/core 0.12.37 → 0.12.38

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 (30) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/services/in-memory-workflow-service.d.ts +7 -2
  3. package/dist/services/in-memory-workflow-service.js +17 -1
  4. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +19 -1
  5. package/dist/wirings/workflow/graph/graph-runner.js +117 -45
  6. package/dist/wirings/workflow/index.d.ts +2 -1
  7. package/dist/wirings/workflow/index.js +2 -1
  8. package/dist/wirings/workflow/pikku-workflow-service.d.ts +63 -5
  9. package/dist/wirings/workflow/pikku-workflow-service.js +197 -66
  10. package/dist/wirings/workflow/workflow-invocation-id.d.ts +20 -0
  11. package/dist/wirings/workflow/workflow-invocation-id.js +38 -0
  12. package/dist/wirings/workflow/workflow-queue-workers.d.ts +2 -0
  13. package/dist/wirings/workflow/workflow.types.d.ts +5 -0
  14. package/package.json +2 -1
  15. package/src/dev/hot-reload.test.ts +5 -0
  16. package/src/services/in-memory-workflow-service.ts +25 -1
  17. package/src/wirings/oauth2/oauth2-client.test.ts +25 -23
  18. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +19 -1
  19. package/src/wirings/workflow/graph/graph-runner.test.ts +87 -3
  20. package/src/wirings/workflow/graph/graph-runner.ts +158 -56
  21. package/src/wirings/workflow/index.ts +3 -0
  22. package/src/wirings/workflow/pikku-workflow-service.ts +264 -86
  23. package/src/wirings/workflow/workflow-dispatch-durability.test.ts +117 -0
  24. package/src/wirings/workflow/workflow-invocation-id.test.ts +53 -0
  25. package/src/wirings/workflow/workflow-invocation-id.ts +48 -0
  26. package/src/wirings/workflow/workflow-queue-workers.ts +2 -0
  27. package/src/wirings/workflow/workflow-retry-policy.test.ts +111 -0
  28. package/src/wirings/workflow/workflow-step-ordinal.test.ts +79 -0
  29. package/src/wirings/workflow/workflow.types.ts +5 -0
  30. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,117 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
5
+ import { pikkuState } from '../../pikku-state.js'
6
+ import { WorkflowDispatchException } from './pikku-workflow-service.js'
7
+
8
+ // Register an `inline: false` function so the workflow's `do()` routes the step
9
+ // through the queue (dispatchStep) instead of running it inline.
10
+ function registerDispatchedFn(rpcName: string): void {
11
+ const funcId = `fn:${rpcName}`
12
+ pikkuState(null, 'rpc', 'meta', { [rpcName]: funcId } as any)
13
+ pikkuState(null, 'function', 'meta', {
14
+ [funcId]: { inline: false },
15
+ } as any)
16
+ }
17
+
18
+ const silentLogger = { error() {}, info() {}, warn() {}, debug() {} }
19
+
20
+ describe('dispatch durability: a transient queue failure is recoverable', () => {
21
+ test('failed dispatch leaves the step PENDING and the run un-failed, then re-dispatches on replay', async () => {
22
+ registerDispatchedFn('doThing')
23
+ let queueUp = false
24
+ pikkuState(null, 'package', 'singletonServices', {
25
+ queueService: {
26
+ add: async () => {
27
+ if (!queueUp) throw new Error('pg-boss is down')
28
+ },
29
+ },
30
+ logger: silentLogger,
31
+ } as any)
32
+
33
+ const ws = new InMemoryWorkflowService()
34
+ const runId = await ws.createRun('flow', {}, false, 'hash', { type: 'test' })
35
+ await ws.insertStepState(runId, 'thing', 'doThing', {})
36
+
37
+ // Queue is down → dispatch throws the TRANSIENT exception (not a step failure).
38
+ await assert.rejects(
39
+ (ws as any).rpcStep(runId, 'thing', 'doThing', {}, {}),
40
+ (err: any) => err instanceof WorkflowDispatchException,
41
+ 'a queue outage surfaces as WorkflowDispatchException'
42
+ )
43
+
44
+ // The step must NOT be stranded in `scheduled` — otherwise replay would pause
45
+ // forever on a job that was never enqueued.
46
+ const afterFail = await ws.getStepState(runId, 'thing')
47
+ assert.equal(
48
+ afterFail.status,
49
+ 'pending',
50
+ 'step stays pending after a failed dispatch'
51
+ )
52
+
53
+ // The run itself must stay alive (not failed) — it is the snapshot we replay.
54
+ const runAfterFail = await ws.getRun(runId)
55
+ assert.notEqual(runAfterFail?.status, 'failed', 'run is not failed by a queue blip')
56
+
57
+ // Queue recovers → replaying the step now dispatches (pauses via async exception)
58
+ // and the step transitions to `scheduled`. A real replay runs through
59
+ // runWorkflowJob which resets the ordinal counter; simulate that so the
60
+ // second reach resolves to the same step key, not the next ordinal.
61
+ ;(ws as any).resetStepOrdinals(runId)
62
+ queueUp = true
63
+ await assert.rejects(
64
+ (ws as any).rpcStep(runId, 'thing', 'doThing', {}, {}),
65
+ (err: any) => err.name === 'WorkflowAsyncException',
66
+ 'recovered dispatch pauses the workflow as normal'
67
+ )
68
+ const afterRecover = await ws.getStepState(runId, 'thing')
69
+ assert.equal(
70
+ afterRecover.status,
71
+ 'scheduled',
72
+ 'step is scheduled only after a successful dispatch'
73
+ )
74
+ })
75
+ })
76
+
77
+ describe('orchestrateWorkflow treats a dispatch failure as non-terminal', () => {
78
+ function makeService(throwIt: () => never) {
79
+ pikkuState(null, 'package', 'singletonServices', {
80
+ queueService: { add: async () => {} },
81
+ logger: silentLogger,
82
+ } as any)
83
+ const ws = new InMemoryWorkflowService()
84
+ // Override the replay to throw whatever the test wants.
85
+ ;(ws as any).runWorkflowJob = async () => throwIt()
86
+ return ws
87
+ }
88
+
89
+ test('WorkflowDispatchException → run NOT marked failed, error rethrown for queue retry', async () => {
90
+ const ws = makeService(() => {
91
+ throw new WorkflowDispatchException('r', 's')
92
+ })
93
+ const runId = await ws.createRun('flow', {}, false, 'hash', { type: 'test' })
94
+
95
+ await assert.rejects(
96
+ ws.orchestrateWorkflow(runId, {}),
97
+ (err: any) => err instanceof WorkflowDispatchException
98
+ )
99
+ const run = await ws.getRun(runId)
100
+ assert.notEqual(
101
+ run?.status,
102
+ 'failed',
103
+ 'a transient dispatch failure must leave the run resumable'
104
+ )
105
+ })
106
+
107
+ test('a genuine step error still fails the run (unchanged behavior)', async () => {
108
+ const ws = makeService(() => {
109
+ throw new Error('business logic blew up')
110
+ })
111
+ const runId = await ws.createRun('flow', {}, false, 'hash', { type: 'test' })
112
+
113
+ await assert.rejects(ws.orchestrateWorkflow(runId, {}))
114
+ const run = await ws.getRun(runId)
115
+ assert.equal(run?.status, 'failed', 'real errors still fail the run')
116
+ })
117
+ })
@@ -0,0 +1,53 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { uuidv5, deriveInvocationId } from './workflow-invocation-id.js'
5
+
6
+ const UUID_RE =
7
+ /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
8
+
9
+ describe('uuidv5', () => {
10
+ test('matches the canonical RFC 4122 v5 vector', () => {
11
+ // www.example.com in the DNS namespace → known fixed UUID. Proves the
12
+ // SHA-1 + version/variant bit-twiddling is a correct v5 implementation.
13
+ const DNS = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'
14
+ assert.equal(
15
+ uuidv5('www.example.com', DNS),
16
+ '2ed6657d-e927-568b-95e1-2665a8aea6a2'
17
+ )
18
+ })
19
+
20
+ test('is deterministic and sets version 5 + RFC variant bits', () => {
21
+ const a = uuidv5('hello')
22
+ const b = uuidv5('hello')
23
+ assert.equal(a, b, 'same input → same UUID')
24
+ assert.match(a, UUID_RE, 'version nibble is 5 and variant is 8/9/a/b')
25
+ })
26
+
27
+ test('different names produce different UUIDs', () => {
28
+ assert.notEqual(uuidv5('a'), uuidv5('b'))
29
+ })
30
+ })
31
+
32
+ describe('deriveInvocationId', () => {
33
+ test('is stable across calls for the same run + step (the dedupe key)', () => {
34
+ const id1 = deriveInvocationId('run-1', 'updateUser')
35
+ const id2 = deriveInvocationId('run-1', 'updateUser')
36
+ assert.equal(id1, id2)
37
+ assert.match(id1, UUID_RE)
38
+ })
39
+
40
+ test('differs per step name within a run', () => {
41
+ assert.notEqual(
42
+ deriveInvocationId('run-1', 'updateUser'),
43
+ deriveInvocationId('run-1', 'chargeCard')
44
+ )
45
+ })
46
+
47
+ test('differs per run for the same step name', () => {
48
+ assert.notEqual(
49
+ deriveInvocationId('run-1', 'updateUser'),
50
+ deriveInvocationId('run-2', 'updateUser')
51
+ )
52
+ })
53
+ })
@@ -0,0 +1,48 @@
1
+ import { createHash } from 'crypto'
2
+
3
+ // Fixed namespace for pikku workflow step invocation IDs. Frozen — changing it
4
+ // would alter every derived invocationId and break dedupe across a deploy.
5
+ const PIKKU_WORKFLOW_NAMESPACE = '70696b6b-7500-5770-9f6c-6f77000a0001'
6
+
7
+ const parseUuid = (uuid: string): Buffer =>
8
+ Buffer.from(uuid.replace(/-/g, ''), 'hex')
9
+
10
+ const formatUuid = (bytes: Buffer): string => {
11
+ const hex = bytes.toString('hex')
12
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
13
+ }
14
+
15
+ /**
16
+ * RFC 4122 v5 (SHA-1, name-based) UUID. Deterministic: the same name +
17
+ * namespace always yields the same UUID — no `uuid` dependency needed.
18
+ */
19
+ export const uuidv5 = (
20
+ name: string,
21
+ namespace: string = PIKKU_WORKFLOW_NAMESPACE
22
+ ): string => {
23
+ const hash = createHash('sha1')
24
+ .update(parseUuid(namespace))
25
+ .update(name, 'utf8')
26
+ .digest()
27
+ const bytes = hash.subarray(0, 16)
28
+ bytes[6] = (bytes[6]! & 0x0f) | 0x50 // version 5
29
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80 // RFC 4122 variant
30
+ return formatUuid(bytes)
31
+ }
32
+
33
+ /**
34
+ * The stable identity of one step *invocation* within a run — the idempotency /
35
+ * dedupe key handed to a step. Unlike `stepId` (minted fresh per attempt), this
36
+ * stays identical across every retry of the same call, because it is derived
37
+ * purely from `runId` + `stepName`, both of which are stable across replays.
38
+ *
39
+ * Same inputs → same UUID, so a step can safely
40
+ * `INSERT … ON CONFLICT (invocation_id)` / pass it as a Stripe idempotency key,
41
+ * and a retry of a half-applied side effect is collapsed onto the first attempt.
42
+ *
43
+ * NOTE: calling the *same* `stepName` more than once in a run is not yet
44
+ * disambiguated here (the store still keys steps by `runId:stepName`); when
45
+ * per-invocation ordinals land, the ordinal becomes the third hash component.
46
+ */
47
+ export const deriveInvocationId = (runId: string, stepName: string): string =>
48
+ uuidv5(`${runId}:${stepName}`)
@@ -14,6 +14,8 @@ export interface WorkflowStepInput {
14
14
  stepName: string
15
15
  rpcName: string
16
16
  data: unknown
17
+ /** Predecessor step name (the walked transition); undefined for entry steps. */
18
+ fromStepName?: string
17
19
  }
18
20
 
19
21
  export interface PikkuWorkflowOrchestratorInput {
@@ -0,0 +1,111 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
5
+ import { pikkuState } from '../../pikku-state.js'
6
+ import { DEFAULT_STEP_RETRIES } from './pikku-workflow-service.js'
7
+ import type { PikkuWorkflowService } from './pikku-workflow-service.js'
8
+ import { deriveInvocationId } from './workflow-invocation-id.js'
9
+ import type { WorkflowStepOptions } from './workflow.types.js'
10
+
11
+ const UUID_RE =
12
+ /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
13
+
14
+ // Expose the protected retry-policy resolver for direct assertion.
15
+ class TestWorkflowService extends InMemoryWorkflowService {
16
+ public resolve(options?: WorkflowStepOptions) {
17
+ return (this as PikkuWorkflowService as any).resolveStepJobOptions(options)
18
+ }
19
+ }
20
+
21
+ describe('resolveStepJobOptions — workflow owns retry policy', () => {
22
+ const ws = new TestWorkflowService()
23
+
24
+ test('unset retries → default + exponential backoff (rides out outages)', () => {
25
+ assert.deepEqual(ws.resolve(undefined), {
26
+ attempts: DEFAULT_STEP_RETRIES + 1,
27
+ backoff: 'exponential',
28
+ })
29
+ })
30
+
31
+ test('retries: 0 → attempts: 1 and NO backoff (the non-idempotent opt-out)', () => {
32
+ // The whole point: an explicit 0 must never inherit the queue default and
33
+ // re-run a step the workflow said to run exactly once.
34
+ assert.deepEqual(ws.resolve({ retries: 0 }), { attempts: 1 })
35
+ })
36
+
37
+ test('explicit retries always honored', () => {
38
+ assert.deepEqual(ws.resolve({ retries: 3 }), {
39
+ attempts: 4,
40
+ backoff: 'exponential',
41
+ })
42
+ })
43
+
44
+ test('numeric retryDelay → fixed backoff', () => {
45
+ assert.deepEqual(ws.resolve({ retries: 3, retryDelay: 250 }), {
46
+ attempts: 4,
47
+ backoff: { type: 'fixed', delay: 250 },
48
+ })
49
+ })
50
+
51
+ test("retryDelay: 'exponential' → exponential backoff", () => {
52
+ assert.deepEqual(ws.resolve({ retries: 2, retryDelay: 'exponential' }), {
53
+ attempts: 3,
54
+ backoff: 'exponential',
55
+ })
56
+ })
57
+ })
58
+
59
+ describe('executeWorkflowStep surfaces a stable invocationId', () => {
60
+ test('invocationId is stable across a retry; stepId is per-attempt', async () => {
61
+ const ws = new InMemoryWorkflowService()
62
+ pikkuState(null, 'package', 'singletonServices', {
63
+ queueService: { add: async () => {} },
64
+ logger: { error() {}, info() {}, warn() {}, debug() {} },
65
+ } as any)
66
+
67
+ const captured: Array<{
68
+ invocationId: string
69
+ stepId: string
70
+ attemptCount: number
71
+ }> = []
72
+ const rpc = {
73
+ rpcWithWire: async (_n: string, _d: any, opts: any) => {
74
+ captured.push(opts.workflowStep)
75
+ if (captured.length === 1) throw new Error('transient boom')
76
+ return { ok: true }
77
+ },
78
+ }
79
+
80
+ const runId = await ws.createRun('invflow', {}, false, 'hash', {
81
+ type: 'test',
82
+ })
83
+ await ws.insertStepState(runId, 'updateUser', 'doUpdateUser', {})
84
+
85
+ // First attempt fails (default retries not exhausted → throws, not failed-run).
86
+ await assert.rejects(
87
+ ws.executeWorkflowStep(runId, 'updateUser', 'doUpdateUser', {}, rpc)
88
+ )
89
+ // Retry attempt succeeds.
90
+ await ws.executeWorkflowStep(runId, 'updateUser', 'doUpdateUser', {}, rpc)
91
+
92
+ assert.equal(captured.length, 2, 'step ran twice')
93
+ assert.equal(
94
+ captured[0]!.invocationId,
95
+ captured[1]!.invocationId,
96
+ 'invocationId is identical across retries — the dedupe key'
97
+ )
98
+ assert.equal(
99
+ captured[0]!.invocationId,
100
+ deriveInvocationId(runId, 'updateUser')
101
+ )
102
+ assert.match(captured[0]!.invocationId, UUID_RE)
103
+ assert.equal(captured[0]!.attemptCount, 1)
104
+ assert.equal(captured[1]!.attemptCount, 2)
105
+ assert.notEqual(
106
+ captured[0]!.stepId,
107
+ captured[1]!.stepId,
108
+ 'stepId is minted per attempt — must NOT be used as the dedupe key'
109
+ )
110
+ })
111
+ })
@@ -0,0 +1,79 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { InMemoryWorkflowService } from '../../services/in-memory-workflow-service.js'
5
+ import { pikkuState } from '../../pikku-state.js'
6
+ import { deriveInvocationId } from './workflow-invocation-id.js'
7
+
8
+ const silentLogger = { error() {}, info() {}, warn() {}, debug() {} }
9
+
10
+ function inlineService() {
11
+ pikkuState(null, 'package', 'singletonServices', {
12
+ queueService: { add: async () => {} },
13
+ logger: silentLogger,
14
+ } as any)
15
+ return new InMemoryWorkflowService()
16
+ }
17
+
18
+ describe('same step name invoked multiple times in one run', () => {
19
+ test('each reach gets its own row + invocationId (no clobber)', async () => {
20
+ const ws = inlineService()
21
+ const runId = await ws.createRun('flow', {}, true, 'hash', { type: 'test' })
22
+ ;(ws as any).inlineRuns.add(runId)
23
+
24
+ // Two `do('process', fn)` reaches in the SAME replay (no reset between).
25
+ const r1 = await (ws as any).inlineStep(runId, 'process', async () => 'first')
26
+ const r2 = await (ws as any).inlineStep(runId, 'process', async () => 'second')
27
+
28
+ assert.equal(r1, 'first')
29
+ assert.equal(r2, 'second', 'second call must NOT return the first cached result')
30
+
31
+ // Distinct rows: 'process' (ordinal 0) and 'process#1'.
32
+ const s0 = await ws.getStepState(runId, 'process')
33
+ const s1 = await ws.getStepState(runId, 'process#1')
34
+ assert.notEqual(s0.stepId, s1.stepId, 'distinct step rows')
35
+ assert.equal(s0.result, 'first')
36
+ assert.equal(s1.result, 'second')
37
+
38
+ // Per-invocation dedupe keys differ (not per-name).
39
+ assert.notEqual(
40
+ deriveInvocationId(runId, 'process'),
41
+ deriveInvocationId(runId, 'process#1')
42
+ )
43
+ })
44
+
45
+ test('ordinal 0 is unchanged: a single call keeps the bare name + its old invocationId', async () => {
46
+ const ws = inlineService()
47
+ const runId = await ws.createRun('flow', {}, true, 'hash', { type: 'test' })
48
+ ;(ws as any).inlineRuns.add(runId)
49
+
50
+ await (ws as any).inlineStep(runId, 'solo', async () => 'x')
51
+ // Stored under the bare name, and the dedupe key matches the pre-ordinal hash.
52
+ const s = await ws.getStepState(runId, 'solo')
53
+ assert.equal(s.result, 'x')
54
+ await assert.rejects(ws.getStepState(runId, 'solo#1'), 'no synthetic row for a single call')
55
+ })
56
+
57
+ test('a fresh replay resets ordinals so the same call resolves to the same row', async () => {
58
+ const ws = inlineService()
59
+ const runId = await ws.createRun('flow', {}, true, 'hash', { type: 'test' })
60
+ ;(ws as any).inlineRuns.add(runId)
61
+
62
+ let runs = 0
63
+ const r1 = await (ws as any).inlineStep(runId, 'once', async () => {
64
+ runs++
65
+ return 'v'
66
+ })
67
+ // Next replay resets the counter; the same `do('once')` must hit the cached
68
+ // row, not mint 'once#1'.
69
+ ;(ws as any).resetStepOrdinals(runId)
70
+ const r2 = await (ws as any).inlineStep(runId, 'once', async () => {
71
+ runs++
72
+ return 'v2'
73
+ })
74
+
75
+ assert.equal(r1, 'v')
76
+ assert.equal(r2, 'v', 'replay returns the cached result of the same step')
77
+ assert.equal(runs, 1, 'step body ran exactly once across replays')
78
+ })
79
+ })
@@ -134,6 +134,9 @@ export interface StepState {
134
134
  retries?: number
135
135
  /** Delay between retries */
136
136
  retryDelay?: string | number
137
+ /** Step name of the predecessor that scheduled this step (the transition/edge
138
+ * walked to reach it); undefined for entry steps. Reconstructs the path. */
139
+ fromStepName?: string
137
140
  /** Creation timestamp */
138
141
  createdAt: Date
139
142
  /** Last update timestamp */
@@ -360,6 +363,8 @@ export type WorkflowStepInput = {
360
363
  stepName: string
361
364
  rpcName: string
362
365
  data: any
366
+ /** Predecessor step name (the walked transition); undefined for entry steps. */
367
+ fromStepName?: string
363
368
  }
364
369
 
365
370
  export type WorkflowOrchestratorInput = {