diff --git a/.changeset/bright-machines-remember.md b/.changeset/bright-machines-remember.md new file mode 100644 index 0000000..3955e10 --- /dev/null +++ b/.changeset/bright-machines-remember.md @@ -0,0 +1,5 @@ +--- +"@typeonce/effect-machine": minor +--- + +Add fully typed shallow and deep history states. History targets restore schema-validated state values, support typed defaults before the first capture, require only the initializers needed by shallow restoration, preserve parallel configurations, and round-trip through snapshot encoding and decoding. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e749014..4e612c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,25 @@ jobs: - run: pnpm --dir ../.. build - run: pnpm install --frozen-lockfile - run: pnpm check + + platformer-example: + runs-on: ubuntu-latest + defaults: + run: + working-directory: examples/platformer + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + with: + package_json_file: examples/platformer/package.json + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + examples/platformer/pnpm-lock.yaml + - run: pnpm --dir ../.. install --frozen-lockfile + - run: pnpm --dir ../.. build + - run: pnpm install --frozen-lockfile + - run: pnpm check diff --git a/README.md b/README.md index d2f7330..85987c3 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ masquerade as an internal result. ## Statechart structure -`Machine.defineStates` accepts atomic, compound, parallel, and final state +`Machine.defineStates` accepts atomic, compound, parallel, final, and history nodes: ```ts @@ -205,6 +205,71 @@ Put data on the narrowest state where it is valid. If several sibling phases share data, prefer storing it on their compound parent instead of copying it into every child state. +### History states + +A history pseudo-state remembers the last active configuration of its parent. +It has no value schema and never appears in an active snapshot. History is +shallow by default; use `history: "deep"` to retain the complete descendant +configuration and its validated values: + +```ts +const States = Machine.defineStates({ + checkout: { + schema: Checkout, + initial: "shipping", + states: { + shipping: Shipping, + payment: { + schema: Payment, + initial: "cardEntry", + states: { + cardEntry: CardEntry, + verifying: Verifying + } + }, + resume: { type: "history", history: "deep" } + } + }, + support: Support +}) +``` + +Implement a typed default for the first transition before any configuration +has been remembered, then target history without supplying a state value: + +```ts +machine.handle({ + checkout: { + history: { + resume: { + default: () => initialCheckoutSnapshot + } + } + }, + support: { + on: { + Resume: ({ target }) => target.history.checkout.resume() + } + } +}) +``` + +Deep history restores every remembered descendant value. Shallow history +restores the parent and direct-child values, then follows normal initial paths. +Only compound or parallel states that shallow restoration can enter implicitly +need an `initial` handler to construct those new child values: + +```ts +payment: { + initial: ; + ;(({ state }) => new CardEntry({ attempt: state.attempt, cardNumber: "" })) +} +``` + +Execution APIs remain unavailable until required history defaults and shallow +initializers have been implemented. History records are part of logical +snapshots and are schema-validated by `encodeSnapshot` and `decodeSnapshot`. + Transition between structurally related tagged states with `Machine.retag`. The source `_tag` is discarded, compatible fields are reused, and missing or incompatible required fields must be supplied: @@ -215,13 +280,14 @@ const saving = Machine.retag(State.cases.Saving, editing) ## Choosing a target builder -Transition contexts expose three typed target builders: +Transition contexts expose four typed target builders: -| Builder | Destination | Configuration behavior | -| --------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| `target.local` | Inside the source's nearest compound scope | Keeps the compound value, active ancestors, and unrelated parallel regions | -| `target.branch` | Anywhere under the source's active top-level root | Replaces the selected branch while keeping omitted active ancestor values and parallel regions | -| `target.full` | Any top-level root | Builds a complete active snapshot for the selected root | +| Builder | Destination | Configuration behavior | +| ---------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `target.local` | Inside the source's nearest compound scope | Keeps the compound value, active ancestors, and unrelated parallel regions | +| `target.branch` | Anywhere under the source's active top-level root | Replaces the selected branch while keeping omitted active ancestor values and parallel regions | +| `target.full` | Any top-level root | Builds a complete active snapshot for the selected root | +| `target.history` | A declared history pseudo-state | Restores its parent's remembered configuration or runs its typed default | When `target.local` or `target.branch` enters an inactive nested parallel state, its callback must select every region, just like `initial` and @@ -404,9 +470,9 @@ restrictions and delivery guarantees are documented on that API. ## Current limits -History states and declarative first-class guards are not part of the current -API. Ordinary TypeScript conditions implement guards. Use `Machine.after` for a -cancellable state-scoped delayed event. +Declarative first-class guards are not part of the current API. Ordinary +TypeScript conditions implement guards. Use `Machine.after` for a cancellable +state-scoped delayed event. ## Guidance for agents and contributors @@ -434,8 +500,9 @@ TypeScript consumer with `skipLibCheck: false`. The [platformer statechart example](./examples/platformer) is a playable SVG demo centered on a schema-first character machine. It demonstrates nested compound locomotion, parallel airborne motion and air-jump regions, independent -facing and wall-contact regions, typed protocol events, state-scoped timers, -and state-driven SVG transforms. +facing and wall-contact regions, a pause/resume flow backed by typed deep +history, typed protocol events, state-scoped timers, and state-driven SVG +transforms. The [Pokémon statechart example](./examples/pokemon) is a standalone React and Vite project demonstrating compound and parallel states, state-scoped invokes, diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 568ed59..f825b9c 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -104,7 +104,7 @@ its extra control is required: the same operation with a returned transition value, not a separate action API. -## Atomic, compound, and parallel states +## Atomic, compound, parallel, and history states Use an atomic state when no child phase can be active beneath it. @@ -201,13 +201,74 @@ const machine = Machine.make({ Do not repeat `type: "final"` in `handle`. Execution APIs reject a machine until every declared output schema has an implementation. +Declare a history pseudo-state below the active parent whose configuration it +should remember. It has no schema, is excluded from active state identifiers, +and is addressed only through `target.history`: + +```ts +const States = Machine.defineStates({ + checkout: { + schema: Checkout, + initial: "shipping", + states: { + shipping: Shipping, + payment: { + schema: Payment, + initial: "cardEntry", + states: { + cardEntry: CardEntry, + verifying: Verifying + } + }, + recent: { type: "history" }, + exact: { type: "history", history: "deep" } + } + }, + support: Support +}) +``` + +Every history node needs a default parent snapshot for the first use: + +```ts +checkout: { + history: { + recent: { default: () => initialCheckoutSnapshot }, + exact: { default: () => initialCheckoutSnapshot } + } +} +``` + +Target it without a value: + +```ts +Resume: ({ target }) => target.history.checkout.exact() +``` + +Deep history restores the complete remembered subtree and its decoded values. +Shallow history restores only parent and direct-child values. If the remembered +child is compound, its configured initial child needs a freshly constructed +value, so implement `initial` only on paths required by shallow history: + +```ts +payment: { + initial: ({ state }) => new CardEntry({ attempt: state.attempt, cardNumber: "" }) +} +``` + +The machine's readiness type tracks missing defaults and shallow initializers. +History is an overwriteable register, not a stack: restoration does not consume +it, and the next parent exit replaces it. Entry actions and invokes run again; +prior effects, actors, and timers are not rewound. + ## Choosing a target -| Builder | Use it when | What it preserves | -| --------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| `target.local` | The destination is inside the nearest compound scope containing the source | The compound value, active ancestors, and unrelated parallel regions | -| `target.branch` | The destination is elsewhere under the active top-level root | Omitted current ancestor values and parallel regions | -| `target.full` | The destination may be under any top-level root | Nothing is inferred for a newly selected root; build its complete active snapshot | +| Builder | Use it when | What it preserves | +| ---------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `target.local` | The destination is inside the nearest compound scope containing the source | The compound value, active ancestors, and unrelated parallel regions | +| `target.branch` | The destination is elsewhere under the active top-level root | Omitted current ancestor values and parallel regions | +| `target.full` | The destination may be under any top-level root | Nothing is inferred for a newly selected root; build its complete active snapshot | +| `target.history` | The destination is a declared history pseudo-state | Its parent's remembered configuration, or its default before the first capture | Entering an inactive parallel state through `target.local` or `target.branch` requires a complete callback with one selection per region. A parallel state @@ -611,10 +672,9 @@ a deeper statechart instead of casting away the diagnostic. The current API does not include: -- history states; - declarative first-class guards; - a complete inspectable graph for arbitrary transition Effects. Use ordinary TypeScript conditions for guards and `Machine.after` for state-scoped timers. Do not invent undocumented state-node properties such as -`guard` or `history`. +`guard`. diff --git a/examples/platformer/README.md b/examples/platformer/README.md index 9075310..0a3331b 100644 --- a/examples/platformer/README.md +++ b/examples/platformer/README.md @@ -21,25 +21,44 @@ pnpm check - **W**, **up**, or **Space** — jump; press again once in the air for a double jump - Touch either wall and jump — turn and kick away; repeat after returning to a wall - **S** or **down** — duck while grounded; dive while airborne +- **P** — pause and resume the exact playable configuration through deep history - **R** — reset ## Statechart `Character` is parallel: `locomotion`, `facing`, and `contact` update -independently. The locomotion region is compound and makes `Grounded` and -`Airborne` mutually exclusive. Each branch is compound again: +independently. The locomotion region switches between `Playing` and `Paused`. +Inside `Playing`, `Grounded` and `Airborne` are mutually exclusive. Each branch +is compound again: ```text Character (parallel) ├─ locomotion -│ ├─ Grounded: Standing | Running | Ducking | Landing -│ └─ Airborne (parallel) -│ ├─ motion: Jumping | Falling | Diving -│ └─ airJump: GroundLock | WallLock | Ready | Spent +│ ├─ Playing +│ │ ├─ Grounded: Standing | Running | Ducking | Landing +│ │ ├─ Airborne (parallel) +│ │ │ ├─ motion: Jumping | Falling | Diving +│ │ │ └─ airJump: GroundLock | WallLock | Ready | Spent +│ │ └─ resume (deep history) +│ └─ Paused ├─ facing: Left | Right └─ contact: NoWall | LeftWall | RightWall ``` +`Pause` exits `Playing`, which records its current deep configuration. Physics +stops while `Paused`. `Resume` targets `Playing.resume`, restoring both the +active descendants and their typed values: for example, an airborne wall jump +returns with its `originY`, `startedAt`, `push`, jump kind, and air-jump lock. +This is one saved configuration, not an undo stack; pausing again replaces the +previous history. The history implementation also supplies a typed default +`Playing` snapshot for the case where the history node is targeted before the +region has ever been exited. + +State-scoped invocations follow normal statechart entry/exit semantics. Pausing +cancels an active landing or air-jump timer, and restoring that state starts its +invocation again. History restores state configuration and values, not elapsed +wall-clock time or the adapter's past events. + State payloads live only where they are valid: `Landing` owns impact and resume direction, while `Airborne` owns only the jump origin. Air-jump availability is modeled entirely as state: lock states own cancellable readiness timers, diff --git a/examples/platformer/index.html b/examples/platformer/index.html index 555ce7d..5315be9 100644 --- a/examples/platformer/index.html +++ b/examples/platformer/index.html @@ -53,11 +53,17 @@

Orbit Courier

+
AD move W/Space jump ×2 S duck / dive + P pause / resume R reset
@@ -72,33 +78,40 @@

Orbit Courier

locomotion

-
-

Grounded

-
- Standing - Running - Ducking - Landing -
+
+ Playing + Paused + resume · H*
-
-

Airborne

-
parallel regions
-
- motion +
+
+

Grounded

- Jumping - Falling - Diving + Standing + Running + Ducking + Landing
-
- air jump -
- ground lock - wall lock - ready - spent +
+

Airborne

+
parallel regions
+
+ motion +
+ Jumping + Falling + Diving +
+
+
+ air jump +
+ ground lock + wall lock + ready + spent +
@@ -140,7 +153,8 @@

wall contact

A small adapter owns coordinates and gravity. The machine owns legal behavior, and the box simply transforms to - show its active state. Wall contact is tracked explicitly, so floor-corner jumps stay ordinary. + show its active state. Pause exits Playing; resume targets its deep-history node and restores the + exact grounded or airborne configuration, including state-local values.

diff --git a/examples/platformer/package.json b/examples/platformer/package.json index b0fed9e..eed4cae 100644 --- a/examples/platformer/package.json +++ b/examples/platformer/package.json @@ -6,10 +6,11 @@ "type": "module", "scripts": { "dev": "vite", + "test": "vitest run", "typecheck": "tsc --noEmit", "build": "pnpm typecheck && vite build", "preview": "vite preview", - "check": "pnpm build" + "check": "pnpm test && pnpm build" }, "dependencies": { "@typeonce/effect-machine": "file:../..", @@ -17,7 +18,8 @@ }, "devDependencies": { "typescript": "6.0.3", - "vite": "8.1.5" + "vite": "8.1.5", + "vitest": "4.1.10" }, "packageManager": "pnpm@10.17.1", "engines": { diff --git a/examples/platformer/pnpm-lock.yaml b/examples/platformer/pnpm-lock.yaml index 4846d66..b1a4472 100644 --- a/examples/platformer/pnpm-lock.yaml +++ b/examples/platformer/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: "9.0" +lockfileVersion: '9.0' settings: autoInstallPeers: true @@ -8,9 +8,10 @@ overrides: effect: 4.0.0-beta.102 importers: + .: dependencies: - "@typeonce/effect-machine": + '@typeonce/effect-machine': specifier: file:../.. version: file:../..(effect@4.0.0-beta.102) effect: @@ -23,214 +24,247 @@ importers: vite: specifier: 8.1.5 version: 8.1.5(yaml@2.9.0) + vitest: + specifier: 4.1.10 + version: 4.1.10(vite@8.1.5(yaml@2.9.0)) packages: - "@emnapi/core@1.11.1": - resolution: - { integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ== } - "@emnapi/runtime@1.11.1": - resolution: - { integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw== } + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - "@emnapi/wasi-threads@1.2.2": - resolution: - { integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA== } + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - "@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4": - resolution: - { integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ== } + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} cpu: [arm64] os: [darwin] - "@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4": - resolution: - { integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w== } + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} cpu: [x64] os: [darwin] - "@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4": - resolution: - { integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw== } + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} cpu: [arm64] os: [linux] - "@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4": - resolution: - { integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw== } + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} cpu: [arm] os: [linux] - "@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4": - resolution: - { integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ== } + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} cpu: [x64] os: [linux] - "@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4": - resolution: - { integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ== } + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} cpu: [x64] os: [win32] - "@napi-rs/wasm-runtime@1.2.1": - resolution: - { integrity: sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ== } - engines: { node: ^20.19.0 || ^22.13.0 || >=23.5.0 } + '@napi-rs/wasm-runtime@1.2.1': + resolution: {integrity: sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: - "@emnapi/core": ^1.7.1 || ^2.0.0-alpha.3 - "@emnapi/runtime": ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 - "@oxc-project/types@0.139.0": - resolution: - { integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw== } + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} - "@rolldown/binding-android-arm64@1.1.5": - resolution: - { integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - "@rolldown/binding-darwin-arm64@1.1.5": - resolution: - { integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - "@rolldown/binding-darwin-x64@1.1.5": - resolution: - { integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - "@rolldown/binding-freebsd-x64@1.1.5": - resolution: - { integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - "@rolldown/binding-linux-arm-gnueabihf@1.1.5": - resolution: - { integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - "@rolldown/binding-linux-arm64-gnu@1.1.5": - resolution: - { integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - "@rolldown/binding-linux-arm64-musl@1.1.5": - resolution: - { integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - "@rolldown/binding-linux-ppc64-gnu@1.1.5": - resolution: - { integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - "@rolldown/binding-linux-s390x-gnu@1.1.5": - resolution: - { integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - "@rolldown/binding-linux-x64-gnu@1.1.5": - resolution: - { integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - "@rolldown/binding-linux-x64-musl@1.1.5": - resolution: - { integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - "@rolldown/binding-openharmony-arm64@1.1.5": - resolution: - { integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - "@rolldown/binding-wasm32-wasi@1.1.5": - resolution: - { integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - "@rolldown/binding-win32-arm64-msvc@1.1.5": - resolution: - { integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - "@rolldown/binding-win32-x64-msvc@1.1.5": - resolution: - { integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA== } - engines: { node: ^20.19.0 || >=22.12.0 } + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - "@rolldown/pluginutils@1.0.1": - resolution: - { integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw== } + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - "@standard-schema/spec@1.1.0": - resolution: - { integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== } + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - "@tybys/wasm-util@0.10.3": - resolution: - { integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg== } + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - "@typeonce/effect-machine@file:../..": - resolution: { directory: ../.., type: directory } - engines: { node: ">=20" } + '@typeonce/effect-machine@file:../..': + resolution: {directory: ../.., type: directory} + engines: {node: '>=20'} peerDependencies: effect: 4.0.0-beta.102 + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + detect-libc@2.1.2: - resolution: - { integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== } - engines: { node: ">=8" } + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} effect@4.0.0-beta.102: - resolution: - { integrity: sha512-z8Y+Q76Hh/kjLFZrXu8tGn6e+tDsg45R+UHhxd190pXxD53OGwf/G/zDxXTkse4HJ5mobNZfitLfUCp4fMvu6w== } + resolution: {integrity: sha512-z8Y+Q76Hh/kjLFZrXu8tGn6e+tDsg45R+UHhxd190pXxD53OGwf/G/zDxXTkse4HJ5mobNZfitLfUCp4fMvu6w==} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} fast-check@4.9.0: - resolution: - { integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg== } - engines: { node: ">=12.17.0" } + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} fdir@6.5.0: - resolution: - { integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== } - engines: { node: ">=12.0.0" } + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} peerDependencies: picomatch: ^3 || ^4 peerDependenciesMeta: @@ -238,210 +272,207 @@ packages: optional: true find-my-way-ts@0.1.6: - resolution: - { integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA== } + resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} fsevents@2.3.3: - resolution: - { integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== } - engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] ini@7.0.0: - resolution: - { integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w== } - engines: { node: ^22.22.2 || ^24.15.0 || >=26.0.0 } + resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} kubernetes-types@1.30.0: - resolution: - { integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q== } + resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} lightningcss-android-arm64@1.33.0: - resolution: - { integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] lightningcss-darwin-arm64@1.33.0: - resolution: - { integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] lightningcss-darwin-x64@1.33.0: - resolution: - { integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] lightningcss-freebsd-x64@1.33.0: - resolution: - { integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] lightningcss-linux-arm-gnueabihf@1.33.0: - resolution: - { integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] lightningcss-linux-arm64-gnu@1.33.0: - resolution: - { integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] lightningcss-linux-arm64-musl@1.33.0: - resolution: - { integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] lightningcss-linux-x64-gnu@1.33.0: - resolution: - { integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] lightningcss-linux-x64-musl@1.33.0: - resolution: - { integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] lightningcss-win32-arm64-msvc@1.33.0: - resolution: - { integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] lightningcss-win32-x64-msvc@1.33.0: - resolution: - { integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] lightningcss@1.33.0: - resolution: - { integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA== } - engines: { node: ">= 12.0.0" } + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} msgpackr-extract@3.0.4: - resolution: - { integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw== } + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} hasBin: true msgpackr@2.0.5: - resolution: - { integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA== } + resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==} multipasta@0.2.8: - resolution: - { integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q== } + resolution: {integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==} nanoid@3.3.16: - resolution: - { integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q== } - engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true node-gyp-build-optional-packages@5.2.2: - resolution: - { integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw== } + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: - resolution: - { integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== } + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} picomatch@4.0.5: - resolution: - { integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A== } - engines: { node: ">=12" } + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} postcss@8.5.25: - resolution: - { integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw== } - engines: { node: ^10 || ^12 || >=14 } + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} pure-rand@8.4.2: - resolution: - { integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng== } + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} rolldown@1.1.5: - resolution: - { integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA== } - engines: { node: ^20.19.0 || >=22.12.0 } + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: - resolution: - { integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== } - engines: { node: ">=0.10.0" } + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} tinyglobby@0.2.17: - resolution: - { integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g== } - engines: { node: ">=12.0.0" } + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} toml@4.3.0: - resolution: - { integrity: sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A== } - engines: { node: ">=20" } + resolution: {integrity: sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==} + engines: {node: '>=20'} tslib@2.8.1: - resolution: - { integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== } + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} typescript@6.0.3: - resolution: - { integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== } - engines: { node: ">=14.17" } + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} hasBin: true uuid@14.0.1: - resolution: - { integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew== } + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true vite@8.1.5: - resolution: - { integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw== } - engines: { node: ^20.19.0 || >=22.12.0 } + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - "@types/node": ^20.19.0 || >=22.12.0 - "@vitejs/devtools": ^0.3.0 + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 esbuild: ^0.27.0 || ^0.28.0 - jiti: ">=1.21.0" + jiti: '>=1.21.0' less: ^4.0.0 sass: ^1.70.0 sass-embedded: ^1.70.0 - stylus: ">=0.54.8" + stylus: '>=0.54.8' sugarss: ^5.0.0 terser: ^5.16.0 tsx: ^4.8.1 yaml: ^2.4.2 peerDependenciesMeta: - "@types/node": + '@types/node': optional: true - "@vitejs/devtools": + '@vitejs/devtools': optional: true esbuild: optional: true @@ -464,123 +495,227 @@ packages: yaml: optional: true + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + yaml@2.9.0: - resolution: - { integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA== } - engines: { node: ">= 14.6" } + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} hasBin: true snapshots: - "@emnapi/core@1.11.1": + + '@emnapi/core@1.11.1': dependencies: - "@emnapi/wasi-threads": 1.2.2 + '@emnapi/wasi-threads': 1.2.2 tslib: 2.8.1 optional: true - "@emnapi/runtime@1.11.1": + '@emnapi/runtime@1.11.1': dependencies: tslib: 2.8.1 optional: true - "@emnapi/wasi-threads@1.2.2": + '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 optional: true - "@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4": + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true - "@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4": + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': optional: true - "@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4": + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': optional: true - "@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4": + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': optional: true - "@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4": + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': optional: true - "@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4": + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': optional: true - "@napi-rs/wasm-runtime@1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)": + '@napi-rs/wasm-runtime@1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: - "@emnapi/core": 1.11.1 - "@emnapi/runtime": 1.11.1 - "@tybys/wasm-util": 0.10.3 + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 optional: true - "@oxc-project/types@0.139.0": {} + '@oxc-project/types@0.139.0': {} - "@rolldown/binding-android-arm64@1.1.5": + '@rolldown/binding-android-arm64@1.1.5': optional: true - "@rolldown/binding-darwin-arm64@1.1.5": + '@rolldown/binding-darwin-arm64@1.1.5': optional: true - "@rolldown/binding-darwin-x64@1.1.5": + '@rolldown/binding-darwin-x64@1.1.5': optional: true - "@rolldown/binding-freebsd-x64@1.1.5": + '@rolldown/binding-freebsd-x64@1.1.5': optional: true - "@rolldown/binding-linux-arm-gnueabihf@1.1.5": + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': optional: true - "@rolldown/binding-linux-arm64-gnu@1.1.5": + '@rolldown/binding-linux-arm64-gnu@1.1.5': optional: true - "@rolldown/binding-linux-arm64-musl@1.1.5": + '@rolldown/binding-linux-arm64-musl@1.1.5': optional: true - "@rolldown/binding-linux-ppc64-gnu@1.1.5": + '@rolldown/binding-linux-ppc64-gnu@1.1.5': optional: true - "@rolldown/binding-linux-s390x-gnu@1.1.5": + '@rolldown/binding-linux-s390x-gnu@1.1.5': optional: true - "@rolldown/binding-linux-x64-gnu@1.1.5": + '@rolldown/binding-linux-x64-gnu@1.1.5': optional: true - "@rolldown/binding-linux-x64-musl@1.1.5": + '@rolldown/binding-linux-x64-musl@1.1.5': optional: true - "@rolldown/binding-openharmony-arm64@1.1.5": + '@rolldown/binding-openharmony-arm64@1.1.5': optional: true - "@rolldown/binding-wasm32-wasi@1.1.5": + '@rolldown/binding-wasm32-wasi@1.1.5': dependencies: - "@emnapi/core": 1.11.1 - "@emnapi/runtime": 1.11.1 - "@napi-rs/wasm-runtime": 1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true - "@rolldown/binding-win32-arm64-msvc@1.1.5": + '@rolldown/binding-win32-arm64-msvc@1.1.5': optional: true - "@rolldown/binding-win32-x64-msvc@1.1.5": + '@rolldown/binding-win32-x64-msvc@1.1.5': optional: true - "@rolldown/pluginutils@1.0.1": {} + '@rolldown/pluginutils@1.0.1': {} - "@standard-schema/spec@1.1.0": {} + '@standard-schema/spec@1.1.0': {} - "@tybys/wasm-util@0.10.3": + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true - "@typeonce/effect-machine@file:../..(effect@4.0.0-beta.102)": + '@typeonce/effect-machine@file:../..(effect@4.0.0-beta.102)': dependencies: effect: 4.0.0-beta.102 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.1.5(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.5(yaml@2.9.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + assertion-error@2.0.1: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + detect-libc@2.1.2: {} effect@4.0.0-beta.102: dependencies: - "@standard-schema/spec": 1.1.0 + '@standard-schema/spec': 1.1.0 fast-check: 4.9.0 find-my-way-ts: 0.1.6 ini: 7.0.0 @@ -591,6 +726,14 @@ snapshots: uuid: 14.0.1 yaml: 2.9.0 + es-module-lexer@2.3.1: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + fast-check@4.9.0: dependencies: pure-rand: 8.4.2 @@ -657,16 +800,20 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + msgpackr-extract@3.0.4: dependencies: node-gyp-build-optional-packages: 5.2.2 optionalDependencies: - "@msgpackr-extract/msgpackr-extract-darwin-arm64": 3.0.4 - "@msgpackr-extract/msgpackr-extract-darwin-x64": 3.0.4 - "@msgpackr-extract/msgpackr-extract-linux-arm": 3.0.4 - "@msgpackr-extract/msgpackr-extract-linux-arm64": 3.0.4 - "@msgpackr-extract/msgpackr-extract-linux-x64": 3.0.4 - "@msgpackr-extract/msgpackr-extract-win32-x64": 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 optional: true msgpackr@2.0.5: @@ -682,6 +829,10 @@ snapshots: detect-libc: 2.1.2 optional: true + obug@2.1.4: {} + + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.5: {} @@ -696,32 +847,44 @@ snapshots: rolldown@1.1.5: dependencies: - "@oxc-project/types": 0.139.0 - "@rolldown/pluginutils": 1.0.1 + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 optionalDependencies: - "@rolldown/binding-android-arm64": 1.1.5 - "@rolldown/binding-darwin-arm64": 1.1.5 - "@rolldown/binding-darwin-x64": 1.1.5 - "@rolldown/binding-freebsd-x64": 1.1.5 - "@rolldown/binding-linux-arm-gnueabihf": 1.1.5 - "@rolldown/binding-linux-arm64-gnu": 1.1.5 - "@rolldown/binding-linux-arm64-musl": 1.1.5 - "@rolldown/binding-linux-ppc64-gnu": 1.1.5 - "@rolldown/binding-linux-s390x-gnu": 1.1.5 - "@rolldown/binding-linux-x64-gnu": 1.1.5 - "@rolldown/binding-linux-x64-musl": 1.1.5 - "@rolldown/binding-openharmony-arm64": 1.1.5 - "@rolldown/binding-wasm32-wasi": 1.1.5 - "@rolldown/binding-win32-arm64-msvc": 1.1.5 - "@rolldown/binding-win32-x64-msvc": 1.1.5 + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + siginfo@2.0.0: {} source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinyrainbow@3.1.1: {} + toml@4.3.0: {} tslib@2.8.1: @@ -742,4 +905,34 @@ snapshots: fsevents: 2.3.3 yaml: 2.9.0 + vitest@4.1.10(vite@8.1.5(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.5(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.1.5(yaml@2.9.0) + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + yaml@2.9.0: {} diff --git a/examples/platformer/src/game.ts b/examples/platformer/src/game.ts index 8ad601f..b6544db 100644 --- a/examples/platformer/src/game.ts +++ b/examples/platformer/src/game.ts @@ -1,5 +1,5 @@ import type { Axis, CharacterEvent, CharacterSnapshot, LocomotionMode } from "./machine.ts" -import { airJumpMode, facingDirection, locomotionState } from "./machine.ts" +import { airJumpMode, facingDirection, isPaused, locomotionState } from "./machine.ts" const FLOOR = 324 const SIZE = 30 @@ -11,7 +11,8 @@ const pose = { Falling: "rotate(12 15 15)", Ducking: "translate(0 15) scale(1 .5)", Diving: "rotate(90 15 15) scale(.8 1.15)", - Landing: "translate(-3 12) scale(1.2 .6)" + Landing: "translate(-3 12) scale(1.2 .6)", + Paused: "translate(0 0)" } as const satisfies Record export class GameAdapter { @@ -43,6 +44,13 @@ export class GameAdapter { if (this.snapshot === undefined) return const dt = Math.min(seconds, 1 / 30) const state = locomotionState(this.snapshot) + + if (state._tag === "Paused") { + this.player.dataset.paused = "true" + return + } + + delete this.player.dataset.paused const mode = state._tag const p = this.position @@ -121,6 +129,15 @@ export class GameAdapter { private readonly onKeyDown = (event: KeyboardEvent) => { if (!this.isGameKey(event.code)) return event.preventDefault() + + if (event.code === "KeyP") { + if (event.repeat || this.snapshot === undefined) return + this.send(isPaused(this.snapshot) ? { _tag: "Resume" } : { _tag: "Pause", at: performance.now() }) + return + } + + if (this.snapshot !== undefined && isPaused(this.snapshot) && event.code !== "KeyR") return + const previousAxis = this.axis this.held.add(event.code) if (previousAxis !== this.axis) this.send({ _tag: "Move", axis: this.axis, at: performance.now() }) @@ -139,6 +156,7 @@ export class GameAdapter { private readonly onKeyUp = (event: KeyboardEvent) => { const previousAxis = this.axis this.held.delete(event.code) + if (this.snapshot !== undefined && isPaused(this.snapshot)) return if (previousAxis !== this.axis) this.send({ _tag: "Move", axis: this.axis, at: performance.now() }) if (event.code === "KeyS" || event.code === "ArrowDown") { this.send({ _tag: "DownReleased", axis: this.axis, at: performance.now() }) @@ -151,6 +169,7 @@ export class GameAdapter { "KeyD", "KeyW", "KeyS", + "KeyP", "KeyR", "Space", "ArrowLeft", diff --git a/examples/platformer/src/machine.test.ts b/examples/platformer/src/machine.test.ts new file mode 100644 index 0000000..aba69ca --- /dev/null +++ b/examples/platformer/src/machine.test.ts @@ -0,0 +1,89 @@ +import { Machine } from "@typeonce/effect-machine" +import { Effect } from "effect" +import { describe, expect, it } from "vitest" +import { CharacterMachine, type CharacterSnapshot, Event } from "./machine.ts" + +const playingSnapshot = (snapshot: CharacterSnapshot) => { + const locomotion = snapshot.states.locomotion.state + if (locomotion.path !== "Character.locomotion.Playing") { + throw new Error(`Expected Playing, received ${locomotion.path}`) + } + return locomotion +} + +const runPlan = (effect: Effect.Effect) => + Effect.runPromise(effect as unknown as Effect.Effect) + +describe("platformer history integration", () => { + it("resumes the exact grounded leaf and its state-local value", async () => { + await runPlan(Effect.gen(function*() { + const initial = yield* Machine.planInitial(CharacterMachine) + const ducking = yield* Machine.plan( + CharacterMachine, + initial.state, + Event.cases.DownPressed.make({ at: 10 }) + ) + const beforePause = playingSnapshot(ducking.next) + + const paused = yield* Machine.plan( + CharacterMachine, + ducking.next, + Event.cases.Pause.make({ at: 20 }) + ) + const pausedLocomotion = paused.next.states.locomotion.state + expect(pausedLocomotion.path).toBe("Character.locomotion.Paused") + expect(pausedLocomotion.value).toEqual({ _tag: "Paused", pausedAt: 20 }) + + const resumed = yield* Machine.plan(CharacterMachine, paused.next, Event.cases.Resume.make({})) + expect(playingSnapshot(resumed.next)).toEqual(beforePause) + expect(resumed.next.states.facing.state.path).toBe("Character.facing.Right") + expect(resumed.next.states.contact.state.path).toBe("Character.contact.NoWall") + })) + }) + + it("resumes both airborne parallel regions and all nested values", async () => { + await runPlan(Effect.gen(function*() { + const initial = yield* Machine.planInitial(CharacterMachine) + const airborne = yield* Machine.plan( + CharacterMachine, + initial.state, + Event.cases.JumpPressed.make({ at: 100, y: 207, wall: -1 }) + ) + const falling = yield* Machine.plan( + CharacterMachine, + airborne.next, + Event.cases.ApexReached.make({ y: 91 }) + ) + const beforePause = playingSnapshot(falling.next) + + const paused = yield* Machine.plan( + CharacterMachine, + falling.next, + Event.cases.Pause.make({ at: 180 }) + ) + expect(paused.next.history?.["Character.locomotion.Playing.resume"]?.active).toEqual([ + "Character", + "Character.locomotion", + "Character.locomotion.Playing", + "Character.locomotion.Playing.Airborne", + "Character.locomotion.Playing.Airborne.motion", + "Character.locomotion.Playing.Airborne.motion.Falling", + "Character.locomotion.Playing.Airborne.airJump", + "Character.locomotion.Playing.Airborne.airJump.AirJumpGroundLock" + ]) + + const resumed = yield* Machine.plan(CharacterMachine, paused.next, Event.cases.Resume.make({})) + const restored = playingSnapshot(resumed.next) + expect(restored).toEqual(beforePause) + + if (restored.state.path !== "Character.locomotion.Playing.Airborne") { + throw new Error(`Expected Airborne, received ${restored.state.path}`) + } + expect(restored.state.value).toEqual({ _tag: "Airborne", originY: 207 }) + expect(restored.state.states.motion.state.value).toEqual({ _tag: "Falling", apexY: 91 }) + expect(restored.state.states.airJump.state.path).toBe( + "Character.locomotion.Playing.Airborne.airJump.AirJumpGroundLock" + ) + })) + }) +}) diff --git a/examples/platformer/src/machine.ts b/examples/platformer/src/machine.ts index 95299d2..4c53eda 100644 --- a/examples/platformer/src/machine.ts +++ b/examples/platformer/src/machine.ts @@ -9,6 +9,8 @@ const JumpKind = Schema.Literals(["Ground", "Double", "Wall"]) const State = Schema.TaggedUnion({ Character: {}, Locomotion: {}, + Playing: {}, + Paused: { pausedAt: Schema.Number }, Grounded: {}, Standing: {}, Running: { startedAt: Schema.Number }, @@ -42,6 +44,8 @@ export const Event = Schema.TaggedUnion({ DownReleased: { axis: Axis, at: Schema.Number }, ApexReached: { y: Schema.Number }, Landed: { impact: Schema.Number, axis: Axis, at: Schema.Number }, + Pause: { at: Schema.Number }, + Resume: {}, Reset: {} }) @@ -62,43 +66,54 @@ export const CharacterStates = Machine.defineStates({ states: { locomotion: { schema: State.cases.Locomotion, - initial: "Grounded", + initial: "Playing", states: { - Grounded: { - schema: State.cases.Grounded, - initial: "Standing", + Playing: { + schema: State.cases.Playing, + initial: "Grounded", states: { - Standing: State.cases.Standing, - Running: State.cases.Running, - Ducking: State.cases.Ducking, - Landing: State.cases.Landing - } - }, - Airborne: { - schema: State.cases.Airborne, - type: "parallel", - states: { - motion: { - schema: State.cases.Motion, - initial: "Jumping", + Grounded: { + schema: State.cases.Grounded, + initial: "Standing", states: { - Jumping: State.cases.Jumping, - Falling: State.cases.Falling, - Diving: State.cases.Diving + Standing: State.cases.Standing, + Running: State.cases.Running, + Ducking: State.cases.Ducking, + Landing: State.cases.Landing } }, - airJump: { - schema: State.cases.AirJump, - initial: "AirJumpGroundLock", + Airborne: { + schema: State.cases.Airborne, + type: "parallel", states: { - AirJumpGroundLock: State.cases.AirJumpGroundLock, - AirJumpWallLock: State.cases.AirJumpWallLock, - AirJumpReady: State.cases.AirJumpReady, - AirJumpSpent: State.cases.AirJumpSpent + motion: { + schema: State.cases.Motion, + initial: "Jumping", + states: { + Jumping: State.cases.Jumping, + Falling: State.cases.Falling, + Diving: State.cases.Diving + } + }, + airJump: { + schema: State.cases.AirJump, + initial: "AirJumpGroundLock", + states: { + AirJumpGroundLock: State.cases.AirJumpGroundLock, + AirJumpWallLock: State.cases.AirJumpWallLock, + AirJumpReady: State.cases.AirJumpReady, + AirJumpSpent: State.cases.AirJumpSpent + } + } } + }, + resume: { + type: "history", + history: "deep" } } - } + }, + Paused: State.cases.Paused } }, facing: { @@ -122,12 +137,29 @@ export const CharacterStates = Machine.defineStates({ } }) +const initialPlaying = (): Machine.Machine.SnapshotByIdentifier< + typeof CharacterStates.states, + "Character.locomotion.Playing" +> => ({ + path: "Character.locomotion.Playing", + value: State.cases.Playing.make({}), + state: { + path: "Character.locomotion.Playing.Grounded", + value: State.cases.Grounded.make({}), + state: { + path: "Character.locomotion.Playing.Grounded.Standing", + value: State.cases.Standing.make({}) + } + } +}) + const initialCharacter = () => CharacterStates.initial.Character(State.cases.Character.make({}), (character) => character .locomotion(State.cases.Locomotion.make({}), (locomotion) => - locomotion.Grounded(State.cases.Grounded.make({}), (grounded) => - grounded.Standing(State.cases.Standing.make({})))) + locomotion.Playing(State.cases.Playing.make({}), (playing) => + playing.Grounded(State.cases.Grounded.make({}), (grounded) => + grounded.Standing(State.cases.Standing.make({}))))) .facing(State.cases.Facing.make({}), (facing) => facing.Right(State.cases.Right.make({}))) .contact(State.cases.WallContact.make({}), (contact) => @@ -147,162 +179,181 @@ export const CharacterMachine = Machine.make({ states: { locomotion: { states: { - Grounded: { - on: { - JumpPressed: ({ event, target }) => - target.full.Character(State.cases.Character.make({}), (character) => - character - .locomotion(State.cases.Locomotion.make({}), (locomotion) => - locomotion.Airborne(State.cases.Airborne.make({ originY: event.y }), (airborne) => - airborne - .motion(State.cases.Motion.make({}), (motion) => - motion.Jumping( - State.cases.Jumping.make({ startedAt: event.at, push: 0, kind: "Ground" }) - )) - .airJump(State.cases.AirJump.make({}), (airJump) => - airJump.AirJumpGroundLock(State.cases.AirJumpGroundLock.make({}))))) - .facing(State.cases.Facing.make({}), (facing) => - facing.Right(State.cases.Right.make({}))) - .contact(State.cases.WallContact.make({}), (contact) => - event.wall === -1 - ? contact.LeftWall(State.cases.LeftWall.make({})) - : event.wall === 1 - ? contact.RightWall(State.cases.RightWall.make({})) - : contact.NoWall(State.cases.NoWall.make({})))) - }, - states: { - Standing: { - on: { - Move: ({ event, target }) => - event.axis === 0 - ? undefined - : target.local.Running(State.cases.Running.make({ startedAt: event.at })), - DownPressed: ({ event, target }) => - target.local.Ducking(State.cases.Ducking.make({ startedAt: event.at })) - } - }, - Running: { - on: { - Move: ({ event, target }) => - event.axis === 0 ? target.local.Standing(State.cases.Standing.make({})) : undefined, - DownPressed: ({ event, target }) => - target.local.Ducking(State.cases.Ducking.make({ startedAt: event.at })) - } - }, - Ducking: { - on: { - DownReleased: ({ event, target }) => - event.axis === 0 - ? target.local.Standing(State.cases.Standing.make({})) - : target.local.Running(State.cases.Running.make({ startedAt: event.at })) - } - }, - Landing: { - invoke: Machine.after("140 millis", InternalEvent.cases.LandingSettled.make({}), { - id: "landing-settle" - }), - on: { - Move: ({ event, state, target }) => - target.local.Landing(Machine.retag(State.cases.Landing, state, { resumeAxis: event.axis })), - LandingSettled: ({ state, target }) => - state.resumeAxis === 0 - ? target.local.Standing(State.cases.Standing.make({})) - : target.local.Running(State.cases.Running.make({ startedAt: state.landedAt + 140 })) - } + Playing: { + history: { + resume: { + default: initialPlaying } - } - }, - Airborne: { + }, on: { - JumpPressed: Effect.fn(function*({ event, runtime }) { - const machine = yield* runtime - const push = awayFrom(event.wall) - yield* machine.raise( - push === 0 - ? InternalEvent.cases.TryAirJump.make({ at: event.at }) - : InternalEvent.cases.WallJump.make({ at: event.at, push }) - ) - }), - Landed: ({ event, target }) => - target.branch.Character.locomotion.Grounded( - State.cases.Grounded.make({}), - (grounded) => - grounded.Landing( - State.cases.Landing.make({ - impact: event.impact, - resumeAxis: event.axis, - landedAt: event.at - }) - ) - ) + Pause: ({ event, target }) => + target.branch.Character.locomotion.Paused(State.cases.Paused.make({ pausedAt: event.at })) }, states: { - motion: { + Grounded: { on: { - DoubleJump: ({ event, target }) => - target.local.Jumping( - State.cases.Jumping.make({ startedAt: event.at, push: 0, kind: "Double" }) - ), - WallJump: ({ event, target }) => - target.local.Jumping( - State.cases.Jumping.make({ startedAt: event.at, push: event.push, kind: "Wall" }) - ) + JumpPressed: ({ event, target }) => + target.full.Character(State.cases.Character.make({}), (character) => + character + .locomotion(State.cases.Locomotion.make({}), (locomotion) => + locomotion.Playing(State.cases.Playing.make({}), (playing) => + playing.Airborne(State.cases.Airborne.make({ originY: event.y }), (airborne) => + airborne + .motion(State.cases.Motion.make({}), (motion) => + motion.Jumping( + State.cases.Jumping.make({ startedAt: event.at, push: 0, kind: "Ground" }) + )) + .airJump(State.cases.AirJump.make({}), (airJump) => + airJump.AirJumpGroundLock(State.cases.AirJumpGroundLock.make({})))))) + .facing(State.cases.Facing.make({}), (facing) => + facing.Right(State.cases.Right.make({}))) + .contact(State.cases.WallContact.make({}), (contact) => + event.wall === -1 + ? contact.LeftWall(State.cases.LeftWall.make({})) + : event.wall === 1 + ? contact.RightWall(State.cases.RightWall.make({})) + : contact.NoWall(State.cases.NoWall.make({})))) }, states: { - Jumping: { + Standing: { on: { - ApexReached: ({ event, target }) => - target.local.Falling(State.cases.Falling.make({ apexY: event.y })), + Move: ({ event, target }) => + event.axis === 0 + ? undefined + : target.local.Running(State.cases.Running.make({ startedAt: event.at })), DownPressed: ({ event, target }) => - target.local.Diving(State.cases.Diving.make({ startedAt: event.at })) + target.local.Ducking(State.cases.Ducking.make({ startedAt: event.at })) } }, - Falling: { + Running: { on: { + Move: ({ event, target }) => + event.axis === 0 ? target.local.Standing(State.cases.Standing.make({})) : undefined, DownPressed: ({ event, target }) => - target.local.Diving(State.cases.Diving.make({ startedAt: event.at })) + target.local.Ducking(State.cases.Ducking.make({ startedAt: event.at })) + } + }, + Ducking: { + on: { + DownReleased: ({ event, target }) => + event.axis === 0 + ? target.local.Standing(State.cases.Standing.make({})) + : target.local.Running(State.cases.Running.make({ startedAt: event.at })) } }, - Diving: {} + Landing: { + invoke: Machine.after("140 millis", InternalEvent.cases.LandingSettled.make({}), { + id: "landing-settle" + }), + on: { + Move: ({ event, state, target }) => + target.local.Landing(Machine.retag(State.cases.Landing, state, { resumeAxis: event.axis })), + LandingSettled: ({ state, target }) => + state.resumeAxis === 0 + ? target.local.Standing(State.cases.Standing.make({})) + : target.local.Running(State.cases.Running.make({ startedAt: state.landedAt + 140 })) + } + } } }, - airJump: { + Airborne: { on: { - WallJump: { - reenter: true, - transition: ({ target }) => target.local.AirJumpWallLock(State.cases.AirJumpWallLock.make({})) - } + JumpPressed: Effect.fn(function*({ event, runtime }) { + const machine = yield* runtime + const push = awayFrom(event.wall) + yield* machine.raise( + push === 0 + ? InternalEvent.cases.TryAirJump.make({ at: event.at }) + : InternalEvent.cases.WallJump.make({ at: event.at, push }) + ) + }), + Landed: ({ event, target }) => + target.branch.Character.locomotion.Playing.Grounded( + State.cases.Grounded.make({}), + (grounded) => + grounded.Landing( + State.cases.Landing.make({ + impact: event.impact, + resumeAxis: event.axis, + landedAt: event.at + }) + ) + ) }, states: { - AirJumpGroundLock: { - invoke: Machine.after("120 millis", InternalEvent.cases.AirJumpUnlocked.make({}), { - id: "ground-air-jump-unlock" - }), - on: { - AirJumpUnlocked: ({ target }) => target.local.AirJumpReady(State.cases.AirJumpReady.make({})) - } - }, - AirJumpWallLock: { - invoke: Machine.after("240 millis", InternalEvent.cases.AirJumpUnlocked.make({}), { - id: "wall-air-jump-unlock" - }), + motion: { on: { - AirJumpUnlocked: ({ target }) => target.local.AirJumpReady(State.cases.AirJumpReady.make({})) + DoubleJump: ({ event, target }) => + target.local.Jumping( + State.cases.Jumping.make({ startedAt: event.at, push: 0, kind: "Double" }) + ), + WallJump: ({ event, target }) => + target.local.Jumping( + State.cases.Jumping.make({ startedAt: event.at, push: event.push, kind: "Wall" }) + ) + }, + states: { + Jumping: { + on: { + ApexReached: ({ event, target }) => + target.local.Falling(State.cases.Falling.make({ apexY: event.y })), + DownPressed: ({ event, target }) => + target.local.Diving(State.cases.Diving.make({ startedAt: event.at })) + } + }, + Falling: { + on: { + DownPressed: ({ event, target }) => + target.local.Diving(State.cases.Diving.make({ startedAt: event.at })) + } + }, + Diving: {} } }, - AirJumpReady: { + airJump: { on: { - TryAirJump: Effect.fn(function*({ event, runtime, target }) { - const machine = yield* runtime - yield* machine.raise(InternalEvent.cases.DoubleJump.make({ at: event.at })) - return target.local.AirJumpSpent(State.cases.AirJumpSpent.make({})) - }) + WallJump: { + reenter: true, + transition: ({ target }) => target.local.AirJumpWallLock(State.cases.AirJumpWallLock.make({})) + } + }, + states: { + AirJumpGroundLock: { + invoke: Machine.after("120 millis", InternalEvent.cases.AirJumpUnlocked.make({}), { + id: "ground-air-jump-unlock" + }), + on: { + AirJumpUnlocked: ({ target }) => target.local.AirJumpReady(State.cases.AirJumpReady.make({})) + } + }, + AirJumpWallLock: { + invoke: Machine.after("240 millis", InternalEvent.cases.AirJumpUnlocked.make({}), { + id: "wall-air-jump-unlock" + }), + on: { + AirJumpUnlocked: ({ target }) => target.local.AirJumpReady(State.cases.AirJumpReady.make({})) + } + }, + AirJumpReady: { + on: { + TryAirJump: Effect.fn(function*({ event, runtime, target }) { + const machine = yield* runtime + yield* machine.raise(InternalEvent.cases.DoubleJump.make({ at: event.at })) + return target.local.AirJumpSpent(State.cases.AirJumpSpent.make({})) + }) + } + }, + AirJumpSpent: {} } - }, - AirJumpSpent: {} + } } } } + }, + Paused: { + on: { + Resume: ({ target }) => target.history.Character.locomotion.Playing.resume() + } } } }, @@ -347,23 +398,35 @@ export const CharacterMachine = Machine.make({ export type CharacterSnapshot = Machine.Machine.Snapshot export type CharacterEvent = Machine.Machine.InputEvent +export const isPaused = (snapshot: CharacterSnapshot) => + snapshot.states.locomotion.state.path === "Character.locomotion.Paused" + export const locomotionState = (snapshot: CharacterSnapshot) => { const locomotion = snapshot.states.locomotion.state - return locomotion.path === "Character.locomotion.Grounded" - ? locomotion.state.value - : locomotion.states.motion.state.value + if (locomotion.path === "Character.locomotion.Paused") { + return locomotion.value + } + + const playing = locomotion.state + return playing.path === "Character.locomotion.Playing.Grounded" + ? playing.state.value + : playing.states.motion.state.value } export type LocomotionMode = ReturnType["_tag"] export const locomotionMode = (snapshot: CharacterSnapshot): LocomotionMode => locomotionState(snapshot)._tag -export const locomotionBranch = (snapshot: CharacterSnapshot) => snapshot.states.locomotion.state.value._tag +export const locomotionBranch = (snapshot: CharacterSnapshot) => { + const locomotion = snapshot.states.locomotion.state + return locomotion.path === "Character.locomotion.Paused" ? locomotion.value._tag : locomotion.state.value._tag +} export const airJumpMode = (snapshot: CharacterSnapshot) => { const locomotion = snapshot.states.locomotion.state - return locomotion.path === "Character.locomotion.Airborne" - ? locomotion.states.airJump.state.value._tag + return locomotion.path === "Character.locomotion.Playing" && + locomotion.state.path === "Character.locomotion.Playing.Airborne" + ? locomotion.state.states.airJump.state.value._tag : undefined } @@ -375,15 +438,21 @@ export const facingDirection = (snapshot: CharacterSnapshot) => snapshot.states. export const activeStateData = (snapshot: CharacterSnapshot) => { const locomotion = snapshot.states.locomotion.state - const { _tag: _branch, ...branchData } = locomotion.value - if (locomotion.path === "Character.locomotion.Grounded") { - const { _tag: _leaf, ...leafData } = locomotion.state.value + if (locomotion.path === "Character.locomotion.Paused") { + const { _tag: _paused, ...pausedData } = locomotion.value + return pausedData + } + + const playing = locomotion.state + const { _tag: _branch, ...branchData } = playing.value + if (playing.path === "Character.locomotion.Playing.Grounded") { + const { _tag: _leaf, ...leafData } = playing.state.value return { ...branchData, ...leafData } } - const { _tag: _motion, ...motionData } = locomotion.states.motion.state.value + const { _tag: _motion, ...motionData } = playing.states.motion.state.value return { ...branchData, ...motionData, - airJump: locomotion.states.airJump.state.value._tag + airJump: playing.states.airJump.state.value._tag } } diff --git a/examples/platformer/src/main.ts b/examples/platformer/src/main.ts index 9048fc0..0eeb008 100644 --- a/examples/platformer/src/main.ts +++ b/examples/platformer/src/main.ts @@ -9,6 +9,7 @@ import { CharacterMachine, type CharacterSnapshot, facingDirection, + isPaused, locomotionBranch, locomotionMode, wallContact @@ -23,6 +24,7 @@ const requiredElement = (selector: string) => { const modeLabel = requiredElement("#active-mode") const stateData = requiredElement("#state-data") const lastEvent = requiredElement("#last-event") +const gameView = requiredElement("#game") const showEvent = (event: CharacterEvent) => { const { _tag, ...payload } = event @@ -49,10 +51,13 @@ const publish = (next: CharacterSnapshot) => { const facing = facingDirection(next) const contact = wallContact(next) const airJump = airJumpMode(next) + const paused = isPaused(next) modeLabel.textContent = [mode, airJump, contact, facing].filter(Boolean).join(" · ") stateData.textContent = JSON.stringify(activeStateData(next)) + gameView.classList.toggle("is-paused", paused) const active = new Set([mode, facing, contact, locomotionBranch(next)]) + if (!paused) active.add("Playing") if (airJump !== undefined) active.add(airJump) document.querySelectorAll("[data-node]").forEach((node) => { node.classList.toggle("is-active", active.has(node.dataset.node ?? "")) diff --git a/examples/platformer/src/styles.css b/examples/platformer/src/styles.css index fd2e7f3..40bc109 100644 --- a/examples/platformer/src/styles.css +++ b/examples/platformer/src/styles.css @@ -146,6 +146,34 @@ h1 { fill: #c77dff; filter: drop-shadow(0 0 3px #c77dff); } +#player[data-paused="true"] { + opacity: 0.55; +} +.pause-overlay { + pointer-events: none; + opacity: 0; + transition: opacity 140ms ease; +} +.pause-overlay rect { + fill: rgb(10 16 33 / 72%); +} +.pause-overlay text { + text-anchor: middle; + font-family: ui-monospace, monospace; +} +.pause-title { + fill: #e9fff9; + font-size: 30px; + font-weight: 800; + letter-spacing: 0.12em; +} +.pause-copy { + fill: #55d6be; + font-size: 11px; +} +#game.is-paused .pause-overlay { + opacity: 1; +} .controls { display: flex; flex-wrap: wrap; @@ -234,6 +262,13 @@ kbd { .branch + .branch { margin-top: 7px; } +.play-state-row { + margin-bottom: 7px; +} +.playing-region { + padding-left: 7px; + border-left: 1px solid #304766; +} .branch h3 { display: inline-block; margin: 0 0 5px; @@ -248,7 +283,9 @@ kbd { display: inline-block; margin-left: 4px; color: #55d6be; - font: 700 8px ui-monospace, monospace; + font: + 700 8px ui-monospace, + monospace; letter-spacing: 0.06em; text-transform: uppercase; } @@ -264,7 +301,9 @@ kbd { display: block; margin-bottom: 4px; color: #637c98; - font: 700 8px ui-monospace, monospace; + font: + 700 8px ui-monospace, + monospace; letter-spacing: 0.08em; text-transform: uppercase; } @@ -284,6 +323,12 @@ kbd { monospace; transition: 120ms ease; } +.state-row .history-node { + border-style: dashed; + border-color: #8a6bc4; + color: #b89de8; + background: rgb(138 107 196 / 8%); +} [data-node].is-active { border-color: #55d6be !important; color: #dffff8 !important; diff --git a/src/Machine.ts b/src/Machine.ts index 99c0ee7..bb637a6 100644 --- a/src/Machine.ts +++ b/src/Machine.ts @@ -101,10 +101,9 @@ type IsAny = 0 extends (1 & A) ? true : false * * **Gotchas** * - * History states and declarative first-class guards are not part of the - * current API. Conditional behavior can be expressed in typed handlers with - * ordinary TypeScript control flow. Use `after` for cancellable state-scoped - * delayed events. + * Declarative first-class guards are not part of the current API. Conditional + * behavior can be expressed in typed handlers with ordinary TypeScript control + * flow. Use `after` for cancellable state-scoped delayed events. * * @category models * @since 4.0.0 @@ -433,14 +432,26 @@ type StateDefinitionError = { readonly "~effect/Machine/DefinitionError": Message } -type ValidateStateTree = { - readonly [Key in keyof States]: ValidateStateNode +type ActiveStateKey = Machine.ActiveStateKey + +type HistoryStateKey = Machine.HistoryStateKey + +type ValidateStateTree = { + readonly [Key in keyof States]: ValidateStateNode } -type ValidateStateNode = Node extends Machine.TaggedSchema ? unknown +type ValidateStateNode = Node extends Machine.HistoryStateNodeConfig ? + AllowHistory extends true ? ValidateHistoryStateNode + : StateDefinitionError<"History states must be declared below an active parent state"> + : Node extends Machine.TaggedSchema ? unknown : Node extends { readonly schema: Machine.TaggedSchema } ? ValidateStateNodeConfig : StateDefinitionError<"State nodes must be tagged schemas or state node configs"> +type ValidateHistoryStateNode = [ + Extract +] extends [never] ? unknown + : StateDefinitionError<"History states cannot declare schemas, children, initial states, or output"> + type ValidateStateNodeConfig = Node extends { readonly states: infer Children } ? ValidateStateNodeWithChildren : ValidateStateNodeWithoutChildren @@ -456,7 +467,7 @@ type ValidateStateNodeWithChildren< Node extends { readonly type: "final" } ? StateDefinitionError<"Final states cannot declare child states"> : Node extends { readonly type: "parallel" } ? "initial" extends keyof Node ? StateDefinitionError<"Parallel states cannot declare an initial child"> - : { readonly states: ValidateStateTree } & ValidateOutputSchema + : { readonly states: ValidateStateTree } & ValidateOutputSchema : "output" extends keyof Node ? StateDefinitionError<"Only final and parallel states can declare output"> : ValidateCompoundStateNode : StateDefinitionError<"Child states must be a state tree"> @@ -464,8 +475,8 @@ type ValidateStateNodeWithChildren< type ValidateCompoundStateNode< Node extends { readonly schema: Machine.TaggedSchema }, Children extends Machine.StateSchemas -> = Node extends { readonly initial: infer Initial } ? Initial extends Extract ? { - readonly states: ValidateStateTree +> = Node extends { readonly initial: infer Initial } ? Initial extends ActiveStateKey ? { + readonly states: ValidateStateTree } : StateDefinitionError<"Compound initial must be one of its direct child keys"> : StateDefinitionError<"Compound states must declare an initial child"> @@ -485,10 +496,11 @@ type DefineStateTreeInput = { } type DefineStateNodeInput = Node extends Machine.TaggedSchema ? Node + : Node extends Machine.HistoryStateNodeConfig ? Machine.HistoryStateNodeConfig : Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? Omit & { readonly states: DefineStateTreeInput } : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? Omit & { - readonly initial: Extract + readonly initial: ActiveStateKey readonly states: DefineStateTreeInput } : Node @@ -577,12 +589,12 @@ type InitialSnapshotBuilderWithPrefix< States extends Machine.StateSchemas, Prefix extends string = "" > = { - readonly [Key in Extract]: InitialSnapshotMethod + readonly [Key in ActiveStateKey]: InitialSnapshotMethod } type InitialSnapshotMethod< States extends Machine.StateSchemas, - StateId extends Extract, + StateId extends ActiveStateKey, Prefix extends string > = & (( @@ -595,7 +607,7 @@ type InitialSnapshotMethod< type InitialSnapshotArguments< States extends Machine.StateSchemas, - StateId extends Extract, + StateId extends ActiveStateKey, Prefix extends string, Path extends string = Machine.JoinPath > = States[StateId] extends infer Node ? @@ -606,7 +618,7 @@ type InitialSnapshotArguments< ) => SnapshotBuilderComplete> ] : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? - Node extends { readonly initial: infer Initial extends Extract } ? [ + Node extends { readonly initial: infer Initial extends ActiveStateKey } ? [ value: Machine.NodeSchema["Type"], state: ( builder: Pick, Initial> @@ -618,7 +630,7 @@ type InitialSnapshotArguments< type InitialSnapshotFromArguments< States extends Machine.StateSchemas, - StateId extends Extract, + StateId extends ActiveStateKey, Prefix extends string, Path extends string = Machine.JoinPath > = States[StateId] extends infer Node ? @@ -629,7 +641,7 @@ type InitialSnapshotFromArguments< ) => SnapshotBuilderComplete, boolean> ] : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? - Node extends { readonly initial: infer Initial extends Extract } ? [ + Node extends { readonly initial: infer Initial extends ActiveStateKey } ? [ input: Machine.NodeSchema["~type.make.in"], state: ( builder: Pick, Initial> @@ -641,7 +653,7 @@ type InitialSnapshotFromArguments< type InitialSnapshotResult< States extends Machine.StateSchemas, - StateId extends Extract, + StateId extends ActiveStateKey, Prefix extends string, Path extends string = Machine.JoinPath > = States[StateId] extends infer Node ? @@ -652,7 +664,7 @@ type InitialSnapshotResult< InitialSnapshotRegionsWithPrefix > : Node extends { readonly states: infer Children extends Machine.StateSchemas } ? - Node extends { readonly initial: infer Initial extends Extract } ? Machine.CompoundSnapshot< + Node extends { readonly initial: infer Initial extends ActiveStateKey } ? Machine.CompoundSnapshot< Path, Machine.NodeSchema["Type"], InitialSnapshotResult @@ -665,13 +677,13 @@ type InitialSnapshotRegionsWithPrefix< States extends Machine.StateSchemas, Prefix extends string > = { - readonly [Key in Extract]: InitialSnapshotResult + readonly [Key in ActiveStateKey]: InitialSnapshotResult } type InitialParallelBuilder< States extends Machine.StateSchemas, Prefix extends string, - Remaining extends Extract = Extract, + Remaining extends ActiveStateKey = ActiveStateKey, Regions = {}, Constructed extends boolean = false > = @@ -705,12 +717,12 @@ type FullSnapshotBuilderWithPrefix< States extends Machine.StateSchemas, Prefix extends string = "" > = { - readonly [Key in Extract]: FullSnapshotMethod + readonly [Key in ActiveStateKey]: FullSnapshotMethod } type FullSnapshotMethod< States extends Machine.StateSchemas, - StateId extends Extract, + StateId extends ActiveStateKey, Prefix extends string > = & (( @@ -723,7 +735,7 @@ type FullSnapshotMethod< type FullSnapshotArguments< States extends Machine.StateSchemas, - StateId extends Extract, + StateId extends ActiveStateKey, Prefix extends string, Path extends string = Machine.JoinPath > = States[StateId] extends infer Node ? @@ -744,7 +756,7 @@ type FullSnapshotArguments< type FullSnapshotFromArguments< States extends Machine.StateSchemas, - StateId extends Extract, + StateId extends ActiveStateKey, Prefix extends string, Path extends string = Machine.JoinPath > = States[StateId] extends infer Node ? @@ -765,7 +777,7 @@ type FullSnapshotFromArguments< type FullSnapshotResult< States extends Machine.StateSchemas, - StateId extends Extract, + StateId extends ActiveStateKey, Prefix extends string, Path extends string = Machine.JoinPath > = Machine.SnapshotByIdentifierWithPath @@ -773,7 +785,7 @@ type FullSnapshotResult< type FullParallelBuilder< States extends Machine.StateSchemas, Prefix extends string, - Remaining extends Extract = Extract, + Remaining extends ActiveStateKey = ActiveStateKey, Regions = {}, Constructed extends boolean = false > = @@ -834,7 +846,7 @@ type StateIdentifierFromPath< type LocalTargetResult< AllStates extends Machine.StateSchemas, States extends Machine.StateSchemas, - StateId extends Extract, + StateId extends ActiveStateKey, Prefix extends string, Path extends string = Machine.JoinPath > = States[StateId] extends { readonly states: infer Children extends Machine.StateSchemas } ? @@ -848,8 +860,8 @@ type LocalTargetResultWithPrefix< States extends Machine.StateSchemas, Prefix extends string > = { - readonly [Key in Extract]: LocalTargetResult -}[Extract] + readonly [Key in ActiveStateKey]: LocalTargetResult +}[ActiveStateKey] type LocalTargetBuilderWithPrefix< AllStates extends Machine.StateSchemas, @@ -857,13 +869,13 @@ type LocalTargetBuilderWithPrefix< Prefix extends string, Source extends Machine.StateIdentifier > = { - readonly [Key in Extract]: LocalTargetMethod + readonly [Key in ActiveStateKey]: LocalTargetMethod } type LocalTargetMethod< AllStates extends Machine.StateSchemas, States extends Machine.StateSchemas, - StateId extends Extract, + StateId extends ActiveStateKey, Prefix extends string, Source extends Machine.StateIdentifier, Path extends string = Machine.JoinPath @@ -957,7 +969,7 @@ type LocalTargetBuilderForScope< type BranchTargetResult< AllStates extends Machine.StateSchemas, States extends Machine.StateSchemas, - StateId extends Extract, + StateId extends ActiveStateKey, Prefix extends string, Path extends string = Machine.JoinPath > = States[StateId] extends { readonly states: infer Children extends Machine.StateSchemas } ? @@ -971,8 +983,8 @@ type BranchTargetResultWithPrefix< States extends Machine.StateSchemas, Prefix extends string > = { - readonly [Key in Extract]: BranchTargetResult -}[Extract] + readonly [Key in ActiveStateKey]: BranchTargetResult +}[ActiveStateKey] type BranchTargetBuilderWithPrefix< AllStates extends Machine.StateSchemas, @@ -980,13 +992,13 @@ type BranchTargetBuilderWithPrefix< Prefix extends string, Source extends Machine.StateIdentifier > = { - readonly [Key in Extract]: BranchTargetMethod + readonly [Key in ActiveStateKey]: BranchTargetMethod } type BranchTargetMethod< AllStates extends Machine.StateSchemas, States extends Machine.StateSchemas, - StateId extends Extract, + StateId extends ActiveStateKey, Prefix extends string, Source extends Machine.StateIdentifier, Path extends string = Machine.JoinPath @@ -1051,12 +1063,82 @@ type BranchTargetMethod< type BranchTargetBuilderForRoot< States extends Machine.StateSchemas, - Root extends Extract, + Root extends ActiveStateKey, Source extends Machine.StateIdentifier > = { readonly [Key in Root]: BranchTargetMethod } +type HistoryContainingKey = { + readonly [Key in Extract]: States[Key] extends Machine.HistoryStateNodeConfig ? Key + : States[Key] extends { readonly states: infer Children extends Machine.StateSchemas } ? + [Machine.HistoryIdentifier] extends [never] ? never : Key + : never +}[Extract] + +type HistoryTargetBuilderWithPrefix< + AllStates extends Machine.StateSchemas, + States extends Machine.StateSchemas, + Prefix extends string +> = { + readonly [Key in HistoryContainingKey]: States[Key] extends Machine.HistoryStateNodeConfig ? + () => Machine.HistoryTarget< + AllStates, + Extract, Machine.HistoryIdentifier> + > + : States[Key] extends { readonly states: infer Children extends Machine.StateSchemas } ? + HistoryTargetBuilderWithPrefix> + : never +} + +type HasDirectShallowHistory = { + readonly [Key in HistoryStateKey]: States[Key] extends { readonly history: "deep" } ? never : Key +}[HistoryStateKey] extends never ? false : true + +type InitializerClosureForNode< + AllStates extends Machine.StateSchemas, + Node, + Path extends string +> = Node extends { readonly type: "parallel"; readonly states: infer Children extends Machine.StateSchemas } ? + | Extract> + | InitializerClosuresForChildren + : Node extends { readonly states: infer Children extends Machine.StateSchemas; readonly initial: infer Initial } ? + | Extract> + | (Initial extends ActiveStateKey ? InitializerClosureForNode< + AllStates, + Children[Initial], + Machine.JoinPath + > + : never) + : never + +type InitializerClosuresForChildren< + AllStates extends Machine.StateSchemas, + States extends Machine.StateSchemas, + Prefix extends string +> = { + readonly [Key in ActiveStateKey]: InitializerClosureForNode< + AllStates, + States[Key], + Machine.JoinPath + > +}[ActiveStateKey] + +type RequiredHistoryInitializersWithPrefix< + AllStates extends Machine.StateSchemas, + States extends Machine.StateSchemas, + Prefix extends string +> = { + readonly [Key in ActiveStateKey]: States[Key] extends { + readonly states: infer Children extends Machine.StateSchemas + } ? + | (HasDirectShallowHistory extends true ? + InitializerClosuresForChildren> + : never) + | RequiredHistoryInitializersWithPrefix> + : never +}[ActiveStateKey] + type SpawnRequirements = Exclude< Requirements, Scope.Scope @@ -1752,13 +1834,32 @@ export declare namespace Machine { readonly states: StateTree } + /** + * Pseudo-state that restores the last active configuration of its parent. + * + * History nodes are transition targets only. They never become active and + * therefore do not declare a state value schema or lifecycle handlers. + * + * @category models + * @since 4.0.0 + */ + export interface HistoryStateNodeConfig { + readonly type: "history" + /** Defaults to shallow history. */ + readonly history?: "shallow" | "deep" + } + /** * Configuration accepted for an object state node. * * @category models * @since 4.0.0 */ - export type StateNodeConfig = AtomicStateNodeConfig | CompoundStateNodeConfig | ParallelStateNodeConfig + export type StateNodeConfig = + | AtomicStateNodeConfig + | CompoundStateNodeConfig + | ParallelStateNodeConfig + | HistoryStateNodeConfig /** * Object state tree keyed by state path. @@ -1876,9 +1977,10 @@ export declare namespace Machine { export interface StateNode { readonly path: string readonly key: string - readonly schema: TaggedSchema + readonly schema: TaggedSchema | undefined readonly output: Schema.Top | undefined - readonly type: "atomic" | "compound" | "parallel" | "final" + readonly type: "atomic" | "compound" | "parallel" | "final" | "history" + readonly history: "shallow" | "deep" | undefined readonly parent: string | undefined readonly children: ReadonlyArray readonly initial: string | undefined @@ -1950,13 +2052,75 @@ export declare namespace Machine { States extends StateSchemas, Prefix extends string = "" > = { - readonly [Key in Extract]: States[Key] extends { readonly states: infer Children } - ? Children extends StateSchemas ? - JoinPath | StateIdentifierWithPrefix> - : JoinPath + readonly [Key in Extract]: States[Key] extends HistoryStateNodeConfig ? never + : States[Key] extends { readonly states: infer Children } + ? Children extends StateSchemas ? + JoinPath | StateIdentifierWithPrefix> + : JoinPath : JoinPath }[Extract] + /** + * Extracts the transition-only history pseudo-state paths in a definition. + * + * @category utility types + * @since 4.0.0 + */ + export type HistoryIdentifier = HistoryIdentifierWithPrefix + + /** @internal */ + export type HistoryIdentifierWithPrefix< + States extends StateSchemas, + Prefix extends string = "" + > = { + readonly [Key in Extract]: States[Key] extends HistoryStateNodeConfig ? JoinPath + : States[Key] extends { readonly states: infer Children extends StateSchemas } ? + HistoryIdentifierWithPrefix> + : never + }[Extract] + + /** Active keys directly declared in a state tree. */ + export type ActiveStateKey = { + readonly [Key in Extract]: States[Key] extends HistoryStateNodeConfig ? never : Key + }[Extract] + + /** History pseudo-state keys directly declared in a state tree. */ + export type HistoryStateKey = { + readonly [Key in Extract]: States[Key] extends HistoryStateNodeConfig ? Key : never + }[Extract] + + /** + * Active states that must implement implicit initial-value construction for + * shallow history restoration. + * + * @category utility types + * @since 4.0.0 + */ + export type RequiredHistoryInitializers = [HistoryIdentifier] extends [never] + ? never + : Extract, StateIdentifier> + + /** Active parent states that own one or more history pseudo-states. */ + export type HistoryParentIdentifier = HistoryIdentifier extends infer HistoryId + ? HistoryId extends string ? Extract, StateIdentifier> : never + : never + + /** History defaults and implicit initializers that remain unimplemented. */ + export type MissingHistoryImplementations< + States extends StateSchemas, + UnhandledStates extends StateIdentifier + > = Extract | RequiredHistoryInitializers> + + /** @internal Readiness proof required by planning and managed execution. */ + export type EnsureHistoryImplementations< + States extends StateSchemas, + UnhandledStates extends StateIdentifier + > = [HistoryIdentifier] extends [never] ? unknown + : [MissingHistoryImplementations] extends [never] ? unknown : + { + readonly "~effect/Machine/MissingHistoryImplementation": MissingHistoryImplementations + } + /** * Extracts a state-tree node by state path. * @@ -2151,11 +2315,11 @@ export declare namespace Machine { Children extends StateSchemas, Prefix extends StateIdentifier > = { - readonly [Key in Extract]: DirectFinalCompletionOutput< + readonly [Key in ActiveStateKey]: DirectFinalCompletionOutput< States, Extract, StateIdentifier> > - }[Extract] + }[ActiveStateKey] /** * Extracts the output passed when a state node completes. @@ -2298,6 +2462,13 @@ export declare namespace Machine { readonly output?: unknown } + /** Encoded values and paths retained by one history pseudo-state. */ + export interface EncodedSnapshotHistoryEntry { + readonly mode: "shallow" | "deep" + readonly active: ReadonlyArray + readonly values: Readonly> + } + /** * Normalized data representation of a machine snapshot. * @@ -2314,6 +2485,7 @@ export declare namespace Machine { readonly _tag: "MachineSnapshot" readonly active: ReadonlyArray readonly completed?: ReadonlyArray + readonly history?: Readonly> } /** @@ -2327,6 +2499,13 @@ export declare namespace Machine { readonly output: unknown } + /** Decoded values and paths retained by one history pseudo-state. */ + export interface SnapshotHistoryEntry { + readonly mode: "shallow" | "deep" + readonly active: ReadonlyArray + readonly values: Readonly> + } + /** * Carries lifecycle metadata required to resume planning from a cloned * snapshot. @@ -2345,6 +2524,7 @@ export declare namespace Machine { */ export interface SnapshotMetadata { readonly completed?: ReadonlyArray + readonly history?: Readonly> } /** @@ -2422,8 +2602,8 @@ export declare namespace Machine { States extends StateSchemas, Prefix extends string > = { - readonly [Key in Extract]: SnapshotByIdentifierWithPath> - }[Extract] + readonly [Key in ActiveStateKey]: SnapshotByIdentifierWithPath> + }[ActiveStateKey] /** * Extracts child snapshots under a parallel parent path prefix, keyed by @@ -2436,7 +2616,7 @@ export declare namespace Machine { States extends StateSchemas, Prefix extends string > = { - readonly [Key in Extract]: SnapshotByIdentifierWithPath> + readonly [Key in ActiveStateKey]: SnapshotByIdentifierWithPath> } /** @@ -2447,7 +2627,7 @@ export declare namespace Machine { */ export type SnapshotByIdentifierWithPath< States extends StateSchemas, - StateId extends Extract, + StateId extends ActiveStateKey, Path extends string > = States[StateId] extends { readonly type: "parallel"; readonly states: infer Children } ? Children extends StateSchemas ? ParallelSnapshot< @@ -2472,8 +2652,8 @@ export declare namespace Machine { * @since 4.0.0 */ export type Snapshot = { - readonly [StateId in Extract]: SnapshotByIdentifier> - }[Extract] + readonly [StateId in ActiveStateKey]: SnapshotByIdentifier> + }[ActiveStateKey] /** * Extracts the root state identifier from a state path. @@ -2560,6 +2740,28 @@ export declare namespace Machine { > } + /** + * Transition instruction that restores a history pseudo-state's parent. + * + * Unlike ordinary targets, history targets carry no state value. The + * planner resolves the remembered concrete configuration, or evaluates the + * history node's typed default when no record exists. + * + * @category models + * @since 4.0.0 + */ + export interface HistoryTarget< + States extends StateSchemas, + HistoryId extends HistoryIdentifier + > { + readonly [Model.HistoryTargetTypeId]: typeof Model.HistoryTargetTypeId + readonly path: HistoryId + readonly parent: Extract, StateIdentifier> + } + + /** Builder containing only history pseudo-state paths. */ + export type HistoryTargetBuilder = HistoryTargetBuilderWithPrefix + /** * Builder for complete transition snapshots. * @@ -2608,7 +2810,7 @@ export declare namespace Machine { Source extends StateIdentifier > = BranchTargetBuilderForRoot< States, - Extract, Extract>, + Extract, ActiveStateKey>, Source > @@ -2665,6 +2867,9 @@ export declare namespace Machine { * @since 4.0.0 */ readonly full: FullTargetBuilder + + /** Restores a declared shallow or deep history pseudo-state. */ + readonly history: HistoryTargetBuilder } /** @@ -2875,7 +3080,7 @@ export declare namespace Machine { StateId extends StateIdentifier > = NodeByIdentifier extends { readonly type: "parallel"; readonly states: infer Children extends StateSchemas } ? { - readonly [Key in Extract]: CompletionOutputByIdentifier< + readonly [Key in ActiveStateKey]: CompletionOutputByIdentifier< States, Extract, StateIdentifier> > @@ -2934,12 +3139,18 @@ export declare namespace Machine { export type HandlerResult = | Snapshot | Target> - | StateConstruction | Target>> + | HistoryTarget> + | StateConstruction< + Snapshot | Target> | HistoryTarget> + > | void | Effect.Effect< | Snapshot | Target> - | StateConstruction | Target>> + | HistoryTarget> + | StateConstruction< + Snapshot | Target> | HistoryTarget> + > | void, E, R @@ -2982,6 +3193,18 @@ export declare namespace Machine { export type StateActionReturn = Key extends keyof Config ? NonNullable extends (...args: any) => infer Ret ? Ret : never : never + /** Extracts the return value from an implicit initial child implementation. */ + export type StateInitialReturn = Config extends { readonly initial?: infer Initial } + ? NonNullable extends (...args: any) => infer Ret ? Ret : never + : never + /** Extracts the return values from a state's history defaults. */ + export type HistoryDefaultReturn = Config extends { readonly history?: infer History } ? { + readonly [Key in keyof NonNullable]: NonNullable[Key] extends { + readonly default: (...args: any) => infer Ret + } ? Ret : + never + }[keyof NonNullable] + : never /** * Extracts the return value from an event transition config. * @@ -3143,6 +3366,8 @@ export declare namespace Machine { | Effect.Services> | Effect.Services> | Effect.Services> + | Effect.Services> + | Effect.Services> | InvokeRequirements /** @@ -3301,8 +3526,81 @@ export declare namespace Machine { ) => HandlerResult } } + readonly initial?: StateInitialHandler } & ActiveOutputHandlerConfig + /** Values supplied when a statechart implicitly enters a state's initial children. */ + export type StateInitialValue< + States extends StateSchemas, + StateId extends StateIdentifier + > = NodeByIdentifier extends infer Node ? + Node extends { readonly type: "parallel"; readonly states: infer Children extends StateSchemas } ? { + readonly [Key in ActiveStateKey]: NodeSchema["Type"] + } + : Node extends { readonly states: infer Children extends StateSchemas; readonly initial: infer Initial } ? + Initial extends ActiveStateKey ? NodeSchema["Type"] : never + : never + : never + + /** Context passed to an implicit child-state initializer. */ + export type StateInitialContext< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateIdentifier + > = StateActionContext + + /** Initial child value implementation for a compound or parallel state. */ + export type StateInitialHandler< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + StateId extends StateIdentifier + > = (context: StateInitialContext) => + | StateInitialValue + | Effect.Effect, any, any> + + /** Context used only when a history node has no previously captured record. */ + export interface HistoryDefaultContext< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + ParentId extends StateIdentifier + > extends PlanningCapabilities, EmitOf> { + readonly event: LifecycleEvent + readonly runtime: RuntimeEffect + readonly target: FullTargetBuilder + readonly parent: ParentId + } + + /** Typed fallback evaluated when a history node has no record yet. */ + export type HistoryDefaultHandler< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + ParentId extends StateIdentifier + > = (context: HistoryDefaultContext) => + | SnapshotByIdentifier + | StateConstruction> + | Effect.Effect< + SnapshotByIdentifier | StateConstruction>, + any, + any + > + + /** Default implementations keyed by direct history child. */ + export type HistoryDefaultConfig< + States extends StateSchemas, + Events extends ReadonlyArray, + Emits extends ReadonlyArray, + ParentId extends StateIdentifier, + Children extends StateSchemas + > = { + readonly [Key in HistoryStateKey]?: { + readonly default: HistoryDefaultHandler + } + } + /** * Configuration accepted for a final state. * @@ -3362,12 +3660,15 @@ export declare namespace Machine { & HandlerConfig & (HandlerChildren extends infer Children extends StateSchemas ? [Children] extends [never] ? { readonly states?: never + readonly history?: never } : { readonly states?: HandlerTree + readonly history?: HistoryDefaultConfig } : { readonly states?: never + readonly history?: never }) type HandlerTree< @@ -3379,7 +3680,7 @@ export declare namespace Machine { R, Prefix extends string > = { - readonly [Key in Extract]?: HandlerNode< + readonly [Key in ActiveStateKey]?: HandlerNode< AllStates, States[Key], Events, @@ -3390,7 +3691,17 @@ export declare namespace Machine { > } - type HandlerNodeConfigKey = "always" | "entry" | "exit" | "invoke" | "on" | "onDone" | "output" | "states" + type HandlerNodeConfigKey = + | "always" + | "entry" + | "exit" + | "history" + | "initial" + | "invoke" + | "on" + | "onDone" + | "output" + | "states" type HandlerValidationError = { readonly "~effect/Machine/HandlerError": Message @@ -3632,7 +3943,12 @@ export declare namespace Machine { Depth extends ReadonlyArray = HandlerDepth > = { readonly [Key in Extract, Extract>]: - | HandlerStateId> + | HandlerImplementedStateId< + AllStates, + States[Key], + HandlerStateId>, + Config[Key] + > | HandlerNodeChildStateIds< AllStates, States[Key], @@ -3642,6 +3958,32 @@ export declare namespace Machine { > }[Extract, Extract>] + type HandlerHasRequiredInitial< + AllStates extends StateSchemas, + StateId extends StateIdentifier, + Config + > = StateId extends RequiredHistoryInitializers ? "initial" extends keyof Config ? true : false : true + + type HandlerHasRequiredHistoryDefaults = Node extends { + readonly states: infer Children extends StateSchemas + } ? [HistoryStateKey] extends [never] ? true + : Config extends { readonly history?: infer HistoryConfig } ? [ + Exclude, Extract, string>> + ] extends [never] ? true + : false + : false + : true + + type HandlerImplementedStateId< + AllStates extends StateSchemas, + Node, + StateId extends StateIdentifier, + Config + > = [HistoryIdentifier] extends [never] ? StateId + : HandlerHasRequiredInitial extends true ? + HandlerHasRequiredHistoryDefaults extends true ? StateId : never + : never + type HandlerNodeChildStateIds< AllStates extends StateSchemas, Node, @@ -3659,6 +4001,8 @@ export declare namespace Machine { | Effect.Error> | Effect.Error> | Effect.Error> + | Effect.Error> + | Effect.Error> | InvokeError type HandlerTreeError< @@ -4103,6 +4447,9 @@ const makeSnapshotBuilder = ( ): unknown => { const builder: Record = {} for (const key of Object.keys(states)) { + if ((states[key] as { readonly type?: unknown }).type === "history") { + continue + } const path = options.prefix === "" ? key : `${options.prefix}.${key}` const node = Model.getStateNodeDefinition(path, states[key]) builder[key] = withFrom( @@ -4129,6 +4476,9 @@ const makeParallelSnapshotBuilder = ( enumerable: false }) for (const key of Object.keys(states)) { + if ((states[key] as { readonly type?: unknown }).type === "history") { + continue + } if (hasProperty(regions, key)) { continue } @@ -4159,6 +4509,9 @@ const getParallelSnapshotBuilderRegions = ( SnapshotBuilderStateTypeId ] for (const key of Object.keys(states)) { + if ((states[key] as { readonly type?: unknown }).type === "history") { + continue + } if (!hasProperty(regions, key)) { throw new Error(`Machine expected parallel state "${path}" builder callback to provide region "${key}"`) } @@ -4439,16 +4792,46 @@ const makeBranchTargetBuilder = ( } } +const makeHistoryTargetBuilder = ( + states: Machine.StateTree, + prefix: string +): unknown => { + const builder: Record = {} + for (const key of Object.keys(states)) { + const path = prefix === "" ? key : `${prefix}.${key}` + const definition = states[key] + if ((definition as { readonly type?: unknown }).type === "history") { + const parent = getParentPathRuntime(path) + builder[key] = () => Model.makeHistoryTarget(path, parent) + continue + } + if (typeof definition === "object" && definition !== null && hasProperty(definition, "states")) { + builder[key] = makeHistoryTargetBuilder(definition.states as Machine.StateTree, path) + } + } + return builder +} + +const getParentPathRuntime = (path: string): string => { + const separator = path.lastIndexOf(".") + if (separator < 0) { + throw new Error(`Machine expected history state "${path}" to have an active parent`) + } + return path.slice(0, separator) +} + const makeTargetBuilder = ( states: States, stateNodes: Machine.StateNodes ) => { const full = makeSnapshotBuilder(states, { mode: "full", prefix: "" }) as Machine.FullTargetBuilder + const history = makeHistoryTargetBuilder(states, "") as Machine.HistoryTargetBuilder return >(source: Source): Machine.TargetBuilder => ({ local: makeLocalTargetBuilder(states, stateNodes, source), branch: makeBranchTargetBuilder(states, stateNodes, source), - full + full, + history }) as Machine.TargetBuilder } @@ -5041,7 +5424,7 @@ export const invokeMachine: { & { readonly child: ChildMachine< Id, - Machine< + & Machine< States, Events, Input, @@ -5055,7 +5438,9 @@ export const invokeMachine: { Emits, OutputStates, InputEvents - > & Machine.EnsureOutputImplementations + > + & Machine.EnsureOutputImplementations + & Machine.EnsureHistoryImplementations > readonly snapshot?: ( context: Machine.InvokeSnapshotContext< @@ -5117,7 +5502,7 @@ export const invokeMachine: { & { readonly child: ChildMachine< Id, - Machine< + & Machine< States, Events, Input, @@ -5131,7 +5516,9 @@ export const invokeMachine: { Emits, OutputStates, InputEvents - > & Machine.EnsureOutputImplementations + > + & Machine.EnsureOutputImplementations + & Machine.EnsureHistoryImplementations > readonly snapshot?: ( context: Machine.InvokeSnapshotContext< @@ -5244,7 +5631,8 @@ export const planInitial: < OutputStates, InputEvents > - & Machine.EnsureOutputImplementations, + & Machine.EnsureOutputImplementations + & Machine.EnsureHistoryImplementations, ...args: [...Machine.InputArgs] ) => Effect.Effect< & { @@ -5361,7 +5749,8 @@ export const plan: < OutputStates, InputEvents > - & Machine.EnsureOutputImplementations, + & Machine.EnsureOutputImplementations + & Machine.EnsureHistoryImplementations, state: Machine.Snapshot, event: Machine.EventOf ) => Effect.Effect< @@ -5839,7 +6228,8 @@ export const start: < OutputStates, InputEvents > - & Machine.EnsureOutputImplementations, + & Machine.EnsureOutputImplementations + & Machine.EnsureHistoryImplementations, ...args: [...Machine.InputArgs] ) => Effect.Effect< MachineRef< diff --git a/src/internal/machineErrors.ts b/src/internal/machineErrors.ts index 147a17b..571354d 100644 --- a/src/internal/machineErrors.ts +++ b/src/internal/machineErrors.ts @@ -11,7 +11,7 @@ import type * as Schema from "effect/Schema" */ export class MachineSchemaEncodeError extends Data.TaggedError("MachineSchemaEncodeError")<{ readonly machineId: string | undefined - readonly boundary: "state" | "output" | "configuration" + readonly boundary: "state" | "output" | "history" | "configuration" readonly state?: string readonly cause: Schema.SchemaError | Cause.Cause }> {} @@ -25,7 +25,7 @@ export class MachineSchemaEncodeError extends Data.TaggedError("MachineSchemaEnc */ export class MachineSchemaDecodeError extends Data.TaggedError("MachineSchemaDecodeError")<{ readonly machineId: string | undefined - readonly boundary: "input" | "event" | "emit" | "state" | "output" | "configuration" + readonly boundary: "input" | "event" | "emit" | "state" | "output" | "history" | "configuration" readonly state?: string readonly event?: string readonly cause: Schema.SchemaError | Cause.Cause diff --git a/src/internal/machineModel.ts b/src/internal/machineModel.ts index 103af88..6da16c8 100644 --- a/src/internal/machineModel.ts +++ b/src/internal/machineModel.ts @@ -16,22 +16,246 @@ export const TargetTypeId = "~effect/Machine/Target" export const TargetSnapshotTypeId: unique symbol = Symbol("effect/Machine/TargetSnapshot") export const StateInputTypeId: unique symbol = Symbol("effect/Machine/StateInput") export const StateConstructionTypeId: unique symbol = Symbol("effect/Machine/StateConstruction") +export const HistoryTargetTypeId: unique symbol = Symbol("effect/Machine/HistoryTarget") interface StateInput { readonly [StateInputTypeId]: typeof StateInputTypeId readonly input: unknown } +/** Internal target produced by the history target builder. History nodes are + * routing instructions and are never part of an active configuration. */ +export interface HistoryTarget { + readonly [HistoryTargetTypeId]: typeof HistoryTargetTypeId + readonly path: string + readonly parent: string +} + +export interface HistoryRecord { + readonly mode: "shallow" | "deep" + readonly parent: string + readonly active: ReadonlySet + readonly values: ReadonlyMap +} + +const validateHistoryRecordControl = (machine: Machine.Any, record: HistoryRecord): void => { + const ancestry = new Set(getPathToRoot(machine, record.parent)) + const visit = (path: string): void => { + const node = getNode(machine, path) + if (node.type === "compound") { + const children = node.children.filter((child) => record.active.has(child)) + if (children.length !== 1) { + throw new Error(`Machine history expected compound state "${path}" to retain one active child`) + } + if (record.mode === "deep") visit(children[0]) + return + } + if (node.type === "parallel") { + for (const child of node.children) { + if (!record.active.has(child)) { + throw new Error(`Machine history expected parallel state "${path}" to retain region "${child}"`) + } + if (record.mode === "deep") visit(child) + } + } + } + visit(record.parent) + for (const path of record.active) { + if (!ancestry.has(path) && !isPathInSubtree(path, record.parent)) { + throw new Error(`Machine history contains state "${path}" outside parent "${record.parent}"`) + } + if ( + record.mode === "shallow" && isDescendantOf(path, record.parent) && + getNode(machine, path).parent !== record.parent + ) { + throw new Error(`Machine shallow history contains deep descendant "${path}"`) + } + } +} + +type SnapshotWithHistory = Machine.AtomicSnapshot & { + readonly history?: Readonly< + Record + readonly values: Readonly> + }> + > +} + +const historyFromSnapshot = ( + machine: Machine.Any, + snapshot: SnapshotWithHistory +): ReadonlyMap => { + const history = new Map() + for (const [path, entry] of Object.entries(snapshot.history ?? {})) { + const historyNode = getNode(machine, path) + if (historyNode.type !== "history" || historyNode.parent === undefined || historyNode.history !== entry.mode) { + throw new Error(`Machine snapshot contains invalid history record "${path}"`) + } + const active = new Set() + const values = new Map() + for (const activePath of entry.active) { + if (active.has(activePath) || !Object.prototype.hasOwnProperty.call(entry.values, activePath)) { + throw new Error(`Machine snapshot contains invalid remembered state "${activePath}"`) + } + const node = getNode(machine, activePath) + if ( + node.type === "history" || + !(isPathInSubtree(activePath, historyNode.parent) || + getPathToRoot(machine, historyNode.parent).includes(activePath)) || + !Schema.is(getStateNodeSchema(node))(entry.values[activePath]) + ) { + throw new Error(`Machine snapshot contains invalid remembered value for "${activePath}"`) + } + active.add(activePath) + values.set(activePath, entry.values[activePath]) + } + if (!active.has(historyNode.parent) || Object.keys(entry.values).length !== active.size) { + throw new Error(`Machine snapshot contains incomplete history record "${path}"`) + } + history.set(path, { + mode: entry.mode, + parent: historyNode.parent, + active, + values + }) + validateHistoryRecordControl(machine, history.get(path)!) + } + return history +} + +const historyFromSnapshotEffect = Effect.fnUntraced(function*( + machine: Machine.Any, + snapshot: SnapshotWithHistory +) { + const history = new Map() + for (const [path, entry] of Object.entries(snapshot.history ?? {})) { + const historyNode = machine.stateNodes.byPath.get(path) + if ( + historyNode === undefined || historyNode.type !== "history" || historyNode.parent === undefined || + historyNode.history !== entry.mode + ) { + return yield* Effect.fail( + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "history", + state: path, + cause: Cause.die(new Error(`Machine snapshot contains invalid history record "${path}"`)) + }) + ) + } + const active = new Set() + const values = new Map() + for (const activePath of entry.active) { + const node = machine.stateNodes.byPath.get(activePath) + if ( + active.has(activePath) || node === undefined || node.type === "history" || + !Object.prototype.hasOwnProperty.call(entry.values, activePath) || + !(isPathInSubtree(activePath, historyNode.parent) || + getPathToRoot(machine, historyNode.parent).includes(activePath)) + ) { + return yield* Effect.fail( + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "history", + state: activePath, + cause: Cause.die(new Error(`Machine snapshot contains invalid remembered state "${activePath}"`)) + }) + ) + } + active.add(activePath) + values.set( + activePath, + yield* decodeBoundary(machine, getStateNodeSchema(node), entry.values[activePath], { + boundary: "history", + state: activePath + }) + ) + } + if (!active.has(historyNode.parent) || Object.keys(entry.values).length !== active.size) { + return yield* Effect.fail( + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "history", + state: path, + cause: Cause.die(new Error(`Machine snapshot contains incomplete history record "${path}"`)) + }) + ) + } + history.set(path, { + mode: entry.mode, + parent: historyNode.parent, + active, + values + }) + try { + validateHistoryRecordControl(machine, history.get(path)!) + } catch (cause) { + return yield* Effect.fail( + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "history", + state: path, + cause: Cause.die(cause) + }) + ) + } + } + return history +}) + +const historyToSnapshot = ( + history: ReadonlyMap +): Readonly< + Record + readonly values: Readonly> + }> +> => { + const entries: Record + readonly values: Readonly> + }> = {} + for (const [path, record] of history) { + entries[path] = { + mode: record.mode, + active: Array.from(record.active), + values: Object.fromEntries(record.values) + } + } + return entries +} + +export const makeHistoryTarget = (path: string, parent: string): HistoryTarget => ({ + [HistoryTargetTypeId]: HistoryTargetTypeId, + path, + parent +}) + +export const isHistoryTarget = (u: unknown): u is HistoryTarget => hasProperty(u, HistoryTargetTypeId) + export const getStateNodeDefinition = ( path: string, definition: Machine.TaggedSchema | Machine.StateNodeConfig ): { - readonly schema: Machine.TaggedSchema + readonly schema: Machine.TaggedSchema | undefined readonly output: Schema.Top | undefined - readonly type: "atomic" | "compound" | "parallel" | "final" + readonly type: "atomic" | "compound" | "parallel" | "final" | "history" readonly initial: string | undefined readonly states: Machine.StateTree | undefined } => { + if (!Schema.isSchema(definition) && (definition as any).type === "history") { + return { + schema: undefined, + output: undefined, + type: "history", + initial: undefined, + states: undefined + } + } if (Schema.isSchema(definition)) { return { schema: definition as Machine.TaggedSchema, @@ -92,7 +316,7 @@ export const compileStateNodes = (states: Machine.StateSchemas): Machine.StateNo } const path = parent === undefined ? key : `${parent}.${key}` const definition = getStateNodeDefinition(path, tree[key]) - const node = { + const node: Machine.StateNode = { path, key, schema: definition.schema, @@ -101,11 +325,20 @@ export const compileStateNodes = (states: Machine.StateSchemas): Machine.StateNo parent, children: [] as ReadonlyArray, initial: definition.initial === undefined ? undefined : `${path}.${definition.initial}`, + history: definition.type === "history" + ? ((tree[key] as any).history === "deep" ? "deep" : "shallow") + : undefined, order } byPath.set(path, node) - paths.push(path) order += 1 + if (definition.type === "history") { + if (parent === undefined) { + throw new Error(`Machine history state "${path}" must belong to a parent state`) + } + continue + } + paths.push(path) if (definition.states !== undefined) { const children = compile(definition.states, path) if (node.type === "compound" && (node.initial === undefined || !children.includes(node.initial))) { @@ -120,7 +353,7 @@ export const compileStateNodes = (states: Machine.StateSchemas): Machine.StateNo return { byPath, roots: compile(states, undefined) - } + } as Machine.StateNodes } export const makeTarget = < @@ -207,6 +440,7 @@ export interface ActiveConfiguration { readonly active: ReadonlySet readonly values: ReadonlyMap readonly outputs: ReadonlyMap + readonly history: ReadonlyMap } export interface FinalCompletion { @@ -215,7 +449,7 @@ export interface FinalCompletion { } export interface DecodeBoundaryOptions { - readonly boundary: "input" | "event" | "emit" | "state" | "output" | "configuration" + readonly boundary: "input" | "event" | "emit" | "state" | "output" | "history" | "configuration" readonly state?: string readonly event?: string } @@ -316,7 +550,7 @@ export const decodeStateValue = ( value: unknown ): Effect.Effect => isStateInput(value) - ? node.schema.makeEffect(value.input).pipe( + ? getStateNodeSchema(node).makeEffect(value.input).pipe( Effect.mapError((cause) => new MachineSchemaDecodeError({ machineId: machine.id, @@ -326,7 +560,7 @@ export const decodeStateValue = ( }) ) ) - : decodeBoundary(machine, node.schema, value, { boundary: "state", state: node.path }) + : decodeBoundary(machine, getStateNodeSchema(node), value, { boundary: "state", state: node.path }) export const decodeOutputValue = ( machine: Machine.Any, @@ -345,6 +579,13 @@ export const getNode = (machine: Machine.Any, path: string): Machine.StateNode = return node } +export const getStateNodeSchema = (node: Machine.StateNode): Machine.TaggedSchema => { + if (node.schema === undefined || node.type === "history") { + throw new Error(`Machine history state "${node.path}" has no active value schema`) + } + return node.schema +} + export const hasOwn = (u: object, key: string): boolean => Object.prototype.hasOwnProperty.call(u, key) export const isDescendantOf = (path: string, ancestor: string): boolean => path.startsWith(`${ancestor}.`) @@ -410,7 +651,11 @@ export const getRootPath = (machine: Machine.Any, configuration: ActiveConfigura export const getActiveValue = (configuration: ActiveConfiguration, path: string): unknown => { if (!configuration.values.has(path)) { - throw new Error(`Machine expected active state "${path}" to have a value`) + throw new Error( + `Machine expected active state "${path}" to have a value (available: ${ + Array.from(configuration.values.keys()).join(", ") + })` + ) } return configuration.values.get(path) } @@ -502,6 +747,24 @@ export const snapshotFromConfiguration = }).completed = completed } + if (configuration.history.size > 0) { + Object.assign(snapshot, { history: historyToSnapshot(configuration.history) }) + } + return snapshot +} + +/** Creates a targetable subtree snapshot while carrying machine-level history + * metadata on that subtree root. This is used when a nested history target + * must preserve active ancestors and unaffected parallel regions. */ +export const snapshotFromConfigurationAtPath = ( + machine: Machine.Any, + configuration: ActiveConfiguration, + path: string +): Machine.SnapshotByIdentifier> => { + const snapshot = snapshotFromPath(machine, configuration, path) + if (configuration.history.size > 0) { + Object.assign(snapshot, { history: historyToSnapshot(configuration.history) }) + } return snapshot } @@ -515,7 +778,7 @@ export const configurationFromSnapshot = ( const visit = (current: Machine.AtomicSnapshot): void => { const node = getNode(machine, String(current.path)) - if (!Schema.is(node.schema)(current.value)) { + if (!Schema.is(getStateNodeSchema(node))(current.value)) { throw new Error(`Machine expected snapshot for "${node.path}" to match its schema`) } active.add(node.path) @@ -559,7 +822,7 @@ export const configurationFromSnapshot = ( } } } - return { active, values, outputs } + return { active, values, outputs, history: historyFromSnapshot(machine, snapshot) } } export const normalizeConfiguration = ( @@ -623,7 +886,12 @@ export const configurationFromSnapshotEffect = Effect.fnUntraced(function*( } } } - return { active, values, outputs } as ActiveConfiguration + return { + active, + values, + outputs, + history: yield* historyFromSnapshotEffect(machine, snapshot) + } as ActiveConfiguration }) export const normalizeConfigurationEffect = ( @@ -664,6 +932,102 @@ export const validateInitialConfiguration = (machine: Machine.Any, configuration } } +/** Capture every history register whose owning parent exits in this microstep. + * The control record is deliberately independent from effects/actions: it is + * part of the logical snapshot and is therefore preserved by pure planning. */ +export const captureHistory = ( + machine: Machine.Any, + current: ActiveConfiguration, + next: ActiveConfiguration, + exitPaths: ReadonlyArray +): ActiveConfiguration => { + if (exitPaths.length === 0) { + return next + } + const exited = new Set(exitPaths) + const history = new Map(next.history) + for (const node of machine.stateNodes.byPath.values() as Iterable) { + if (node.type !== "history" || node.parent === undefined || !exited.has(node.parent)) { + continue + } + const mode = node.history === "deep" ? "deep" : "shallow" + const active = new Set() + for (const ancestor of getPathToRoot(machine, node.parent)) { + if (current.active.has(ancestor)) { + active.add(ancestor) + } + } + for (const path of current.active) { + if ( + path === node.parent || + (mode === "deep" && isDescendantOf(path, node.parent)) || + (mode === "shallow" && getNode(machine, path).parent === node.parent) + ) { + active.add(path) + } + } + const values = new Map() + for (const path of active) { + values.set(path, getActiveValue(current, path)) + } + history.set(node.path, { + mode, + parent: node.parent, + active, + values + }) + } + return { + active: next.active, + values: next.values, + outputs: next.outputs, + history + } +} + +export const getHistoryRecord = ( + configuration: ActiveConfiguration, + path: string +): HistoryRecord | undefined => configuration.history.get(path) + +/** Builds the remembered portion of a configuration. Shallow records are + * intentionally incomplete below their direct child; the planner completes + * them by invoking only the required typed initializers. */ +export const configurationFromHistoryRecord = ( + machine: Machine.Any, + current: ActiveConfiguration, + record: HistoryRecord +): ActiveConfiguration => { + const active = new Set(record.active) + const values = new Map(record.values) + const outputs = new Map() + const ancestors = getPathToRoot(machine, record.parent) + const ancestorSet = new Set(ancestors) + + // A history transition can occur while an ancestor parallel state remains + // active. Its unaffected regions retain their current configuration. + for (const ancestor of ancestors) { + const node = getNode(machine, ancestor) + if (node.type !== "parallel") { + continue + } + for (const child of node.children) { + if (ancestorSet.has(child) || record.active.has(child) || !current.active.has(child)) { + continue + } + for (const path of current.active) { + if (isPathInSubtree(path, child)) { + active.add(path) + if (current.values.has(path)) values.set(path, current.values.get(path)) + if (current.outputs.has(path)) outputs.set(path, current.outputs.get(path)) + } + } + } + } + + return { active, values, outputs, history: current.history } +} + export const configurationFromTargetPathEffect = Effect.fnUntraced(function*( machine: Machine.Any, current: ActiveConfiguration, @@ -718,7 +1082,12 @@ export const configurationFromTargetPathEffect = Effect.fnUntraced(function*( throw new Error(`Machine target "${node.path}" must include an active child state`) } - return { active, values, outputs } as ActiveConfiguration + return { + active, + values, + outputs, + history: current.history + } as ActiveConfiguration }) export const configurationFromTargetSnapshotEffect = Effect.fnUntraced(function*( @@ -768,7 +1137,12 @@ export const configurationFromTargetSnapshotEffect = Effect.fnUntraced(function* } } - return { active, values, outputs } as ActiveConfiguration + return { + active, + values, + outputs, + history: new Map([...current.history, ...subtree.history]) + } as ActiveConfiguration }) export const normalizeTargetConfigurationEffect = ( @@ -798,7 +1172,12 @@ export const normalizeTargetConfigurationEffect = ({ + ...configuration, + history: new Map([...current.history, ...configuration.history]) + })) + ) } throw new Error("Machine expected transition target to be a snapshot or target builder result") } @@ -1022,7 +1401,8 @@ export const completeConfigurationEffect: < const completed = { active: configuration.active, values: configuration.values, - outputs + outputs, + history: configuration.history } for ( const path of Array.from(completed.active).sort((left, right) => { @@ -1047,7 +1427,15 @@ const EncodedSnapshotSchema = Schema.Struct({ completed: Schema.optional(Schema.Array(Schema.Struct({ path: Schema.String, output: Schema.optional(Schema.Unknown) - }))) + }))), + history: Schema.optional(Schema.Record( + Schema.String, + Schema.Struct({ + mode: Schema.Literals(["shallow", "deep"]), + active: Schema.Array(Schema.String), + values: Schema.Record(Schema.String, Schema.Unknown) + }) + )) }) const encodeBoundary = ( @@ -1055,7 +1443,7 @@ const encodeBoundary = ( schema: Schema.Top, value: unknown, options: { - readonly boundary: "state" | "output" + readonly boundary: "state" | "output" | "history" readonly state: string } ): Effect.Effect => @@ -1075,7 +1463,7 @@ const decodeEncodedBoundary = ( schema: Schema.Top, value: unknown, options: { - readonly boundary: "state" | "output" + readonly boundary: "state" | "output" | "history" readonly state: string } ): Effect.Effect => @@ -1162,7 +1550,7 @@ export const encodeSnapshot = ( Effect.mapError((error) => new MachineSchemaEncodeError({ machineId: machine.id, - boundary: error.boundary === "state" ? "state" : "configuration", + boundary: error.boundary === "state" || error.boundary === "history" ? error.boundary : "configuration", ...(error.state === undefined ? {} : { state: error.state }), cause: error.cause }) @@ -1185,7 +1573,7 @@ export const encodeSnapshot = ( const node = getNode(machine, path) active.push({ path, - value: yield* encodeBoundary(machine, node.schema, getActiveValue(configuration, path), { + value: yield* encodeBoundary(machine, getStateNodeSchema(node), getActiveValue(configuration, path), { boundary: "state", state: path }) @@ -1216,10 +1604,83 @@ export const encodeSnapshot = ( }) } + const history: Record = {} + for ( + const [historyPath, record] of Array.from(configuration.history).sort(([left], [right]) => + left.localeCompare(right) + ) + ) { + const historyNode = machine.stateNodes.byPath.get(historyPath) + if ( + historyNode === undefined || historyNode.type !== "history" || historyNode.parent !== record.parent || + historyNode.history !== record.mode + ) { + return yield* Effect.fail( + new MachineSchemaEncodeError({ + machineId: machine.id, + boundary: "history", + state: historyPath, + cause: Cause.die(new Error(`Machine snapshot contains invalid history record "${historyPath}"`)) + }) + ) + } + try { + validateHistoryRecordControl(machine, record) + } catch (cause) { + return yield* Effect.fail( + new MachineSchemaEncodeError({ + machineId: machine.id, + boundary: "history", + state: historyPath, + cause: Cause.die(cause) + }) + ) + } + const encodedValues: Record = {} + for (const path of record.active) { + const stateNode = machine.stateNodes.byPath.get(path) + if ( + stateNode === undefined || stateNode.type === "history" || !record.values.has(path) || + !(isPathInSubtree(path, record.parent) || getPathToRoot(machine, record.parent).includes(path)) + ) { + return yield* Effect.fail( + new MachineSchemaEncodeError({ + machineId: machine.id, + boundary: "history", + state: path, + cause: Cause.die(new Error(`Machine snapshot contains invalid remembered state "${path}"`)) + }) + ) + } + encodedValues[path] = yield* encodeBoundary( + machine, + getStateNodeSchema(stateNode), + record.values.get(path), + { boundary: "history", state: path } + ) + } + if (record.values.size !== record.active.size) { + return yield* Effect.fail( + new MachineSchemaEncodeError({ + machineId: machine.id, + boundary: "history", + state: historyPath, + cause: Cause.die(new Error(`Machine history record "${historyPath}" contains values outside its paths`)) + }) + ) + } + history[historyPath] = { + mode: record.mode, + active: Array.from(record.active).sort((left, right) => compareDocumentOrder(machine, left, right)), + values: encodedValues + } + } + return { _tag: "MachineSnapshot" as const, active, - ...(completed.length === 0 ? {} : { completed }) + ...(completed.length === 0 ? {} : { completed }), + ...(Object.keys(history).length === 0 ? {} : { history }) } }).pipe(Effect.catchCause((cause) => failEncodeCause(machine, cause))) @@ -1247,17 +1708,112 @@ export const decodeSnapshot = ( active.add(entry.path) values.set( entry.path, - yield* decodeEncodedBoundary(machine, node.schema, entry.value, { + yield* decodeEncodedBoundary(machine, getStateNodeSchema(node), entry.value, { boundary: "state", state: entry.path }) ) } + const history = new Map() + for (const [historyPath, encodedRecord] of Object.entries(decoded.history ?? {})) { + const historyNode = machine.stateNodes.byPath.get(historyPath) + if ( + historyNode === undefined || historyNode.type !== "history" || historyNode.parent === undefined || + historyNode.history !== encodedRecord.mode + ) { + return yield* Effect.fail( + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "history", + state: historyPath, + cause: Cause.die(new Error(`Machine encoded snapshot contains invalid history record "${historyPath}"`)) + }) + ) + } + const rememberedActive = new Set() + const rememberedValues = new Map() + for (const path of encodedRecord.active) { + if (rememberedActive.has(path)) { + return yield* Effect.fail( + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "history", + state: path, + cause: Cause.die(new Error(`Machine encoded history contains duplicate state "${path}"`)) + }) + ) + } + const stateNode = machine.stateNodes.byPath.get(path) + if ( + stateNode === undefined || stateNode.type === "history" || + !Object.prototype.hasOwnProperty.call(encodedRecord.values, path) || + !(isPathInSubtree(path, historyNode.parent) || getPathToRoot(machine, historyNode.parent).includes(path)) + ) { + return yield* Effect.fail( + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "history", + state: path, + cause: Cause.die(new Error(`Machine encoded snapshot contains invalid remembered state "${path}"`)) + }) + ) + } + rememberedActive.add(path) + rememberedValues.set( + path, + yield* decodeEncodedBoundary(machine, getStateNodeSchema(stateNode), encodedRecord.values[path], { + boundary: "history", + state: path + }) + ) + } + if (Object.keys(encodedRecord.values).length !== rememberedActive.size) { + return yield* Effect.fail( + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "history", + state: historyPath, + cause: Cause.die(new Error(`Machine encoded history "${historyPath}" contains values outside its paths`)) + }) + ) + } + if (!rememberedActive.has(historyNode.parent)) { + return yield* Effect.fail( + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "history", + state: historyPath, + cause: Cause.die(new Error(`Machine encoded history "${historyPath}" does not contain its parent state`)) + }) + ) + } + const record: HistoryRecord = { + mode: encodedRecord.mode, + parent: historyNode.parent, + active: rememberedActive, + values: rememberedValues + } + try { + validateHistoryRecordControl(machine, record) + } catch (cause) { + return yield* Effect.fail( + new MachineSchemaDecodeError({ + machineId: machine.id, + boundary: "history", + state: historyPath, + cause: Cause.die(cause) + }) + ) + } + history.set(historyPath, record) + } + const configuration: ActiveConfiguration = { active, values, - outputs: new Map() + outputs: new Map(), + history } const snapshot = validateEncodedConfiguration(machine, configuration) const completions: Array = [] diff --git a/src/internal/machinePlanner.ts b/src/internal/machinePlanner.ts index 5342286..b13d73c 100644 --- a/src/internal/machinePlanner.ts +++ b/src/internal/machinePlanner.ts @@ -13,14 +13,18 @@ import type { ActionRequirement, InitialEvent as MachineInitialEvent, Machine, R import { InfiniteTransitionError, MachineSchemaDecodeError, StartupError } from "./machineErrors.js" import { type ActiveConfiguration, + captureHistory, compareDocumentOrder, completeConfigurationEffect, + configurationFromHistoryRecord, decodeEmit, decodeEvent, decodeInput, + decodeStateValue, getActiveLeafPathFrom, getActiveLeafPaths, getActiveValue, + getHistoryRecord, getInitialEntryPaths, getLeafPath, getNode, @@ -30,13 +34,17 @@ import { getRootPath, isActiveFinalConfiguration, isDescendantOf, + isHistoryTarget, + isPathInSubtree, isSnapshot, isTarget, + makeTarget, normalizeConfiguration, normalizeConfigurationEffect, normalizeTargetConfigurationEffect, pathDepth, snapshotFromConfiguration, + snapshotFromConfigurationAtPath, validateInitialConfiguration } from "./machineModel.js" import type { ProcessScope } from "./machineRuntime.js" @@ -291,6 +299,179 @@ const collectTransition = Effect.fnUntraced(function*< } }) +const collectStateInitializer = Effect.fnUntraced(function*( + machine: Machine.Any, + handler: (context: any) => unknown, + context: any +) { + const deferredActions = yield* makeDeferredActions + const deferredRaisedEvents = yield* makeDeferredRaisedEvents + const result = handler(context) + const value = Effect.isEffect(result) + ? yield* provideDeferredServices(result, machine, deferredActions, deferredRaisedEvents) + : result + return { + value, + actions: yield* deferredActions.read, + raisedEvents: yield* deferredRaisedEvents.read, + emittedEvents: yield* deferredRaisedEvents.readEmitted + } +}) + +/** Completes the intentionally partial configuration held by shallow history. + * Only a compound node with no remembered child invokes an initializer; deep + * history never reaches this path. */ +const completeHistoryConfiguration = Effect.fnUntraced(function*( + machine: Machine.Any, + configuration: ActiveConfiguration, + event: unknown +) { + const active = new Set(configuration.active) + const values = new Map(configuration.values) + const actions: Array = [] + const raisedEvents: Array = [] + const emittedEvents: Array = [] + + let changed = true + while (changed) { + changed = false + for (const path of Array.from(active).sort((left, right) => compareDocumentOrder(machine, left, right))) { + const node = getNode(machine, path) + if (node.type === "compound" && !node.children.some((child) => active.has(child))) { + if (node.initial === undefined) { + throw new Error(`Machine shallow history expected compound state "${path}" to have an initial child`) + } + const initializer = machine.handlers[path]?.initial + if (initializer === undefined) { + throw new Error(`Machine shallow history requires an initial value implementation for state "${path}"`) + } + const current = { + active, + values, + outputs: configuration.outputs, + history: configuration.history + } as ActiveConfiguration + const initialized = yield* collectStateInitializer(machine, initializer, { + state: getActiveValue(current, path), + parent: getParentValue(machine, current, path), + parents: getParentValues(machine, current, path), + event, + ...makePlanningCapabilities() + }) + const child = getNode(machine, node.initial) + active.add(child.path) + values.set(child.path, yield* decodeStateValue(machine, child, initialized.value)) + actions.push(...initialized.actions) + raisedEvents.push(...initialized.raisedEvents) + emittedEvents.push(...initialized.emittedEvents) + changed = true + } + if (node.type === "parallel") { + const missing = node.children.filter((childPath) => !active.has(childPath)) + if (missing.length > 0) { + const initializer = machine.handlers[path]?.initial + if (initializer === undefined) { + throw new Error(`Machine shallow history requires an initial value implementation for state "${path}"`) + } + const current = { + active, + values, + outputs: configuration.outputs, + history: configuration.history + } as ActiveConfiguration + const initialized = yield* collectStateInitializer(machine, initializer, { + state: getActiveValue(current, path), + parent: getParentValue(machine, current, path), + parents: getParentValues(machine, current, path), + event, + ...makePlanningCapabilities() + }) + if (typeof initialized.value !== "object" || initialized.value === null) { + throw new Error(`Machine parallel state initializer for "${path}" must return its region values`) + } + for (const childPath of missing) { + const child = getNode(machine, childPath) + if (!Object.prototype.hasOwnProperty.call(initialized.value, child.key)) { + throw new Error(`Machine parallel state initializer for "${path}" must return region "${child.key}"`) + } + active.add(child.path) + values.set( + child.path, + yield* decodeStateValue(machine, child, (initialized.value as Record)[child.key]) + ) + } + actions.push(...initialized.actions) + raisedEvents.push(...initialized.raisedEvents) + emittedEvents.push(...initialized.emittedEvents) + changed = true + } + } + } + } + return { + configuration: { + active, + values, + outputs: new Map(), + history: configuration.history + } as ActiveConfiguration, + actions, + raisedEvents, + emittedEvents + } +}) + +const resolveHistoryTarget = Effect.fnUntraced(function*( + machine: Machine.Any, + configuration: ActiveConfiguration, + target: { readonly path: string; readonly parent: string }, + event: unknown +) { + const node = getNode(machine, target.path) + if (node.type !== "history" || node.parent !== target.parent) { + throw new Error(`Machine expected history target "${target.path}" to resolve to its declared parent`) + } + const record = getHistoryRecord(configuration, target.path) + if (record !== undefined) { + const restored = configurationFromHistoryRecord(machine, configuration, record) + // Deep records are already complete below the history parent. This pass is + // still required for parallel ancestors outside that parent whose other + // regions must be initialized when the ancestor is re-entered. + const completed = yield* completeHistoryConfiguration(machine, restored, event) + const snapshot = snapshotFromConfigurationAtPath(machine, completed.configuration, target.parent) + return { + target: makeTarget(target.parent as any, snapshot.value as any, { snapshot: snapshot as any }), + actions: completed.actions, + raisedEvents: completed.raisedEvents, + emittedEvents: completed.emittedEvents + } + } + + const key = node.key + const fallback = machine.handlers[target.parent]?.history?.[key]?.default + if (fallback === undefined) { + throw new Error(`Machine history state "${target.path}" requires a default implementation`) + } + const collected = yield* collectTransition(machine, fallback, { + event, + ...makePlanningCapabilities(), + target: machine.makeTargetBuilder(target.parent).full, + parent: target.parent + }) + if (collected.state === undefined || isHistoryTarget(collected.state) || !isSnapshot(collected.state)) { + throw new Error(`Machine history default for "${target.path}" must return a concrete parent snapshot`) + } + if (String(collected.state.path) !== target.parent) { + throw new Error(`Machine history default for "${target.path}" must return state "${target.parent}"`) + } + return { + target: makeTarget(target.parent as any, collected.state.value as any, { snapshot: collected.state as any }), + actions: collected.actions, + raisedEvents: collected.raisedEvents, + emittedEvents: collected.emittedEvents + } +}) + type SelectedTransition = { readonly sourcePath: string readonly leafPath: string @@ -785,11 +966,45 @@ const collectEvaluatedTransition = Effect.fnUntraced(function*< selection.transition.transition, selection.context ) - const target = transitionResult.state === undefined + const unresolvedTarget = transitionResult.state === undefined ? undefined : transitionResult.state as | Machine.Snapshot | Machine.Target> + let historyResolution: { + readonly target: unknown + readonly actions: ReadonlyArray + readonly raisedEvents: ReadonlyArray + readonly emittedEvents: ReadonlyArray + } | undefined + const reenteredHistoryParent = unresolvedTarget !== undefined && isHistoryTarget(unresolvedTarget) && + selection.transition.reenter && state.active.has(unresolvedTarget.parent) + if (unresolvedTarget !== undefined && isHistoryTarget(unresolvedTarget)) { + // A reentering transition may exit the history node's own parent. SCXML + // history observes that same exit, so resolve against a provisional + // capture rather than an older record (or the default). + const provisionalBoundary = selection.transition.reenter + ? getNode(machine, selection.sourcePath).parent + : getLeastCommonAncestor(machine, stateIdentifier, unresolvedTarget.parent) + const provisionalExitPaths = reenteredHistoryParent + ? sortExitPaths( + machine, + Array.from(state.active).filter((path) => isPathInSubtree(path, unresolvedTarget.parent)) + ) + : getExitPaths(machine, state, provisionalBoundary) + const stateAtHistoryResolution = provisionalExitPaths.includes(unresolvedTarget.parent) + ? captureHistory(machine, state, state, provisionalExitPaths) + : state + historyResolution = yield* resolveHistoryTarget( + machine, + stateAtHistoryResolution, + unresolvedTarget, + (selection.context as any).event + ) + } + const target = historyResolution === undefined ? unresolvedTarget : historyResolution.target as + | Machine.Snapshot + | Machine.Target> const targetPath = target === undefined ? undefined : getTargetNodePath(target) const stateAfterTransition = target === undefined ? state @@ -800,9 +1015,9 @@ const collectEvaluatedTransition = Effect.fnUntraced(function*< return { selection, target, - actions: transitionResult.actions, - raisedEvents: transitionResult.raisedEvents, - emittedEvents: transitionResult.emittedEvents, + actions: [...transitionResult.actions, ...(historyResolution?.actions ?? [])], + raisedEvents: [...transitionResult.raisedEvents, ...(historyResolution?.raisedEvents ?? [])], + emittedEvents: [...transitionResult.emittedEvents, ...(historyResolution?.emittedEvents ?? [])], changed, exitPaths: [], entryPaths: [] @@ -816,12 +1031,22 @@ const collectEvaluatedTransition = Effect.fnUntraced(function*< return { selection, target, - actions: transitionResult.actions, - raisedEvents: transitionResult.raisedEvents, - emittedEvents: transitionResult.emittedEvents, + actions: [...transitionResult.actions, ...(historyResolution?.actions ?? [])], + raisedEvents: [...transitionResult.raisedEvents, ...(historyResolution?.raisedEvents ?? [])], + emittedEvents: [...transitionResult.emittedEvents, ...(historyResolution?.emittedEvents ?? [])], changed, - exitPaths: getExitPaths(machine, state, boundary), - entryPaths: getEntryPaths(machine, stateAfterTransition, boundary) + exitPaths: reenteredHistoryParent + ? sortExitPaths( + machine, + Array.from(state.active).filter((path) => isPathInSubtree(path, unresolvedTarget!.parent)) + ) + : getExitPaths(machine, state, boundary), + entryPaths: reenteredHistoryParent + ? sortEntryPaths( + machine, + Array.from(stateAfterTransition.active).filter((path) => isPathInSubtree(path, unresolvedTarget!.parent)) + ) + : getEntryPaths(machine, stateAfterTransition, boundary) } as EvaluatedTransition }) @@ -1163,6 +1388,7 @@ const microstep: < const exitPaths = sortExitPaths(machine, sortedTransitions.flatMap((transition) => transition.exitPaths)) const entryPaths = sortEntryPaths(machine, sortedTransitions.flatMap((transition) => transition.entryPaths)) + stateAfterTransition = captureHistory(machine, state, stateAfterTransition, exitPaths) const exit = yield* collectStateActions( machine, state, diff --git a/test/MachineHistory.test.ts b/test/MachineHistory.test.ts new file mode 100644 index 0000000..194ef44 --- /dev/null +++ b/test/MachineHistory.test.ts @@ -0,0 +1,725 @@ +import { assert, describe, it } from "@effect/vitest" +import { Context, Data, Effect, Fiber, Schema, Stream } from "effect" +import { Machine } from "../src/index.js" + +class Checkout extends Schema.TaggedClass("Checkout")("Checkout", { + orderId: Schema.String +}) {} +class Shipping extends Schema.TaggedClass("Shipping")("Shipping", { + address: Schema.String +}) {} +class Payment extends Schema.TaggedClass("Payment")("Payment", { + attempt: Schema.Number +}) {} +class CardEntry extends Schema.TaggedClass("CardEntry")("CardEntry", { + cardNumber: Schema.String +}) {} +class Verifying extends Schema.TaggedClass("Verifying")("Verifying", { + challengeId: Schema.String +}) {} +class Support extends Schema.TaggedClass("Support")("Support", { + ticket: Schema.String +}) {} + +class Leave extends Schema.TaggedClass("Leave")("Leave", {}) {} +class ResumeShallow extends Schema.TaggedClass("ResumeShallow")("ResumeShallow", {}) {} +class ResumeDeep extends Schema.TaggedClass("ResumeDeep")("ResumeDeep", {}) {} +class GoShipping extends Schema.TaggedClass("GoShipping")("GoShipping", { + address: Schema.String +}) {} +class EnterVerifying extends Schema.TaggedClass("EnterVerifying")("EnterVerifying", {}) {} +class ReenterHistory extends Schema.TaggedClass("ReenterHistory")("ReenterHistory", {}) {} + +class CardDefaults extends Context.Service()("test/MachineHistory/CardDefaults") {} + +class InitializerFailure extends Data.TaggedError("InitializerFailure")<{ + readonly attempt: number +}> {} + +class Workspace extends Schema.TaggedClass("Workspace")("Workspace", { + id: Schema.String +}) {} +class Editor extends Schema.TaggedClass("Editor")("Editor", { + documentId: Schema.String +}) {} +class Writing extends Schema.TaggedClass("Writing")("Writing", { + draft: Schema.String +}) {} +class Preview extends Schema.TaggedClass("Preview")("Preview", { + page: Schema.Number +}) {} +class Sidebar extends Schema.TaggedClass("Sidebar")("Sidebar", { + width: Schema.Number +}) {} +class Files extends Schema.TaggedClass("Files")("Files", { + directory: Schema.String +}) {} +class Search extends Schema.TaggedClass("Search")("Search", { + query: Schema.String +}) {} +class Away extends Schema.TaggedClass("Away")("Away", {}) {} + +class LeaveWorkspace extends Schema.TaggedClass("LeaveWorkspace")("LeaveWorkspace", {}) {} +class ResumeWorkspaceShallow extends Schema.TaggedClass("ResumeWorkspaceShallow")( + "ResumeWorkspaceShallow", + {} +) {} +class ResumeWorkspaceDeep extends Schema.TaggedClass("ResumeWorkspaceDeep")( + "ResumeWorkspaceDeep", + {} +) {} +class RestoreEditor extends Schema.TaggedClass("RestoreEditor")("RestoreEditor", {}) {} + +const CheckoutStates = Machine.defineStates({ + checkout: { + schema: Checkout, + initial: "shipping", + states: { + shipping: Shipping, + payment: { + schema: Payment, + initial: "cardEntry", + states: { + cardEntry: CardEntry, + verifying: Verifying + } + }, + recent: { + type: "history" + }, + exact: { + type: "history", + history: "deep" + } + } + }, + support: Support +}) + +const checkoutPaymentVerifying = ( + orderId: string, + attempt: number, + challengeId: string +): Machine.Machine.Snapshot => ({ + path: "checkout", + value: new Checkout({ orderId }), + state: { + path: "checkout.payment", + value: new Payment({ attempt }), + state: { + path: "checkout.payment.verifying", + value: new Verifying({ challengeId }) + } + } +}) + +const checkoutShipping = (orderId: string, address: string) => + CheckoutStates.initial.checkout( + new Checkout({ orderId }), + (checkout) => checkout.shipping(new Shipping({ address })) + ) + +const makeCheckoutMachine = ( + initial: Machine.Machine.Snapshot, + onInitialize?: () => void, + lifecycle?: Array, + onDefault?: () => void +) => + Machine.make({ + states: CheckoutStates.states, + events: [Leave, ResumeShallow, ResumeDeep, GoShipping, EnterVerifying, ReenterHistory], + initial: () => initial + }).handle({ + checkout: { + entry: () => Machine.action(Effect.sync(() => lifecycle?.push("entry:checkout"))), + exit: () => Machine.action(Effect.sync(() => lifecycle?.push("exit:checkout"))), + history: { + recent: { + default: () => { + onDefault?.() + return checkoutShipping("fallback-order", "fallback-address") + } + }, + exact: { + default: () => { + onDefault?.() + return checkoutShipping("fallback-order", "fallback-address") + } + } + }, + on: { + Leave: ({ target }) => target.full.support(new Support({ ticket: "ticket-1" })), + GoShipping: ({ event, target }) => target.local.shipping(new Shipping({ address: event.address })), + ReenterHistory: { + reenter: true, + transition: ({ target }) => target.history.checkout.exact() + } + }, + states: { + shipping: { + on: { + EnterVerifying: ({ target }) => + target.local.payment( + new Payment({ attempt: 2 }), + (payment) => payment.verifying(new Verifying({ challengeId: "challenge-7" })) + ) + } + }, + payment: { + entry: () => Machine.action(Effect.sync(() => lifecycle?.push("entry:payment"))), + exit: () => Machine.action(Effect.sync(() => lifecycle?.push("exit:payment"))), + initial: ({ state }) => { + onInitialize?.() + return new CardEntry({ cardNumber: `fresh-${state.attempt}` }) + }, + states: { + verifying: { + entry: () => Machine.action(Effect.sync(() => lifecycle?.push("entry:verifying"))), + exit: () => Machine.action(Effect.sync(() => lifecycle?.push("exit:verifying"))) + } + } + } + } + }, + support: { + entry: () => Machine.action(Effect.sync(() => lifecycle?.push("entry:support"))), + exit: () => Machine.action(Effect.sync(() => lifecycle?.push("exit:support"))), + on: { + ResumeShallow: ({ target }) => target.history.checkout.recent(), + ResumeDeep: ({ target }) => target.history.checkout.exact() + } + } + }) + +const waitForPath = ( + actor: Machine.MachineRef, + path: string +) => + actor.changes.pipe( + Stream.filter((snapshot) => snapshot.status === "active" && hasPath(snapshot.state, path)), + Stream.take(1), + Stream.runCollect, + Effect.map((snapshots) => Array.from(snapshots)[0]!) + ) + +const hasPath = (snapshot: unknown, path: string): boolean => { + if (typeof snapshot !== "object" || snapshot === null) return false + const value = snapshot as any + if (value.path === path) return true + if (value.state !== undefined && hasPath(value.state, path)) return true + return value.states !== undefined && Object.values(value.states).some((child) => hasPath(child, path)) +} + +const sendAndWaitForPath = ( + actor: Machine.MachineRef, + event: Event, + path: string +) => + Effect.gen(function*() { + const observer = yield* waitForPath(actor, path).pipe(Effect.forkChild) + yield* actor.send(event) + return yield* Fiber.join(observer) + }) + +const WorkspaceStates = Machine.defineStates({ + workspace: { + schema: Workspace, + type: "parallel", + states: { + editor: { + schema: Editor, + initial: "writing", + states: { + writing: Writing, + preview: Preview + } + }, + sidebar: { + schema: Sidebar, + initial: "files", + states: { + files: Files, + search: Search + } + }, + recent: { + type: "history" + }, + exact: { + type: "history", + history: "deep" + } + } + }, + away: Away +}) + +const activeWorkspace: Machine.Machine.Snapshot = { + path: "workspace", + value: new Workspace({ id: "workspace-1" }), + states: { + editor: { + path: "workspace.editor", + value: new Editor({ documentId: "document-1" }), + state: { + path: "workspace.editor.preview", + value: new Preview({ page: 4 }) + } + }, + sidebar: { + path: "workspace.sidebar", + value: new Sidebar({ width: 320 }), + state: { + path: "workspace.sidebar.search", + value: new Search({ query: "history" }) + } + } + } +} + +const makeWorkspaceMachine = (initialized: Array) => + Machine.make({ + states: WorkspaceStates.states, + events: [LeaveWorkspace, ResumeWorkspaceShallow, ResumeWorkspaceDeep], + initial: () => activeWorkspace + }).handle({ + workspace: { + history: { + recent: { + default: () => + WorkspaceStates.initial.workspace( + new Workspace({ id: "fallback" }), + (workspace) => + workspace + .editor( + new Editor({ documentId: "fallback" }), + (editor) => editor.writing(new Writing({ draft: "" })) + ) + .sidebar( + new Sidebar({ width: 200 }), + (sidebar) => sidebar.files(new Files({ directory: "/" })) + ) + ) + }, + exact: { + default: () => + WorkspaceStates.initial.workspace( + new Workspace({ id: "fallback" }), + (workspace) => + workspace + .editor( + new Editor({ documentId: "fallback" }), + (editor) => editor.writing(new Writing({ draft: "" })) + ) + .sidebar( + new Sidebar({ width: 200 }), + (sidebar) => sidebar.files(new Files({ directory: "/" })) + ) + ) + } + }, + on: { + LeaveWorkspace: ({ target }) => target.full.away(new Away({})) + }, + states: { + editor: { + initial: ({ state }) => { + initialized.push("editor") + return new Writing({ draft: `fresh:${state.documentId}` }) + } + }, + sidebar: { + initial: ({ state }) => { + initialized.push("sidebar") + return new Files({ directory: `/fresh/${state.width}` }) + } + } + } + }, + away: { + on: { + ResumeWorkspaceShallow: ({ target }) => target.history.workspace.recent(), + ResumeWorkspaceDeep: ({ target }) => target.history.workspace.exact() + } + } + }) + +const NestedHistoryStates = Machine.defineStates({ + workspace: { + schema: Workspace, + type: "parallel", + states: { + editor: { + schema: Editor, + initial: "writing", + states: { + writing: Writing, + preview: Preview, + exact: { + type: "history", + history: "deep" + } + } + }, + sidebar: Search + } + } +}) + +const nestedParallelSnapshot: Machine.Machine.Snapshot = { + path: "workspace", + value: new Workspace({ id: "workspace-1" }), + states: { + editor: { + path: "workspace.editor", + value: new Editor({ documentId: "document-1" }), + state: { + path: "workspace.editor.preview", + value: new Preview({ page: 4 }) + } + }, + sidebar: { + path: "workspace.sidebar", + value: new Search({ query: "untouched" }) + } + } +} + +const nestedHistoryMachine = Machine.make({ + states: NestedHistoryStates.states, + events: [RestoreEditor], + initial: () => + NestedHistoryStates.initial.workspace( + new Workspace({ id: "workspace-1" }), + (workspace) => + workspace + .editor( + new Editor({ documentId: "document-1" }), + (editor) => editor.writing(new Writing({ draft: "" })) + ) + .sidebar(new Search({ query: "untouched" })) + ) +}).handle({ + workspace: { + states: { + editor: { + history: { + exact: { + default: () => ({ + path: "workspace.editor", + value: new Editor({ documentId: "fallback" }), + state: { + path: "workspace.editor.writing", + value: new Writing({ draft: "" }) + } + }) + } + }, + states: { + preview: { + on: { + RestoreEditor: { + reenter: true, + transition: ({ target }) => target.history.workspace.editor.exact() + } + } + } + } + } + } + } +}) + +const makeEffectfulInitializerMachine = (initial: Machine.Machine.Snapshot) => + Machine.make({ + states: CheckoutStates.states, + events: [Leave, ResumeShallow], + initial: () => initial + }).handle({ + checkout: { + history: { + recent: { + default: () => checkoutShipping("fallback", "fallback") + }, + exact: { + default: () => checkoutShipping("fallback", "fallback") + } + }, + on: { + Leave: ({ target }) => target.full.support(new Support({ ticket: "ticket-1" })) + }, + states: { + payment: { + initial: ({ state }) => + Effect.gen(function*() { + const defaults = yield* CardDefaults + if (state.attempt < 0) { + return yield* new InitializerFailure({ attempt: state.attempt }) + } + return new CardEntry({ cardNumber: defaults.cardNumber }) + }) + } + } + }, + support: { + on: { + ResumeShallow: ({ target }) => target.history.checkout.recent() + } + } + }) + +describe("Machine history states", () => { + it.effect("uses the typed default before a history record exists", () => + Effect.gen(function*() { + let initialized = 0 + const machine = makeCheckoutMachine( + CheckoutStates.initial.support(new Support({ ticket: "new" })), + () => initialized++ + ) + + const initial = yield* Machine.planInitial(machine) + const resumed = yield* Machine.plan(machine, initial.state, new ResumeDeep({})) + + assert.deepStrictEqual(resumed.next, checkoutShipping("fallback-order", "fallback-address")) + assert.strictEqual(initialized, 0) + })) + + it.effect("deep history restores exact values and is overwritten rather than consumed or stacked", () => + Effect.gen(function*() { + const original = checkoutPaymentVerifying("order-1", 2, "challenge-7") + const machine = makeCheckoutMachine(original) + + const firstLeave = yield* Machine.plan(machine, original, new Leave({})) + assert.deepStrictEqual(Object.keys(firstLeave.next.history ?? {}).sort(), [ + "checkout.exact", + "checkout.recent" + ]) + + const exact = yield* Machine.plan(machine, firstLeave.next, new ResumeDeep({})) + assert.strictEqual(exact.next.path, original.path) + assert.deepStrictEqual(exact.next.value, original.value) + assert.deepStrictEqual((exact.next as any).state, (original as any).state) + assert.deepStrictEqual(exact.next.history, firstLeave.next.history) + + const shipping = yield* Machine.plan(machine, exact.next, new GoShipping({ address: "Second Street" })) + const secondLeave = yield* Machine.plan(machine, shipping.next, new Leave({})) + const resumedOnce = yield* Machine.plan(machine, secondLeave.next, new ResumeDeep({})) + assert.strictEqual(resumedOnce.next.path, "checkout") + assert.deepStrictEqual((resumedOnce.next as any).state, { + path: "checkout.shipping", + value: new Shipping({ address: "Second Street" }) + }) + + const thirdLeave = yield* Machine.plan(machine, resumedOnce.next, new Leave({})) + const resumedTwice = yield* Machine.plan(machine, thirdLeave.next, new ResumeDeep({})) + assert.deepStrictEqual((resumedTwice.next as any).state, (resumedOnce.next as any).state) + })) + + it.effect("shallow history retains parent and direct-child values and freshly initializes descendants", () => + Effect.gen(function*() { + let initialized = 0 + const original = checkoutPaymentVerifying("order-1", 3, "challenge-7") + const machine = makeCheckoutMachine(original, () => initialized++) + + const left = yield* Machine.plan(machine, original, new Leave({})) + const resumed = yield* Machine.plan(machine, left.next, new ResumeShallow({})) + + assert.strictEqual(initialized, 1) + assert.deepStrictEqual(resumed.next.value, new Checkout({ orderId: "order-1" })) + assert.deepStrictEqual((resumed.next as any).state.value, new Payment({ attempt: 3 })) + assert.deepStrictEqual((resumed.next as any).state.state, { + path: "checkout.payment.cardEntry", + value: new CardEntry({ cardNumber: "fresh-3" }) + }) + + const leftAgain = yield* Machine.plan(machine, resumed.next, new Leave({})) + const resumedAgain = yield* Machine.plan(machine, leftAgain.next, new ResumeShallow({})) + assert.strictEqual(initialized, 2) + assert.deepStrictEqual((resumedAgain.next as any).state.state.value, new CardEntry({ cardNumber: "fresh-3" })) + })) + + it.effect("round-trips history and rejects corrupted remembered paths and values", () => + Effect.gen(function*() { + const original = checkoutPaymentVerifying("order-1", 2, "challenge-7") + const machine = makeCheckoutMachine(original) + const left = yield* Machine.plan(machine, original, new Leave({})) + + const encoded = yield* Machine.encodeSnapshot(machine, left.next) + const decoded = yield* Machine.decodeSnapshot(machine, JSON.parse(JSON.stringify(encoded))) + assert.deepStrictEqual(decoded, left.next) + assert.instanceOf(decoded.history?.["checkout.exact"]?.values["checkout"], Checkout) + assert.instanceOf(decoded.history?.["checkout.exact"]?.values["checkout.payment.verifying"], Verifying) + + const invalidPath = structuredClone(encoded) as any + invalidPath.history["checkout.exact"].active.push("checkout.missing") + invalidPath.history["checkout.exact"].values["checkout.missing"] = { _tag: "Verifying", challengeId: "x" } + const pathError = yield* Machine.decodeSnapshot(machine, invalidPath).pipe(Effect.flip) + assert.instanceOf(pathError, Machine.MachineSchemaDecodeError) + assert.strictEqual(pathError.boundary, "history") + + const invalidValue = structuredClone(encoded) as any + invalidValue.history["checkout.exact"].values["checkout.payment.verifying"].challengeId = 123 + const valueError = yield* Machine.decodeSnapshot(machine, invalidValue).pipe(Effect.flip) + assert.instanceOf(valueError, Machine.MachineSchemaDecodeError) + assert.strictEqual(valueError.boundary, "history") + assert.strictEqual(valueError.state, "checkout.payment.verifying") + })) + + it.effect("captures the current subtree before resolving a reentering transition to its own history", () => + Effect.gen(function*() { + let defaults = 0 + const original = checkoutPaymentVerifying("order-1", 2, "challenge-7") + const machine = makeCheckoutMachine(original, undefined, undefined, () => defaults++) + + const reentered = yield* Machine.plan(machine, original, new ReenterHistory({})) + + assert.strictEqual(defaults, 0) + assert.strictEqual(reentered.next.path, "checkout") + assert.deepStrictEqual(reentered.next.value, original.value) + assert.deepStrictEqual((reentered.next as any).state, (original as any).state) + assert.deepStrictEqual(reentered.next.history?.["checkout.exact"]?.active, [ + "checkout", + "checkout.payment", + "checkout.payment.verifying" + ]) + })) + + it.effect("restores every parallel region deeply and initializes each region for shallow history", () => + Effect.gen(function*() { + const initialized: Array = [] + const machine = makeWorkspaceMachine(initialized) + const left = yield* Machine.plan(machine, activeWorkspace, new LeaveWorkspace({})) + + assert.deepStrictEqual(left.next.history?.["workspace.recent"]?.active, [ + "workspace", + "workspace.editor", + "workspace.sidebar" + ]) + assert.deepStrictEqual(left.next.history?.["workspace.exact"]?.active, [ + "workspace", + "workspace.editor", + "workspace.editor.preview", + "workspace.sidebar", + "workspace.sidebar.search" + ]) + + const deep = yield* Machine.plan(machine, left.next, new ResumeWorkspaceDeep({})) + assert.deepStrictEqual((deep.next as any).states, (activeWorkspace as any).states) + assert.deepStrictEqual(initialized, []) + + const leftAgain = yield* Machine.plan(machine, deep.next, new LeaveWorkspace({})) + const shallow = yield* Machine.plan(machine, leftAgain.next, new ResumeWorkspaceShallow({})) + assert.deepStrictEqual(initialized, ["editor", "sidebar"]) + assert.deepStrictEqual((shallow.next as any).states.editor, { + path: "workspace.editor", + value: new Editor({ documentId: "document-1" }), + state: { + path: "workspace.editor.writing", + value: new Writing({ draft: "fresh:document-1" }) + } + }) + assert.deepStrictEqual((shallow.next as any).states.sidebar, { + path: "workspace.sidebar", + value: new Sidebar({ width: 320 }), + state: { + path: "workspace.sidebar.files", + value: new Files({ directory: "/fresh/320" }) + } + }) + })) + + it.effect("restores nested history without replacing an unaffected parallel sibling", () => + Effect.gen(function*() { + const restored = yield* Machine.plan( + nestedHistoryMachine, + nestedParallelSnapshot, + new RestoreEditor({}) + ) + + assert.deepStrictEqual((restored.next as any).states.editor, (nestedParallelSnapshot as any).states.editor) + assert.deepStrictEqual((restored.next as any).states.sidebar, { + path: "workspace.sidebar", + value: new Search({ query: "untouched" }) + }) + assert.deepStrictEqual(restored.next.history?.["workspace.editor.exact"]?.active, [ + "workspace", + "workspace.editor", + "workspace.editor.preview" + ]) + })) + + it.effect("runs effectful shallow initializers with their service and error channels", () => + Effect.gen(function*() { + const defaults = CardDefaults.of({ cardNumber: "4242" }) + const original = checkoutPaymentVerifying("order-1", 4, "challenge-7") + const machine = makeEffectfulInitializerMachine(original) + const left = yield* Machine.plan(machine, original, new Leave({})).pipe( + Effect.provideService(CardDefaults, defaults) + ) + const resumed = yield* Machine.plan(machine, left.next, new ResumeShallow({})).pipe( + Effect.provideService(CardDefaults, defaults) + ) + assert.deepStrictEqual((resumed.next as any).state.state.value, new CardEntry({ cardNumber: "4242" })) + + const invalid = checkoutPaymentVerifying("order-2", -1, "challenge-8") + const invalidLeft = yield* Machine.plan(machine, invalid, new Leave({})).pipe( + Effect.provideService(CardDefaults, defaults) + ) + const error = yield* Machine.plan(machine, invalidLeft.next, new ResumeShallow({})).pipe( + Effect.provideService(CardDefaults, defaults), + Effect.flip + ) + assert.deepStrictEqual(error, new InitializerFailure({ attempt: -1 })) + })) + + it.effect("exits leaf-to-root and re-enters root-to-leaf on every history restoration", () => + Effect.gen(function*() { + const lifecycle: Array = [] + const machine = makeCheckoutMachine( + checkoutShipping("order-1", "Main Street"), + undefined, + lifecycle + ) + const actor = yield* Machine.start(machine) + yield* Effect.yieldNow + yield* sendAndWaitForPath(actor, new EnterVerifying({}), "checkout.payment.verifying") + yield* Effect.yieldNow + lifecycle.length = 0 + + yield* sendAndWaitForPath(actor, new Leave({}), "support") + yield* Effect.yieldNow + assert.deepStrictEqual(lifecycle, [ + "exit:verifying", + "exit:payment", + "exit:checkout", + "entry:support" + ]) + + lifecycle.length = 0 + yield* sendAndWaitForPath(actor, new ResumeDeep({}), "checkout") + yield* Effect.yieldNow + assert.deepStrictEqual(lifecycle, [ + "exit:support", + "entry:checkout", + "entry:payment", + "entry:verifying" + ]) + + lifecycle.length = 0 + yield* sendAndWaitForPath(actor, new Leave({}), "support") + yield* sendAndWaitForPath(actor, new ResumeDeep({}), "checkout") + yield* Effect.yieldNow + assert.deepStrictEqual(lifecycle, [ + "exit:verifying", + "exit:payment", + "exit:checkout", + "entry:support", + "exit:support", + "entry:checkout", + "entry:payment", + "entry:verifying" + ]) + })) +}) diff --git a/typetest/MachineHistory.tst.ts b/typetest/MachineHistory.tst.ts new file mode 100644 index 0000000..d77f6a1 --- /dev/null +++ b/typetest/MachineHistory.tst.ts @@ -0,0 +1,444 @@ +import { Context, Effect, Schema } from "effect" +import { describe, expect, it } from "tstyche" +import { Machine } from "../src/index.js" + +class Checkout extends Schema.TaggedClass("Checkout")("Checkout", { + orderId: Schema.String +}) {} + +class Shipping extends Schema.TaggedClass("Shipping")("Shipping", { + address: Schema.String +}) {} + +class Payment extends Schema.TaggedClass("Payment")("Payment", { + attempt: Schema.Number +}) {} + +class CardEntry extends Schema.TaggedClass("CardEntry")("CardEntry", { + cardNumber: Schema.String +}) {} + +class Verifying extends Schema.TaggedClass("Verifying")("Verifying", { + challengeId: Schema.String +}) {} + +class Support extends Schema.TaggedClass("Support")("Support", {}) {} + +class Resume extends Schema.TaggedClass("Resume")("Resume", {}) {} + +class InitialRequirement extends Context.Service()("test/MachineHistory/InitialRequirement") {} + +const States = Machine.defineStates({ + checkout: { + schema: Checkout, + initial: "shipping", + states: { + shipping: Shipping, + payment: { + schema: Payment, + initial: "cardEntry", + states: { + cardEntry: CardEntry, + verifying: Verifying + } + }, + recent: { + type: "history" + }, + exact: { + type: "history", + history: "deep" + } + } + }, + support: Support +}) + +describe("Machine history states", () => { + it("separates active and history identifiers", () => { + expect>().type.toBe< + | "checkout" + | "checkout.shipping" + | "checkout.payment" + | "checkout.payment.cardEntry" + | "checkout.payment.verifying" + | "support" + >() + expect>().type.toBe< + "checkout.recent" | "checkout.exact" + >() + }) + + it("excludes history pseudo-states from snapshots and initial builders", () => { + type Snapshot = Machine.Machine.Snapshot + expect<"checkout.recent">().type.not.toBeAssignableTo() + States.initial.checkout( + new Checkout({ orderId: "order-1" }), + (checkout) => { + expect(checkout).type.not.toHaveProperty("recent") + expect(checkout).type.not.toHaveProperty("exact") + return checkout.shipping(new Shipping({ address: "Main Street" })) + } + ) + expect(States.get).type.not.toBeCallableWith( + States.initial.support(new Support({})), + "checkout.recent" + ) + }) + + it("exposes zero-argument history targets without value overrides", () => { + const machine = Machine.make({ + states: States.states, + events: [Resume], + initial: () => States.initial.support(new Support({})) + }).handle({ + support: { + on: { + Resume: ({ target }) => { + expect(target.history.checkout.recent).type.toBeCallableWith() + expect(target.history.checkout.recent).type.not.toBeCallableWith(new Checkout({ orderId: "new" })) + expect(target.history.checkout.exact).type.toBeCallableWith() + expect(target.full).type.not.toHaveProperty("recent") + expect(target.local).type.not.toHaveProperty("recent") + expect(target.branch).type.not.toHaveProperty("recent") + return target.history.checkout.exact() + } + } + } + }) + + expect(machine).type.toBeAssignableTo() + }) + + it("requires typed defaults and only the shallow-dependent initializer", () => { + expect>().type.toBe<"checkout.payment">() + + const machine = Machine.make({ + states: States.states, + events: [Resume], + initial: () => States.initial.support(new Support({})) + }).handle({ + support: { + on: { + Resume: ({ target }) => target.history.checkout.recent() + } + } + }) + + expect(Machine.planInitial).type.not.toBeCallableWith(machine) + + const complete = machine.handle({ + checkout: { + history: { + recent: { + default: ({ parent, target }) => { + expect(parent).type.toBe<"checkout">() + expect(target).type.toBe>() + return States.initial.checkout( + new Checkout({ orderId: "fallback" }), + (checkout) => checkout.shipping(new Shipping({ address: "" })) + ) + } + }, + exact: { + default: () => + States.initial.checkout( + new Checkout({ orderId: "fallback" }), + (checkout) => checkout.shipping(new Shipping({ address: "" })) + ) + } + }, + states: { + payment: { + initial: ({ state, parent, parents }) => { + expect(state).type.toBe() + expect(parent).type.toBe() + expect(parents).type.toBe<{ readonly checkout: Checkout }>() + return new CardEntry({ cardNumber: `attempt-${state.attempt}` }) + } + } + } + } + }) + + expect(Machine.planInitial).type.toBeCallableWith(complete) + }) + + it("rejects defaults outside the history parent and wrong initial child values", () => { + const machine = Machine.make({ + states: States.states, + events: [Resume], + initial: () => States.initial.support(new Support({})) + }) + + expect(machine.handle).type.not.toBeCallableWith({ + checkout: { + history: { + recent: { + default: () => States.initial.support(new Support({})) + }, + exact: { + default: () => States.initial.support(new Support({})) + } + } + } + }) + + expect(machine.handle).type.not.toBeCallableWith({ + checkout: { + states: { + payment: { + initial: () => new Verifying({ challengeId: "wrong-child" }) + } + } + } + }) + + expect(machine.handle).type.not.toBeCallableWith({ + checkout: { + states: { + recent: {} + } + } + }) + }) + + it("tracks defaults and shallow initializers across successive handle calls", () => { + const machine = Machine.make({ + states: States.states, + events: [Resume], + initial: () => States.initial.support(new Support({})) + }) + const afterDefaults = machine.handle({ + checkout: { + history: { + recent: { + default: () => + States.initial.checkout( + new Checkout({ orderId: "fallback" }), + (checkout) => checkout.shipping(new Shipping({ address: "" })) + ) + }, + exact: { + default: () => + States.initial.checkout( + new Checkout({ orderId: "fallback" }), + (checkout) => checkout.shipping(new Shipping({ address: "" })) + ) + } + } + } + }) + expect(Machine.planInitial).type.not.toBeCallableWith(afterDefaults) + + const complete = afterDefaults.handle({ + checkout: { + states: { + payment: { + initial: ({ state }) => + Effect.gen(function*() { + const defaults = yield* InitialRequirement + if (state.attempt < 0) { + return yield* Effect.fail("initial-failed" as const) + } + return new CardEntry({ cardNumber: defaults.cardNumber }) + }) + } + } + } + }) + + expect(Machine.planInitial).type.toBeCallableWith(complete) + expect>().type.toBe() + expect>().type.toBe<"initial-failed">() + }) + + it("does not require nested initializers for deep-only history", () => { + const DeepOnlyStates = Machine.defineStates({ + checkout: { + schema: Checkout, + initial: "payment", + states: { + payment: { + schema: Payment, + initial: "cardEntry", + states: { + cardEntry: CardEntry, + verifying: Verifying + } + }, + exact: { + type: "history", + history: "deep" + } + } + }, + support: Support + }) + expect>().type.toBe() + + const machine = Machine.make({ + states: DeepOnlyStates.states, + events: [Resume], + initial: () => DeepOnlyStates.initial.support(new Support({})) + }).handle({ + checkout: { + history: { + exact: { + default: () => ({ + path: "checkout", + value: new Checkout({ orderId: "fallback" }), + state: { + path: "checkout.payment", + value: new Payment({ attempt: 1 }), + state: { + path: "checkout.payment.cardEntry", + value: new CardEntry({ cardNumber: "" }) + } + } + }) + } + } + } + }) + + expect(Machine.planInitial).type.toBeCallableWith(machine) + }) + + it("requires an exact region-value map when shallow restoration descends through a parallel state", () => { + const ParallelStates = Machine.defineStates({ + outer: { + schema: Checkout, + initial: "all", + states: { + all: { + schema: Payment, + type: "parallel", + states: { + shipping: Shipping, + card: CardEntry + } + }, + recent: { + type: "history" + } + } + }, + support: Support + }) + expect>().type.toBe<"outer.all">() + + const machine = Machine.make({ + states: ParallelStates.states, + events: [Resume], + initial: () => ParallelStates.initial.support(new Support({})) + }) + const complete = machine.handle({ + outer: { + history: { + recent: { + default: () => + ParallelStates.initial.outer( + new Checkout({ orderId: "fallback" }), + (outer) => + outer.all( + new Payment({ attempt: 1 }), + (all) => + all + .shipping(new Shipping({ address: "" })) + .card(new CardEntry({ cardNumber: "" })) + ) + ) + } + }, + states: { + all: { + initial: ({ state }) => { + expect(state).type.toBe() + return { + shipping: new Shipping({ address: `attempt-${state.attempt}` }), + card: new CardEntry({ cardNumber: "" }) + } + } + } + } + } + }) + expect(Machine.planInitial).type.toBeCallableWith(complete) + + expect(machine.handle).type.not.toBeCallableWith({ + outer: { + states: { + all: { + initial: () => ({ + shipping: new Shipping({ address: "missing-card" }) + }) + } + } + } + }) + }) + + it("rejects root history nodes and active-state properties on history nodes", () => { + expect(Machine.defineStates).type.not.toBeCallableWith({ + rootHistory: { + type: "history" + } + }) + expect(Machine.defineStates).type.not.toBeCallableWith({ + checkout: { + schema: Checkout, + initial: "shipping", + states: { + shipping: Shipping, + history: { + type: "history", + schema: Support + } + } + } + }) + expect(Machine.defineStates).type.not.toBeCallableWith({ + checkout: { + schema: Checkout, + initial: "shipping", + states: { + shipping: Shipping, + history: { + type: "history", + states: { + child: Support + } + } + } + } + }) + expect(Machine.defineStates).type.not.toBeCallableWith({ + checkout: { + schema: Checkout, + initial: "history", + states: { + shipping: Shipping, + history: { + type: "history" + } + } + } + }) + expect(Machine.defineStates).type.not.toBeCallableWith({ + checkout: { + schema: Checkout, + initial: "shipping", + states: { + shipping: Shipping, + history: { + type: "history", + history: "stack" + } + } + } + }) + }) +})