Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ coverage/

# npm package lock
package-lock.json
yarn.lock
yarn.lock.nightshift/
61 changes: 61 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# xstate-migrate

Migration library for persisted XState v5 state machine snapshots. Generates and applies JSON Patch (RFC 6902) operations to evolve snapshots from one machine version to another.

## Stack

TypeScript, XState v5 (peer dep), fast-json-patch, Jest, Stryker, TLA+

## Feedback commands

Run in order; all must pass before committing:

1. `pnpm test` — Jest unit tests
2. `pnpm test:coverage` — Jest with 80% coverage threshold
3. `pnpm test:mutate` — Stryker mutation testing (break at 70%)

## Knowledge base

Do NOT load all docs upfront. Read this file, then load the specific doc relevant to your current task.

| Topic | Location | Load when |
|-------|----------|-----------|
| Architecture & algorithm | [docs/architecture.md](docs/architecture.md) | Understanding how migration works |
| Testing strategy | [docs/testing-strategy.md](docs/testing-strategy.md) | Writing or modifying tests |
| TLA+ formal verification | [docs/tlaplus/](docs/tlaplus/) | Running or writing TLA+ specs |
| ADRs | [docs/adrs/](docs/adrs/) | Making structural decisions; check precedent |

## Core principles

1. **Context preservation** — Never remove or modify existing context properties. Only `add` operations are generated.
2. **State validity** — Invalid states (removed in new machine version) are replaced with initial values. Valid states are never touched.
3. **Path consistency** — All state path lookups must use the same dot-replacement strategy as `getValidStates` (idMap keys with dots replaced by slashes).
4. **Snapshot immutability** — `applyMigrations` deep-clones before patching. The original snapshot is never mutated.

## Key conventions

- **Single source file**: Core logic lives in `src/migrate.ts` (~92 lines). Keep it small.
- **Types**: `src/types.ts` defines the `XStateMigrate` interface.
- **Tests**: `src/migrate.test.ts` — 3 describe blocks: core migrations, mutation testing survivors, typed input.
- **Internal API access**: Uses `machine.idMap` (undocumented XState internal). Guarded with runtime type check.
- **Peer dependency**: XState `^5.28.0` is a peer dep — consumers provide it.

## Keeping docs current

| If you change... | Then update... |
|------------------|----------------|
| Migration algorithm in `migrate.ts` | `docs/architecture.md` |
| Test strategy or test structure | `docs/testing-strategy.md` |
| A structural decision | Add an ADR in `docs/adrs/` |
| Stack, conventions, or principles | This file (`CLAUDE.md`) |

## Off-limits

- Do NOT use `--no-verify` on git hooks
- Do NOT commit credentials or API keys
- Do NOT modify `machine.idMap` access pattern without understanding XState internals

## Git

- Main branch: `main`
- Remote: `git@github.com:actor-kit/xstate-migrate.git`
29 changes: 29 additions & 0 deletions docs/adrs/2026-03-15-dot-replacement-consistency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Dot Replacement Consistency in State Path Lookups

**Date:** 2026-03-15
**Status:** Accepted

## Context

The `handleStateValue` function has two code paths for validating persisted state values:

1. **Object branch** (line 55): Handles nested state objects where children are strings
2. **String branch** (line 68): Handles top-level string state values

Both construct a lookup path and check it against `validStates`, which is built from `machine.idMap` keys with dots replaced by slashes (line 12).

The string branch applied `.replace(/\./g, '/')` to its lookup path, but the object branch did not. This meant that for machines with dotted IDs (e.g., `id: "my.app"`), the object branch constructed `"my.app/auth/idle"` while the valid set contained `"my/app/auth/idle"`. The lookup failed, causing **valid states to be incorrectly replaced**.

## Decision

Add `.replace(/\./g, '/')` to the object branch path construction (line 55) to match the string branch and `getValidStates`.

## Discovery

Found via TLA+ formal verification (`DotReplaceBug.tla`). The model explored 16 states and flagged the invariant violation. Confirmed with a failing TypeScript test using `id: "my.app"` with parallel regions.

## Consequences

- Machines with dotted IDs now work correctly for nested/parallel state validation
- Both code paths are consistent with the valid states set construction
- The existing test for dotted IDs (line 521) only covered the string branch — two new tests added for object branch coverage at multiple nesting depths
70 changes: 70 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Architecture

## Overview

xstate-migrate exposes two functions via the `XStateMigrate` interface:

1. **`generateMigrations(machine, persistedSnapshot, input?)`** — Compares a persisted snapshot against a new machine version and produces JSON Patch operations.
2. **`applyMigrations(persistedSnapshot, migrations)`** — Deep-clones a snapshot and applies the patch operations.

## Algorithm: generateMigrations

### Phase 1: Context diffing (lines 29-38)

```
persistedContext ──┐
├── fast-json-patch compare() ──→ filter to "add" only ──→ prepend "/context"
initialContext ──┘
```

- Compares persisted context against the new machine's initial context
- Only `add` operations pass the filter — existing properties are NEVER removed or replaced
- Each operation path is prefixed with `/context` for the snapshot structure

### Phase 2: State validation (lines 40-78)

```
machine.idMap ──→ getValidStates() ──→ Set of valid paths (dots replaced with /)
persistedSnapshot.value ──→ handleStateValue() ──→ recursive walk
┌─────────┴──────────┐
Object branch String branch
(nested regions) (leaf state name)
│ │
forEach child key validate path
│ (with dot replacement)
if child is string:
validate path
(with dot replacement)
else:
recurse deeper
```

**Critical detail**: Both branches must apply `.replace(/\./g, '/')` when constructing lookup paths, because `getValidStates` replaces dots in idMap keys. See ADR `2026-03-15-dot-replacement-consistency.md`.

### Phase 3: Combine operations (line 82)

Value operations (state replacements) come first, then context operations (property additions).

## Algorithm: applyMigrations

1. Deep clone via `JSON.parse(JSON.stringify(persistedSnapshot))`
2. Apply all operations via `fast-json-patch.applyPatch()`
3. Return the cloned, patched snapshot

## Internal dependencies

- **`machine.idMap`** — Undocumented XState internal. A `Map<string, StateNode>` where keys are dot-separated state paths (e.g., `"my.app.auth.idle"`). Guarded with runtime type checking (lines 6-15).
- **`fast-json-patch`** — RFC 6902 implementation for compare and apply operations.

## State value shapes

XState snapshots have `.value` in one of these shapes:

| Shape | Example | When |
|-------|---------|------|
| String | `"idle"` | Simple machine, top-level state |
| Object (nested) | `{ parent: "child" }` | Compound state |
| Object (parallel) | `{ regionA: "s1", regionB: "s2" }` | Parallel regions |
| Object (deep) | `{ parent: { child: "grandchild" } }` | Multi-level nesting |
55 changes: 55 additions & 0 deletions docs/testing-strategy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Testing Strategy

## Philosophy

- **TDD**: Red-green-refactor for all changes
- **Mutation testing**: Stryker validates test quality; dedicated "mutation testing survivors" test suite kills escaped mutants
- **Formal verification**: TLA+ models for algorithm-level invariant checking
- **No mocks**: Tests use real XState machines and actors

## Test structure

All tests in `src/migrate.test.ts`:

| Suite | Tests | Purpose |
|-------|-------|---------|
| XState Migration | 11 | Core behavior: context diffing, state validation, nesting, parallel |
| Mutation testing survivors | 8 | Kill specific Stryker mutants: null guards, typeof branches, dotted IDs |
| Typed input | 1 | Machines with `setup()` and runtime dependency injection |

## Key test patterns

### Real machines over fixtures
Tests create real XState machines with `createMachine()`, start actors, transition states, and snapshot — not hand-crafted JSON. This catches issues with actual XState behavior.

### Cast snapshots for edge cases
For testing invalid/weird states that can't be reached through normal machine transitions, tests cast to `AnyMachineSnapshot`:
```typescript
const snapshot = { context: {}, value: { region: 'removed' }, status: 'active' } as unknown as AnyMachineSnapshot;
```

### Mutation survivor tests
When Stryker finds surviving mutants, a targeted test is added to the "Mutation testing survivors" suite with a comment referencing the specific line/condition being tested.

## TLA+ formal verification

TLA+ specs live in `docs/tlaplus/`. They model algorithm invariants that are hard to test exhaustively:

| Spec | States checked | What it verifies |
|------|---------------|------------------|
| DotReplaceBug.tla | 16 | Path construction consistency between object/string branches |
| ContextPreservation.tla | 512 | Add-only filter preserves all persisted context keys |
| StateTraversal.tla | 90 | Valid states preserved, invalid states replaced, no extras |

### Running TLA+ specs

```bash
java -cp /path/to/tla2tools.jar tlc2.TLC SpecName -config SpecName.cfg -workers auto
```

## Stryker configuration

- **Mutate**: `src/**/*.ts` (excluding tests and index)
- **Thresholds**: high=90, low=80, break=70
- **Runner**: Jest
- **Checker**: TypeScript
11 changes: 11 additions & 0 deletions docs/tlaplus/ContextPreservation.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
SPECIFICATION Spec

CONSTANTS
Keys = {"count", "name", "data", "extra"}
Values = {"v1", "v2", "v3"}

INVARIANT
TypeOK
PersistedKeysPreserved
OnlyAddOps
AddedKeysCorrect
108 changes: 108 additions & 0 deletions docs/tlaplus/ContextPreservation.tla
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
-------------------------- MODULE ContextPreservation -------------------------
(*
* TLA+ spec to verify the context migration invariant:
* "Existing context properties must NEVER be lost or modified."
*
* The algorithm (lines 29-38 of migrate.ts):
* 1. compare(persistedContext, initialContext) produces JSON Patch ops
* 2. Filter to only "add" operations
* 3. Prepend "/context" to paths
*
* INVARIANT: After applying migrations, every key-value pair from
* persistedContext must still be present with the same value.
*
* We model context as a set of key-value pairs and simulate what
* compare() would produce for different scenarios.
*)

EXTENDS TLC, FiniteSets, Naturals

CONSTANTS
Keys, \* Universe of possible context keys
Values \* Universe of possible values

VARIABLES
persistedKeys, \* Set of keys in persisted context
initialKeys, \* Set of keys in initial (new machine) context
ops, \* Set of {op, key} operations produced by compare
filteredOps, \* After filtering to "add" only
resultKeys, \* Keys present after applying migrations
done

vars == <<persistedKeys, initialKeys, ops, filteredOps, resultKeys, done>>

\* -----------------------------------------------------------------------
\* JSON Patch compare() produces these operation types:
\* "add" - key exists in target but not source
\* "remove" - key exists in source but not target
\* "replace" - key exists in both but with different value
\*
\* For context migration:
\* source = persistedContext
\* target = initialContext
\* -----------------------------------------------------------------------

\* Model what compare() would produce
CompareOps(persisted, initial) ==
\* Keys only in initial -> add
{ [op |-> "add", key |-> k] : k \in initial \ persisted }
\cup
\* Keys only in persisted -> remove
{ [op |-> "remove", key |-> k] : k \in persisted \ initial }
\cup
\* Keys in both -> could be "replace" if values differ
\* We conservatively model ALL shared keys as potential replaces
{ [op |-> "replace", key |-> k] : k \in persisted \cap initial }

\* Filter to add-only
FilterAdd(operations) ==
{ o \in operations : o.op = "add" }

\* -----------------------------------------------------------------------
Init ==
/\ persistedKeys \in SUBSET Keys
/\ initialKeys \in SUBSET Keys
/\ ops = {}
/\ filteredOps = {}
/\ resultKeys = {}
/\ done = FALSE

GenerateOps ==
/\ ~done
/\ ops' = CompareOps(persistedKeys, initialKeys)
/\ filteredOps' = FilterAdd(CompareOps(persistedKeys, initialKeys))
\* Result = persisted keys + newly added keys
/\ resultKeys' = persistedKeys \cup { o.key : o \in FilterAdd(CompareOps(persistedKeys, initialKeys)) }
/\ done' = TRUE
/\ UNCHANGED <<persistedKeys, initialKeys>>

Done ==
/\ done
/\ UNCHANGED vars

Next == GenerateOps \/ Done

Spec == Init /\ [][Next]_vars

\* -----------------------------------------------------------------------
\* INVARIANTS
\* -----------------------------------------------------------------------

\* Every persisted key must survive migration
PersistedKeysPreserved ==
done => persistedKeys \subseteq resultKeys

\* No "remove" or "replace" operations should be in the filtered set
OnlyAddOps ==
done => \A o \in filteredOps : o.op = "add"

\* Added keys come from initial context only
AddedKeysCorrect ==
done => \A o \in filteredOps : o.key \in initialKeys

TypeOK ==
/\ persistedKeys \subseteq Keys
/\ initialKeys \subseteq Keys
/\ done \in BOOLEAN

=============================================================================
9 changes: 9 additions & 0 deletions docs/tlaplus/DotReplaceBug.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
SPECIFICATION Spec

CONSTANTS
RegionKeys = {"auth", "nav"}
StateNames = {"idle", "active"}

INVARIANT
TypeOK
NoBug
Loading
Loading