@modular-react/journeys 0.1.0
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/LICENSE +21 -0
- package/README.md +1669 -0
- package/dist/index.d.ts +754 -0
- package/dist/index.js +303 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime-DyU_PmaC.js +599 -0
- package/dist/runtime-DyU_PmaC.js.map +1 -0
- package/dist/testing.d.ts +121 -0
- package/dist/testing.js +102 -0
- package/dist/testing.js.map +1 -0
- package/package.json +57 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime-DyU_PmaC.js","names":[],"sources":["../src/validation.ts","../src/runtime.ts"],"sourcesContent":["import type { ModuleDescriptor } from \"@modular-react/core\";\nimport type { AnyJourneyDefinition, RegisteredJourney } from \"./types.js\";\n\n/**\n * Aggregated error thrown when one or more registered journeys reference\n * module ids, entry names, or exit names that do not exist (or that\n * disagree on `allowBack`). Mirrors the style of core's\n * `validateDependencies` — accumulate all issues, throw once.\n */\nexport class JourneyValidationError extends Error {\n readonly issues: readonly string[];\n constructor(issues: readonly string[]) {\n super(`[@modular-react/journeys] Invalid journey registration:\\n - ${issues.join(\"\\n - \")}`);\n this.name = \"JourneyValidationError\";\n this.issues = issues;\n }\n}\n\nexport class JourneyHydrationError extends Error {\n constructor(message: string, options?: ErrorOptions) {\n super(`[@modular-react/journeys] ${message}`, options);\n this.name = \"JourneyHydrationError\";\n }\n}\n\n/**\n * Thrown when `runtime.start()` / `runtime.hydrate()` is called with a\n * journey id that is not registered. Distinct class so shells can\n * discriminate \"this journey is gone after an upgrade, drop the tab\"\n * from transient or validation failures.\n */\nexport class UnknownJourneyError extends Error {\n readonly journeyId: string;\n constructor(journeyId: string, registered: readonly string[]) {\n super(\n `[@modular-react/journeys] Unknown journey id \"${journeyId}\". Registered: ${\n registered.join(\", \") || \"(none)\"\n }`,\n );\n this.name = \"UnknownJourneyError\";\n this.journeyId = journeyId;\n }\n}\n\nexport function validateJourneyContracts(\n journeys: readonly RegisteredJourney[],\n modules: readonly ModuleDescriptor<any, any, any, any>[],\n): void {\n const issues: string[] = [];\n const moduleById = new Map<string, ModuleDescriptor<any, any, any, any>>();\n for (const mod of modules) moduleById.set(mod.id, mod);\n\n // Guard against a module declaring an exit literally named `allowBack`.\n // Per-entry transitions on a journey use `allowBack: boolean` as a\n // control key and an exit of the same name would be silently skipped by\n // the per-exit iteration below. Fail loudly at registration time instead.\n for (const mod of modules) {\n if (mod.exitPoints && Object.prototype.hasOwnProperty.call(mod.exitPoints, \"allowBack\")) {\n issues.push(\n `module \"${mod.id}\" declares an exit named \"allowBack\", which collides with the reserved ` +\n `per-entry transition control key. Rename the exit (e.g. \"allowBackExit\").`,\n );\n }\n }\n\n const seenIds = new Set<string>();\n for (const reg of journeys) {\n const def = reg.definition;\n if (seenIds.has(def.id)) {\n issues.push(`journey \"${def.id}\" is registered more than once`);\n }\n seenIds.add(def.id);\n\n // Validate transitions map. The inner objects must be non-null — we\n // accept `AnyJourneyDefinition`, so a caller that sidesteps the typed\n // `defineJourney` helper can hand us `{ transitions: { foo: null } }`\n // or `{ bar: { baz: null } }`; we want those to become an accumulated\n // issue instead of a TypeError that short-circuits the loop.\n const transitions = (def.transitions ?? {}) as Record<string, unknown>;\n for (const [moduleId, perModule] of Object.entries(transitions)) {\n const mod = moduleById.get(moduleId);\n if (!mod) {\n issues.push(\n `journey \"${def.id}\" references unknown module id \"${moduleId}\" in transitions`,\n );\n continue;\n }\n if (!perModule || typeof perModule !== \"object\") {\n issues.push(\n `journey \"${def.id}\" has malformed transitions for module \"${moduleId}\" (expected an object)`,\n );\n continue;\n }\n for (const [entryName, perEntry] of Object.entries(perModule as Record<string, unknown>)) {\n const entry = mod.entryPoints?.[entryName];\n if (!entry) {\n issues.push(`journey \"${def.id}\" references unknown entry \"${moduleId}.${entryName}\"`);\n continue;\n }\n if (!perEntry || typeof perEntry !== \"object\") {\n issues.push(\n `journey \"${def.id}\" has malformed transitions for entry \"${moduleId}.${entryName}\" (expected an object)`,\n );\n continue;\n }\n const perEntryObj = perEntry as Record<string, unknown>;\n for (const exitName of Object.keys(perEntryObj)) {\n if (exitName === \"allowBack\") continue;\n if (!mod.exitPoints || !(exitName in mod.exitPoints)) {\n issues.push(\n `journey \"${def.id}\" references unknown exit \"${moduleId}.${entryName}.${exitName}\"`,\n );\n }\n }\n if (perEntryObj.allowBack === true) {\n const descriptorAllowBack = entry.allowBack;\n if (descriptorAllowBack !== \"preserve-state\" && descriptorAllowBack !== \"rollback\") {\n issues.push(\n `journey \"${def.id}\" sets allowBack on \"${moduleId}.${entryName}\" but the module entry does not declare allowBack`,\n );\n }\n }\n }\n }\n }\n\n if (issues.length > 0) throw new JourneyValidationError(issues);\n}\n\n/**\n * Shallow sanity check on a journey definition's own shape. Use this for\n * authoring ergonomics; structural contract checks live in\n * {@link validateJourneyContracts}.\n */\nexport function validateJourneyDefinition(def: AnyJourneyDefinition): readonly string[] {\n const issues: string[] = [];\n if (!def.id || typeof def.id !== \"string\") issues.push(\"journey is missing a string id\");\n if (!def.version || typeof def.version !== \"string\")\n issues.push(`journey \"${def.id ?? \"(unknown)\"}\" is missing a string version`);\n if (typeof def.initialState !== \"function\")\n issues.push(`journey \"${def.id}\" must declare initialState as a function`);\n if (typeof def.start !== \"function\")\n issues.push(`journey \"${def.id}\" must declare start as a function`);\n if (!def.transitions || typeof def.transitions !== \"object\")\n issues.push(`journey \"${def.id}\" must declare transitions`);\n return issues;\n}\n","import type { JourneyHandleRef, ModuleDescriptor } from \"@modular-react/core\";\nimport type {\n AnyJourneyDefinition,\n InstanceId,\n JourneyDefinition,\n JourneyDefinitionSummary,\n JourneyInstance,\n JourneyPersistence,\n JourneyRuntime,\n JourneyStatus,\n JourneyStep,\n ModuleTypeMap,\n RegisteredJourney,\n SerializedJourney,\n StepSpec,\n TransitionEvent,\n TransitionResult,\n} from \"./types.js\";\nimport { JourneyHydrationError, UnknownJourneyError } from \"./validation.js\";\n\nexport interface InstanceRecord<TState = unknown> {\n id: InstanceId;\n journeyId: string;\n status: JourneyStatus;\n step: JourneyStep | null;\n history: JourneyStep[];\n /** Snapshots captured per history entry — indexed alongside history. */\n rollbackSnapshots: (TState | undefined)[];\n /** True when any entry in `rollbackSnapshots` holds a real snapshot. */\n hasRollbackSnapshot: boolean;\n state: TState;\n terminalPayload: unknown;\n startedAt: string;\n updatedAt: string;\n /** Monotonically increasing token used to invalidate stale exit/goBack calls. */\n stepToken: number;\n /** Persistence key computed on start. Stable for the instance's lifetime. */\n persistenceKey: string | null;\n terminalFired: boolean;\n /** Total retries the outlet has consumed for this instance (across all steps). */\n retryCount: number;\n listeners: Set<() => void>;\n pendingSave: SerializedJourney<TState> | null;\n saveInFlight: boolean;\n /**\n * True when `removePersisted` fires while `saveInFlight` is still set:\n * the remove is deferred until the save settles so adapters that don't\n * serialize their own ops can't see remove→save reordering and leave\n * an orphaned blob in storage.\n */\n pendingRemove: boolean;\n /**\n * Monotonically incrementing revision bumped when an observable field\n * changes (status/step/state/history/terminalPayload). Used to memoize\n * the public `JourneyInstance` snapshot so that `getInstance(id)` returns\n * a stable reference between changes — a requirement of\n * `useSyncExternalStore`.\n */\n revision: number;\n /** Cached snapshot keyed by `revision`; rebuilt on the next read if stale. */\n cachedSnapshot: { revision: number; instance: JourneyInstance } | null;\n /** Cached exit/goBack closures keyed by stepToken. */\n cachedCallbacks: {\n stepToken: number;\n exit: (name: string, output?: unknown) => void;\n goBack: (() => void) | undefined;\n } | null;\n}\n\nexport interface JourneyRuntimeOptions {\n readonly debug?: boolean;\n /**\n * Module descriptors keyed by id — the runtime needs them to resolve\n * `allowBack` mode ('preserve-state' | 'rollback' | false) at goBack time.\n * When omitted, `goBack` falls back to 'preserve-state' for any journey\n * transition that opts in via `allowBack: true`.\n */\n readonly modules?: Readonly<Record<string, ModuleDescriptor<any, any, any, any>>>;\n}\n\n/**\n * Module-private store of runtime internals. Keeps `__bindStepCallbacks`,\n * `__getRecord`, `__getRegistered`, and the bound module descriptor map off\n * the public `JourneyRuntime` surface (which would otherwise show up in\n * autocomplete, `Object.keys`, etc.). Access via {@link getInternals}.\n */\nconst INTERNALS = new WeakMap<JourneyRuntime, JourneyRuntimeInternals>();\n\n/**\n * Create a journey runtime bound to a set of registered journeys. The\n * registry integration assembles this once at resolve time; the runtime is\n * owned by the manifest and exposed as `manifest.journeys`.\n *\n * Passing an empty `registered` array yields a no-op runtime: every public\n * method is safe to call, and `start()` will throw \"unknown journey id\" —\n * matching the normal \"not registered\" failure mode and letting shells skip\n * null-guards on `manifest.journeys`.\n */\nexport function createJourneyRuntime(\n registered: readonly RegisteredJourney[],\n options: JourneyRuntimeOptions = {},\n): JourneyRuntime {\n const debug = options.debug ?? defaultDebug();\n const moduleMap = options.modules ?? {};\n const definitions = new Map<string, RegisteredJourney>();\n for (const entry of registered) definitions.set(entry.definition.id, entry);\n const instances = new Map<InstanceId, InstanceRecord>();\n // keyIndex is namespaced internally by journeyId so two journeys that\n // happen to return the same `keyFor` string do not alias onto the same\n // instance. The adapter sees only the user-defined portion; the prefix is\n // applied inside the runtime.\n const keyIndex = new Map<string, InstanceId>();\n\n function indexKey(journeyId: string, userKey: string): string {\n return `${journeyId}::${userKey}`;\n }\n\n // ---------------------------------------------------------------------------\n // Helpers\n // ---------------------------------------------------------------------------\n\n function notify(record: InstanceRecord) {\n record.revision += 1;\n record.cachedSnapshot = null;\n for (const listener of record.listeners) {\n try {\n listener();\n } catch (err) {\n if (debug) console.error(\"[@modular-react/journeys] listener threw\", err);\n }\n }\n }\n\n function nowIso(): string {\n return new Date().toISOString();\n }\n\n function mintInstanceId(): InstanceId {\n try {\n const cryptoObj = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;\n if (cryptoObj?.randomUUID) return `ji_${cryptoObj.randomUUID()}`;\n } catch {\n // Fall through to the Math.random fallback.\n }\n const rand = Math.random().toString(36).slice(2, 10);\n return `ji_${Date.now().toString(36)}_${rand}`;\n }\n\n function summarize(reg: RegisteredJourney): JourneyDefinitionSummary {\n return {\n id: reg.definition.id,\n version: reg.definition.version,\n meta: reg.definition.meta,\n };\n }\n\n function assertKnown(journeyId: string): RegisteredJourney {\n const reg = definitions.get(journeyId);\n if (!reg) {\n throw new UnknownJourneyError(journeyId, [...definitions.keys()]);\n }\n return reg;\n }\n\n function stepFromSpec(spec: StepSpec<ModuleTypeMap>): JourneyStep {\n return { moduleId: spec.module, entry: spec.entry, input: spec.input };\n }\n\n function entryAllowBackMode(step: JourneyStep | null): \"preserve-state\" | \"rollback\" | false {\n if (!step) return false;\n const mod = moduleMap[step.moduleId];\n const entry = mod?.entryPoints?.[step.entry];\n const raw = entry?.allowBack;\n if (raw === \"rollback\" || raw === \"preserve-state\") return raw;\n return false;\n }\n\n function journeyAllowsBack(definition: AnyJourneyDefinition, step: JourneyStep | null): boolean {\n if (!step) return false;\n const perModule = (definition.transitions as Record<string, any> | undefined)?.[step.moduleId];\n const perEntry = perModule?.[step.entry];\n return perEntry?.allowBack === true;\n }\n\n function cloneSnapshot(state: unknown): unknown {\n if (state === null || typeof state !== \"object\") return state;\n const cloned: unknown = Array.isArray(state) ? [...state] : { ...(state as object) };\n // Dev-mode probe: freeze the snapshot so a transition that mutates\n // rolled-back state in place fails loudly instead of silently corrupting\n // the history. The freeze is shallow — deep mutation still slips through\n // (documented limitation L8), but catches the most common footgun.\n if (debug) {\n try {\n Object.freeze(cloned);\n } catch {\n // Some engines reject freezing exotic objects; swallow.\n }\n }\n return cloned;\n }\n\n function trimHistory(record: InstanceRecord, reg: RegisteredJourney) {\n const cap = reg.options?.maxHistory;\n // `undefined`, zero, and negative all mean \"unbounded\" — zero is treated\n // as the same escape hatch as a negative cap so a misconfigured `0`\n // cannot silently disable `goBack` by trimming history on every transition.\n if (cap === undefined || cap <= 0) return;\n while (record.history.length > cap) {\n record.history.shift();\n record.rollbackSnapshots.shift();\n }\n record.hasRollbackSnapshot = record.rollbackSnapshots.some((s) => s !== undefined);\n }\n\n // ---------------------------------------------------------------------------\n // Persistence save pipeline (§10.2)\n // ---------------------------------------------------------------------------\n\n function schedulePersist<TState>(\n record: InstanceRecord<TState>,\n persistence: JourneyPersistence<TState>,\n ) {\n const blob = serialize(record);\n if (record.saveInFlight) {\n record.pendingSave = blob;\n return;\n }\n void runSave(record, persistence, blob);\n }\n\n async function runSave<TState>(\n record: InstanceRecord<TState>,\n persistence: JourneyPersistence<TState>,\n blob: SerializedJourney<TState>,\n ) {\n record.saveInFlight = true;\n try {\n if (!record.persistenceKey) return;\n await persistence.save(record.persistenceKey, blob);\n } catch (err) {\n if (debug) {\n console.error(\n `[@modular-react/journeys] Failed to persist \"${record.journeyId}\" instance ${record.id}`,\n err,\n );\n }\n } finally {\n record.saveInFlight = false;\n // A terminal transition arrived while the save was in flight. The\n // remove was deferred to this point so adapters that do not serialize\n // their own ops don't see remove → save reordering. Skip the pending\n // save — its blob is about to be obsolete anyway.\n if (record.pendingRemove) {\n record.pendingRemove = false;\n record.pendingSave = null;\n if (record.persistenceKey) fireAndForgetRemove(persistence, record.persistenceKey);\n } else if (record.pendingSave) {\n const next = record.pendingSave;\n record.pendingSave = null;\n void runSave(record, persistence, next);\n }\n }\n }\n\n function removePersisted<TState>(\n record: InstanceRecord<TState>,\n persistence: JourneyPersistence<TState>,\n ) {\n if (!record.persistenceKey) return;\n record.pendingSave = null;\n const key = record.persistenceKey;\n keyIndex.delete(indexKey(record.journeyId, key));\n if (record.saveInFlight) {\n // Defer the remove until the save settles. `runSave`'s finally block\n // picks this up and fires the remove with the same key.\n record.pendingRemove = true;\n return;\n }\n fireAndForgetRemove(persistence, key);\n }\n\n /**\n * Delete a blob we've decided to discard (terminal, corrupt, unmigrateable)\n * without mutating any live instance record. Used from the `start()` paths\n * where we've probed the adapter and then chosen to mint a fresh instance.\n */\n function discardBlob<TState>(persistence: JourneyPersistence<TState>, key: string) {\n fireAndForgetRemove(persistence, key);\n }\n\n function fireAndForgetRemove<TState>(persistence: JourneyPersistence<TState>, key: string) {\n try {\n const maybe = persistence.remove(key);\n if (maybe && typeof (maybe as Promise<void>).catch === \"function\") {\n (maybe as Promise<void>).catch((err) => {\n if (debug) console.error(\"[@modular-react/journeys] persistence.remove rejected\", err);\n });\n }\n } catch (err) {\n if (debug) console.error(\"[@modular-react/journeys] persistence.remove threw\", err);\n }\n }\n\n function serialize<TState>(record: InstanceRecord<TState>): SerializedJourney<TState> {\n return {\n definitionId: record.journeyId,\n version: definitions.get(record.journeyId)!.definition.version,\n instanceId: record.id,\n status:\n record.status === \"loading\" ? \"active\" : (record.status as SerializedJourney[\"status\"]),\n step: record.step,\n history: [...record.history],\n // Preserve alignment with `history` — map `undefined` to `null` so the\n // shape survives JSON. Only emit when we actually hold snapshots.\n rollbackSnapshots: record.hasRollbackSnapshot\n ? record.rollbackSnapshots.map((s) => (s === undefined ? null : s))\n : undefined,\n terminalPayload:\n record.status === \"completed\" || record.status === \"aborted\"\n ? record.terminalPayload\n : undefined,\n state: record.state,\n startedAt: record.startedAt,\n updatedAt: record.updatedAt,\n };\n }\n\n // ---------------------------------------------------------------------------\n // Hook firing\n // ---------------------------------------------------------------------------\n\n function fireOnTransition(\n reg: RegisteredJourney,\n record: InstanceRecord,\n from: JourneyStep | null,\n to: JourneyStep | null,\n exit: string | null,\n ) {\n const ev: TransitionEvent = {\n journeyId: record.journeyId,\n instanceId: record.id,\n from,\n to,\n exit,\n state: record.state,\n // Defensive copy — `record.history` is mutated in place on every\n // transition, and async consumers (analytics batchers, deferred\n // telemetry) would otherwise observe later mutations when they\n // finally inspect the event.\n history: [...record.history],\n };\n try {\n reg.definition.onTransition?.(ev);\n } catch (err) {\n if (debug) console.error(\"[@modular-react/journeys] onTransition (definition) threw\", err);\n }\n try {\n reg.options?.onTransition?.(ev);\n } catch (err) {\n if (debug) console.error(\"[@modular-react/journeys] onTransition (registration) threw\", err);\n }\n }\n\n function fireOnComplete(reg: RegisteredJourney, record: InstanceRecord, result: unknown) {\n if (record.terminalFired) return;\n record.terminalFired = true;\n const ctx = {\n journeyId: record.journeyId,\n instanceId: record.id,\n state: record.state,\n history: record.history,\n };\n try {\n reg.definition.onComplete?.(ctx, result);\n } catch (err) {\n if (debug) console.error(\"[@modular-react/journeys] onComplete (definition) threw\", err);\n }\n try {\n reg.options?.onComplete?.(ctx, result);\n } catch (err) {\n if (debug) console.error(\"[@modular-react/journeys] onComplete (registration) threw\", err);\n }\n }\n\n function fireOnAbort(reg: RegisteredJourney, record: InstanceRecord, reason: unknown) {\n if (record.terminalFired) return;\n record.terminalFired = true;\n const ctx = {\n journeyId: record.journeyId,\n instanceId: record.id,\n state: record.state,\n history: record.history,\n };\n try {\n reg.definition.onAbort?.(ctx, reason);\n } catch (err) {\n if (debug) console.error(\"[@modular-react/journeys] onAbort (definition) threw\", err);\n }\n try {\n reg.options?.onAbort?.(ctx, reason);\n } catch (err) {\n if (debug) console.error(\"[@modular-react/journeys] onAbort (registration) threw\", err);\n }\n }\n\n function fireOnError(\n reg: RegisteredJourney,\n record: InstanceRecord,\n err: unknown,\n step: JourneyStep | null,\n ) {\n try {\n reg.options?.onError?.(err, { step });\n } catch (hookErr) {\n if (debug) console.error(\"[@modular-react/journeys] onError (registration) threw\", hookErr);\n }\n }\n\n // ---------------------------------------------------------------------------\n // Transition application\n // ---------------------------------------------------------------------------\n\n function applyTransition(\n record: InstanceRecord,\n reg: RegisteredJourney,\n result: TransitionResult<ModuleTypeMap, unknown>,\n exitName: string | null,\n ) {\n const previousStep = record.step;\n // Snapshot the *pre-transition* state (before any state update) — this\n // is what goBack should restore into the step we're about to leave.\n // \"state\" in result signals an explicit write, even if the new value is\n // `undefined` (legitimate for state types that allow it).\n const preState = record.state;\n if (\"state\" in result) {\n record.state = result.state;\n }\n\n if (\"next\" in result) {\n const nextStep = stepFromSpec(result.next);\n if (debug) {\n // Validation at resolveManifest() catches static misconfiguration,\n // but transition handlers branch at runtime and can return a\n // dynamically-built `next` that points at a module or entry that\n // isn't registered. The outlet would then render its generic\n // \"no entry on the registered modules\" message with no hint about\n // which transition was responsible. Warn here so the authoring loop\n // surfaces the source.\n if (Object.keys(moduleMap).length > 0) {\n const mod = moduleMap[nextStep.moduleId];\n if (!mod) {\n console.warn(\n `[@modular-react/journeys] Transition on \"${previousStep?.moduleId}.${previousStep?.entry}\" returned next.module=\"${nextStep.moduleId}\" which is not in the runtime's module map — the outlet will render a \"no entry\" error.`,\n );\n } else if (!mod.entryPoints?.[nextStep.entry]) {\n console.warn(\n `[@modular-react/journeys] Transition on \"${previousStep?.moduleId}.${previousStep?.entry}\" returned next.entry=\"${nextStep.moduleId}.${nextStep.entry}\" which is not a declared entry on that module.`,\n );\n }\n }\n }\n if (previousStep) {\n record.history.push(previousStep);\n // Clone the pre-state snapshot only when the step we're entering\n // opts in to rollback — avoids unnecessary work for preserve-state\n // / no-back entries. Shallow clone keeps the snapshot stable against\n // accidental top-level mutation.\n const nextMode = entryAllowBackModeForStep(nextStep);\n if (nextMode === \"rollback\") {\n record.rollbackSnapshots.push(cloneSnapshot(preState));\n record.hasRollbackSnapshot = true;\n } else {\n record.rollbackSnapshots.push(undefined);\n }\n }\n record.step = nextStep;\n record.status = \"active\";\n record.stepToken += 1;\n record.updatedAt = nowIso();\n record.cachedCallbacks = null;\n trimHistory(record, reg);\n fireOnTransition(reg, record, previousStep, nextStep, exitName);\n } else if (\"complete\" in result) {\n if (previousStep) {\n record.history.push(previousStep);\n record.rollbackSnapshots.push(undefined);\n }\n record.step = null;\n record.status = \"completed\";\n record.terminalPayload = result.complete;\n record.stepToken += 1;\n record.updatedAt = nowIso();\n record.cachedCallbacks = null;\n trimHistory(record, reg);\n fireOnTransition(reg, record, previousStep, null, exitName);\n fireOnComplete(reg, record, result.complete);\n } else if (\"abort\" in result) {\n if (previousStep) {\n record.history.push(previousStep);\n record.rollbackSnapshots.push(undefined);\n }\n record.step = null;\n record.status = \"aborted\";\n record.terminalPayload = result.abort;\n record.stepToken += 1;\n record.updatedAt = nowIso();\n record.cachedCallbacks = null;\n trimHistory(record, reg);\n fireOnTransition(reg, record, previousStep, null, exitName);\n fireOnAbort(reg, record, result.abort);\n }\n\n const persistence = reg.options?.persistence;\n if (persistence) {\n if (record.status === \"active\") schedulePersist(record, persistence);\n else removePersisted(record, persistence);\n }\n\n notify(record);\n }\n\n function entryAllowBackModeForStep(\n step: JourneyStep | null,\n ): \"preserve-state\" | \"rollback\" | false {\n return entryAllowBackMode(step);\n }\n\n function dispatchExit(\n record: InstanceRecord,\n reg: RegisteredJourney,\n stepToken: number,\n exitName: string,\n output: unknown,\n ) {\n if (record.status !== \"active\") {\n if (debug) {\n console.warn(\n `[@modular-react/journeys] Exit(\"${exitName}\") dropped on instance ${record.id} — status=${record.status}. ` +\n `(This is the expected no-op when an exit fires before the initial async load settles; ` +\n `await the load or subscribe for status changes before dispatching.)`,\n );\n }\n return;\n }\n if (record.stepToken !== stepToken) {\n if (debug) {\n console.warn(\n `[@modular-react/journeys] Stale exit(\"${exitName}\") dropped on instance ${record.id}`,\n );\n }\n return;\n }\n const step = record.step;\n if (!step) return;\n const perModule = (reg.definition.transitions as Record<string, any> | undefined)?.[\n step.moduleId\n ];\n const perEntry = perModule?.[step.entry];\n const handler = perEntry?.[exitName] as\n | ((ctx: {\n state: unknown;\n input: unknown;\n output: unknown;\n }) => TransitionResult<ModuleTypeMap, unknown>)\n | undefined;\n if (typeof handler !== \"function\") {\n if (debug) {\n console.warn(\n `[@modular-react/journeys] No transition for exit(\"${exitName}\") on ${step.moduleId}.${step.entry} — ignoring.`,\n );\n }\n return;\n }\n let result: TransitionResult<ModuleTypeMap, unknown>;\n try {\n result = handler({ state: record.state, input: step.input, output });\n } catch (err) {\n if (debug) console.error(\"[@modular-react/journeys] transition handler threw\", err);\n fireOnError(reg, record, err, step);\n applyTransition(\n record,\n reg,\n { abort: { reason: \"transition-error\", exit: exitName, error: err } },\n exitName,\n );\n return;\n }\n // Transitions must be pure and synchronous. A handler that returns a\n // thenable almost certainly forgot to put the async work inside a loading\n // entry point — applying the thenable as the transition result would be\n // a silent no-op (it is not `{ next | complete | abort }`), so warn and\n // treat it as an abort.\n if (result && typeof (result as { then?: unknown }).then === \"function\") {\n if (debug) {\n console.error(\n `[@modular-react/journeys] Transition handler for ${step.moduleId}.${step.entry}.\"${exitName}\" returned a Promise. Transitions must be synchronous and pure — put async work inside a loading entry point on a module.`,\n );\n }\n applyTransition(\n record,\n reg,\n { abort: { reason: \"transition-returned-promise\", exit: exitName } },\n exitName,\n );\n return;\n }\n applyTransition(record, reg, result, exitName);\n }\n\n function dispatchGoBack(record: InstanceRecord, reg: RegisteredJourney, stepToken: number) {\n if (record.status !== \"active\") return;\n if (record.stepToken !== stepToken) return;\n if (record.history.length === 0) return;\n\n const step = record.step;\n if (!step) return;\n // Journey-side opt-in\n if (!journeyAllowsBack(reg.definition, step)) return;\n\n const previousStep = record.history.pop()!;\n const snapshot = record.rollbackSnapshots.pop();\n const mode = entryAllowBackMode(step);\n if (mode === \"rollback\" && snapshot !== undefined) {\n record.state = snapshot;\n }\n record.hasRollbackSnapshot = record.rollbackSnapshots.some((s) => s !== undefined);\n record.step = previousStep;\n record.stepToken += 1;\n record.updatedAt = nowIso();\n record.cachedCallbacks = null;\n fireOnTransition(reg, record, step, previousStep, null);\n const persistence = reg.options?.persistence;\n if (persistence) schedulePersist(record, persistence);\n notify(record);\n }\n\n function bindStepCallbacks(record: InstanceRecord, reg: RegisteredJourney) {\n if (record.cachedCallbacks && record.cachedCallbacks.stepToken === record.stepToken) {\n return record.cachedCallbacks;\n }\n const token = record.stepToken;\n const exit = (name: string, output?: unknown) => {\n dispatchExit(record, reg, token, name, output);\n };\n let mode = entryAllowBackMode(record.step);\n // Documented fallback (see `JourneyRuntimeOptions.modules`): when the\n // runtime is built without a module descriptor for this step but the\n // journey's transition opts in via `allowBack: true`, treat the mode as\n // 'preserve-state' so `goBack` stays wired. Without this fallback the\n // headless simulator (which never passes a moduleMap) and any runtime\n // created without module descriptors would see `goBack` silently\n // disappear, contradicting the documented behavior.\n if (\n mode === false &&\n record.step &&\n journeyAllowsBack(reg.definition, record.step) &&\n !moduleMap[record.step.moduleId]\n ) {\n mode = \"preserve-state\";\n }\n const canGoBack =\n mode !== false && journeyAllowsBack(reg.definition, record.step) && record.history.length > 0;\n const goBack = canGoBack\n ? () => {\n dispatchGoBack(record, reg, token);\n }\n : undefined;\n record.cachedCallbacks = { stepToken: token, exit, goBack };\n return record.cachedCallbacks;\n }\n\n // ---------------------------------------------------------------------------\n // Lifecycle\n // ---------------------------------------------------------------------------\n\n function buildInstance(record: InstanceRecord): JourneyInstance {\n if (record.cachedSnapshot && record.cachedSnapshot.revision === record.revision) {\n return record.cachedSnapshot.instance;\n }\n // Copy-on-build: history is mutated in place on every transition, so\n // consumers that diff against a prior `instance.history` reference\n // (React deps, effect closures, useMemo) need a frozen-per-revision\n // snapshot. Cheap — bounded by the history cap, and only rebuilt when\n // the revision bumps.\n const historySnapshot: readonly JourneyStep[] = [...record.history];\n const instance: JourneyInstance = {\n id: record.id,\n journeyId: record.journeyId,\n status: record.status,\n step: record.step,\n history: historySnapshot,\n state: record.state,\n terminalPayload:\n record.status === \"completed\" || record.status === \"aborted\"\n ? record.terminalPayload\n : undefined,\n startedAt: record.startedAt,\n updatedAt: record.updatedAt,\n serialize: () => serialize(record),\n };\n record.cachedSnapshot = { revision: record.revision, instance };\n return instance;\n }\n\n function createRecord(\n reg: RegisteredJourney,\n instanceId: InstanceId,\n persistenceKey: string | null,\n initialState: unknown,\n ): InstanceRecord {\n const startedAt = nowIso();\n return {\n id: instanceId,\n journeyId: reg.definition.id,\n status: \"loading\",\n step: null,\n history: [],\n rollbackSnapshots: [],\n hasRollbackSnapshot: false,\n state: initialState,\n terminalPayload: undefined,\n startedAt,\n updatedAt: startedAt,\n stepToken: 0,\n persistenceKey,\n terminalFired: false,\n retryCount: 0,\n listeners: new Set(),\n pendingSave: null,\n saveInFlight: false,\n pendingRemove: false,\n revision: 0,\n cachedSnapshot: null,\n cachedCallbacks: null,\n };\n }\n\n function startFresh(\n reg: RegisteredJourney,\n input: unknown,\n existingRecord?: InstanceRecord,\n ): InstanceId {\n const def = reg.definition as JourneyDefinition<any, any, unknown>;\n const record =\n existingRecord ?? createRecord(reg, mintInstanceId(), null, def.initialState(input));\n if (!existingRecord) {\n instances.set(record.id, record);\n } else {\n // Recycling a record — typically because an async probe failed or a\n // partial hydrate threw. Reset every field that could carry stale\n // state from the record's previous life. Without this, a hydrate that\n // populated `history` / `rollbackSnapshots` before throwing would\n // leak those entries into the \"fresh\" instance.\n record.state = def.initialState(input);\n record.history = [];\n record.rollbackSnapshots = [];\n record.hasRollbackSnapshot = false;\n }\n const startStep = stepFromSpec(def.start(record.state, input));\n record.step = startStep;\n record.status = \"active\";\n record.stepToken += 1;\n record.terminalFired = false;\n record.terminalPayload = undefined;\n // A recycled record can carry a retry count from its previous life (an\n // async-load failure that fell through to `startFresh`, for example).\n // The new run is a fresh journey — reset the budget.\n record.retryCount = 0;\n record.updatedAt = nowIso();\n record.cachedCallbacks = null;\n fireOnTransition(reg, record, null, startStep, null);\n const persistence = reg.options?.persistence;\n if (persistence) schedulePersist(record, persistence);\n notify(record);\n return record.id;\n }\n\n function hydrateInto(record: InstanceRecord, blob: SerializedJourney<unknown>) {\n const historyLen = blob.history.length;\n // Align rollbackSnapshots with history — mismatched lengths corrupt\n // `goBack` (pop() would take the wrong pair). Reject upfront instead of\n // silently misbehaving later.\n if (blob.rollbackSnapshots && blob.rollbackSnapshots.length !== historyLen) {\n throw new JourneyHydrationError(\n `Blob for journey \"${record.journeyId}\" has rollbackSnapshots.length=${blob.rollbackSnapshots.length} but history.length=${historyLen}. Fix the persisted blob (pad rollbackSnapshots with null for non-rollback entries) or provide onHydrate to migrate.`,\n );\n }\n record.state = blob.state;\n record.step = blob.step;\n record.history = [...blob.history];\n if (blob.rollbackSnapshots) {\n record.rollbackSnapshots = blob.rollbackSnapshots.map((s) =>\n s === null ? undefined : s,\n ) as (unknown | undefined)[];\n record.hasRollbackSnapshot = record.rollbackSnapshots.some((s) => s !== undefined);\n } else {\n // Legacy blobs without rollbackSnapshots — treat as if every history\n // entry had no snapshot. Keeps the two arrays length-aligned.\n record.rollbackSnapshots = Array.from({ length: historyLen }, () => undefined);\n record.hasRollbackSnapshot = false;\n }\n record.status = blob.status;\n record.terminalPayload = blob.terminalPayload;\n record.startedAt = blob.startedAt;\n record.updatedAt = blob.updatedAt;\n record.stepToken += 1;\n record.terminalFired = blob.status !== \"active\";\n record.cachedCallbacks = null;\n }\n\n function probeLoad(\n reg: RegisteredJourney,\n persistence: JourneyPersistence<unknown>,\n key: string,\n ): SerializedJourney<unknown> | null | Promise<SerializedJourney<unknown> | null> {\n let loaded: SerializedJourney<unknown> | null | Promise<SerializedJourney<unknown> | null>;\n try {\n loaded = persistence.load(key) as\n | SerializedJourney<unknown>\n | null\n | Promise<SerializedJourney<unknown> | null>;\n } catch (err) {\n if (debug) console.error(\"[@modular-react/journeys] persistence.load threw\", err);\n return null;\n }\n if (loaded && typeof (loaded as Promise<unknown>).then === \"function\") {\n return loaded as Promise<SerializedJourney<unknown> | null>;\n }\n return loaded as SerializedJourney<unknown> | null;\n }\n\n type MigrateResult =\n | { ok: true; blob: SerializedJourney<unknown> }\n | { ok: false; reason: \"version-mismatch\" }\n | { ok: false; reason: \"on-hydrate-threw\"; cause: unknown };\n\n function migrateBlob(reg: RegisteredJourney, blob: SerializedJourney<unknown>): MigrateResult {\n let migrated: SerializedJourney<unknown> = blob;\n let ranAny = false;\n if (reg.definition.onHydrate) {\n ranAny = true;\n try {\n migrated = reg.definition.onHydrate(migrated) as SerializedJourney<unknown>;\n } catch (err) {\n if (debug) console.error(\"[@modular-react/journeys] onHydrate (definition) threw\", err);\n return { ok: false, reason: \"on-hydrate-threw\", cause: err };\n }\n }\n // Registration-level `onHydrate` runs after the definition's — shells can\n // layer environment-specific post-migration tweaks (redaction, id\n // rewriting) without touching journey authoring code.\n const regHydrate = reg.options?.onHydrate as\n | ((b: SerializedJourney<unknown>) => SerializedJourney<unknown>)\n | undefined;\n if (regHydrate) {\n ranAny = true;\n try {\n migrated = regHydrate(migrated);\n } catch (err) {\n if (debug) console.error(\"[@modular-react/journeys] onHydrate (registration) threw\", err);\n return { ok: false, reason: \"on-hydrate-threw\", cause: err };\n }\n }\n if (ranAny) {\n return { ok: true, blob: migrated };\n }\n if (blob.version !== reg.definition.version) {\n return { ok: false, reason: \"version-mismatch\" };\n }\n return { ok: true, blob };\n }\n\n // ---------------------------------------------------------------------------\n // Runtime surface\n // ---------------------------------------------------------------------------\n\n const runtime: JourneyRuntime = {\n start<TInput>(\n journeyIdOrHandle: string | JourneyHandleRef<string, TInput>,\n ...rest: [input?: TInput]\n ): InstanceId {\n const input = (rest.length > 0 ? rest[0] : undefined) as TInput;\n // Accept either a bare id or a `JourneyHandle`-shaped object. The\n // handle form is the `start<TId, TInput>(handle, input)` overload; it\n // only exists to type-check `input` — the runtime behaviour is\n // identical either way.\n const journeyId =\n typeof journeyIdOrHandle === \"string\" ? journeyIdOrHandle : journeyIdOrHandle.id;\n const reg = assertKnown(journeyId);\n const persistence = reg.options?.persistence;\n\n if (persistence) {\n const key = persistence.keyFor({\n journeyId: reg.definition.id,\n input,\n });\n const indexed = indexKey(reg.definition.id, key);\n // Idempotency: return the existing instance for this key whenever it\n // is still in flight — \"active\" OR \"loading\". Returning a fresh id\n // while a load is pending would orphan the loading instance and\n // trigger a second `load()`.\n const existingId = keyIndex.get(indexed);\n const existing = existingId ? instances.get(existingId) : null;\n if (existing && (existing.status === \"active\" || existing.status === \"loading\")) {\n return existing.id;\n }\n\n const def = reg.definition as JourneyDefinition<any, any, unknown>;\n const loaded = probeLoad(reg, persistence as JourneyPersistence<unknown>, key);\n\n if (loaded && typeof (loaded as Promise<unknown>).then === \"function\") {\n // Async probe — mint a placeholder instance in `loading` status,\n // but initialize `state` from `initialState(input)` immediately so\n // consumers reading state during loading never see `undefined`.\n // If the blob later hydrates, state is overwritten.\n const instanceId = mintInstanceId();\n const record = createRecord(reg, instanceId, key, def.initialState(input));\n instances.set(instanceId, record);\n keyIndex.set(indexed, instanceId);\n notify(record);\n\n void (loaded as Promise<SerializedJourney<unknown> | null>).then(\n (blob) => {\n // The caller may have ended the instance before the load\n // settled (tab closed, navigation, explicit `runtime.end`).\n // In that case the record is already terminal and we must\n // not resurrect it with startFresh or a hydrate.\n if (record.status !== \"loading\") return;\n if (!blob || blob.status !== \"active\") {\n // Discard terminal/missing blob and mint a fresh instance\n // under the same key. A terminal blob left in storage would\n // be re-fetched on every subsequent start().\n if (blob) discardBlob(persistence as JourneyPersistence<unknown>, key);\n startFresh(reg, input, record);\n return;\n }\n const migrated = migrateBlob(reg, blob);\n if (!migrated.ok) {\n discardBlob(persistence as JourneyPersistence<unknown>, key);\n startFresh(reg, input, record);\n return;\n }\n try {\n hydrateInto(record, migrated.blob);\n } catch (err) {\n if (debug)\n console.error(\"[@modular-react/journeys] hydrate after async load failed\", err);\n discardBlob(persistence as JourneyPersistence<unknown>, key);\n startFresh(reg, input, record);\n return;\n }\n notify(record);\n },\n (err) => {\n if (debug) console.error(\"[@modular-react/journeys] persistence.load rejected\", err);\n if (record.status !== \"loading\") return;\n startFresh(reg, input, record);\n },\n );\n return instanceId;\n }\n\n const blob = loaded as SerializedJourney<unknown> | null;\n if (blob && blob.status === \"active\") {\n const migrated = migrateBlob(reg, blob);\n if (migrated.ok) {\n // Guard against a blob whose recorded id collides with a live\n // instance (corrupted / hand-edited blob, or two journeys sharing\n // a persistence keyspace). Mint a fresh id instead of clobbering\n // the existing entry — matches `hydrate()`'s existing rejection\n // of re-hydrate over an existing id.\n const instanceId =\n migrated.blob.instanceId && !instances.has(migrated.blob.instanceId)\n ? migrated.blob.instanceId\n : mintInstanceId();\n const record = createRecord(reg, instanceId, key, def.initialState(input));\n instances.set(instanceId, record);\n keyIndex.set(indexed, instanceId);\n try {\n hydrateInto(record, migrated.blob);\n } catch (err) {\n if (debug)\n console.error(\"[@modular-react/journeys] hydrate during start failed\", err);\n // Cleanup the half-built record and fall through to startFresh\n // under the same key.\n instances.delete(instanceId);\n keyIndex.delete(indexed);\n discardBlob(persistence as JourneyPersistence<unknown>, key);\n const freshId = mintInstanceId();\n const freshRecord = createRecord(reg, freshId, key, def.initialState(input));\n instances.set(freshId, freshRecord);\n keyIndex.set(indexed, freshId);\n return startFresh(reg, input, freshRecord);\n }\n notify(record);\n return instanceId;\n }\n // Migration failed: discard the stale blob so it doesn't get\n // re-fetched forever.\n discardBlob(persistence as JourneyPersistence<unknown>, key);\n } else if (blob) {\n // Terminal blob — drop it before reusing the key for a fresh run.\n discardBlob(persistence as JourneyPersistence<unknown>, key);\n }\n\n // No blob / terminal blob / migration failed — mint a fresh instance\n // that still owns the key, so subsequent `start()` calls are\n // idempotent.\n const instanceId = mintInstanceId();\n const record = createRecord(reg, instanceId, key, def.initialState(input));\n instances.set(instanceId, record);\n keyIndex.set(indexed, instanceId);\n return startFresh(reg, input, record);\n }\n\n return startFresh(reg, input);\n },\n\n hydrate<TState>(journeyId: string, blob: SerializedJourney<TState>): InstanceId {\n const reg = assertKnown(journeyId);\n const migrated = migrateBlob(reg, blob as SerializedJourney<unknown>);\n if (!migrated.ok) {\n if (migrated.reason === \"on-hydrate-threw\") {\n // Surface the original throw via `.cause` so callers can\n // distinguish a migrator bug from a true version mismatch and\n // log the underlying error without losing the stack.\n throw new JourneyHydrationError(\n `onHydrate threw while migrating blob for \"${journeyId}\" (blob=${blob.version} def=${reg.definition.version}).`,\n { cause: migrated.cause },\n );\n }\n throw new JourneyHydrationError(\n `Hydrate version mismatch for \"${journeyId}\": blob=${blob.version} def=${reg.definition.version}. Provide onHydrate to migrate.`,\n );\n }\n const instanceId = migrated.blob.instanceId || mintInstanceId();\n // Guard against silent overwrite — two hydrates of the same blob would\n // otherwise clobber live state and orphan existing listeners.\n if (instances.has(instanceId)) {\n throw new JourneyHydrationError(\n `Cannot hydrate journey \"${journeyId}\" with instance id \"${instanceId}\" — an instance with the same id is already in memory. Call forget(id) first if you intend to replace it.`,\n );\n }\n\n // If the migrated blob has an input we can use to compute the key, we\n // could re-index — but `SerializedJourney` doesn't carry the original\n // `input`, so explicit hydrate stays persistence-unlinked. Callers\n // that want round-trip persistence should use `start()` which owns the\n // key lifecycle. Document this on the API.\n const record = createRecord(reg, instanceId, null, migrated.blob.state);\n instances.set(instanceId, record);\n try {\n hydrateInto(record, migrated.blob);\n } catch (err) {\n // Don't leak the half-built loading placeholder — otherwise\n // subsequent getInstance(id) returns partial state, a retry hits\n // the \"already in memory\" guard, and forget(id) is a no-op\n // (status never reached terminal). Mirror the sync-start path\n // which already cleans up in the same situation.\n instances.delete(instanceId);\n throw err;\n }\n notify(record);\n return instanceId;\n },\n\n getInstance(id) {\n const record = instances.get(id);\n return record ? buildInstance(record) : null;\n },\n\n listInstances() {\n return [...instances.keys()];\n },\n\n listDefinitions() {\n return [...definitions.values()].map(summarize);\n },\n\n isRegistered(journeyId) {\n return definitions.has(journeyId);\n },\n\n subscribe(id, listener) {\n const record = instances.get(id);\n if (!record) return () => {};\n record.listeners.add(listener);\n return () => {\n record.listeners.delete(listener);\n };\n },\n\n end(id, reason) {\n const record = instances.get(id);\n if (!record) return;\n if (record.status === \"completed\" || record.status === \"aborted\") return;\n const reg = definitions.get(record.journeyId);\n if (!reg) return;\n // An outlet that unmounts mid-load should still be able to tear the\n // placeholder instance down. The journey never \"started\" as far as the\n // author is concerned, so skip `onAbandon` (it would see a null step)\n // and transition straight to `aborted` with the supplied reason.\n if (record.status === \"loading\") {\n applyTransition(record, reg, { abort: { reason: reason ?? \"abandoned\" } }, null);\n return;\n }\n const defaultAbort: TransitionResult<ModuleTypeMap, unknown> = {\n abort: { reason: reason ?? \"abandoned\" },\n };\n let result: TransitionResult<ModuleTypeMap, unknown> = defaultAbort;\n // Registration-level `onAbandon` overrides the definition's — shells\n // can swap the abandon outcome without modifying journey authoring code\n // (e.g. complete instead of abort on tab close). If absent, fall back\n // to the definition's handler.\n const abandonHandler = reg.options?.onAbandon ?? reg.definition.onAbandon;\n if (abandonHandler) {\n try {\n result = abandonHandler({\n journeyId: record.journeyId,\n instanceId: record.id,\n step: record.step,\n state: record.state,\n reason: reason ?? \"abandoned\",\n }) as TransitionResult<ModuleTypeMap, unknown>;\n } catch (err) {\n // Surface the handler crash through the registration-level onError\n // hook before falling back to the default abort. Preserve the\n // caller-supplied `reason` (and surface `onAbandon`'s own error as\n // `cause`) so a throw in a shell's onAbandon doesn't silently\n // erase the original abort context.\n if (debug) console.error(\"[@modular-react/journeys] onAbandon threw\", err);\n fireOnError(reg, record, err, record.step);\n result = {\n abort: {\n reason: reason ?? \"abandoned\",\n cause: \"onAbandon-threw\",\n error: err,\n },\n };\n }\n }\n applyTransition(record, reg, result, null);\n },\n\n forget(id) {\n const record = instances.get(id);\n if (!record) return;\n if (record.status !== \"completed\" && record.status !== \"aborted\") return;\n if (record.persistenceKey) keyIndex.delete(indexKey(record.journeyId, record.persistenceKey));\n record.listeners.clear();\n instances.delete(id);\n },\n\n forgetTerminal() {\n let removed = 0;\n for (const [id, record] of instances) {\n if (record.status === \"completed\" || record.status === \"aborted\") {\n if (record.persistenceKey) {\n keyIndex.delete(indexKey(record.journeyId, record.persistenceKey));\n }\n record.listeners.clear();\n instances.delete(id);\n removed += 1;\n }\n }\n return removed;\n },\n };\n\n function dispatchComponentError(id: InstanceId, err: unknown, step: JourneyStep): void {\n const record = instances.get(id);\n if (!record) return;\n const reg = definitions.get(record.journeyId);\n if (!reg) return;\n fireOnError(reg, record, err, step);\n }\n\n // Internals used by the outlet and testing helpers — kept on a WeakMap\n // rather than on the runtime object to keep the public surface clean.\n const internals: JourneyRuntimeInternals = {\n __bindStepCallbacks: bindStepCallbacks,\n __getRecord: (id: InstanceId) => instances.get(id),\n __getRegistered: (id: string) => definitions.get(id),\n __moduleMap: moduleMap,\n __debug: debug,\n __fireComponentError: dispatchComponentError,\n };\n INTERNALS.set(runtime, internals);\n\n return runtime;\n}\n\nfunction defaultDebug(): boolean {\n const g = globalThis as { process?: { env?: { NODE_ENV?: string } } };\n return !!g.process && g.process.env?.NODE_ENV !== \"production\";\n}\n\nexport interface JourneyRuntimeInternals {\n __bindStepCallbacks(\n record: InstanceRecord,\n reg: RegisteredJourney,\n ): {\n exit: (name: string, output?: unknown) => void;\n goBack?: () => void;\n stepToken: number;\n };\n __getRecord(id: InstanceId): InstanceRecord | undefined;\n __getRegistered(id: string): RegisteredJourney | undefined;\n /** Module descriptors bound to this runtime — the `<JourneyOutlet>` reads\n * this to resolve step components without the caller threading `modules`\n * through as a prop. */\n __moduleMap: Readonly<Record<string, ModuleDescriptor<any, any, any, any>>>;\n /** Runtime's resolved debug flag — useful for dev-mode probes in the\n * outlet / module-tab. */\n __debug: boolean;\n /**\n * Fires the registration-level `onError` hook for a component-level throw\n * caught by the outlet's error boundary. Routed through the runtime so\n * the outlet never has to reach into `reg.options.onError` directly —\n * keeps the runtime the single owner of hook firing.\n */\n __fireComponentError(id: InstanceId, err: unknown, step: JourneyStep): void;\n}\n\nexport function getInternals(runtime: JourneyRuntime): JourneyRuntimeInternals {\n const internals = INTERNALS.get(runtime);\n if (!internals) {\n throw new Error(\n \"[@modular-react/journeys] getInternals() called on a runtime that was not produced by createJourneyRuntime().\",\n );\n }\n return internals;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { AbandonCtx } from '@modular-react/core';
|
|
2
|
+
import { InstanceId } from '@modular-react/core';
|
|
3
|
+
import { JourneyRuntime } from '@modular-react/core';
|
|
4
|
+
import { JourneyStatus } from '@modular-react/core';
|
|
5
|
+
import { JourneyStep } from '@modular-react/core';
|
|
6
|
+
import { ModuleTypeMap } from '@modular-react/core';
|
|
7
|
+
import { SerializedJourney } from '@modular-react/core';
|
|
8
|
+
import { StepSpec } from '@modular-react/core';
|
|
9
|
+
import { TerminalCtx } from '@modular-react/core';
|
|
10
|
+
import { TransitionEvent as TransitionEvent_2 } from '@modular-react/core';
|
|
11
|
+
import { TransitionMap } from '@modular-react/core';
|
|
12
|
+
import { TransitionResult } from '@modular-react/core';
|
|
13
|
+
|
|
14
|
+
export declare function createTestHarness(runtime: JourneyRuntime): JourneyTestHarness;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Snapshot of the mutable runtime record for a single instance. Returned by
|
|
18
|
+
* `JourneyTestHarness.inspect` so tests can assert on fields that the
|
|
19
|
+
* public `JourneyInstance` surface intentionally does not expose (stepToken,
|
|
20
|
+
* retryCount). Everything else is also available via `runtime.getInstance`.
|
|
21
|
+
*/
|
|
22
|
+
export declare interface InstanceSnapshot<TState = unknown> {
|
|
23
|
+
readonly status: JourneyStatus;
|
|
24
|
+
readonly step: JourneyStep | null;
|
|
25
|
+
readonly state: TState;
|
|
26
|
+
readonly history: readonly JourneyStep[];
|
|
27
|
+
readonly stepToken: number;
|
|
28
|
+
readonly retryCount: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
declare interface JourneyDefinition<TModules extends ModuleTypeMap, TState, TInput = void> {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly version: string;
|
|
34
|
+
readonly meta?: Readonly<Record<string, unknown>>;
|
|
35
|
+
readonly initialState: (input: TInput) => TState;
|
|
36
|
+
readonly start: (state: TState, input: TInput) => StepSpec<TModules>;
|
|
37
|
+
readonly transitions: TransitionMap<TModules, TState>;
|
|
38
|
+
readonly onTransition?: (ev: TransitionEvent_2<TModules, TState>) => void;
|
|
39
|
+
readonly onAbandon?: (ctx: AbandonCtx<TModules, TState>) => TransitionResult<TModules, TState>;
|
|
40
|
+
readonly onComplete?: (ctx: TerminalCtx<TState>, result: unknown) => void;
|
|
41
|
+
readonly onAbort?: (ctx: TerminalCtx<TState>, reason: unknown) => void;
|
|
42
|
+
readonly onHydrate?: (blob: SerializedJourney<TState>) => SerializedJourney<TState>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Headless simulator for a journey definition. Fires exits / goBack without
|
|
47
|
+
* mounting React and exposes state / step / history / the recorded
|
|
48
|
+
* `TransitionEvent` stream for assertions.
|
|
49
|
+
*
|
|
50
|
+
* Intended for pure-logic unit tests of transition graphs.
|
|
51
|
+
*/
|
|
52
|
+
export declare interface JourneySimulator<_TModules extends ModuleTypeMap, TState> {
|
|
53
|
+
readonly journeyId: string;
|
|
54
|
+
readonly instanceId: string;
|
|
55
|
+
/** Current step — null once the journey completes or aborts. */
|
|
56
|
+
readonly step: JourneyStep | null;
|
|
57
|
+
/**
|
|
58
|
+
* Same as `step`, but throws if the journey has terminated. Use this in
|
|
59
|
+
* tests to skip optional chaining on the common "still running" path —
|
|
60
|
+
* the throw spells out the unexpected status (`completed` / `aborted`)
|
|
61
|
+
* and is far easier to debug than a `Cannot read property 'moduleId' of
|
|
62
|
+
* null` thrown by an assertion line.
|
|
63
|
+
*/
|
|
64
|
+
readonly currentStep: JourneyStep;
|
|
65
|
+
readonly state: TState;
|
|
66
|
+
readonly history: readonly JourneyStep[];
|
|
67
|
+
readonly status: "loading" | "active" | "completed" | "aborted";
|
|
68
|
+
/**
|
|
69
|
+
* Every `TransitionEvent` the runtime has fired since the simulator
|
|
70
|
+
* started. Useful for assertions on analytics rules without having to
|
|
71
|
+
* attach an `onTransition` by hand.
|
|
72
|
+
*/
|
|
73
|
+
readonly transitions: readonly TransitionEvent_2[];
|
|
74
|
+
/**
|
|
75
|
+
* Terminal payload from the `complete` / `abort` transition that ended
|
|
76
|
+
* the journey. `undefined` while the journey is still active.
|
|
77
|
+
*/
|
|
78
|
+
readonly terminalPayload: unknown;
|
|
79
|
+
fireExit(name: string, output?: unknown): void;
|
|
80
|
+
goBack(): void;
|
|
81
|
+
end(reason?: unknown): void;
|
|
82
|
+
/**
|
|
83
|
+
* Serialize the simulator's current instance into the same blob shape
|
|
84
|
+
* a persistence adapter would see. Useful for pinning the exact blob
|
|
85
|
+
* shape tests expect to round-trip, and for asserting `rollbackSnapshots`
|
|
86
|
+
* alignment with `history` without reaching into runtime internals.
|
|
87
|
+
*/
|
|
88
|
+
serialize(): SerializedJourney<TState>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Test-only accessor that drives a runtime's internals from the outside —
|
|
93
|
+
* fire exits, walk back, peek at per-instance state. Prefer
|
|
94
|
+
* {@link simulateJourney} for pure-logic transition tests; use this when you
|
|
95
|
+
* already have a live runtime (e.g. one produced by the registry) and need
|
|
96
|
+
* to poke it from a test without mounting the outlet.
|
|
97
|
+
*
|
|
98
|
+
* The harness is the supported replacement for directly importing the
|
|
99
|
+
* runtime's `__`-prefixed internals, which are kept off the public export
|
|
100
|
+
* surface intentionally.
|
|
101
|
+
*/
|
|
102
|
+
export declare interface JourneyTestHarness {
|
|
103
|
+
fireExit(id: InstanceId, name: string, output?: unknown): void;
|
|
104
|
+
goBack(id: InstanceId): void;
|
|
105
|
+
inspect<TState = unknown>(id: InstanceId): InstanceSnapshot<TState>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Headlessly drive a journey definition — see {@link JourneySimulator}.
|
|
110
|
+
*
|
|
111
|
+
* The second argument is the journey's `TInput`. When a journey declares
|
|
112
|
+
* no input (`TInput extends void`), callers can omit it entirely:
|
|
113
|
+
*
|
|
114
|
+
* ```ts
|
|
115
|
+
* simulateJourney(noInputJourney); // no input required
|
|
116
|
+
* simulateJourney(inputJourney, { id: 1 }); // input required and typed
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
export declare function simulateJourney<TModules extends ModuleTypeMap, TState, TInput>(definition: JourneyDefinition<TModules, TState, TInput>, ...rest: [TInput] extends [void] ? [] | [input?: TInput] : [input: TInput]): JourneySimulator<TModules, TState>;
|
|
120
|
+
|
|
121
|
+
export { }
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { n as e, t } from "./runtime-DyU_PmaC.js";
|
|
2
|
+
//#region src/simulate-journey.ts
|
|
3
|
+
function n(e, ...n) {
|
|
4
|
+
let i = n.length > 0 ? n[0] : void 0, a = [], o = t([{
|
|
5
|
+
definition: e,
|
|
6
|
+
options: { onTransition: (e) => {
|
|
7
|
+
a.push(e);
|
|
8
|
+
} }
|
|
9
|
+
}]), s = o.start(e.id, i), c = r(o);
|
|
10
|
+
function l() {
|
|
11
|
+
return c.inspect(s);
|
|
12
|
+
}
|
|
13
|
+
function u() {
|
|
14
|
+
let e = o.getInstance(s);
|
|
15
|
+
if (!e) throw Error(`[simulateJourney] instance ${s} not found`);
|
|
16
|
+
return e;
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
journeyId: e.id,
|
|
20
|
+
instanceId: s,
|
|
21
|
+
get step() {
|
|
22
|
+
return l().step;
|
|
23
|
+
},
|
|
24
|
+
get currentStep() {
|
|
25
|
+
let e = l();
|
|
26
|
+
if (!e.step) throw Error(`[simulateJourney] no current step (status=${e.status}). Use \`step\` if a null step is expected.`);
|
|
27
|
+
return e.step;
|
|
28
|
+
},
|
|
29
|
+
get state() {
|
|
30
|
+
return l().state;
|
|
31
|
+
},
|
|
32
|
+
get history() {
|
|
33
|
+
return l().history;
|
|
34
|
+
},
|
|
35
|
+
get status() {
|
|
36
|
+
return l().status;
|
|
37
|
+
},
|
|
38
|
+
get transitions() {
|
|
39
|
+
return a;
|
|
40
|
+
},
|
|
41
|
+
get terminalPayload() {
|
|
42
|
+
return u().terminalPayload;
|
|
43
|
+
},
|
|
44
|
+
serialize() {
|
|
45
|
+
return u().serialize();
|
|
46
|
+
},
|
|
47
|
+
fireExit(e, t) {
|
|
48
|
+
c.fireExit(s, e, t);
|
|
49
|
+
},
|
|
50
|
+
goBack() {
|
|
51
|
+
c.goBack(s);
|
|
52
|
+
},
|
|
53
|
+
end(e) {
|
|
54
|
+
o.end(s, e);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/testing.ts
|
|
60
|
+
function r(t) {
|
|
61
|
+
let n = e(t);
|
|
62
|
+
function r(e) {
|
|
63
|
+
let t = n.__getRecord(e);
|
|
64
|
+
if (!t) throw Error(`[@modular-react/journeys/testing] No instance with id "${e}". Pass the id returned by runtime.start(...).`);
|
|
65
|
+
return t;
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
fireExit(e, t, i) {
|
|
69
|
+
let a = r(e), o = n.__getRegistered(a.journeyId);
|
|
70
|
+
if (!o) throw Error(`[@modular-react/journeys/testing] Journey "${a.journeyId}" is not registered with this runtime.`);
|
|
71
|
+
if (a.status === "loading") throw Error(`[@modular-react/journeys/testing] fireExit("${t}") called on instance "${e}" while status=loading. Await the runtime's async load probe (typically \`await Promise.resolve()\` a few times, or expose a subscribe hook in your test) before dispatching exits.`);
|
|
72
|
+
if (a.status !== "active") throw Error(`[@modular-react/journeys/testing] fireExit("${t}") called on terminal instance "${e}" (status=${a.status}).`);
|
|
73
|
+
n.__bindStepCallbacks(a, o).exit(t, i);
|
|
74
|
+
},
|
|
75
|
+
goBack(e) {
|
|
76
|
+
let t = r(e), i = n.__getRegistered(t.journeyId);
|
|
77
|
+
if (!i) throw Error(`[@modular-react/journeys/testing] Journey "${t.journeyId}" is not registered with this runtime.`);
|
|
78
|
+
if (t.status === "loading") throw Error(`[@modular-react/journeys/testing] goBack() called on instance "${e}" while status=loading. Await the runtime's async load probe before dispatching.`);
|
|
79
|
+
let a = n.__bindStepCallbacks(t, i);
|
|
80
|
+
if (!a.goBack) {
|
|
81
|
+
let n = t.step ? `${t.step.moduleId}.${t.step.entry}` : "(no step)";
|
|
82
|
+
throw Error(`[@modular-react/journeys/testing] goBack is unavailable on instance "${e}" (step=${n}). The journey's transition must declare allowBack: true AND the current step must have at least one history entry.`);
|
|
83
|
+
}
|
|
84
|
+
a.goBack();
|
|
85
|
+
},
|
|
86
|
+
inspect(e) {
|
|
87
|
+
let t = r(e);
|
|
88
|
+
return {
|
|
89
|
+
status: t.status,
|
|
90
|
+
step: t.step,
|
|
91
|
+
state: t.state,
|
|
92
|
+
history: [...t.history],
|
|
93
|
+
stepToken: t.stepToken,
|
|
94
|
+
retryCount: t.retryCount
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
100
|
+
export { r as createTestHarness, n as simulateJourney };
|
|
101
|
+
|
|
102
|
+
//# sourceMappingURL=testing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testing.js","names":[],"sources":["../src/simulate-journey.ts","../src/testing.ts"],"sourcesContent":["import { createJourneyRuntime } from \"./runtime.js\";\nimport { createTestHarness } from \"./testing.js\";\nimport type {\n AnyJourneyDefinition,\n JourneyDefinition,\n JourneyStep,\n ModuleTypeMap,\n SerializedJourney,\n TransitionEvent,\n} from \"./types.js\";\n\n/**\n * Headless simulator for a journey definition. Fires exits / goBack without\n * mounting React and exposes state / step / history / the recorded\n * `TransitionEvent` stream for assertions.\n *\n * Intended for pure-logic unit tests of transition graphs.\n */\nexport interface JourneySimulator<_TModules extends ModuleTypeMap, TState> {\n readonly journeyId: string;\n readonly instanceId: string;\n /** Current step — null once the journey completes or aborts. */\n readonly step: JourneyStep | null;\n /**\n * Same as `step`, but throws if the journey has terminated. Use this in\n * tests to skip optional chaining on the common \"still running\" path —\n * the throw spells out the unexpected status (`completed` / `aborted`)\n * and is far easier to debug than a `Cannot read property 'moduleId' of\n * null` thrown by an assertion line.\n */\n readonly currentStep: JourneyStep;\n readonly state: TState;\n readonly history: readonly JourneyStep[];\n readonly status: \"loading\" | \"active\" | \"completed\" | \"aborted\";\n /**\n * Every `TransitionEvent` the runtime has fired since the simulator\n * started. Useful for assertions on analytics rules without having to\n * attach an `onTransition` by hand.\n */\n readonly transitions: readonly TransitionEvent[];\n /**\n * Terminal payload from the `complete` / `abort` transition that ended\n * the journey. `undefined` while the journey is still active.\n */\n readonly terminalPayload: unknown;\n\n fireExit(name: string, output?: unknown): void;\n goBack(): void;\n end(reason?: unknown): void;\n /**\n * Serialize the simulator's current instance into the same blob shape\n * a persistence adapter would see. Useful for pinning the exact blob\n * shape tests expect to round-trip, and for asserting `rollbackSnapshots`\n * alignment with `history` without reaching into runtime internals.\n */\n serialize(): SerializedJourney<TState>;\n}\n\n/**\n * Headlessly drive a journey definition — see {@link JourneySimulator}.\n *\n * The second argument is the journey's `TInput`. When a journey declares\n * no input (`TInput extends void`), callers can omit it entirely:\n *\n * ```ts\n * simulateJourney(noInputJourney); // no input required\n * simulateJourney(inputJourney, { id: 1 }); // input required and typed\n * ```\n */\nexport function simulateJourney<TModules extends ModuleTypeMap, TState, TInput>(\n definition: JourneyDefinition<TModules, TState, TInput>,\n ...rest: [TInput] extends [void] ? [] | [input?: TInput] : [input: TInput]\n): JourneySimulator<TModules, TState> {\n const input = (rest.length > 0 ? rest[0] : undefined) as TInput;\n // Attach our own recorder on top of whatever `onTransition` the definition\n // declares — the runtime already invokes both (definition first, then\n // registration option), so this does not shadow the journey's own hook.\n const transitions: TransitionEvent[] = [];\n const runtime = createJourneyRuntime([\n {\n definition: definition as AnyJourneyDefinition,\n options: {\n onTransition: (ev) => {\n transitions.push(ev);\n },\n },\n },\n ]);\n const instanceId = runtime.start(definition.id, input);\n const harness = createTestHarness(runtime);\n\n function snapshot() {\n return harness.inspect<TState>(instanceId);\n }\n\n function instanceOrThrow() {\n const inst = runtime.getInstance(instanceId);\n if (!inst) throw new Error(`[simulateJourney] instance ${instanceId} not found`);\n return inst;\n }\n\n return {\n journeyId: definition.id,\n instanceId,\n get step() {\n return snapshot().step;\n },\n get currentStep() {\n const snap = snapshot();\n if (!snap.step) {\n throw new Error(\n `[simulateJourney] no current step (status=${snap.status}). Use \\`step\\` if a null step is expected.`,\n );\n }\n return snap.step;\n },\n get state() {\n return snapshot().state;\n },\n get history() {\n return snapshot().history;\n },\n get status() {\n return snapshot().status;\n },\n get transitions() {\n return transitions;\n },\n get terminalPayload() {\n return instanceOrThrow().terminalPayload;\n },\n serialize() {\n return instanceOrThrow().serialize() as SerializedJourney<TState>;\n },\n fireExit(name, output) {\n harness.fireExit(instanceId, name, output);\n },\n goBack() {\n harness.goBack(instanceId);\n },\n end(reason) {\n runtime.end(instanceId, reason);\n },\n };\n}\n","import type { InstanceId, JourneyRuntime, JourneyStatus, JourneyStep } from \"@modular-react/core\";\n\nimport { getInternals } from \"./runtime.js\";\n\nexport { simulateJourney } from \"./simulate-journey.js\";\nexport type { JourneySimulator } from \"./simulate-journey.js\";\n\n/**\n * Snapshot of the mutable runtime record for a single instance. Returned by\n * `JourneyTestHarness.inspect` so tests can assert on fields that the\n * public `JourneyInstance` surface intentionally does not expose (stepToken,\n * retryCount). Everything else is also available via `runtime.getInstance`.\n */\nexport interface InstanceSnapshot<TState = unknown> {\n readonly status: JourneyStatus;\n readonly step: JourneyStep | null;\n readonly state: TState;\n readonly history: readonly JourneyStep[];\n readonly stepToken: number;\n readonly retryCount: number;\n}\n\n/**\n * Test-only accessor that drives a runtime's internals from the outside —\n * fire exits, walk back, peek at per-instance state. Prefer\n * {@link simulateJourney} for pure-logic transition tests; use this when you\n * already have a live runtime (e.g. one produced by the registry) and need\n * to poke it from a test without mounting the outlet.\n *\n * The harness is the supported replacement for directly importing the\n * runtime's `__`-prefixed internals, which are kept off the public export\n * surface intentionally.\n */\nexport interface JourneyTestHarness {\n fireExit(id: InstanceId, name: string, output?: unknown): void;\n goBack(id: InstanceId): void;\n inspect<TState = unknown>(id: InstanceId): InstanceSnapshot<TState>;\n}\n\nexport function createTestHarness(runtime: JourneyRuntime): JourneyTestHarness {\n const internals = getInternals(runtime);\n\n function recordOrThrow(id: InstanceId) {\n const record = internals.__getRecord(id);\n if (!record) {\n throw new Error(\n `[@modular-react/journeys/testing] No instance with id \"${id}\". Pass the id returned by runtime.start(...).`,\n );\n }\n return record;\n }\n\n return {\n fireExit(id, name, output) {\n const record = recordOrThrow(id);\n const reg = internals.__getRegistered(record.journeyId);\n if (!reg) {\n throw new Error(\n `[@modular-react/journeys/testing] Journey \"${record.journeyId}\" is not registered with this runtime.`,\n );\n }\n // Calling fireExit on a loading instance is a silent no-op at the\n // runtime level (the runtime has no step to resolve against yet).\n // In tests this almost always indicates the caller forgot to await\n // the persistence load probe. Throw early so the test fails on the\n // offending call instead of on a later `expect(step?.entry)` read.\n if (record.status === \"loading\") {\n throw new Error(\n `[@modular-react/journeys/testing] fireExit(\"${name}\") called on instance \"${id}\" while status=loading. ` +\n `Await the runtime's async load probe (typically \\`await Promise.resolve()\\` a few times, or expose a subscribe hook in your test) before dispatching exits.`,\n );\n }\n if (record.status !== \"active\") {\n throw new Error(\n `[@modular-react/journeys/testing] fireExit(\"${name}\") called on terminal instance \"${id}\" (status=${record.status}).`,\n );\n }\n internals.__bindStepCallbacks(record, reg).exit(name, output);\n },\n goBack(id) {\n const record = recordOrThrow(id);\n const reg = internals.__getRegistered(record.journeyId);\n if (!reg) {\n throw new Error(\n `[@modular-react/journeys/testing] Journey \"${record.journeyId}\" is not registered with this runtime.`,\n );\n }\n if (record.status === \"loading\") {\n throw new Error(\n `[@modular-react/journeys/testing] goBack() called on instance \"${id}\" while status=loading. ` +\n `Await the runtime's async load probe before dispatching.`,\n );\n }\n const callbacks = internals.__bindStepCallbacks(record, reg);\n if (!callbacks.goBack) {\n // Silently no-oping here would quietly \"pass\" a test that expects\n // back navigation to work — the common `goBack walks back…` pattern\n // asserts state *after* the call, so a no-op masks the wiring bug.\n // Throw with context so the test fails on the offending call instead.\n const stepLabel = record.step\n ? `${record.step.moduleId}.${record.step.entry}`\n : \"(no step)\";\n throw new Error(\n `[@modular-react/journeys/testing] goBack is unavailable on instance \"${id}\" (step=${stepLabel}). ` +\n `The journey's transition must declare allowBack: true AND the current step must have at least one history entry.`,\n );\n }\n callbacks.goBack();\n },\n inspect<TState = unknown>(id: InstanceId): InstanceSnapshot<TState> {\n const record = recordOrThrow(id);\n // Snapshot — `history` is a live array on the runtime record and will\n // grow as the journey advances. Copy so assertions captured by the\n // caller stay stable when the next `fireExit` runs.\n return {\n status: record.status,\n step: record.step,\n state: record.state as TState,\n history: [...record.history],\n stepToken: record.stepToken,\n retryCount: record.retryCount,\n };\n },\n };\n}\n"],"mappings":";;AAqEA,SAAgB,EACd,GACA,GAAG,GACiC;CACpC,IAAM,IAAS,EAAK,SAAS,IAAI,EAAK,KAAK,KAAA,GAIrC,IAAiC,EAAE,EACnC,IAAU,EAAqB,CACnC;EACc;EACZ,SAAS,EACP,eAAe,MAAO;AACpB,KAAY,KAAK,EAAG;KAEvB;EACF,CACF,CAAC,EACI,IAAa,EAAQ,MAAM,EAAW,IAAI,EAAM,EAChD,IAAU,EAAkB,EAAQ;CAE1C,SAAS,IAAW;AAClB,SAAO,EAAQ,QAAgB,EAAW;;CAG5C,SAAS,IAAkB;EACzB,IAAM,IAAO,EAAQ,YAAY,EAAW;AAC5C,MAAI,CAAC,EAAM,OAAU,MAAM,8BAA8B,EAAW,YAAY;AAChF,SAAO;;AAGT,QAAO;EACL,WAAW,EAAW;EACtB;EACA,IAAI,OAAO;AACT,UAAO,GAAU,CAAC;;EAEpB,IAAI,cAAc;GAChB,IAAM,IAAO,GAAU;AACvB,OAAI,CAAC,EAAK,KACR,OAAU,MACR,6CAA6C,EAAK,OAAO,6CAC1D;AAEH,UAAO,EAAK;;EAEd,IAAI,QAAQ;AACV,UAAO,GAAU,CAAC;;EAEpB,IAAI,UAAU;AACZ,UAAO,GAAU,CAAC;;EAEpB,IAAI,SAAS;AACX,UAAO,GAAU,CAAC;;EAEpB,IAAI,cAAc;AAChB,UAAO;;EAET,IAAI,kBAAkB;AACpB,UAAO,GAAiB,CAAC;;EAE3B,YAAY;AACV,UAAO,GAAiB,CAAC,WAAW;;EAEtC,SAAS,GAAM,GAAQ;AACrB,KAAQ,SAAS,GAAY,GAAM,EAAO;;EAE5C,SAAS;AACP,KAAQ,OAAO,EAAW;;EAE5B,IAAI,GAAQ;AACV,KAAQ,IAAI,GAAY,EAAO;;EAElC;;;;ACxGH,SAAgB,EAAkB,GAA6C;CAC7E,IAAM,IAAY,EAAa,EAAQ;CAEvC,SAAS,EAAc,GAAgB;EACrC,IAAM,IAAS,EAAU,YAAY,EAAG;AACxC,MAAI,CAAC,EACH,OAAU,MACR,0DAA0D,EAAG,gDAC9D;AAEH,SAAO;;AAGT,QAAO;EACL,SAAS,GAAI,GAAM,GAAQ;GACzB,IAAM,IAAS,EAAc,EAAG,EAC1B,IAAM,EAAU,gBAAgB,EAAO,UAAU;AACvD,OAAI,CAAC,EACH,OAAU,MACR,8CAA8C,EAAO,UAAU,wCAChE;AAOH,OAAI,EAAO,WAAW,UACpB,OAAU,MACR,+CAA+C,EAAK,yBAAyB,EAAG,qLAEjF;AAEH,OAAI,EAAO,WAAW,SACpB,OAAU,MACR,+CAA+C,EAAK,kCAAkC,EAAG,YAAY,EAAO,OAAO,IACpH;AAEH,KAAU,oBAAoB,GAAQ,EAAI,CAAC,KAAK,GAAM,EAAO;;EAE/D,OAAO,GAAI;GACT,IAAM,IAAS,EAAc,EAAG,EAC1B,IAAM,EAAU,gBAAgB,EAAO,UAAU;AACvD,OAAI,CAAC,EACH,OAAU,MACR,8CAA8C,EAAO,UAAU,wCAChE;AAEH,OAAI,EAAO,WAAW,UACpB,OAAU,MACR,kEAAkE,EAAG,kFAEtE;GAEH,IAAM,IAAY,EAAU,oBAAoB,GAAQ,EAAI;AAC5D,OAAI,CAAC,EAAU,QAAQ;IAKrB,IAAM,IAAY,EAAO,OACrB,GAAG,EAAO,KAAK,SAAS,GAAG,EAAO,KAAK,UACvC;AACJ,UAAU,MACR,wEAAwE,EAAG,UAAU,EAAU,qHAEhG;;AAEH,KAAU,QAAQ;;EAEpB,QAA0B,GAA0C;GAClE,IAAM,IAAS,EAAc,EAAG;AAIhC,UAAO;IACL,QAAQ,EAAO;IACf,MAAM,EAAO;IACb,OAAO,EAAO;IACd,SAAS,CAAC,GAAG,EAAO,QAAQ;IAC5B,WAAW,EAAO;IAClB,YAAY,EAAO;IACpB;;EAEJ"}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@modular-react/journeys",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Typed, serializable workflows that compose multiple modules. A journey declares entry/exit transitions between modules and owns shared state; modules stay journey-unaware.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git://github.com/kibertoad/modular-react.git",
|
|
8
|
+
"directory": "packages/journeys"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./testing": {
|
|
22
|
+
"types": "./dist/testing.d.ts",
|
|
23
|
+
"import": "./dist/testing.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@testing-library/react": "^16.0.0",
|
|
31
|
+
"@types/react": "^19.0.0",
|
|
32
|
+
"@types/react-dom": "^19.0.0",
|
|
33
|
+
"happy-dom": "^20.9.0",
|
|
34
|
+
"oxfmt": "^0.46.0",
|
|
35
|
+
"oxlint": "^1.61.0",
|
|
36
|
+
"react": "^19.2.5",
|
|
37
|
+
"react-dom": "^19.0.0",
|
|
38
|
+
"typescript": "^6.0.2",
|
|
39
|
+
"vite": "^8.0.10",
|
|
40
|
+
"vite-plugin-dts": "^4.5.4",
|
|
41
|
+
"vitest": "^4.1.5",
|
|
42
|
+
"@modular-react/core": "1.5.0",
|
|
43
|
+
"@modular-react/react": "1.4.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@modular-react/core": "^1.2.0",
|
|
47
|
+
"@modular-react/react": "^1.2.0",
|
|
48
|
+
"react": "^19.0.0",
|
|
49
|
+
"react-dom": "^19.0.0"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "vite build",
|
|
53
|
+
"dev": "vite build --watch",
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"typecheck": "tsc --noEmit"
|
|
56
|
+
}
|
|
57
|
+
}
|