@wooksjs/event-wf 0.7.15 → 0.7.16

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/dist/index.cjs CHANGED
@@ -57,8 +57,6 @@ function resumeWfContext(options, seeds, fn) {
57
57
  //#region packages/event-wf/src/outlets/outlet-context.ts
58
58
  /** Registered outlet handlers, keyed by name */
59
59
  const outletsRegistryKey = (0, _wooksjs_event_core.key)("wf.outlets.registry");
60
- /** Active state strategy for current request */
61
- const stateStrategyKey = (0, _wooksjs_event_core.key)("wf.outlets.stateStrategy");
62
60
  /** Finished response set by workflow steps */
63
61
  const wfFinishedKey = (0, _wooksjs_event_core.key)("wf.outlets.finished");
64
62
 
@@ -72,7 +70,6 @@ const wfFinishedKey = (0, _wooksjs_event_core.key)("wf.outlets.finished");
72
70
  * where steps need to inspect or modify outlet state directly.
73
71
  */
74
72
  const useWfOutlet = (0, _wooksjs_event_core.defineWook)((ctx) => ({
75
- getStateStrategy: () => ctx.get(stateStrategyKey),
76
73
  getOutlets: () => ctx.get(outletsRegistryKey),
77
74
  getOutlet: (name) => ctx.get(outletsRegistryKey)?.get(name) ?? null
78
75
  }));
@@ -96,8 +93,81 @@ const useWfFinished = (0, _wooksjs_event_core.defineWook)((ctx) => ({
96
93
  get: () => ctx.has(wfFinishedKey) ? ctx.get(wfFinishedKey) : void 0
97
94
  }));
98
95
 
96
+ //#endregion
97
+ //#region packages/event-wf/src/strategy-context.ts
98
+ /** Name of the strategy currently active for the running workflow. */
99
+ const stateStrategyNameKey = (0, _wooksjs_event_core.key)("wf.strategyName");
100
+ /** Strategy names must match this regex (validated at swap call + config). */
101
+ const STRATEGY_NAME_RE = /^[A-Za-z0-9_-]+$/;
102
+
103
+ //#endregion
104
+ //#region packages/event-wf/src/outlets/use-wf-strategy.ts
105
+ /**
106
+ * Composable for inspecting / swapping the active state strategy name from
107
+ * within a workflow step. The new name applies to the NEXT pause — it travels
108
+ * back to the outlet trigger via `output.inputRequired.stateStrategy`, and
109
+ * persists in the issued token's prefix.
110
+ *
111
+ * The composable only validates the name FORMAT (regex). Existence in the
112
+ * trigger's strategy registry is validated at pause time by the trigger
113
+ * itself — `swapStrategy('typo')` here will not throw; the trigger will
114
+ * throw on pause when it can't find 'typo' in its registry.
115
+ *
116
+ * @example
117
+ * ```ts
118
+ * // Step that escalates from in-memory state to a durable KV store before
119
+ * // pausing for a long-running approval:
120
+ * app.step('await-approval', {
121
+ * handler: () => {
122
+ * swapStrategy('kv')
123
+ * return outletHttp({ fields: ['decision'] })
124
+ * },
125
+ * })
126
+ * ```
127
+ */
128
+ const useWfStrategy = (0, _wooksjs_event_core.defineWook)((ctx) => ({
129
+ current: () => ctx.get(stateStrategyNameKey),
130
+ swap: (name) => {
131
+ if (!STRATEGY_NAME_RE.test(name)) throw new Error(`swapStrategy: invalid name '${name}' — must match ${STRATEGY_NAME_RE}`);
132
+ ctx.set(stateStrategyNameKey, name);
133
+ }
134
+ }));
135
+ /**
136
+ * Sugar — calls `useWfStrategy().swap(name)`. Returns `undefined` so a step
137
+ * can write `return swapStrategy('kv')` when no outlet pause follows.
138
+ */
139
+ function swapStrategy(name) {
140
+ useWfStrategy().swap(name);
141
+ }
142
+
99
143
  //#endregion
100
144
  //#region packages/event-wf/src/outlets/trigger.ts
145
+ function wrapToken(name, raw) {
146
+ return `${name}.${raw}`;
147
+ }
148
+ function unwrapToken(token) {
149
+ const i = token.indexOf(".");
150
+ if (i <= 0 || i === token.length - 1) return null;
151
+ return {
152
+ name: token.slice(0, i),
153
+ raw: token.slice(i + 1)
154
+ };
155
+ }
156
+ function normalizeStateConfig(state) {
157
+ if (typeof state === "object" && state !== null && "strategies" in state) {
158
+ const registry = state.strategies;
159
+ for (const name of Object.keys(registry)) if (!STRATEGY_NAME_RE.test(name)) throw new Error(`Invalid strategy name '${name}': must match /^[A-Za-z0-9_-]+$/`);
160
+ const def = state.default;
161
+ return {
162
+ registry,
163
+ resolveDefaultName: typeof def === "function" ? def : (_wfid) => def
164
+ };
165
+ }
166
+ return {
167
+ registry: { default: state },
168
+ resolveDefaultName: () => "default"
169
+ };
170
+ }
101
171
  /**
102
172
  * Handle an HTTP request that starts or resumes a workflow.
103
173
  *
@@ -129,6 +199,7 @@ async function handleWfOutletRequest(config, deps) {
129
199
  const registry = new Map(config.outlets.map((o) => [o.name, o]));
130
200
  ctx.set(outletsRegistryKey, registry);
131
201
  ctx.set(wfFinishedKey, void 0);
202
+ const { registry: strategyRegistry, resolveDefaultName } = normalizeStateConfig(config.state);
132
203
  const { parseBody } = (0, _wooksjs_http_body.useBody)();
133
204
  const { params } = (0, _wooksjs_event_http.useUrlParams)();
134
205
  const { getCookie } = (0, _wooksjs_event_http.useCookies)();
@@ -144,27 +215,31 @@ async function handleWfOutletRequest(config, deps) {
144
215
  }
145
216
  const wfid = body?.[wfidName] ?? queryParams.get(wfidName) ?? void 0;
146
217
  const input = body?.input;
147
- const resolveStrategy = (id) => typeof config.state === "function" ? config.state(id) : config.state;
148
218
  let output;
149
- let strategyReResolved = false;
219
+ let incomingName;
220
+ let incomingRaw;
150
221
  if (token) {
151
- const strategy = resolveStrategy(wfid ?? "");
152
- ctx.set(stateStrategyKey, strategy);
153
- const state = await strategy.consume(token);
222
+ const unwrapped = unwrapToken(token);
223
+ if (!unwrapped) {
224
+ response.setStatus(410);
225
+ return { error: "Invalid workflow state token" };
226
+ }
227
+ const strategy = Object.prototype.hasOwnProperty.call(strategyRegistry, unwrapped.name) ? strategyRegistry[unwrapped.name] : void 0;
228
+ if (!strategy) {
229
+ response.setStatus(410);
230
+ return { error: "Invalid workflow state token" };
231
+ }
232
+ incomingName = unwrapped.name;
233
+ incomingRaw = unwrapped.raw;
234
+ const state = await strategy.consume(unwrapped.raw);
154
235
  if (!state) {
155
236
  response.setStatus(410);
156
237
  return { error: "Invalid or expired workflow state" };
157
238
  }
158
- if (state.schemaId !== (wfid ?? "")) {
159
- const realStrategy = resolveStrategy(state.schemaId);
160
- if (realStrategy !== strategy) {
161
- ctx.set(stateStrategyKey, realStrategy);
162
- strategyReResolved = true;
163
- }
164
- }
165
239
  output = await deps.resume(state, {
166
240
  input,
167
- eventContext: ctx
241
+ eventContext: ctx,
242
+ strategy: { name: incomingName }
168
243
  });
169
244
  } else if (wfid) {
170
245
  if (config.allow?.length && !config.allow.includes(wfid)) {
@@ -175,12 +250,13 @@ async function handleWfOutletRequest(config, deps) {
175
250
  response.setStatus(403);
176
251
  return { error: `Workflow '${wfid}' is blocked` };
177
252
  }
178
- const strategy = resolveStrategy(wfid);
179
- ctx.set(stateStrategyKey, strategy);
253
+ const defaultName = resolveDefaultName(wfid);
254
+ if (!(Object.prototype.hasOwnProperty.call(strategyRegistry, defaultName) ? strategyRegistry[defaultName] : void 0)) throw new Error(`Default strategy '${defaultName}' not found in registry. Known: ${Object.keys(strategyRegistry).join(", ")}`);
180
255
  const initialContext = config.initialContext ? config.initialContext(body, wfid) : {};
181
256
  output = await deps.start(wfid, initialContext, {
182
257
  input,
183
- eventContext: ctx
258
+ eventContext: ctx,
259
+ strategy: { name: defaultName }
184
260
  });
185
261
  } else {
186
262
  response.setStatus(400);
@@ -211,13 +287,16 @@ async function handleWfOutletRequest(config, deps) {
211
287
  response.setStatus(500);
212
288
  return { error: `Unknown outlet: '${outletReq.outlet}'` };
213
289
  }
214
- const strategy = ctx.get(stateStrategyKey);
290
+ const finalName = outletReq.stateStrategy;
291
+ if (finalName === void 0) throw new Error("Workflow paused without `stateStrategy` on inputRequired — the WF adapter must augment the output with the active strategy name.");
292
+ const finalStrategy = Object.prototype.hasOwnProperty.call(strategyRegistry, finalName) ? strategyRegistry[finalName] : void 0;
293
+ if (!finalStrategy) throw new Error(`Workflow paused with unknown strategy '${finalName}' — step swapped to a name not in the trigger's registry. Known: ${Object.keys(strategyRegistry).join(", ")}`);
215
294
  const stateWithMeta = {
216
295
  ...output.state,
217
296
  meta: { outlet: outletReq.outlet }
218
297
  };
219
- const reuseHandle = token && !strategyReResolved ? { handle: token } : void 0;
220
- const newToken = await strategy.persist(stateWithMeta, output.expires ? { ttl: output.expires - Date.now() } : void 0, reuseHandle);
298
+ const reuseHandle = incomingName !== void 0 && incomingName === finalName && incomingRaw !== void 0 ? { handle: incomingRaw } : void 0;
299
+ const newToken = wrapToken(finalName, await finalStrategy.persist(stateWithMeta, output.expires ? { ttl: output.expires - Date.now() } : void 0, reuseHandle));
221
300
  const outOfBand = outletHandler.tokenDelivery === "out-of-band";
222
301
  if (tokenWrite === "cookie" && !outOfBand) response.setCookie(tokenName, newToken, {
223
302
  httpOnly: true,
@@ -427,6 +506,7 @@ var WooksWf = class extends wooks.WooksAdapterBase {
427
506
  indexes,
428
507
  input
429
508
  }, async () => {
509
+ if (opts?.strategy?.name !== void 0) (0, _wooksjs_event_core.current)().set(stateStrategyNameKey, opts.strategy.name);
430
510
  const { handlers: foundHandlers } = this.wooks.lookup("WF_FLOW", `/${schemaId}`.replace(/^\/+/u, "/"));
431
511
  const handlers = foundHandlers || this.opts?.onNotFound && [this.opts.onNotFound] || null;
432
512
  if (handlers && handlers.length > 0) {
@@ -460,6 +540,10 @@ var WooksWf = class extends wooks.WooksAdapterBase {
460
540
  throw error;
461
541
  }
462
542
  clean();
543
+ if (result.inputRequired) {
544
+ const finalName = (0, _wooksjs_event_core.current)().get(stateStrategyNameKey);
545
+ if (finalName !== void 0) result.inputRequired.stateStrategy = finalName;
546
+ }
463
547
  if (result.resume) result.resume = (_input) => this.resume(result.state, {
464
548
  input: _input,
465
549
  spy,
@@ -555,6 +639,7 @@ Object.defineProperty(exports, 'outletHttp', {
555
639
  });
556
640
  exports.resumeKey = resumeKey;
557
641
  exports.resumeWfContext = resumeWfContext;
642
+ exports.swapStrategy = swapStrategy;
558
643
  Object.defineProperty(exports, 'useLogger', {
559
644
  enumerable: true,
560
645
  get: function () {
@@ -570,5 +655,6 @@ Object.defineProperty(exports, 'useRouteParams', {
570
655
  exports.useWfFinished = useWfFinished;
571
656
  exports.useWfOutlet = useWfOutlet;
572
657
  exports.useWfState = useWfState;
658
+ exports.useWfStrategy = useWfStrategy;
573
659
  exports.wfKind = wfKind;
574
660
  exports.wfShortcuts = wfShortcuts;
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import * as _wooksjs_event_core from '@wooksjs/event-core';
2
2
  import { EventContextOptions, EventKindSeeds, EventContext } from '@wooksjs/event-core';
3
3
  export { EventContext, EventContextOptions, useLogger, useRouteParams } from '@wooksjs/event-core';
4
4
  import * as _prostojs_wf_outlets from '@prostojs/wf/outlets';
5
- import { WfStateStrategy, WfOutlet, WfOutletRequest } from '@prostojs/wf/outlets';
5
+ import { WfOutletRequest, WfStateStrategy, WfOutlet } from '@prostojs/wf/outlets';
6
6
  export { EncapsulatedStateStrategy, HandleStateStrategy, WfOutlet, WfOutletRequest, WfOutletResult, WfState, WfStateStore, WfStateStoreMemory, WfStateStrategy, outlet, outletEmail, outletHttp } from '@prostojs/wf/outlets';
7
7
  import { TFlowOutput, Workflow, Step, TWorkflowSpy, TStepHandler, TWorkflowSchema } from '@prostojs/wf';
8
8
  export { StepRetriableError, TStepHandler, TWorkflowSchema } from '@prostojs/wf';
@@ -50,7 +50,6 @@ declare function resumeWfContext<R>(options: EventContextOptions, seeds: EventKi
50
50
  * where steps need to inspect or modify outlet state directly.
51
51
  */
52
52
  declare const useWfOutlet: _wooksjs_event_core.WookComposable<{
53
- getStateStrategy: () => _prostojs_wf_outlets.WfStateStrategy;
54
53
  getOutlets: () => Map<string, _prostojs_wf_outlets.WfOutlet>;
55
54
  getOutlet: (name: string) => _prostojs_wf_outlets.WfOutlet | null;
56
55
  }>;
@@ -85,6 +84,58 @@ declare const useWfFinished: _wooksjs_event_core.WookComposable<{
85
84
  get: () => WfFinishedResponse | undefined;
86
85
  }>;
87
86
 
87
+ /**
88
+ * Composable for inspecting / swapping the active state strategy name from
89
+ * within a workflow step. The new name applies to the NEXT pause — it travels
90
+ * back to the outlet trigger via `output.inputRequired.stateStrategy`, and
91
+ * persists in the issued token's prefix.
92
+ *
93
+ * The composable only validates the name FORMAT (regex). Existence in the
94
+ * trigger's strategy registry is validated at pause time by the trigger
95
+ * itself — `swapStrategy('typo')` here will not throw; the trigger will
96
+ * throw on pause when it can't find 'typo' in its registry.
97
+ *
98
+ * @example
99
+ * ```ts
100
+ * // Step that escalates from in-memory state to a durable KV store before
101
+ * // pausing for a long-running approval:
102
+ * app.step('await-approval', {
103
+ * handler: () => {
104
+ * swapStrategy('kv')
105
+ * return outletHttp({ fields: ['decision'] })
106
+ * },
107
+ * })
108
+ * ```
109
+ */
110
+ declare const useWfStrategy: _wooksjs_event_core.WookComposable<{
111
+ /** Name of the strategy currently set for the next persist. */
112
+ current: () => string;
113
+ /**
114
+ * Swap the active strategy by name. Validates name format only; unknown
115
+ * names surface as a loud error at pause time from the trigger.
116
+ */
117
+ swap: (name: string) => void;
118
+ }>;
119
+ /**
120
+ * Sugar — calls `useWfStrategy().swap(name)`. Returns `undefined` so a step
121
+ * can write `return swapStrategy('kv')` when no outlet pause follows.
122
+ */
123
+ declare function swapStrategy(name: string): undefined;
124
+
125
+ /**
126
+ * `WfOutletRequest` extended with the strategy name that was active when the
127
+ * workflow paused. The WF adapter augments `output.inputRequired` with this
128
+ * field so callers can persist the next token under the post-swap strategy
129
+ * without depending on EventContext write-through.
130
+ *
131
+ * The field is named generically (`stateStrategy`) — outlet handlers that
132
+ * don't care about strategies ignore it; outlet triggers and offline
133
+ * resume drivers read it to look up the right strategy in their registry.
134
+ */
135
+ type WfPauseRequest<P = unknown> = WfOutletRequest<P> & {
136
+ stateStrategy?: string;
137
+ };
138
+
88
139
  interface WfOutletTokenConfig {
89
140
  /** Where to read state token from incoming request (default: `['body', 'query', 'cookie']`) */
90
141
  read?: Array<'body' | 'query' | 'cookie'>;
@@ -99,26 +150,28 @@ interface WfOutletTriggerConfig {
99
150
  /** Blacklist of workflow IDs. Checked after allow. */
100
151
  block?: string[];
101
152
  /**
102
- * State persistence strategy. Either a single strategy shared by all
103
- * workflows, or a function that returns a strategy per workflow ID.
153
+ * State persistence strategy. Two forms:
104
154
  *
105
- * **Constraint when using the function form.** The trigger resolves the
106
- * strategy at resume time using the `wfid` from the request. If the resume
107
- * request does not include `wfid` (e.g. cookie-only transport, token-only
108
- * body), the trigger calls `config.state('')` meaning:
155
+ * - **Single-strategy shortcut**: pass a `WfStateStrategy` directly. The
156
+ * trigger registers it internally under the name `'default'`.
157
+ * - **Named map**: `{ strategies, default }` where `strategies` is a
158
+ * `Record<name, WfStateStrategy>` and `default` is either the name to use
159
+ * on workflow start or a function `(wfid) => name` that picks per
160
+ * workflow id. Steps may then call `swapStrategy(name)` to escalate the
161
+ * *next* outlet pause to a different strategy.
109
162
  *
110
- * - EITHER all strategies returned by the function must share the same
111
- * underlying storage (same Redis instance, same `WfStateStore`, same
112
- * encryption key), so `consume`/`retrieve` operations work regardless of
113
- * which strategy instance is picked;
114
- * - OR every resume request must carry `wfid` so the correct strategy is
115
- * always resolved.
163
+ * The active strategy name is embedded in the issued token as `<name>.<raw>`,
164
+ * so resume always picks the strategy that persisted the state. Each
165
+ * strategy can therefore have its own independent storage (no need for
166
+ * shared keyspaces between strategies).
116
167
  *
117
- * Violating this contract silently breaks single-use token invalidation:
118
- * the `consume` call runs against the wrong strategy's storage, and the
119
- * token remains live in the real strategy.
168
+ * Strategy names must match `/^[A-Za-z0-9_-]+$/` (validated at trigger
169
+ * invocation).
120
170
  */
121
- state: WfStateStrategy | ((wfid: string) => WfStateStrategy);
171
+ state: WfStateStrategy | {
172
+ strategies: Record<string, WfStateStrategy>;
173
+ default: string | ((wfid: string) => string);
174
+ };
122
175
  /** Registered outlets */
123
176
  outlets: WfOutlet[];
124
177
  /** Token configuration (reading, writing, naming) */
@@ -148,7 +201,10 @@ interface WfOutletTriggerDeps {
148
201
  start: (schemaId: string, context: unknown, opts?: {
149
202
  input?: unknown;
150
203
  eventContext?: unknown;
151
- }) => Promise<TFlowOutput<unknown, unknown, WfOutletRequest>>;
204
+ strategy?: {
205
+ name: string;
206
+ };
207
+ }) => Promise<TFlowOutput<unknown, unknown, WfPauseRequest>>;
152
208
  /** Resume a workflow. Provided by WooksWf or MoostWf. */
153
209
  resume: (state: {
154
210
  schemaId: string;
@@ -157,7 +213,10 @@ interface WfOutletTriggerDeps {
157
213
  }, opts?: {
158
214
  input?: unknown;
159
215
  eventContext?: unknown;
160
- }) => Promise<TFlowOutput<unknown, unknown, WfOutletRequest>>;
216
+ strategy?: {
217
+ name: string;
218
+ };
219
+ }) => Promise<TFlowOutput<unknown, unknown, WfPauseRequest>>;
161
220
  }
162
221
 
163
222
  /**
@@ -281,6 +340,20 @@ interface TWfRunOptions<I = unknown, T = unknown, IR = unknown> {
281
340
  * Pass `current()` from within an active event scope (HTTP handler, etc.).
282
341
  */
283
342
  eventContext?: EventContext;
343
+ /**
344
+ * Initial state strategy for the workflow run. Sets the strategy name on
345
+ * the WF event context so steps can inspect it via `useWfStrategy().current()`
346
+ * and swap it via `useWfStrategy().swap(name)`. The final post-swap name
347
+ * is reflected on `output.inputRequired.stateStrategy` (when paused) so
348
+ * callers can persist under the right keyspace without depending on
349
+ * EventContext write-through.
350
+ *
351
+ * The adapter only carries the name — strategy instances live in the
352
+ * caller (HTTP trigger, offline driver, etc.).
353
+ */
354
+ strategy?: {
355
+ name: string;
356
+ };
284
357
  }
285
358
  /** Wooks adapter for defining and executing workflow schemas with step-based routing. */
286
359
  declare class WooksWf<T = any, IR = any> extends WooksAdapterBase {
@@ -349,5 +422,5 @@ declare class WooksWf<T = any, IR = any> extends WooksAdapterBase {
349
422
  */
350
423
  declare function createWfApp<T>(opts?: TWooksWfOptions, wooks?: Wooks | WooksAdapterBase): WooksWf<T, any>;
351
424
 
352
- export { WooksWf, createEmailOutlet, createHttpOutlet, createOutletHandler, createWfApp, createWfContext, handleWfOutletRequest, resumeKey, resumeWfContext, useWfFinished, useWfOutlet, useWfState, wfKind, wfShortcuts };
353
- export type { TWFEventInput, TWfRunOptions, TWooksWfOptions, WfFinishedResponse, WfOutletTokenConfig, WfOutletTriggerConfig, WfOutletTriggerDeps };
425
+ export { WooksWf, createEmailOutlet, createHttpOutlet, createOutletHandler, createWfApp, createWfContext, handleWfOutletRequest, resumeKey, resumeWfContext, swapStrategy, useWfFinished, useWfOutlet, useWfState, useWfStrategy, wfKind, wfShortcuts };
426
+ export type { TWFEventInput, TWfRunOptions, TWooksWfOptions, WfFinishedResponse, WfOutletTokenConfig, WfOutletTriggerConfig, WfOutletTriggerDeps, WfPauseRequest };
package/dist/index.mjs CHANGED
@@ -56,8 +56,6 @@ function resumeWfContext(options, seeds, fn) {
56
56
  //#region packages/event-wf/src/outlets/outlet-context.ts
57
57
  /** Registered outlet handlers, keyed by name */
58
58
  const outletsRegistryKey = key("wf.outlets.registry");
59
- /** Active state strategy for current request */
60
- const stateStrategyKey = key("wf.outlets.stateStrategy");
61
59
  /** Finished response set by workflow steps */
62
60
  const wfFinishedKey = key("wf.outlets.finished");
63
61
 
@@ -71,7 +69,6 @@ const wfFinishedKey = key("wf.outlets.finished");
71
69
  * where steps need to inspect or modify outlet state directly.
72
70
  */
73
71
  const useWfOutlet = defineWook((ctx) => ({
74
- getStateStrategy: () => ctx.get(stateStrategyKey),
75
72
  getOutlets: () => ctx.get(outletsRegistryKey),
76
73
  getOutlet: (name) => ctx.get(outletsRegistryKey)?.get(name) ?? null
77
74
  }));
@@ -95,8 +92,81 @@ const useWfFinished = defineWook((ctx) => ({
95
92
  get: () => ctx.has(wfFinishedKey) ? ctx.get(wfFinishedKey) : void 0
96
93
  }));
97
94
 
95
+ //#endregion
96
+ //#region packages/event-wf/src/strategy-context.ts
97
+ /** Name of the strategy currently active for the running workflow. */
98
+ const stateStrategyNameKey = key("wf.strategyName");
99
+ /** Strategy names must match this regex (validated at swap call + config). */
100
+ const STRATEGY_NAME_RE = /^[A-Za-z0-9_-]+$/;
101
+
102
+ //#endregion
103
+ //#region packages/event-wf/src/outlets/use-wf-strategy.ts
104
+ /**
105
+ * Composable for inspecting / swapping the active state strategy name from
106
+ * within a workflow step. The new name applies to the NEXT pause — it travels
107
+ * back to the outlet trigger via `output.inputRequired.stateStrategy`, and
108
+ * persists in the issued token's prefix.
109
+ *
110
+ * The composable only validates the name FORMAT (regex). Existence in the
111
+ * trigger's strategy registry is validated at pause time by the trigger
112
+ * itself — `swapStrategy('typo')` here will not throw; the trigger will
113
+ * throw on pause when it can't find 'typo' in its registry.
114
+ *
115
+ * @example
116
+ * ```ts
117
+ * // Step that escalates from in-memory state to a durable KV store before
118
+ * // pausing for a long-running approval:
119
+ * app.step('await-approval', {
120
+ * handler: () => {
121
+ * swapStrategy('kv')
122
+ * return outletHttp({ fields: ['decision'] })
123
+ * },
124
+ * })
125
+ * ```
126
+ */
127
+ const useWfStrategy = defineWook((ctx) => ({
128
+ current: () => ctx.get(stateStrategyNameKey),
129
+ swap: (name) => {
130
+ if (!STRATEGY_NAME_RE.test(name)) throw new Error(`swapStrategy: invalid name '${name}' — must match ${STRATEGY_NAME_RE}`);
131
+ ctx.set(stateStrategyNameKey, name);
132
+ }
133
+ }));
134
+ /**
135
+ * Sugar — calls `useWfStrategy().swap(name)`. Returns `undefined` so a step
136
+ * can write `return swapStrategy('kv')` when no outlet pause follows.
137
+ */
138
+ function swapStrategy(name) {
139
+ useWfStrategy().swap(name);
140
+ }
141
+
98
142
  //#endregion
99
143
  //#region packages/event-wf/src/outlets/trigger.ts
144
+ function wrapToken(name, raw) {
145
+ return `${name}.${raw}`;
146
+ }
147
+ function unwrapToken(token) {
148
+ const i = token.indexOf(".");
149
+ if (i <= 0 || i === token.length - 1) return null;
150
+ return {
151
+ name: token.slice(0, i),
152
+ raw: token.slice(i + 1)
153
+ };
154
+ }
155
+ function normalizeStateConfig(state) {
156
+ if (typeof state === "object" && state !== null && "strategies" in state) {
157
+ const registry = state.strategies;
158
+ for (const name of Object.keys(registry)) if (!STRATEGY_NAME_RE.test(name)) throw new Error(`Invalid strategy name '${name}': must match /^[A-Za-z0-9_-]+$/`);
159
+ const def = state.default;
160
+ return {
161
+ registry,
162
+ resolveDefaultName: typeof def === "function" ? def : (_wfid) => def
163
+ };
164
+ }
165
+ return {
166
+ registry: { default: state },
167
+ resolveDefaultName: () => "default"
168
+ };
169
+ }
100
170
  /**
101
171
  * Handle an HTTP request that starts or resumes a workflow.
102
172
  *
@@ -128,6 +198,7 @@ async function handleWfOutletRequest(config, deps) {
128
198
  const registry = new Map(config.outlets.map((o) => [o.name, o]));
129
199
  ctx.set(outletsRegistryKey, registry);
130
200
  ctx.set(wfFinishedKey, void 0);
201
+ const { registry: strategyRegistry, resolveDefaultName } = normalizeStateConfig(config.state);
131
202
  const { parseBody } = useBody();
132
203
  const { params } = useUrlParams();
133
204
  const { getCookie } = useCookies();
@@ -143,27 +214,31 @@ async function handleWfOutletRequest(config, deps) {
143
214
  }
144
215
  const wfid = body?.[wfidName] ?? queryParams.get(wfidName) ?? void 0;
145
216
  const input = body?.input;
146
- const resolveStrategy = (id) => typeof config.state === "function" ? config.state(id) : config.state;
147
217
  let output;
148
- let strategyReResolved = false;
218
+ let incomingName;
219
+ let incomingRaw;
149
220
  if (token) {
150
- const strategy = resolveStrategy(wfid ?? "");
151
- ctx.set(stateStrategyKey, strategy);
152
- const state = await strategy.consume(token);
221
+ const unwrapped = unwrapToken(token);
222
+ if (!unwrapped) {
223
+ response.setStatus(410);
224
+ return { error: "Invalid workflow state token" };
225
+ }
226
+ const strategy = Object.prototype.hasOwnProperty.call(strategyRegistry, unwrapped.name) ? strategyRegistry[unwrapped.name] : void 0;
227
+ if (!strategy) {
228
+ response.setStatus(410);
229
+ return { error: "Invalid workflow state token" };
230
+ }
231
+ incomingName = unwrapped.name;
232
+ incomingRaw = unwrapped.raw;
233
+ const state = await strategy.consume(unwrapped.raw);
153
234
  if (!state) {
154
235
  response.setStatus(410);
155
236
  return { error: "Invalid or expired workflow state" };
156
237
  }
157
- if (state.schemaId !== (wfid ?? "")) {
158
- const realStrategy = resolveStrategy(state.schemaId);
159
- if (realStrategy !== strategy) {
160
- ctx.set(stateStrategyKey, realStrategy);
161
- strategyReResolved = true;
162
- }
163
- }
164
238
  output = await deps.resume(state, {
165
239
  input,
166
- eventContext: ctx
240
+ eventContext: ctx,
241
+ strategy: { name: incomingName }
167
242
  });
168
243
  } else if (wfid) {
169
244
  if (config.allow?.length && !config.allow.includes(wfid)) {
@@ -174,12 +249,13 @@ async function handleWfOutletRequest(config, deps) {
174
249
  response.setStatus(403);
175
250
  return { error: `Workflow '${wfid}' is blocked` };
176
251
  }
177
- const strategy = resolveStrategy(wfid);
178
- ctx.set(stateStrategyKey, strategy);
252
+ const defaultName = resolveDefaultName(wfid);
253
+ if (!(Object.prototype.hasOwnProperty.call(strategyRegistry, defaultName) ? strategyRegistry[defaultName] : void 0)) throw new Error(`Default strategy '${defaultName}' not found in registry. Known: ${Object.keys(strategyRegistry).join(", ")}`);
179
254
  const initialContext = config.initialContext ? config.initialContext(body, wfid) : {};
180
255
  output = await deps.start(wfid, initialContext, {
181
256
  input,
182
- eventContext: ctx
257
+ eventContext: ctx,
258
+ strategy: { name: defaultName }
183
259
  });
184
260
  } else {
185
261
  response.setStatus(400);
@@ -210,13 +286,16 @@ async function handleWfOutletRequest(config, deps) {
210
286
  response.setStatus(500);
211
287
  return { error: `Unknown outlet: '${outletReq.outlet}'` };
212
288
  }
213
- const strategy = ctx.get(stateStrategyKey);
289
+ const finalName = outletReq.stateStrategy;
290
+ if (finalName === void 0) throw new Error("Workflow paused without `stateStrategy` on inputRequired — the WF adapter must augment the output with the active strategy name.");
291
+ const finalStrategy = Object.prototype.hasOwnProperty.call(strategyRegistry, finalName) ? strategyRegistry[finalName] : void 0;
292
+ if (!finalStrategy) throw new Error(`Workflow paused with unknown strategy '${finalName}' — step swapped to a name not in the trigger's registry. Known: ${Object.keys(strategyRegistry).join(", ")}`);
214
293
  const stateWithMeta = {
215
294
  ...output.state,
216
295
  meta: { outlet: outletReq.outlet }
217
296
  };
218
- const reuseHandle = token && !strategyReResolved ? { handle: token } : void 0;
219
- const newToken = await strategy.persist(stateWithMeta, output.expires ? { ttl: output.expires - Date.now() } : void 0, reuseHandle);
297
+ const reuseHandle = incomingName !== void 0 && incomingName === finalName && incomingRaw !== void 0 ? { handle: incomingRaw } : void 0;
298
+ const newToken = wrapToken(finalName, await finalStrategy.persist(stateWithMeta, output.expires ? { ttl: output.expires - Date.now() } : void 0, reuseHandle));
220
299
  const outOfBand = outletHandler.tokenDelivery === "out-of-band";
221
300
  if (tokenWrite === "cookie" && !outOfBand) response.setCookie(tokenName, newToken, {
222
301
  httpOnly: true,
@@ -426,6 +505,7 @@ var WooksWf = class extends WooksAdapterBase {
426
505
  indexes,
427
506
  input
428
507
  }, async () => {
508
+ if (opts?.strategy?.name !== void 0) current().set(stateStrategyNameKey, opts.strategy.name);
429
509
  const { handlers: foundHandlers } = this.wooks.lookup("WF_FLOW", `/${schemaId}`.replace(/^\/+/u, "/"));
430
510
  const handlers = foundHandlers || this.opts?.onNotFound && [this.opts.onNotFound] || null;
431
511
  if (handlers && handlers.length > 0) {
@@ -459,6 +539,10 @@ var WooksWf = class extends WooksAdapterBase {
459
539
  throw error;
460
540
  }
461
541
  clean();
542
+ if (result.inputRequired) {
543
+ const finalName = current().get(stateStrategyNameKey);
544
+ if (finalName !== void 0) result.inputRequired.stateStrategy = finalName;
545
+ }
462
546
  if (result.resume) result.resume = (_input) => this.resume(result.state, {
463
547
  input: _input,
464
548
  spy,
@@ -503,4 +587,4 @@ function createWfApp(opts, wooks) {
503
587
  }
504
588
 
505
589
  //#endregion
506
- export { EncapsulatedStateStrategy, HandleStateStrategy, StepRetriableError, WfStateStoreMemory, WooksWf, createEmailOutlet, createHttpOutlet, createOutletHandler, createWfApp, createWfContext, handleWfOutletRequest, outlet, outletEmail, outletHttp, resumeKey, resumeWfContext, useLogger, useRouteParams, useWfFinished, useWfOutlet, useWfState, wfKind, wfShortcuts };
590
+ export { EncapsulatedStateStrategy, HandleStateStrategy, StepRetriableError, WfStateStoreMemory, WooksWf, createEmailOutlet, createHttpOutlet, createOutletHandler, createWfApp, createWfContext, handleWfOutletRequest, outlet, outletEmail, outletHttp, resumeKey, resumeWfContext, swapStrategy, useLogger, useRouteParams, useWfFinished, useWfOutlet, useWfState, useWfStrategy, wfKind, wfShortcuts };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wooksjs/event-wf",
3
- "version": "0.7.15",
3
+ "version": "0.7.16",
4
4
  "description": "@wooksjs/event-wf",
5
5
  "keywords": [
6
6
  "app",
@@ -42,17 +42,17 @@
42
42
  "devDependencies": {
43
43
  "typescript": "^5.9.3",
44
44
  "vitest": "^3.2.4",
45
- "@wooksjs/event-core": "^0.7.15",
46
- "@wooksjs/event-http": "^0.7.15",
47
- "wooks": "^0.7.15",
48
- "@wooksjs/http-body": "^0.7.15"
45
+ "@wooksjs/event-core": "^0.7.16",
46
+ "@wooksjs/event-http": "^0.7.16",
47
+ "wooks": "^0.7.16",
48
+ "@wooksjs/http-body": "^0.7.16"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "@prostojs/logger": "^0.4.3",
52
- "@wooksjs/event-core": "^0.7.15",
53
- "wooks": "^0.7.15",
54
- "@wooksjs/event-http": "^0.7.15",
55
- "@wooksjs/http-body": "^0.7.15"
52
+ "@wooksjs/event-core": "^0.7.16",
53
+ "@wooksjs/http-body": "^0.7.16",
54
+ "wooks": "^0.7.16",
55
+ "@wooksjs/event-http": "^0.7.16"
56
56
  },
57
57
  "peerDependenciesMeta": {
58
58
  "@wooksjs/event-http": {