diff --git a/.claude/skills/refrakt/SKILL.md b/.claude/skills/refrakt/SKILL.md new file mode 100644 index 0000000..48edd6c --- /dev/null +++ b/.claude/skills/refrakt/SKILL.md @@ -0,0 +1,154 @@ +--- +name: refrakt +description: How to use Refrakt signals-based state management library +--- + +Refrakt is a lightweight state management library built on TC39 signals. + +## Modules + +- `refrakt` — curated entry point: re-exports the primary constructors and core types +- `refrakt/signal.js` — signal primitives: `signal`, `computed`, `effect`, `untrack` +- `refrakt/reducer.js` — pure reducer-based state: `reducer`, `withLogging` +- `refrakt/store.js` — transactional state with effects: `store`, `tx`, `noFx`, `withLogging` +- `refrakt/send.js` — action utilities: `forward` +- `refrakt/scope.js` — scoped child stores: `scope` +- `refrakt/iter.js` — async iterator utilities: `mergeAsync`, `sequenceAsync`, `mapAsync` +- `refrakt/never.js` — exhaustiveness helpers: `assertNever`, `NeverError` + +## Reducer (pure state, no effects) + +Use `reducer` when you only need state transitions with no side effects. + +```ts +import { type Reducer, reducer } from "refrakt/reducer.js"; +import { assertNever } from "refrakt/never.js"; + +type Action = { type: "increment" } | { type: "decrement" }; + +const update: Reducer = (state, action) => { + switch (action.type) { + case "increment": + return state + 1; + case "decrement": + return state - 1; + default: + return assertNever(action); + } +}; + +const counter = reducer(update, 0); +counter.send({ type: "increment" }); +counter.get(); // 1 +``` + +## Store (state + effects) + +Use `store` when you need side effects. The update function returns a transaction via `tx(state, fx?)`. Effects are async generators that yield actions back to the store. The `state()` callback passed to fx reads current store state — use it to check flags and cancel effects. + +```ts +import { type Update, store, tx } from "refrakt/store.js"; +import { assertNever } from "refrakt/never.js"; + +type Model = { isRunning: boolean; elapsed: number }; +type Action = { type: "start" } | { type: "stop" } | { type: "tick" }; + +const update: Update = (state, action) => { + switch (action.type) { + case "start": + return tx( + { ...state, isRunning: true } as Model, + async function* (state) { + while (state().isRunning) { + await delay(1000); + if (state().isRunning) { + yield { type: "tick" }; + } + } + }, + ); + case "stop": + return tx({ ...state, isRunning: false } as Model); + case "tick": + return tx({ ...state, elapsed: state.elapsed + 1 } as Model); + default: + return assertNever(action); + } +}; + +const myStore = store(update, { isRunning: false, elapsed: 0 }); +``` + +### Store with context + +Pass a third argument to `store` for dependency injection: + +```ts +const update: Update = ( + state, + action, + context, +) => { + switch (action.type) { + case "fetch": + return tx({ ...state, loading: true } as Model, async function* () { + const data = await context.api.getData(); + yield { type: "set", data }; + }); + // ... + } +}; + +const myStore = store(update, initialState, { api: myApi }); +``` + +## Signals + +```ts +import { signal, computed, effect, untrack } from "refrakt/signal.js"; + +const count = signal(0); +const doubled = computed(() => count.get() * 2); + +// Effects are batched to microtask +const cleanup = effect(() => { + console.log(count.get()); +}); + +// Read without tracking +const value = untrack(() => count.get()); +``` + +## Scope + +Create a child store that projects a subset of parent state and maps actions: + +```ts +import { scope } from "refrakt/scope.js"; + +const child = scope({ + store: parentStore, + get: (state) => state.child, + tag: (action) => ({ type: "child", action }), +}); +``` + +## Forward + +Transform a send function to tag actions: + +```ts +import { forward } from "refrakt/send.js"; + +const sendChild = forward(parentStore.send, (action) => ({ + type: "child", + action, +})); +``` + +## Key patterns + +- **Exhaustive switches**: Use `assertNever(action)` (from `refrakt/never.js`) in the default arm. TypeScript will error if the switch isn't exhaustive. +- **Cancelling effects**: Use `state()` inside fx to read current state. Check a flag (e.g. `isRunning`) to break out of loops. +- **Atomic check-then-update**: The update function sees state at the moment of dispatch, preventing race conditions. +- **`as Model` assertions**: Use `as Model` when spreading state in `tx()` calls to help TypeScript infer the transaction type. diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index dd96310..b3d5cd8 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -26,5 +26,9 @@ jobs: node-version: ${{ matrix.node-version }} cache: "npm" - run: npm ci + - run: npm run check + - run: npm run check:examples + - run: npm run lint + - run: npm run check-fmt - run: npm run build --if-present - run: npm test diff --git a/README.md b/README.md index 709fd09..0d1ab7a 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,233 @@ # Refrakt: state management with signals -A lightweight, scalable state management library built on top of signals. Pairs well with [Lit](https://lit.dev/) and other frameworks that support [TC39 signals](https://github.com/proposal-signals/signal-polyfill). +A lightweight state management library built on top of signals. Pairs well with [Lit](https://lit.dev/) and other frameworks that support [TC39 signals](https://github.com/proposal-signals/signal-polyfill). -At its core, Refrakt is just a signal defined with a reducer. But don't underestimate it! Using middleware, you can scale Refrakt all the way up into a powerful store with managed side effects and more. +Refrakt is built around a simple concept: define a signal with a reducer, send actions to update it. ## Features - **Fine-grained reactivity**: Built on top of TC39 signals. -- **Effects**: Optional managed side effects via async generators. -- **Middleware**: Enhance store behavior via function composition. +- **Managed fx**: Elm-style transactional fx management. +- **Scoped stores**: Create child stores that project parent state and tag actions. - **Minimal dependencies**: Uses only `signal-polyfill` library for maximum compatibility. +## Importing + +Refrakt is published as ES modules. The package entry point re-exports the +primary constructors and core types: + +```typescript +import { signal, computed, effect, reducer, store, tx, scope } from "refrakt"; +``` + +Every module is also available as a subpath import. Reach for these to pull in +long-tail helpers such as `withLogging`, `untrack`, `noFx`, and the `iter` +async-iterator utilities, which are intentionally kept off the main entry point: + +```typescript +import { signal, computed, effect, untrack } from "refrakt/signal.js"; +import { reducer, withLogging } from "refrakt/reducer.js"; +import { store, tx, noFx, withLogging } from "refrakt/store.js"; +import { scope } from "refrakt/scope.js"; +import { forward } from "refrakt/send.js"; +import { assertNever } from "refrakt/never.js"; +import { mergeAsync, sequenceAsync, mapAsync } from "refrakt/iter.js"; +``` + +The examples below use subpath imports throughout. + ## Example Here's a simple counter example using [Lit](https://lit.dev/) for UI. ```typescript -import { store } from "refrakt"; -import { LitElement, html } from 'lit'; -import { customElement } from 'lit/decorators.js'; -import { SignalWatcher } from '@lit-labs/signals'; +import { reducer } from "refrakt/reducer.js"; +import { assertNever } from "refrakt/never.js"; +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { SignalWatcher } from "@lit-labs/signals"; type Model = { count: number }; -type Action = { type: 'inc' } | { type: 'dec' }; +type Action = { type: "inc" } | { type: "dec" }; -const updateCounter = (state: Model, action: Action) => { +const update = (state: Model, action: Action): Model => { switch (action.type) { - case 'inc': return { count: state.count + 1 }; - case 'dec': return { count: state.count - 1 }; - default: return state; + case "inc": + return { count: state.count + 1 }; + case "dec": + return { count: state.count - 1 }; + default: + return assertNever(action); } }; -const counter = store(updateCounter, { count: 0 }); +const counter = reducer(update, { count: 0 }); -@customElement('counter-app') +@customElement("counter-app") class CounterApp extends SignalWatcher(LitElement) { render() { return html`

Count: ${counter.get().count}

- - + +
`; } } ``` -## Store +## Reducer -`store()` creates a signal that can only be updated via its reducer function. It's conceptually similar to React's `useReducer` hook, except it's based on signals. +`reducer()` creates a signal updated via a pure reducer function. It works like React's `useReducer()` hook, except it's a signal. ```typescript -import { store } from 'refrakt'; +import { reducer } from "refrakt/reducer.js"; +import { assertNever } from "refrakt/never.js"; type CounterAction = - | { type: 'increment' } - | { type: 'decrement' } - | { type: 'set', value: number }; + | { type: "increment" } + | { type: "decrement" } + | { type: "set"; value: number }; + +const update = (state: number, action: CounterAction): number => { + switch (action.type) { + case "increment": + return state + 1; + case "decrement": + return state - 1; + case "set": + return action.value; + default: + return assertNever(action); + } +}; -type CounterModel = { count: number }; +const counterStore = reducer(update, 0); + +counterStore.send({ type: "increment" }); +console.log(counterStore.get()); // 1 +``` + +## Store -const update = (state: CounterModel, action: CounterAction) => { +`store()` returns a signal of exactly the same type as `reducer()`, but with additional support for managed side-effects. Instead of returning just the next state, a store's reducer returns a transaction object containing the next state _and_ optional side-effects. Side-effects are modeled as async generator functions that yield zero or more actions back to the store. + +```typescript +import { store, tx, type Tx } from "refrakt/store.js"; +import { assertNever } from "refrakt/never.js"; + +type Model = { count: number; fetching: boolean }; + +type Action = + | { type: "increment" } + | { type: "fetch" } + | { type: "fetch-complete"; value: number }; + +const update = (state: Model, action: Action): Tx => { switch (action.type) { - case 'increment': - return { count: state.count + 1 }; - case 'decrement': - return { count: state.count - 1 }; - case 'set': - return { count: action.value }; + case "increment": + return tx({ ...state, count: state.count + 1 }); + case "fetch": + // State update and fx in a single transaction + return tx({ ...state, fetching: true }, async function* () { + const response = await fetch("/api/count"); + const data = await response.json(); + yield { type: "fetch-complete", value: data.count }; + }); + case "fetch-complete": + return tx({ ...state, count: action.value, fetching: false }); default: - return state; + return assertNever(action); } }; -const counterStore = store(update, { count: 0 }); +const counterStore = store(update, { count: 0, fetching: false }); +counterStore.send({ type: "fetch" }); // Sets `fetching` and kicks off fx generator +counterStore.get().fetching; // true +``` + +`tx(state, fx?)` offers a convenience function for creating transaction objects. Transactions are just plain objects with state and fx properties: -// Send actions to update state -counterStore.send({ type: 'increment' }); -console.log(counterStore.get().count); // 1 +```typescript +type Tx = { + state: Model; + fx: (state: () => Model) => AsyncGenerator; +}; ``` -All actions go through the update function. There is no other way to update the store's state. This gives you consistent and predictable state management that is easy to test. By writing unit tests for the update function, you can ensure that your application's state is always valid. +The fx generator function also receives a `state()` function, which it can use to check on the state of the store after time has elapsed. + +```typescript +async function* (state: () => Model) { + await sleep(2000); + if (state().cancel) { + return; + } + yield { type: "msg", value: "hello world" } +} +``` + +### Transactional side-effects + +Why store? Why transactions? For simple side-effects, a signal or reducer combined with `effect()` may be enough. For example, this gives you the equivalent of React's `useEffect()`: + +```typescript +import { effect } from "refrakt/signal.js"; +import { reducer } from "refrakt/reducer.js"; + +const state = reducer((state: Model, action: Action) => { + // ... +}, initial); + +// Fires every time state changes +effect(() => { + service.doSomething(state.get()); +}); +``` + +However, when side-effects become sufficiently complex, you may want to reach for a store. The key advantage is that store lets you implement structured and **transactional** side-effects. + +**Fx** are issued in response to actions _during the same transaction as the state_. This means you can implement atomic check-then-update-then-fx patterns in response to actions. For example, preventing duplicate fetches: + +```typescript +case 'fetch': + // Already fetching? Do nothing. + if (state.fetching) { + return tx(state); + } + // Set flag AND issue fx atomically + return tx( + { ...state, fetching: true }, + async function* () { + const data = await fetchData(); + yield { type: 'fetch-complete', value: data }; + } + ); +``` + +Because each update runs sequentially and atomically, and the flag and fx are set during the same transaction, there is no window where a duplicate fetch can slip through. It can be difficult to achieve this kind of atomic control over effects when state and effects are handled separately. + +### Context + +`store()` accepts an optional third `context` argument, passed to the update function on every action: -You can create multiple stores for different components, or create a single central store for your entire application state. It's up to you! +```typescript +const update: Update = (state, action, services) => { + // ... +}; + +const myStore = store(update, initialState, services); +``` -Because stores are just signals, you can use computed signals to scope store state, or even combine state from multiple stores. Signals give you a lot of flexibility to mix and match approaches. Refrakt even offers a `scope` middleware to create scoped child stores from parent stores (see below). +This can be used to expose external services to the update function. ## Signals The signals module re-exports the TC39 signals polyfill, as well as providing a handful of convenience functions. ```typescript -import { signal, computed, effect } from 'refrakt/signal.js'; +import { signal, computed, effect } from "refrakt/signal.js"; // Create a `State` signal const count = signal(10); @@ -107,7 +241,7 @@ When you want to react to signal changes, you can use `effect`. Effects are auto ```ts // React to changes const cleanup = effect(() => { - console.log('Count:', count.get(), 'Doubled:', doubled.get()); + console.log("Count:", count.get(), "Doubled:", doubled.get()); }); count.set(20); // Logs: "Count: 20 Doubled: 40" @@ -118,145 +252,67 @@ Because stores are just another signal, you can use `computed` to scope down sta ```ts // Only updates when username changes -const username = computed(() => store.get().account.profile.username); +const username = computed(() => myStore.get().account.profile.username); ``` +## Logging -## Fx middleware - -While `effect()` is great for running simple side-effects whenever data changes, you sometimes want a more structured approach for managing complex side-effects. The `fx` middleware provides a powerful way to handle managed side effects using async generators. - -The `fx` middleware takes a single function that returns an async generator. +Both `store` and `reducer` provide a `withLogging` function that wraps an update/reducer function with debug logging. ```typescript -type Fx = ( - state: () => Model, // Get current state - action: Action // Action triggering this effect -) => AsyncGenerator; // Yielded actions are sent back to store -``` +import { store, tx, withLogging, type Update } from "refrakt/store.js"; -The effect generator function is called for each new action sent to the store, allowing it to perform async work and yield back zero or more actions in response. - -```typescript -import { store, pipe } from 'refrakt'; -import { fx, type Fx } from 'refrakt/middleware/fx.js'; - -const fetchProfileFx: Fx = async function* (state, action) { - if (action.type === "fetch-profile") { - const response = await fetch("/api/v1/profile", { - method: "GET", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(action.id), - }); - - const json = await response.json(); - - yield { - type: "fetch-profile-success", - value: json - }; - - return; - } +const update: Update = (state, action) => { + // ... }; -// Apply middleware -const appStore = pipe( - store(appReducer, initialState), - fx(fetchProfileFx) -); - -// Trigger effect -appStore.send({ type: 'fetch-profile' }); -```` - -The effect generator function also receives a getter function that returns the current state. This allows effects to decide if they should keep running by checking the application state, giving us a simple mechanism for effect cancellation. - -```typescript -type AppAction = - | { type: 'start-clock' } - | { type: 'stop-clock' } - | { type: 'tick' }; - -const clockFx: Fx = async function* (state, action) { - if (action.type === "start-clock") { - // Run effect until application model says to stop - while (state().isClockRunning === true) { - yield { type: 'tick' }; - await sleep(1000); - } - } -}; +const loggedUpdate = withLogging(update); +const myStore = store(loggedUpdate, initialState); ``` -`fx` can also take an additional `context` parameter. This can be used to pass additional information to the effect generator, such as services for performing I/O operations. +```typescript +import { reducer, withLogging, type Reducer } from "refrakt/reducer.js"; -```ts -import services from './services.js'; - -const loginFx: Fx = async function* (state, action, context) { - if (action.type === "login") { - const success = await auth.login(); - if (success) { - yield { type: 'login-success' }; - } else { - yield { type: 'login-failure' }; - } - } +const step: Reducer = (state, action) => { + // ... }; -const appStore = pipe( - store(appReducer, initialState), - fx(loginFx, services) -); -```` - -Because effects are just async generators, they can be easily composed and mapped. The `iter` submodule provides a handful of useful utility functions for merging and mapping async generators: +const loggedStep = withLogging(step, { name: "myReducer" }); +const myStore = reducer(loggedStep, initialState); +``` -- `mergeAsync(...iterables)` - Merge multiple async iterables, yielding values in interleaved order as they become available -- `sequenceAsync(...iterables)` - Sequence async iterables, yielding all values from the first before moving to the next -- `mapAsync(iterable, transform)` - Transform each value in an async iterable using a sync or async function +Example output: -## Logger middleware +``` +<- store { type: 'increment' } +-> store { count: 1 } +``` -Logs all actions and state changes to the console: +You can also pass a `log` predicate to conditionally enable logging: ```typescript -import { store, pipe } from 'refrakt'; -import { logger } from 'refrakt/middleware/logger.js'; - -const myStore = pipe( - store(update, initialState), - logger({ prefix: 'MyStore: ' }) -); -``` - -Example output: -``` -MyStore: < { type: 'increment' } -MyStore: > { count: 1 } +const loggedUpdate = withLogging(update, { + log: () => import.meta.env.DEV, +}); ``` -## Scope middleware +## Scope -Scope lets you create a scoped child store from a parent store. It returns a new store that is indistinguishable from a top-level store. However, this child store's state is derived from the parent state, and all messages are routed through the parent store. +`scope` creates a child store from a parent store. The child store's state is derived from the parent state, and all actions are tagged and routed through the parent store. ```typescript -import { store, pipe } from 'refrakt'; -import { scope } from 'refrakt/middleware/scope.js'; - -const childStore = pipe( - parentStore, - scope( - // Get child state from parent state - (state: Model) => state.child, - // Tag child actions, transforming them into parent actions - (action: ChildAction) => ({ - type: "child", - value: action - }) - ) -); +import { scope } from "refrakt/scope.js"; + +const childStore = scope({ + store: parentStore, + // Project parent state to child state + get: (state: ParentModel) => state.child, + // Tag child actions, transforming them into parent actions + tag: (action: ChildAction): ParentAction => ({ + type: "child", + action, + }), +}); ``` One way you can use scope is to create components that can be used in _either_ an island architecture style, or in a more Elmish subcomponent style. @@ -265,17 +321,17 @@ Components can be initialized with their own store by default. This store can be ```typescript // child-component.ts -import { store, type Store } from "refrakt"; -import { LitElement, html } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; -import { watch } from '@lit-labs/signals'; +import { store, tx, type StoreSignal } from "refrakt/store.js"; +import { LitElement, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { watch } from "@lit-labs/signals"; // ... -@customElement('child-component') +@customElement("child-component") class ChildComponent extends LitElement { @property({ attribute: false }) - store: Store = store(update, { count: 0 }); + store: StoreSignal = store(update, { count: 0 }); // ... } @@ -283,28 +339,25 @@ class ChildComponent extends LitElement { ```typescript // parent-component.ts -import { pipe } from "refrakt"; -import { scope } from "refrakt/middleware/scope.js"; -import { LitElement, html } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { scope } from "refrakt/scope.js"; +import { LitElement, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; import * as ChildComponent from "./child-component.js"; // ... -const childStore = pipe( - parentStore, - scope( - (state: ParentModel) => state.child, - (msg: ChildAction): ParentAction => ({ type: "child", msg }) - ), -); +const childStore = scope({ + store: parentStore, + get: (state: ParentModel) => state.child, + tag: (action: ChildAction): ParentAction => ({ type: "child", action }), +}); -@customElement('parent-component') +@customElement("parent-component") class ParentComponent extends LitElement { render() { return html`
- +
`; } @@ -313,63 +366,19 @@ class ParentComponent extends LitElement { Because scoped stores are indistinguishable from parent stores, you can replace the default child store, and the child component will be none the wiser. This allows for a form of dependency injection where parent components can intercept and react to child actions, as well as customize child component behavior. -## Custom Middleware +## Async Iterator Utilities -Middleware are just functions of `(store: Store) => Store` that wrap the store, returning a new store with enhanced behavior. +The `iter` submodule provides utility functions for working with async generators. These can be useful for merging and mapping fx between component domains. -That means you can simply pass the store to a middleware function: - -```ts -const loggerMiddleware = logger(); -const myStore = loggerMiddleware(store(update, initial)); -``` - -Simple! However, if you're applying more than one middleware, these nested function calls can get a little tedious. `pipe()` makes this a bit more ergonomic. It applies multiple middleware functions to the store from left-to-right, returning the final decorated store: - -```typescript -import { store, pipe } from 'refrakt'; -import { fx } from 'refrakt/middleware/fx.js'; -import { logger } from 'refrakt/middleware/logger.js'; - -const counterStore = pipe( - store(update, { count: 0 }), - logger({ prefix: 'Counter: ' }), - fx(mySaga) -); -``` - -This compositional approach makes it easy to add, remove, or write your own middleware. Just write a function that takes a store and returns a new store with enhanced behavior: - -```typescript -import type { Store } from 'refrakt'; - -const timingMiddleware = () => - (store: Store): Store => { - const timedSend = (action: Action) => { - const start = performance.now(); - store.send(action); - const duration = performance.now() - start; - console.log(`Action took ${duration}ms`); - }; - - return { - get: store.get, - send: timedSend - }; - }; - -// Use with pipe -const myStore = pipe( - store(update, initialState), - timingMiddleware() -); -``` +- `mergeAsync(...iterables)` - Merge multiple async iterables, yielding values in interleaved order as they become available +- `sequenceAsync(...iterables)` - Sequence async iterables, yielding all values from the first before moving to the next +- `mapAsync(iterable, transform)` - Transform each value in an async iterable using a sync or async function ## Utility Functions -- `action(type, value)` - Create tagged actions: `action('set', 42)` → `{ type: 'set', value: 42 }` -- `forward(send, transform)` - Decorates send function so that it transform actions before sending -- `updateUnknown(state, action)` - Default handler for unknown actions (logs warning) +- `forward(send, tag)` - Transform a send function so that it tags actions on the way out (`refrakt/send.js`) +- `tx(state, fx?)` - Create a transaction with state and optional fx (`refrakt/store.js`) +- `assertNever(value)` - Enforces exhaustive switches via `never` type. Use in reducers to enforce exhaustive action handling (available in `refrakt/never.js`) ## License diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..1eab5fa --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,16 @@ +import tseslint from "typescript-eslint"; + +export default tseslint.config( + tseslint.configs.recommended, + { + ignores: ["dist/"], + }, + { + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }, + ], + }, + }, +); diff --git a/examples/simple-counter/main.ts b/examples/simple-counter/main.ts new file mode 100644 index 0000000..d04ea7d --- /dev/null +++ b/examples/simple-counter/main.ts @@ -0,0 +1,32 @@ +/// A simple counter using Reducer +import { type Reducer, reducer } from "refrakt/reducer.js"; +import { effect } from "refrakt/signal.js"; +import { assertNever } from "refrakt/never.js"; + +type Action = { type: "increment" } | { type: "decrement" } | { type: "reset" }; + +const update: Reducer = (state, action) => { + switch (action.type) { + case "increment": + return state + 1; + case "decrement": + return state - 1; + case "reset": + return 0; + default: + return assertNever(action); + } +}; + +const counter = reducer(update, 0); + +// Use an effect to log state changes +effect(() => { + console.log("Count:", counter.get()); +}); + +counter.send({ type: "increment" }); +counter.send({ type: "increment" }); +counter.send({ type: "increment" }); +counter.send({ type: "decrement" }); +counter.send({ type: "reset" }); diff --git a/examples/stopwatch/main.ts b/examples/stopwatch/main.ts new file mode 100644 index 0000000..8abe70e --- /dev/null +++ b/examples/stopwatch/main.ts @@ -0,0 +1,74 @@ +/// A stopwatch using Store with fx +/// Demonstrates using state() within an effect to read current state +/// and cancel the effect when isRunning becomes false. +import { type Update, store, tx } from "refrakt/store.js"; +import { effect } from "refrakt/signal.js"; +import { assertNever } from "refrakt/never.js"; + +type Model = { + isRunning: boolean; + elapsed: number; +}; + +type Action = + | { type: "start" } + | { type: "stop" } + | { type: "reset" } + | { type: "tick" }; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const update: Update = (state, action) => { + switch (action.type) { + case "start": + return tx( + { ...state, isRunning: true } as Model, + async function* (state) { + // Tick every second while running. + // state() reads current store state, so we can check the + // isRunning flag to know when to stop. + while (state().isRunning) { + await delay(1000); + // Check again after the delay, since state may have changed + if (state().isRunning) { + yield { type: "tick" }; + } + } + }, + ); + case "stop": + return tx({ ...state, isRunning: false } as Model); + case "reset": + return tx({ ...state, elapsed: 0, isRunning: false } as Model); + case "tick": + return tx({ ...state, elapsed: state.elapsed + 1 } as Model); + default: + return assertNever(action); + } +}; + +const stopwatch = store(update, { isRunning: false, elapsed: 0 }); + +// Log state changes +effect(() => { + const { isRunning, elapsed } = stopwatch.get(); + const minutes = Math.floor(elapsed / 60); + const seconds = elapsed % 60; + const time = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; + console.log(`${time} ${isRunning ? "(running)" : "(stopped)"}`); +}); + +// Start the stopwatch +stopwatch.send({ type: "start" }); + +// Stop after 5 seconds +setTimeout(() => { + stopwatch.send({ type: "stop" }); + console.log("Stopped"); + + // Reset after another second + setTimeout(() => { + stopwatch.send({ type: "reset" }); + console.log("Reset"); + }, 1000); +}, 5000); diff --git a/examples/tsconfig.json b/examples/tsconfig.json new file mode 100644 index 0000000..9edb472 --- /dev/null +++ b/examples/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "..", + "paths": { + "refrakt/*": ["../src/*"] + } + }, + "include": ["**/*.ts"] +} diff --git a/package-lock.json b/package-lock.json index 54b7abd..3946c60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,192 @@ }, "devDependencies": { "@types/node": "^24.8.0", - "typescript": "^5.7.2" + "eslint": "^10.1.0", + "prettier": "^3.8.1", + "typescript": "^5.7.2", + "typescript-eslint": "^8.57.2" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.3", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.1.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.8.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.0.tgz", @@ -23,7 +206,904 @@ "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.14.0" + "undici-types": "~7.14.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", + "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", + "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.3", + "@eslint/config-helpers": "^0.5.3", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" } }, "node_modules/signal-polyfill": { @@ -32,6 +1112,49 @@ "integrity": "sha512-p63Y4Er5/eMQ9RHg0M0Y64NlsQKpiu6MDdhBXpyywRuWiPywhJTpKJ1iB5K2hJEbFZ0BnDS7ZkJ+0AfTuL37Rg==", "license": "Apache-2.0" }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -46,12 +1169,85 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz", + "integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.2", + "@typescript-eslint/parser": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/undici-types": { "version": "7.14.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", "dev": true, "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 34d9d4f..e5cf167 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.3.2", "description": "A lightweight, scalable state management library built on top of TC39 signals", "type": "module", + "sideEffects": false, "exports": { ".": { "types": "./dist/main.d.ts", @@ -16,25 +17,25 @@ "types": "./dist/signal.d.ts", "import": "./dist/signal.js" }, + "./send.js": { + "types": "./dist/send.d.ts", + "import": "./dist/send.js" + }, "./iter.js": { "types": "./dist/iter.d.ts", "import": "./dist/iter.js" }, - "./pipe.js": { - "types": "./dist/pipe.d.ts", - "import": "./dist/pipe.js" - }, - "./middleware/logger.js": { - "types": "./dist/middleware/logger.d.ts", - "import": "./dist/middleware/logger.js" + "./reducer.js": { + "types": "./dist/reducer.d.ts", + "import": "./dist/reducer.js" }, - "./middleware/fx.js": { - "types": "./dist/middleware/fx.d.ts", - "import": "./dist/middleware/fx.js" + "./scope.js": { + "types": "./dist/scope.d.ts", + "import": "./dist/scope.js" }, - "./middleware/scope.js": { - "types": "./dist/middleware/scope.d.ts", - "import": "./dist/middleware/scope.js" + "./never.js": { + "types": "./dist/never.d.ts", + "import": "./dist/never.js" } }, "files": [ @@ -46,9 +47,15 @@ "LICENSE-MIT" ], "scripts": { + "prebuild": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", "build": "tsc", - "prepublishOnly": "npm run build", - "test": "npm run build && node --test \"dist/**/*.test.js\"" + "prepublishOnly": "npm run check && npm run check:examples && npm run lint && npm run check-fmt && npm run build", + "test": "npm run build && node --test \"dist/**/*.test.js\"", + "check": "tsc --noEmit", + "check:examples": "tsc -p examples/tsconfig.json", + "fmt": "prettier --write .", + "lint": "eslint .", + "check-fmt": "prettier --check ." }, "keywords": [ "signals", @@ -68,7 +75,10 @@ }, "devDependencies": { "@types/node": "^24.8.0", - "typescript": "^5.7.2" + "eslint": "^10.1.0", + "prettier": "^3.8.1", + "typescript": "^5.7.2", + "typescript-eslint": "^8.57.2" }, "repository": { "type": "git", diff --git a/src/iter.test.ts b/src/iter.test.ts index eaf0752..8c2bf62 100644 --- a/src/iter.test.ts +++ b/src/iter.test.ts @@ -169,10 +169,10 @@ test("mapAsync - propagates mapper errors", async () => { return x * 2; }; - await assert.rejects( - () => collectAll(mapAsync(source, mapper)), - { name: "Error", message: "Test error" }, - ); + await assert.rejects(() => collectAll(mapAsync(source, mapper)), { + name: "Error", + message: "Test error", + }); }); test("Complex scenario - combining all utilities", async () => { @@ -224,10 +224,10 @@ test("Edge case - iterator that throws during iteration", async () => { throw new Error("Iterator error"); } - await assert.rejects( - () => collectAll(mergeAsync(errorIterator())), - { name: "Error", message: "Iterator error" }, - ); + await assert.rejects(() => collectAll(mergeAsync(errorIterator())), { + name: "Error", + message: "Iterator error", + }); }); test("Edge case - concurrent access to same iterator", async () => { @@ -235,11 +235,7 @@ test("Edge case - concurrent access to same iterator", async () => { const iterator = toAsyncIterator(source); // Try to access the same iterator concurrently - const promises = [ - iterator.next(), - iterator.next(), - iterator.next(), - ]; + const promises = [iterator.next(), iterator.next(), iterator.next()]; const results = await Promise.all(promises); @@ -247,5 +243,8 @@ test("Edge case - concurrent access to same iterator", async () => { assert.strictEqual(results[0].value, 1); assert.strictEqual(results[1].value, 2); assert.strictEqual(results[2].value, 3); - assert.strictEqual(results.every((r) => !r.done), true); + assert.strictEqual( + results.every((r) => !r.done), + true, + ); }); diff --git a/src/iter.ts b/src/iter.ts index a085a36..3672feb 100644 --- a/src/iter.ts +++ b/src/iter.ts @@ -1,9 +1,9 @@ /** A promise or value */ export type Awaitable = Promise | T; -export const toAsyncIterator = ( - iterable: AsyncIterable, -): AsyncIterator => { +export const toAsyncIterator = ( + iterable: AsyncIterable, +): AsyncIterator => { return iterable[Symbol.asyncIterator](); }; @@ -49,10 +49,7 @@ export async function* mergeAsync(...iterables: AsyncIterable[]) { yield result.value; // Immediately queue the next value from this iterator - pending.set( - key, - _getNextKeyedResult(iterators[key], key), - ); + pending.set(key, _getNextKeyedResult(iterators[key], key)); } // If done, iterator is exhausted and won't be added back } diff --git a/src/main.ts b/src/main.ts index 62a7c0e..e4278b9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,25 @@ -export * from "./store.js"; -export * from "./signal.js"; -export { pipe } from "./pipe.js"; +// Curated entry point. Re-exports the primary constructors and core types. +// Long-tail helpers (`withLogging`, `untrack`, `noFx`, the `iter` utilities) +// are available from their respective subpath modules, e.g. `refrakt/iter.js`. + +// Signals +export { signal, computed, effect, Signal } from "./signal.js"; +export type { AnySignal, Cleanup } from "./signal.js"; + +// Reducer +export { reducer } from "./reducer.js"; +export type { Reducer, ReducerSignal } from "./reducer.js"; + +// Store +export { store, tx } from "./store.js"; +export type { StoreSignal, Update, Tx, Fx } from "./store.js"; + +// Scope +export { scope } from "./scope.js"; + +// Send +export { forward } from "./send.js"; +export type { Send, SendableSignal } from "./send.js"; + +// Exhaustiveness +export { assertNever, NeverError } from "./never.js"; diff --git a/src/middleware/fx.test.ts b/src/middleware/fx.test.ts deleted file mode 100644 index e07e00e..0000000 --- a/src/middleware/fx.test.ts +++ /dev/null @@ -1,418 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { type Fx, fx } from "./fx.js"; -import { type Reducer, store } from "../store.js"; -import { pipe } from "../pipe.js"; - -// Test types -type CounterAction = - | { type: "increment" } - | { type: "decrement" } - | { type: "set"; value: number } - | { type: "async_increment" } - | { type: "async_double" } - | { type: "async_error" } - | { type: "multi_step" } - | { type: "no_effect" }; - -type CounterState = { - count: number; - loading: boolean; - error?: string; -}; - -// Helper function to create a promise that resolves after a delay -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -test("fx - handles simple async effects", async () => { - const counterReducer: Reducer = (state, action) => { - switch (action.type) { - case "increment": - return { ...state, count: state.count + 1 }; - case "decrement": - return { ...state, count: state.count - 1 }; - case "set": - return { ...state, count: action.value }; - case "async_increment": - return { ...state, loading: true }; - default: - return state; - } - }; - - const testFx: Fx = async function*(get, action) { - if (action.type === "async_increment") { - await delay(10); - yield { type: "increment" }; - yield { type: "set", value: get().count + 5 }; - } - }; - - const counterStore = pipe( - store(counterReducer, { count: 0, loading: false } as CounterState), - fx(testFx), - ); - - counterStore.send({ type: "async_increment" }); - assert.strictEqual(counterStore.get().loading, true); - - // Wait for async effects to complete - await delay(20); - - assert.strictEqual(counterStore.get().count, 6); // 0 + 1 + 5 -}); - -test("fx - handles multiple yielded actions", async () => { - const actions: CounterAction[] = []; - - const counterReducer: Reducer = (state, action) => { - actions.push(action); - switch (action.type) { - case "increment": - return { ...state, count: state.count + 1 }; - case "decrement": - return { ...state, count: state.count - 1 }; - case "multi_step": - return { ...state, loading: true }; - default: - return state; - } - }; - - const testFx: Fx = async function*(_get, action) { - if (action.type === "multi_step") { - yield { type: "increment" }; - await delay(5); - yield { type: "increment" }; - yield { type: "decrement" }; - await delay(5); - yield { type: "increment" }; - } - }; - - const counterStore = pipe( - store(counterReducer, { count: 0, loading: false }), - fx(testFx), - ); - - counterStore.send({ type: "multi_step" }); - - // Wait for all async effects - await delay(20); - - assert.strictEqual(counterStore.get().count, 2); // +1 +1 -1 +1 = 2 - assert.strictEqual(actions.length, 5); // multi_step + 4 yielded actions - assert.strictEqual(actions[0].type, "multi_step"); - assert.strictEqual(actions[1].type, "increment"); - assert.strictEqual(actions[2].type, "increment"); - assert.strictEqual(actions[3].type, "decrement"); - assert.strictEqual(actions[4].type, "increment"); -}); - -test("fx - ignores actions that don't trigger effects", async () => { - const actions: CounterAction[] = []; - - const counterReducer: Reducer = (state, action) => { - actions.push(action); - switch (action.type) { - case "increment": - return { ...state, count: state.count + 1 }; - case "no_effect": - return { ...state, loading: false }; - default: - return state; - } - }; - - const testFx: Fx = async function*(_get, action) { - if (action.type === "increment") { - await delay(5); - yield { type: "increment" }; - } - // no_effect action doesn't trigger any fx - }; - - const counterStore = pipe( - store(counterReducer, { count: 0, loading: false }), - fx(testFx), - ); - - counterStore.send({ type: "no_effect" }); - assert.strictEqual(actions.length, 1); - assert.strictEqual(actions[0].type, "no_effect"); - - await delay(10); - - // No additional actions should have been sent - assert.strictEqual(actions.length, 1); - assert.strictEqual(counterStore.get().count, 0); -}); - -test("fx - can access current state", async () => { - const counterReducer: Reducer = (state, action) => { - switch (action.type) { - case "increment": - return { ...state, count: state.count + 1 }; - case "set": - return { ...state, count: action.value }; - case "async_double": - return { ...state, loading: true }; - default: - return state; - } - }; - - const testFx: Fx = async function*(get, action) { - if (action.type === "async_double") { - const currentCount = get().count; - await delay(5); - yield { type: "set", value: currentCount * 2 }; - } - }; - - const counterStore = pipe( - store(counterReducer, { count: 5, loading: false }), - fx(testFx), - ); - - counterStore.send({ type: "async_double" }); - - await delay(10); - - assert.strictEqual(counterStore.get().count, 10); // 5 * 2 -}); - -test("fx - handles errors gracefully", async () => { - const originalWarn = console.warn; - let warningMessage = ""; - - // Mock console.warn - console.warn = (msg: string, _error: unknown) => { - warningMessage = `${msg}`; - }; - - try { - const counterReducer: Reducer = (state, action) => { - switch (action.type) { - case "increment": - return { ...state, count: state.count + 1 }; - case "async_error": - return { ...state, loading: true }; - default: - return state; - } - }; - - const testFx: Fx = async function*( - _get, - action, - ) { - if (action.type === "async_error") { - await delay(5); - throw new Error("Test error"); - } - }; - - const counterStore = pipe( - store(counterReducer, { count: 0, loading: false }), - fx(testFx), - ); - - counterStore.send({ type: "async_error" }); - assert.strictEqual(counterStore.get().loading, true); - - await delay(10); - - // Store state should remain unchanged after error - assert.strictEqual(counterStore.get().count, 0); - assert.strictEqual(counterStore.get().loading, true); - assert.strictEqual(warningMessage, "Error in fx"); - } finally { - // Restore console.warn - console.warn = originalWarn; - } -}); - -test("fx - works with empty generator", async () => { - const actions: CounterAction[] = []; - - const counterReducer: Reducer = (state, action) => { - actions.push(action); - return state; - }; - - const testFx: Fx = async function*( - _get, - _action, - ) { - // Empty generator - no yields - await delay(5); - }; - - const counterStore = pipe( - store(counterReducer, { count: 0, loading: false }), - fx(testFx), - ); - - counterStore.send({ type: "increment" }); - - await delay(10); - - assert.strictEqual(actions.length, 1); - assert.strictEqual(actions[0].type, "increment"); -}); - -test("fx - preserves action order", async () => { - const processedActions: string[] = []; - - const counterReducer: Reducer = (state, action) => { - processedActions.push(`reducer:${action.type}`); - switch (action.type) { - case "increment": - return { ...state, count: state.count + 1 }; - case "async_increment": - return { ...state, loading: true }; - default: - return state; - } - }; - - const testFx: Fx = async function*(_get, action) { - if (action.type === "async_increment") { - processedActions.push(`fx:${action.type}`); - await delay(5); - yield { type: "increment" }; - } - }; - - const counterStore = pipe( - store(counterReducer, { count: 0, loading: false }), - fx(testFx), - ); - - counterStore.send({ type: "async_increment" }); - - await delay(10); - - assert.deepStrictEqual(processedActions, [ - "reducer:async_increment", - "fx:async_increment", - "reducer:increment", - ]); -}); - -test("fx - can chain multiple async operations", async () => { - const counterReducer: Reducer = (state, action) => { - switch (action.type) { - case "increment": - return { ...state, count: state.count + 1 }; - case "set": - return { ...state, count: action.value }; - case "async_increment": - return { ...state, loading: true }; - default: - return state; - } - }; - - const testFx: Fx = async function*(get, action) { - if (action.type === "async_increment") { - // First async operation - await delay(5); - yield { type: "increment" }; - - // Second async operation - await delay(5); - yield { type: "increment" }; - - // Third operation using current state - const currentCount = get().count; - yield { type: "set", value: currentCount + 10 }; - } - }; - - const counterStore = pipe( - store(counterReducer, { count: 0, loading: false }), - fx(testFx), - ); - - counterStore.send({ type: "async_increment" }); - - await delay(20); - - assert.strictEqual(counterStore.get().count, 12); // 0 + 1 + 1 + 10 -}); - -test("fx - runs concurrently for multiple actions", async () => { - const timestamps: number[] = []; - - const counterReducer: Reducer = (state, action) => { - switch (action.type) { - case "increment": - timestamps.push(Date.now()); - return { ...state, count: state.count + 1 }; - case "async_increment": - return { ...state, loading: true }; - default: - return state; - } - }; - - const testFx: Fx = async function*(_get, action) { - if (action.type === "async_increment") { - await delay(10); - yield { type: "increment" }; - } - }; - - const counterStore = pipe( - store(counterReducer, { count: 0, loading: false }), - fx(testFx), - ); - - // Send multiple async actions quickly - counterStore.send({ type: "async_increment" }); - counterStore.send({ type: "async_increment" }); - counterStore.send({ type: "async_increment" }); - - await delay(20); - - assert.strictEqual(counterStore.get().count, 3); - assert.strictEqual(timestamps.length, 3); - - // All should complete around the same time (within 5ms of each other) - const timeDiff = Math.max(...timestamps) - Math.min(...timestamps); - assert.strictEqual(timeDiff < 5, true); -}); - -test("fx - passes context to fx generator function", (_t, done) => { - type State = number; - - type Action = { type: "increment" }; - - type Context = number; - - const testFx: Fx = async function*(_get, _action, context) { - if (context === 10) { - return done(); - } else { - return done(new Error("Context not set")); - } - }; - - const counterReducer: Reducer = (state, action) => { - switch (action.type) { - case "increment": - return state + 1; - default: - return state; - } - }; - - const testStore = pipe( - store(counterReducer, 0), - fx(testFx, 10), - ); - - testStore.send({ type: "increment" }); -}); diff --git a/src/middleware/fx.ts b/src/middleware/fx.ts deleted file mode 100644 index 6db6c55..0000000 --- a/src/middleware/fx.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { type Store } from "../store.js"; -import { peek } from "../signal.js"; - -export type Fx = ( - state: () => Model, - action: Action, - context: Context, -) => AsyncGenerator; - -const forkFx = async ( - generator: AsyncGenerator, - send: (action: Action) => void, -) => { - try { - for await (const action of generator) { - send(action); - } - } catch (error) { - console.warn("Error in fx", error); - } -}; - -/** - * Fx middleware provides managed effects modeled as async generators. - * Each incoming action spawns a new forked fx at the top level that can yield - * zero or more actions. - * - * Effects have access to a getter function for the current state of the store - * allowing them to make decisions about when to continue and when to exit. - * Cancellable tasks can be modeled by recording relevant state on the model and - * checking the current state within the generator. - * - * `fx` can also take an optional second `context` parameter. This can be used to - * pass additional information to the effect generator, such as services for - * performing I/O operations. - * - * @usage - * ```ts - * import { store, pipe, middleware } from "refrakt"; - * - * async function* effects(state: () => Model, action: Action) { - * if (action.type === "some-action") { - * yield { type: "some-other-action", payload: "some-payload" }; - * } - * } - * - * const myStore = pipe( - * store(update, initial), - * middleware.fx(effects), - * ); - * ``` - */ -export function fx( - fx: Fx, -): (store: Store) => Store; -export function fx( - fx: Fx, - context: Context, -): (store: Store) => Store; -export function fx( - fx: Fx, - context?: Context, -): (store: Store) => Store { - return ( - { get, send }: Store, - ): Store => { - const peekState = () => peek(get); - - const sendWithFx = (action: Action) => { - // First, apply the action to update the state - send(action); - // Then generate and fork the effect - const effect = fx(peekState, action, context!); - forkFx(effect, sendWithFx); - }; - - return { - get, - send: sendWithFx, - }; - }; -} diff --git a/src/middleware/logger.ts b/src/middleware/logger.ts deleted file mode 100644 index 9e121bd..0000000 --- a/src/middleware/logger.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { type Store } from "../store.js"; -import { peek } from "../signal.js"; - -/** - * Logger middleware that logs actions and state changes to the console. - * @usage - * ```ts - * const myStore = pipe( - * reducer(update, initial), - * logger(), - * ); - * ``` - */ -export const logger = ({ - prefix = "", - log = true, -}: { - log?: boolean; - prefix?: string; -} = {}) => -( - { get, send }: Store, -): Store => { - const sendWithLogging = (action: Action) => { - if (log) console.log(`${prefix}<`, action); - send(action); - if (log) console.log(`${prefix}>`, peek(get)); - }; - - return { - get, - send: sendWithLogging, - }; -}; diff --git a/src/middleware/scope.test.ts b/src/middleware/scope.test.ts deleted file mode 100644 index e246abe..0000000 --- a/src/middleware/scope.test.ts +++ /dev/null @@ -1,587 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { scope } from "./scope.js"; -import { type Reducer, store } from "../store.js"; -import { pipe } from "../pipe.js"; -import { effect } from "../signal.js"; - -// Test types for parent store -type ParentAction = - | { type: "increment_counter" } - | { type: "decrement_counter" } - | { type: "set_counter"; value: number } - | { type: "set_name"; name: string } - | { type: "child_increment" } - | { type: "child_set"; value: number }; - -interface ParentState { - counter: number; - name: string; - timestamp: number; -} - -// Test types for child store -type ChildAction = - | { type: "increment" } - | { type: "set"; value: number }; - -test("scope - creates scoped store with subset of parent state", () => { - const parentReducer: Reducer = (state, action) => { - switch (action.type) { - case "increment_counter": - return { ...state, counter: state.counter + 1 }; - case "set_counter": - return { ...state, counter: action.value }; - case "set_name": - return { ...state, name: action.name }; - default: - return state; - } - }; - - const parentStore = store(parentReducer, { - counter: 0, - name: "test", - timestamp: 0, - }); - - // Create a scoped store that only exposes the counter - const childStore = pipe( - parentStore, - scope( - (state) => state.counter, - (_action: ChildAction) => ({ type: "child_increment" }), - ), - ); - - assert.strictEqual(childStore.get(), 0); - assert.strictEqual(parentStore.get().counter, 0); -}); - -test("scope - maps child actions to parent actions", () => { - const parentReducer: Reducer = (state, action) => { - switch (action.type) { - case "increment_counter": - return { ...state, counter: state.counter + 1 }; - case "child_increment": - return { ...state, counter: state.counter + 1 }; - case "child_set": - return { ...state, counter: action.value }; - default: - return state; - } - }; - - const parentStore = store(parentReducer, { - counter: 0, - name: "test", - timestamp: 0, - }); - - const childStore = pipe( - parentStore, - scope( - (state) => state.counter, - (action: ChildAction) => { - switch (action.type) { - case "increment": - return { type: "child_increment" }; - case "set": - return { type: "child_set", value: action.value }; - } - }, - ), - ); - - // Send action to child store - childStore.send({ type: "increment" }); - assert.strictEqual(childStore.get(), 1); - assert.strictEqual(parentStore.get().counter, 1); - - childStore.send({ type: "set", value: 10 }); - assert.strictEqual(childStore.get(), 10); - assert.strictEqual(parentStore.get().counter, 10); -}); - -test("scope - reflects parent state changes in scoped store", () => { - const parentReducer: Reducer = (state, action) => { - switch (action.type) { - case "increment_counter": - return { ...state, counter: state.counter + 1 }; - case "set_counter": - return { ...state, counter: action.value }; - default: - return state; - } - }; - - const parentStore = store(parentReducer, { - counter: 5, - name: "test", - timestamp: 0, - }); - - const childStore = pipe( - parentStore, - scope( - (state) => state.counter, - (_action: ChildAction) => ({ type: "child_increment" }), - ), - ); - - assert.strictEqual(childStore.get(), 5); - - // Update parent store directly - parentStore.send({ type: "increment_counter" }); - assert.strictEqual(childStore.get(), 6); - - parentStore.send({ type: "set_counter", value: 100 }); - assert.strictEqual(childStore.get(), 100); -}); - -test("scope - isolates child from unrelated parent state changes", () => { - const parentReducer: Reducer = (state, action) => { - switch (action.type) { - case "set_name": - return { ...state, name: action.name }; - case "set_counter": - return { ...state, counter: action.value }; - default: - return state; - } - }; - - const parentStore = store(parentReducer, { - counter: 0, - name: "test", - timestamp: 0, - }); - - const childStore = pipe( - parentStore, - scope( - (state) => state.counter, - (_action: ChildAction) => ({ type: "child_increment" }), - ), - ); - - assert.strictEqual(childStore.get(), 0); - - // Change unrelated parent state - parentStore.send({ type: "set_name", name: "new name" }); - - // Child store should still return the same counter value - assert.strictEqual(childStore.get(), 0); - assert.strictEqual(parentStore.get().name, "new name"); -}); - -test("scope - works with complex state transformations", () => { - interface ComplexParent { - user: { - id: number; - profile: { - name: string; - age: number; - }; - settings: { - theme: string; - notifications: boolean; - }; - }; - } - - type ComplexParentAction = - | { type: "set_name"; name: string } - | { type: "set_age"; age: number } - | { type: "child_set_name"; name: string }; - - type ChildProfileAction = { type: "set_name"; name: string }; - - const complexReducer: Reducer = ( - state, - action, - ) => { - switch (action.type) { - case "set_name": - case "child_set_name": - return { - ...state, - user: { - ...state.user, - profile: { ...state.user.profile, name: action.name }, - }, - }; - case "set_age": - return { - ...state, - user: { - ...state.user, - profile: { ...state.user.profile, age: action.age }, - }, - }; - default: - return state; - } - }; - - const parentStore = store(complexReducer, { - user: { - id: 1, - profile: { name: "Alice", age: 30 }, - settings: { theme: "dark", notifications: true }, - }, - }); - - // Scope to just the profile - const profileStore = pipe( - parentStore, - scope( - (state) => state.user.profile, - (action: ChildProfileAction) => ({ type: "child_set_name", name: action.name }), - ), - ); - - assert.deepStrictEqual(profileStore.get(), { name: "Alice", age: 30 }); - - // Send action through child - profileStore.send({ type: "set_name", name: "Bob" }); - assert.strictEqual(profileStore.get().name, "Bob"); - assert.strictEqual(parentStore.get().user.profile.name, "Bob"); - - // Update parent - parentStore.send({ type: "set_age", age: 31 }); - assert.strictEqual(profileStore.get().age, 31); -}); - -test("scope - works with primitive value scopes", () => { - type PrimitiveParentAction = - | { type: "set_name"; name: string } - | { type: "child_set_name"; name: string }; - - const parentReducer: Reducer = ( - state, - action, - ) => { - switch (action.type) { - case "set_name": - case "child_set_name": - return { ...state, name: action.name }; - default: - return state; - } - }; - - const parentStore = store(parentReducer, { - counter: 0, - name: "initial", - timestamp: 0, - }); - - // Scope to just the name string - const nameStore = pipe( - parentStore, - scope( - (state) => state.name, - (action: { type: "set"; value: string }) => ({ - type: "child_set_name", - name: action.value, - }), - ), - ); - - assert.strictEqual(nameStore.get(), "initial"); - - nameStore.send({ type: "set", value: "updated" }); - assert.strictEqual(nameStore.get(), "updated"); - assert.strictEqual(parentStore.get().name, "updated"); -}); - -test("scope - can be chained for nested scoping", () => { - interface NestedState { - level1: { - level2: { - level3: { - value: number; - }; - }; - }; - } - - type NestedAction = - | { type: "set_value"; value: number } - | { type: "l1_set_value"; value: number } - | { type: "l2_set_value"; value: number } - | { type: "l3_set_value"; value: number }; - - const nestedReducer: Reducer = (state, action) => { - switch (action.type) { - case "set_value": - case "l1_set_value": - case "l2_set_value": - case "l3_set_value": - return { - ...state, - level1: { - ...state.level1, - level2: { - ...state.level1.level2, - level3: { value: action.value }, - }, - }, - }; - default: - return state; - } - }; - - const rootStore = store(nestedReducer, { - level1: { level2: { level3: { value: 0 } } }, - }); - - // First level of scoping - const level1Store = pipe( - rootStore, - scope( - (state) => state.level1, - (action: NestedAction) => - action.type === "set_value" - ? { type: "l1_set_value", value: action.value } - : action, - ), - ); - - // Second level of scoping - const level2Store = pipe( - level1Store, - scope( - (state) => state.level2, - (action: NestedAction) => - action.type === "set_value" - ? { type: "l2_set_value", value: action.value } - : action, - ), - ); - - // Third level of scoping - const level3Store = pipe( - level2Store, - scope( - (state) => state.level3, - (action: NestedAction) => - action.type === "set_value" - ? { type: "l3_set_value", value: action.value } - : action, - ), - ); - - assert.deepStrictEqual(level3Store.get(), { value: 0 }); - - level3Store.send({ type: "set_value", value: 42 }); - assert.strictEqual(level3Store.get().value, 42); - assert.strictEqual(rootStore.get().level1.level2.level3.value, 42); -}); - -test("scope - works with effects and reactivity", async () => { - const parentReducer: Reducer = (state, action) => { - switch (action.type) { - case "increment_counter": - return { ...state, counter: state.counter + 1 }; - case "child_increment": - return { ...state, counter: state.counter + 1 }; - default: - return state; - } - }; - - const parentStore = store(parentReducer, { - counter: 0, - name: "test", - timestamp: 0, - }); - - const childStore = pipe( - parentStore, - scope( - (state) => state.counter, - (_action: ChildAction) => ({ type: "child_increment" }), - ), - ); - - const effectValues: number[] = []; - - const cleanup = effect(() => { - effectValues.push(childStore.get()); - }); - - // Initial value - await new Promise((resolve) => setTimeout(resolve, 0)); - assert.strictEqual(effectValues[0], 0); - - // Update through child - childStore.send({ type: "increment" }); - await new Promise((resolve) => setTimeout(resolve, 0)); - assert.strictEqual(effectValues[effectValues.length - 1], 1); - - // Update through parent - parentStore.send({ type: "increment_counter" }); - await new Promise((resolve) => setTimeout(resolve, 0)); - assert.strictEqual(effectValues[effectValues.length - 1], 2); - - cleanup(); -}); - -test("scope - multiple scoped stores from same parent", () => { - const parentReducer: Reducer = (state, action) => { - switch (action.type) { - case "increment_counter": - return { ...state, counter: state.counter + 1 }; - case "set_name": - return { ...state, name: action.name }; - case "child_increment": - return { ...state, counter: state.counter + 1 }; - case "child_set": - return { ...state, counter: action.value }; - default: - return state; - } - }; - - const parentStore = store(parentReducer, { - counter: 10, - name: "parent", - timestamp: 0, - }); - - // First scoped store - counter - const counterStore = pipe( - parentStore, - scope( - (state) => state.counter, - (action: ChildAction) => { - switch (action.type) { - case "increment": - return { type: "child_increment" }; - case "set": - return { type: "child_set", value: action.value }; - } - }, - ), - ); - - // Second scoped store - name - const nameStore = pipe( - parentStore, - scope( - (state) => state.name, - (action: { type: "set_name"; name: string }) => action, - ), - ); - - assert.strictEqual(counterStore.get(), 10); - assert.strictEqual(nameStore.get(), "parent"); - - // Update counter - counterStore.send({ type: "increment" }); - assert.strictEqual(counterStore.get(), 11); - assert.strictEqual(nameStore.get(), "parent"); // unchanged - - // Update name - nameStore.send({ type: "set_name", name: "updated" }); - assert.strictEqual(counterStore.get(), 11); // unchanged - assert.strictEqual(nameStore.get(), "updated"); -}); - -test("scope - handles computed transformations", () => { - interface TodoState { - todos: Array<{ id: number; text: string; completed: boolean }>; - } - - type TodoAction = - | { type: "toggle"; id: number } - | { type: "child_toggle"; id: number }; - - type CompletedAction = { type: "toggle"; id: number }; - - const todoReducer: Reducer = (state, action) => { - switch (action.type) { - case "toggle": - case "child_toggle": - return { - ...state, - todos: state.todos.map((todo) => - todo.id === action.id ? { ...todo, completed: !todo.completed } : todo - ), - }; - default: - return state; - } - }; - - const parentStore = store(todoReducer, { - todos: [ - { id: 1, text: "Task 1", completed: false }, - { id: 2, text: "Task 2", completed: true }, - { id: 3, text: "Task 3", completed: false }, - ], - }); - - // Scope to only completed todos - const completedStore = pipe( - parentStore, - scope( - (state) => state.todos.filter((todo) => todo.completed), - (action: CompletedAction) => ({ type: "child_toggle", id: action.id }), - ), - ); - - assert.strictEqual(completedStore.get().length, 1); - assert.strictEqual(completedStore.get()[0].id, 2); - - // Toggle a completed todo - completedStore.send({ type: "toggle", id: 2 }); - - // Now no todos are completed - assert.strictEqual(completedStore.get().length, 0); - - // Toggle another todo through parent - parentStore.send({ type: "toggle", id: 1 }); - assert.strictEqual(completedStore.get().length, 1); - assert.strictEqual(completedStore.get()[0].id, 1); -}); - -test("scope - preserves referential equality for unchanged scopes", () => { - const parentReducer: Reducer = (state, action) => { - switch (action.type) { - case "set_name": - return { ...state, name: action.name }; - case "increment_counter": - return { ...state, counter: state.counter + 1 }; - default: - return state; - } - }; - - const parentStore = store(parentReducer, { - counter: 0, - name: "test", - timestamp: 0, - }); - - const childStore = pipe( - parentStore, - scope( - (state) => state.counter, - (_action: ChildAction) => ({ type: "child_increment" }), - ), - ); - - const firstGet = childStore.get(); - - // Change unrelated parent state - parentStore.send({ type: "set_name", name: "new name" }); - - const secondGet = childStore.get(); - - // The scoped value should be the same (both return 0) - assert.strictEqual(firstGet, secondGet); -}); diff --git a/src/middleware/scope.ts b/src/middleware/scope.ts deleted file mode 100644 index 8cabe11..0000000 --- a/src/middleware/scope.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { forward, type Store } from "../store.js"; -import { computed } from "../signal.js"; - -/** - * Create a scoped store. - * Scoped stores are useful for creating isolated state and behavior for child components. - * A scoped store exposes a computed subset of the parent state and maps the - * actions you send to it from the child domain to the parent domain. - * @usage - * ```ts - * const parentStore = store(updateParent, { count: 0 }); - * const childStore = pipe( - * parentStore, - * scope((state) => state.count, (action) => action), - * ); - * ``` - */ -export const scope = ( - get: (state: ModelA) => ModelB, - tag: (action: ActionB) => ActionA, -) => -( - store: Store, -): Store => { - const $state = computed(() => get(store.get())); - const send = forward(store.send, tag); - return { - get: () => $state.get(), - send, - }; -}; diff --git a/src/never.ts b/src/never.ts new file mode 100644 index 0000000..e373cce --- /dev/null +++ b/src/never.ts @@ -0,0 +1,19 @@ +/** Error for unexpected values. Carries value for inspection. */ +export class NeverError extends TypeError { + override name = "NeverError"; + readonly value: unknown; + + constructor(message: string, value: unknown, options?: ErrorOptions) { + super(message, options); + this.value = value; + } +} + +/** + * Use in the default arm of a reducer as an exhaustiveness check. + * Because `value` is of type `never`, Typescript will show an error if the + * switch is not exhaustive. + */ +export const assertNever = (value: never): never => { + throw new NeverError(`Unexpected value`, value); +}; diff --git a/src/pipe.test.ts b/src/pipe.test.ts deleted file mode 100644 index 4ebef4a..0000000 --- a/src/pipe.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { pipe } from "./pipe.js"; - -const add1 = (x: number) => x + 1; - -test("pipes value through functions", () => { - const result = pipe(0, add1, add1); - assert.strictEqual(result, 2); -}); - -test("pipes value through many functions", () => { - const result = pipe( - 0, - add1, - add1, - add1, - add1, - add1, - add1, - add1, - add1, - add1, - add1, - add1, - add1, - add1, - add1, - add1, - add1, - add1, - add1, - add1, - add1, - ); - assert.strictEqual(result, 20); -}); - -test("pipes different types, preserving type safety", () => { - const toString = (x: number) => x.toString(); - - const result = pipe( - 0, - add1, - toString, - ); - assert.strictEqual(result, "1"); -}); diff --git a/src/pipe.ts b/src/pipe.ts deleted file mode 100644 index e6264a5..0000000 --- a/src/pipe.ts +++ /dev/null @@ -1,326 +0,0 @@ -/** - * Applies functions over value from left to right. - * @param value - The initial value to pipe - * @param fns - The functions to pipe the value through - * @returns The final value after all functions have been applied - */ -export function pipe(value: A): A; -export function pipe(value: A, a: (input: A) => B): B; -export function pipe( - value: A, - a: (input: A) => B, - b: (input: B) => C, -): C; -export function pipe( - value: A, - a: (input: A) => B, - b: (input: B) => C, - c: (input: C) => D, -): D; -export function pipe( - value: A, - a: (input: A) => B, - b: (input: B) => C, - c: (input: C) => D, - d: (input: D) => E, -): E; -export function pipe( - value: A, - a: (input: A) => B, - b: (input: B) => C, - c: (input: C) => D, - d: (input: D) => E, - e: (input: E) => F, -): F; -export function pipe( - value: A, - a: (input: A) => B, - b: (input: B) => C, - c: (input: C) => D, - d: (input: D) => E, - e: (input: E) => F, - f: (input: F) => G, -): G; -export function pipe( - value: A, - a: (input: A) => B, - b: (input: B) => C, - c: (input: C) => D, - d: (input: D) => E, - e: (input: E) => F, - f: (input: F) => G, - g: (input: G) => H, -): H; -export function pipe( - value: A, - a: (input: A) => B, - b: (input: B) => C, - c: (input: C) => D, - d: (input: D) => E, - e: (input: E) => F, - f: (input: F) => G, - g: (input: G) => H, - h: (input: H) => I, -): I; -export function pipe( - value: A, - a: (input: A) => B, - b: (input: B) => C, - c: (input: C) => D, - d: (input: D) => E, - e: (input: E) => F, - f: (input: F) => G, - g: (input: G) => H, - h: (input: H) => I, - i: (input: I) => J, -): J; -export function pipe( - value: A, - a: (input: A) => B, - b: (input: B) => C, - c: (input: C) => D, - d: (input: D) => E, - e: (input: E) => F, - f: (input: F) => G, - g: (input: G) => H, - h: (input: H) => I, - i: (input: I) => J, - j: (input: J) => K, -): K; -export function pipe( - value: A, - a: (input: A) => B, - b: (input: B) => C, - c: (input: C) => D, - d: (input: D) => E, - e: (input: E) => F, - f: (input: F) => G, - g: (input: G) => H, - h: (input: H) => I, - i: (input: I) => J, - j: (input: J) => K, - k: (input: K) => L, -): L; -export function pipe( - value: A, - a: (input: A) => B, - b: (input: B) => C, - c: (input: C) => D, - d: (input: D) => E, - e: (input: E) => F, - f: (input: F) => G, - g: (input: G) => H, - h: (input: H) => I, - i: (input: I) => J, - j: (input: J) => K, - k: (input: K) => L, - l: (input: L) => M, -): M; -export function pipe( - value: A, - a: (input: A) => B, - b: (input: B) => C, - c: (input: C) => D, - d: (input: D) => E, - e: (input: E) => F, - f: (input: F) => G, - g: (input: G) => H, - h: (input: H) => I, - i: (input: I) => J, - j: (input: J) => K, - k: (input: K) => L, - l: (input: L) => M, - m: (input: M) => N, -): N; -export function pipe( - value: A, - a: (input: A) => B, - b: (input: B) => C, - c: (input: C) => D, - d: (input: D) => E, - e: (input: E) => F, - f: (input: F) => G, - g: (input: G) => H, - h: (input: H) => I, - i: (input: I) => J, - j: (input: J) => K, - k: (input: K) => L, - l: (input: L) => M, - m: (input: M) => N, - n: (input: N) => O, -): O; -export function pipe( - value: A, - a: (input: A) => B, - b: (input: B) => C, - c: (input: C) => D, - d: (input: D) => E, - e: (input: E) => F, - f: (input: F) => G, - g: (input: G) => H, - h: (input: H) => I, - i: (input: I) => J, - j: (input: J) => K, - k: (input: K) => L, - l: (input: L) => M, - m: (input: M) => N, - n: (input: N) => O, - o: (input: O) => P, -): P; -export function pipe( - value: A, - a: (input: A) => B, - b: (input: B) => C, - c: (input: C) => D, - d: (input: D) => E, - e: (input: E) => F, - f: (input: F) => G, - g: (input: G) => H, - h: (input: H) => I, - i: (input: I) => J, - j: (input: J) => K, - k: (input: K) => L, - l: (input: L) => M, - m: (input: M) => N, - n: (input: N) => O, - o: (input: O) => P, - p: (input: P) => Q, -): Q; -export function pipe( - value: A, - a: (input: A) => B, - b: (input: B) => C, - c: (input: C) => D, - d: (input: D) => E, - e: (input: E) => F, - f: (input: F) => G, - g: (input: G) => H, - h: (input: H) => I, - i: (input: I) => J, - j: (input: J) => K, - k: (input: K) => L, - l: (input: L) => M, - m: (input: M) => N, - n: (input: N) => O, - o: (input: O) => P, - p: (input: P) => Q, - q: (input: Q) => R, -): R; -export function pipe( - value: A, - a: (input: A) => B, - b: (input: B) => C, - c: (input: C) => D, - d: (input: D) => E, - e: (input: E) => F, - f: (input: F) => G, - g: (input: G) => H, - h: (input: H) => I, - i: (input: I) => J, - j: (input: J) => K, - k: (input: K) => L, - l: (input: L) => M, - m: (input: M) => N, - n: (input: N) => O, - o: (input: O) => P, - p: (input: P) => Q, - q: (input: Q) => R, - r: (input: R) => S, -): S; -export function pipe< - A, - B, - C, - D, - E, - F, - G, - H, - I, - J, - K, - L, - M, - N, - O, - P, - Q, - R, - S, - T, ->( - value: A, - a: (input: A) => B, - b: (input: B) => C, - c: (input: C) => D, - d: (input: D) => E, - e: (input: E) => F, - f: (input: F) => G, - g: (input: G) => H, - h: (input: H) => I, - i: (input: I) => J, - j: (input: J) => K, - k: (input: K) => L, - l: (input: L) => M, - m: (input: M) => N, - n: (input: N) => O, - o: (input: O) => P, - p: (input: P) => Q, - q: (input: Q) => R, - r: (input: R) => S, - s: (input: S) => T, -): T; -export function pipe< - A, - B, - C, - D, - E, - F, - G, - H, - I, - J, - K, - L, - M, - N, - O, - P, - Q, - R, - S, - T, - U, ->( - value: A, - a: (input: A) => B, - b: (input: B) => C, - c: (input: C) => D, - d: (input: D) => E, - e: (input: E) => F, - f: (input: F) => G, - g: (input: G) => H, - h: (input: H) => I, - i: (input: I) => J, - j: (input: J) => K, - k: (input: K) => L, - l: (input: L) => M, - m: (input: M) => N, - n: (input: N) => O, - o: (input: O) => P, - p: (input: P) => Q, - q: (input: Q) => R, - r: (input: R) => S, - s: (input: S) => T, - t: (input: T) => U, -): U; -export function pipe( - value: unknown, - ...fns: Array<(input: unknown) => unknown> -): unknown { - for (const fn of fns) { - value = fn(value); - } - return value; -} diff --git a/src/reducer.test.ts b/src/reducer.test.ts new file mode 100644 index 0000000..f1b7571 --- /dev/null +++ b/src/reducer.test.ts @@ -0,0 +1,214 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { reducer, type Reducer, withLogging } from "./reducer.js"; +import { assertNever } from "./never.js"; + +// Test types for actions +type CounterAction = + | { type: "increment" } + | { type: "decrement" } + | { type: "set"; value: number } + | { type: "add"; value: number }; + +type TodoAction = + | { type: "add"; text: string } + | { type: "toggle"; id: number } + | { type: "remove"; id: number }; + +interface Todo { + id: number; + text: string; + completed: boolean; +} + +interface TodoState { + todos: Todo[]; + nextId: number; +} + +test("reducer - creates reducer with initial state", () => { + const counterReducer: Reducer = (state, action) => { + switch (action.type) { + case "increment": + return state + 1; + case "decrement": + return state - 1; + case "set": + return action.value; + case "add": + return state + action.value; + default: + return assertNever(action); + } + }; + + const counterStore = reducer(counterReducer, 0); + + assert.strictEqual(counterStore.get(), 0); +}); + +test("reducer - handles actions through send", () => { + const counterReducer: Reducer = (state, action) => { + switch (action.type) { + case "increment": + return state + 1; + case "decrement": + return state - 1; + case "set": + return action.value; + case "add": + return state + action.value; + default: + return assertNever(action); + } + }; + + const counterStore = reducer(counterReducer, 0); + + counterStore.send({ type: "increment" }); + assert.strictEqual(counterStore.get(), 1); + + counterStore.send({ type: "increment" }); + assert.strictEqual(counterStore.get(), 2); + + counterStore.send({ type: "decrement" }); + assert.strictEqual(counterStore.get(), 1); + + counterStore.send({ type: "set", value: 10 }); + assert.strictEqual(counterStore.get(), 10); + + counterStore.send({ type: "add", value: 5 }); + assert.strictEqual(counterStore.get(), 15); +}); + +test("reducer - works with complex state", () => { + const todoReducer: Reducer = (state, action) => { + switch (action.type) { + case "add": + return { + ...state, + todos: [ + ...state.todos, + { + id: state.nextId, + text: action.text, + completed: false, + }, + ], + nextId: state.nextId + 1, + }; + case "toggle": + return { + ...state, + todos: state.todos.map((todo) => + todo.id === action.id + ? { ...todo, completed: !todo.completed } + : todo, + ), + }; + case "remove": + return { + ...state, + todos: state.todos.filter((todo) => todo.id !== action.id), + }; + default: + return assertNever(action); + } + }; + + const todoStore = reducer(todoReducer, { todos: [], nextId: 1 }); + + assert.strictEqual(todoStore.get().todos.length, 0); + + todoStore.send({ type: "add", text: "Buy milk" }); + assert.strictEqual(todoStore.get().todos.length, 1); + assert.strictEqual(todoStore.get().todos[0].text, "Buy milk"); + assert.strictEqual(todoStore.get().todos[0].completed, false); + assert.strictEqual(todoStore.get().todos[0].id, 1); + + todoStore.send({ type: "add", text: "Walk dog" }); + assert.strictEqual(todoStore.get().todos.length, 2); + assert.strictEqual(todoStore.get().nextId, 3); + + todoStore.send({ type: "toggle", id: 1 }); + assert.strictEqual(todoStore.get().todos[0].completed, true); + + todoStore.send({ type: "remove", id: 1 }); + assert.strictEqual(todoStore.get().todos.length, 1); + assert.strictEqual(todoStore.get().todos[0].text, "Walk dog"); +}); + +test("withLogging - logs actions and state transitions", () => { + const logs: string[] = []; + const originalDebug = console.debug; + console.debug = (...args: unknown[]) => { + logs.push(args.map(String).join(" ")); + }; + + try { + const counterReducer: Reducer = (state, action) => { + switch (action.type) { + case "increment": + return state + 1; + case "decrement": + return state - 1; + default: + return state; + } + }; + + const logged = withLogging(counterReducer, { name: "counter" }); + const counterStore = reducer(logged, 0); + + counterStore.send({ type: "increment" }); + + assert.strictEqual(logs.length, 2); + assert.ok(logs[0].includes("<-")); + assert.ok(logs[0].includes("counter")); + assert.ok(logs[1].includes("->")); + assert.ok(logs[1].includes("counter")); + } finally { + console.debug = originalDebug; + } +}); + +test("withLogging - respects log predicate", () => { + const logs: string[] = []; + const originalDebug = console.debug; + console.debug = (...args: unknown[]) => { + logs.push(args.map(String).join(" ")); + }; + + try { + const counterReducer: Reducer = (state, action) => { + switch (action.type) { + case "increment": + return state + 1; + default: + return state; + } + }; + + const logged = withLogging(counterReducer, { log: () => false }); + const counterStore = reducer(logged, 0); + + counterStore.send({ type: "increment" }); + assert.strictEqual(counterStore.get(), 1); + assert.strictEqual(logs.length, 0); + } finally { + console.debug = originalDebug; + } +}); + +test("assertNever - throws on an unreachable value", () => { + const unknownAction = { type: "unknown", data: "test" }; + + assert.throws( + // @ts-expect-error - we want to test assertNever + () => assertNever(unknownAction), + { + name: "NeverError", + value: { type: "unknown", data: "test" }, + }, + ); +}); diff --git a/src/reducer.ts b/src/reducer.ts new file mode 100644 index 0000000..a4739d3 --- /dev/null +++ b/src/reducer.ts @@ -0,0 +1,70 @@ +import { signal } from "./signal.js"; +import { type SendableSignal } from "./send.js"; + +export type Reducer = (state: Model, action: Action) => Model; + +/** The signal returned by `reducer()`: a readable state with a `send` method. */ +export type ReducerSignal = SendableSignal; + +/** + * Create a signals-based reducer that updates through the provided `step` + * reducer function. + * @arg step - The reducer function that updates the store state. + * @arg initial - The initial state of the store. + * @returns A store object with a signal for the state and a send method. + */ +export const reducer = ( + step: Reducer, + initial: Model, +): ReducerSignal => { + const $state = signal(initial); + + /** + * Get the current state. + * This method is hard-bound to the reducer so you can pass it around as a function. + */ + const get = () => $state.get(); + + /** + * Send an action to the reducer. + * This method is hard-bound to the reducer so you can pass it around as a function. + */ + const send = (action: Action) => { + const next = step($state.get(), action); + $state.set(next); + }; + + return { get, send }; +}; + +const alwaysLog = () => true; + +/** + * Wrap update function with logging. + * @param update - The reducer function to wrap. + * @param options - options object + * @param options.name - Name of the reducer in log messages. Defaults to "reducer". + * @param options.log - A function that returns `true` if logging should be enabled. + * @returns A new update function that logs state transitions. + */ +export const withLogging = ( + update: Reducer, + { + name = "reducer", + log = alwaysLog, + }: { + name?: string; + log?: () => boolean; + } = {}, +): Reducer => { + return (state: Model, action: Action) => { + if (log()) { + console.debug("<-", name, action); + } + const next = update(state, action); + if (log()) { + console.debug("->", name, next); + } + return next; + }; +}; diff --git a/src/scope.test.ts b/src/scope.test.ts new file mode 100644 index 0000000..52db0bb --- /dev/null +++ b/src/scope.test.ts @@ -0,0 +1,93 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { reducer, type Reducer } from "./reducer.js"; +import { scope } from "./scope.js"; + +type ParentModel = { + child: { count: number }; + other: string; +}; + +type ChildAction = { type: "increment" } | { type: "decrement" }; + +type ParentAction = + | { type: "child"; action: ChildAction } + | { type: "set-other"; value: string }; + +const parentReducer: Reducer = (state, action) => { + switch (action.type) { + case "child": + switch (action.action.type) { + case "increment": + return { ...state, child: { count: state.child.count + 1 } }; + case "decrement": + return { ...state, child: { count: state.child.count - 1 } }; + default: + return state; + } + case "set-other": + return { ...state, other: action.value }; + default: + return state; + } +}; + +test("scope - projects parent state to child state", () => { + const parent = reducer(parentReducer, { child: { count: 0 }, other: "hi" }); + + const child = scope({ + store: parent, + get: (state: ParentModel) => state.child, + tag: (action: ChildAction): ParentAction => ({ type: "child", action }), + }); + + assert.deepStrictEqual(child.get(), { count: 0 }); +}); + +test("scope - tags and forwards actions to parent", () => { + const parent = reducer(parentReducer, { child: { count: 0 }, other: "hi" }); + + const child = scope({ + store: parent, + get: (state: ParentModel) => state.child, + tag: (action: ChildAction): ParentAction => ({ type: "child", action }), + }); + + child.send({ type: "increment" }); + + assert.deepStrictEqual(child.get(), { count: 1 }); + assert.strictEqual(parent.get().child.count, 1); +}); + +test("scope - child state updates when parent state changes", () => { + const parent = reducer(parentReducer, { child: { count: 0 }, other: "hi" }); + + const child = scope({ + store: parent, + get: (state: ParentModel) => state.child, + tag: (action: ChildAction): ParentAction => ({ type: "child", action }), + }); + + // Update parent directly + parent.send({ type: "child", action: { type: "increment" } }); + + assert.deepStrictEqual(child.get(), { count: 1 }); +}); + +test("scope - multiple actions through scoped store", () => { + const parent = reducer(parentReducer, { child: { count: 0 }, other: "hi" }); + + const child = scope({ + store: parent, + get: (state: ParentModel) => state.child, + tag: (action: ChildAction): ParentAction => ({ type: "child", action }), + }); + + child.send({ type: "increment" }); + child.send({ type: "increment" }); + child.send({ type: "increment" }); + child.send({ type: "decrement" }); + + assert.deepStrictEqual(child.get(), { count: 2 }); + assert.strictEqual(parent.get().other, "hi"); +}); diff --git a/src/scope.ts b/src/scope.ts new file mode 100644 index 0000000..003c363 --- /dev/null +++ b/src/scope.ts @@ -0,0 +1,34 @@ +import { type SendableSignal, forward } from "./send.js"; +import { computed } from "./signal.js"; + +/** + * Create a scoped reducer. + * Scoped reducers are useful for creating isolated state and behavior for child components. + * A scoped reducer exposes a computed subset of the parent state and maps the + * actions you send to it from the child domain to the parent domain. + * @usage + * ```ts + * const parentStore = store(updateParent, { count: 0 }); + * const childStore = scope({ + * store: parentStore, + * get: (state) => state.count, + * tag: (action) => ({ type: "child", action }), + * }); + * ``` + */ +export const scope = ({ + store, + get, + tag, +}: { + store: SendableSignal; + get: (state: ModelA) => ModelB; + tag: (action: ActionB) => ActionA; +}): SendableSignal => { + const $state = computed(() => get(store.get())); + const send = forward(store.send, tag); + return { + get: () => $state.get(), + send, + }; +}; diff --git a/src/send.test.ts b/src/send.test.ts new file mode 100644 index 0000000..2861e69 --- /dev/null +++ b/src/send.test.ts @@ -0,0 +1,44 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { forward } from "./send.js"; + +test("forward - transforms actions", () => { + const receivedActions: string[] = []; + + const parentSend = (action: string) => { + receivedActions.push(action); + }; + + const childSend = forward( + parentSend, + (childAction: number) => `child:${childAction}`, + ); + + childSend(1); + childSend(2); + childSend(3); + + assert.deepStrictEqual(receivedActions, ["child:1", "child:2", "child:3"]); +}); + +test("forward - works with complex transformations", () => { + type ParentAction = { type: "parent"; data: string }; + type ChildAction = { type: "child"; value: number }; + + const receivedActions: ParentAction[] = []; + + const parentSend = (action: ParentAction) => { + receivedActions.push(action); + }; + + const childSend = forward(parentSend, (childAction: ChildAction) => ({ + type: "parent" as const, + data: `transformed-${childAction.value}`, + })); + + childSend({ type: "child", value: 42 }); + + assert.strictEqual(receivedActions.length, 1); + assert.strictEqual(receivedActions[0].type, "parent"); + assert.strictEqual(receivedActions[0].data, "transformed-42"); +}); diff --git a/src/send.ts b/src/send.ts new file mode 100644 index 0000000..f12da61 --- /dev/null +++ b/src/send.ts @@ -0,0 +1,20 @@ +import { type AnySignal } from "./signal.js"; + +export type Send = (action: Action) => void; + +export type SendableSignal = AnySignal & { + send: Send; +}; + +/** + * Transform a send function so that it tags actions on the way out. + * This can be useful for mapping actions from one component domain to another. + */ +export const forward = + ( + send: (action: ActionA) => void, + tag: (action: ActionB) => ActionA, + ) => + (action: ActionB): void => { + send(tag(action)); + }; diff --git a/src/signal.test.ts b/src/signal.test.ts index 8ebcc26..9b22cef 100644 --- a/src/signal.test.ts +++ b/src/signal.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { computed, effect, peek, signal } from "./signal.js"; +import { computed, effect, signal, untrack } from "./signal.js"; test("signal - creates and updates state", () => { const count = signal(0); @@ -174,7 +174,7 @@ test("effect - cleanup stops tracking", async () => { assert.strictEqual(effectRuns, 2); // Should not increase }); -test("peek - reads without tracking", async () => { +test("untrack - reads without tracking", async () => { const count = signal(0); let effectRuns = 0; let peekedValue = -1; @@ -183,8 +183,8 @@ test("peek - reads without tracking", async () => { effectRuns++; // Read count normally (tracked) count.get(); - // Peek at count (not tracked) - peekedValue = peek(() => count.get()); + // Read count without tracking it + peekedValue = untrack(() => count.get()); }); assert.strictEqual(effectRuns, 1); @@ -200,7 +200,7 @@ test("peek - reads without tracking", async () => { cleanup(); }); -test("peek - prevents tracking in complex scenarios", async () => { +test("untrack - prevents tracking in complex scenarios", async () => { const trigger = signal(0); const data = signal("initial"); let effectRuns = 0; @@ -210,7 +210,7 @@ test("peek - prevents tracking in complex scenarios", async () => { effectRuns++; // Only track trigger, not data trigger.get(); - const peekedData = peek(() => data.get()); + const peekedData = untrack(() => data.get()); results.push(peekedData); }); @@ -222,7 +222,7 @@ test("peek - prevents tracking in complex scenarios", async () => { await new Promise((resolve) => queueMicrotask(() => resolve())); assert.strictEqual(effectRuns, 1); // Should not change - // Changing trigger should trigger effect and peek at current data + // Changing trigger should trigger effect and read current data trigger.set(1); await new Promise((resolve) => queueMicrotask(() => resolve())); assert.strictEqual(effectRuns, 2); @@ -279,7 +279,7 @@ test("effect - handles errors gracefully", async () => { if (count.get() === 5) { throw new Error("Test error"); } - } catch (_) { + } catch (_error) { errorThrown = true; } }); diff --git a/src/signal.ts b/src/signal.ts index f0bf31f..5570209 100644 --- a/src/signal.ts +++ b/src/signal.ts @@ -56,7 +56,7 @@ export const computed = (callback: () => T): Signal.Computed => new Signal.Computed(callback); /** - * Peek at the value of a signal without tracking it. + * Run a callback, reading signals without tracking them as dependencies. * Be careful with this function, it can lead to unsound code! */ -export const peek = (cb: () => T): T => Signal.subtle.untrack(cb); +export const untrack = (cb: () => T): T => Signal.subtle.untrack(cb); diff --git a/src/store.test.ts b/src/store.test.ts index 25aec4a..8c5b5a7 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -1,6 +1,10 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { forward, action, type Reducer, store, updateUnknown } from "./store.js"; +import { store, tx, type Update, withLogging } from "./store.js"; +import { assertNever } from "./never.js"; + +// Helper function to create a promise that resolves after a delay +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // Test types for actions type CounterAction = @@ -9,60 +13,44 @@ type CounterAction = | { type: "set"; value: number } | { type: "add"; value: number }; -type TodoAction = - | { type: "add"; text: string } - | { type: "toggle"; id: number } - | { type: "remove"; id: number }; - -interface Todo { - id: number; - text: string; - completed: boolean; -} - -interface TodoState { - todos: Todo[]; - nextId: number; -} - test("store - creates store with initial state", () => { - const counterReducer: Reducer = (state, action) => { + const update: Update = (state, action) => { switch (action.type) { case "increment": - return state + 1; + return tx(state + 1); case "decrement": - return state - 1; + return tx(state - 1); case "set": - return action.value; + return tx(action.value); case "add": - return state + action.value; + return tx(state + action.value); default: - return updateUnknown(state, action); + return assertNever(action); } }; - const counterStore = store(counterReducer, 0); + const counterStore = store(update, 0); assert.strictEqual(counterStore.get(), 0); }); test("store - handles actions through send", () => { - const counterReducer: Reducer = (state, action) => { + const update: Update = (state, action) => { switch (action.type) { case "increment": - return state + 1; + return tx(state + 1); case "decrement": - return state - 1; + return tx(state - 1); case "set": - return action.value; + return tx(action.value); case "add": - return state + action.value; + return tx(state + action.value); default: - return updateUnknown(state, action); + return assertNever(action); } }; - const counterStore = store(counterReducer, 0); + const counterStore = store(update, 0); counterStore.send({ type: "increment" }); assert.strictEqual(counterStore.get(), 1); @@ -80,145 +68,204 @@ test("store - handles actions through send", () => { assert.strictEqual(counterStore.get(), 15); }); -test("store - works with complex state", () => { - const todoReducer: Reducer = (state, action) => { +test("store - transactional effects yield actions", async () => { + type Action = { type: "fetch" } | { type: "set"; value: number }; + + const update: Update = (state, action) => { switch (action.type) { - case "add": - return { - ...state, - todos: [...state.todos, { - id: state.nextId, - text: action.text, - completed: false, - }], - nextId: state.nextId + 1, - }; - case "toggle": - return { - ...state, - todos: state.todos.map((todo) => - todo.id === action.id - ? { ...todo, completed: !todo.completed } - : todo - ), - }; - case "remove": - return { - ...state, - todos: state.todos.filter((todo) => todo.id !== action.id), - }; + case "fetch": + return tx(state, async function* () { + await delay(10); + yield { type: "set", value: 42 }; + }); + case "set": + return tx(action.value); default: - return updateUnknown(state, action); + return assertNever(action); } }; - const todoStore = store(todoReducer, { todos: [], nextId: 1 }); - - assert.strictEqual(todoStore.get().todos.length, 0); + const myStore = store(update, 0); - todoStore.send({ type: "add", text: "Buy milk" }); - assert.strictEqual(todoStore.get().todos.length, 1); - assert.strictEqual(todoStore.get().todos[0].text, "Buy milk"); - assert.strictEqual(todoStore.get().todos[0].completed, false); - assert.strictEqual(todoStore.get().todos[0].id, 1); + myStore.send({ type: "fetch" }); + assert.strictEqual(myStore.get(), 0); - todoStore.send({ type: "add", text: "Walk dog" }); - assert.strictEqual(todoStore.get().todos.length, 2); - assert.strictEqual(todoStore.get().nextId, 3); + await delay(20); - todoStore.send({ type: "toggle", id: 1 }); - assert.strictEqual(todoStore.get().todos[0].completed, true); - - todoStore.send({ type: "remove", id: 1 }); - assert.strictEqual(todoStore.get().todos.length, 1); - assert.strictEqual(todoStore.get().todos[0].text, "Walk dog"); + assert.strictEqual(myStore.get(), 42); }); -test("action - creates tagged action", () => { - const testAction = action("test", 42); +test("store - effects can yield multiple actions", async () => { + type Action = { type: "start" } | { type: "increment" }; - assert.strictEqual(testAction.type, "test"); - assert.strictEqual(testAction.value, 42); -}); + const update: Update = (state, action) => { + switch (action.type) { + case "start": + return tx(state, async function* () { + yield { type: "increment" }; + await delay(5); + yield { type: "increment" }; + yield { type: "increment" }; + }); + case "increment": + return tx(state + 1); + default: + return assertNever(action); + } + }; -test("action - works with different value types", () => { - const stringAction = action("string", "hello"); - assert.strictEqual(stringAction.type, "string"); - assert.strictEqual(stringAction.value, "hello"); + const myStore = store(update, 0); + myStore.send({ type: "start" }); - const objectAction = action("object", { key: "value" }); - assert.strictEqual(objectAction.type, "object"); - assert.deepStrictEqual(objectAction.value, { key: "value" }); + await delay(20); - const arrayAction = action("array", [1, 2, 3]); - assert.strictEqual(arrayAction.type, "array"); - assert.deepStrictEqual(arrayAction.value, [1, 2, 3]); + assert.strictEqual(myStore.get(), 3); }); -test("forward - transforms actions", () => { - const receivedActions: string[] = []; +test("store - transactional check-then-update-then-effect", async () => { + type Model = { + count: number; + fetching: boolean; + }; + + type Action = { type: "fetch" } | { type: "set"; value: number }; - const parentSend = (action: string) => { - receivedActions.push(action); + const update: Update = (state, action) => { + switch (action.type) { + case "fetch": + // Check: if already fetching, do nothing (no duplicate effects) + if (state.fetching) { + return tx(state); + } + // Update flag AND issue effect in a single transaction + return tx( + { ...state, fetching: true }, + async function* () { + await delay(10); + yield { type: "set", value: 42 }; + }, + ); + case "set": + return tx({ ...state, count: action.value, fetching: false } as Model); + default: + return assertNever(action); + } }; - const childSend = forward( - parentSend, - (childAction: number) => `child:${childAction}`, - ); + const myStore = store(update, { count: 0, fetching: false }); - childSend(1); - childSend(2); - childSend(3); + // First fetch: sets fetching=true, starts effect + myStore.send({ type: "fetch" }); + assert.strictEqual(myStore.get().fetching, true); - assert.deepStrictEqual(receivedActions, ["child:1", "child:2", "child:3"]); -}); + // Second fetch while first is in-flight: no-op (no duplicate effect) + myStore.send({ type: "fetch" }); + assert.strictEqual(myStore.get().fetching, true); -test("forward - works with complex transformations", () => { - type ParentAction = { type: "parent"; data: string }; - type ChildAction = { type: "child"; value: number }; + await delay(20); + + assert.strictEqual(myStore.get().count, 42); + assert.strictEqual(myStore.get().fetching, false); +}); - const receivedActions: ParentAction[] = []; +test("store - context is passed to update function", () => { + type Context = { multiplier: number }; - const parentSend = (action: ParentAction) => { - receivedActions.push(action); + const update: Update = ( + state, + action, + context, + ) => { + switch (action.type) { + case "increment": + return tx(state + context.multiplier); + case "decrement": + return tx(state - context.multiplier); + default: + return tx(state); + } }; - const childSend = forward(parentSend, (childAction: ChildAction) => ({ - type: "parent" as const, - data: `transformed-${childAction.value}`, - })); + const myStore = store(update, 0, { multiplier: 10 }); - childSend({ type: "child", value: 42 }); + myStore.send({ type: "increment" }); + assert.strictEqual(myStore.get(), 10); - assert.strictEqual(receivedActions.length, 1); - assert.strictEqual(receivedActions[0].type, "parent"); - assert.strictEqual(receivedActions[0].data, "transformed-42"); + myStore.send({ type: "decrement" }); + assert.strictEqual(myStore.get(), 0); }); -test("updateUnknown - logs warning and returns state unchanged", () => { - const originalWarn = console.warn; - let warningMessage = ""; - - // Mock console.warn - console.warn = (msg: string, data: unknown) => { - warningMessage = `${msg} ${JSON.stringify(data)}`; +test("withLogging - logs actions and state transitions", () => { + const logs: string[] = []; + const originalDebug = console.debug; + console.debug = (...args: unknown[]) => { + logs.push(args.map(String).join(" ")); }; try { - const state = { count: 5 }; - const unknownAction = { type: "unknown", data: "test" }; + const update: Update = (state, action) => { + switch (action.type) { + case "increment": + return tx(state + 1); + case "decrement": + return tx(state - 1); + default: + return tx(state); + } + }; + + const logged = withLogging(update, { name: "counter" }); + const counterStore = store(logged, 0); + + counterStore.send({ type: "increment" }); + + assert.strictEqual(logs.length, 2); + assert.ok(logs[0].includes("<-")); + assert.ok(logs[0].includes("counter")); + assert.ok(logs[1].includes("->")); + assert.ok(logs[1].includes("counter")); + } finally { + console.debug = originalDebug; + } +}); - // @ts-ignore - we want to test updateUnknown - const result = updateUnknown(state, unknownAction); +test("withLogging - respects log predicate", () => { + const logs: string[] = []; + const originalDebug = console.debug; + console.debug = (...args: unknown[]) => { + logs.push(args.map(String).join(" ")); + }; - assert.strictEqual(result, state); - assert.strictEqual( - warningMessage, - 'Unknown action {"type":"unknown","data":"test"}', - ); + try { + const update: Update = (state, action) => { + switch (action.type) { + case "increment": + return tx(state + 1); + default: + return tx(state); + } + }; + + const logged = withLogging(update, { log: () => false }); + const counterStore = store(logged, 0); + + counterStore.send({ type: "increment" }); + assert.strictEqual(counterStore.get(), 1); + assert.strictEqual(logs.length, 0); } finally { - // Restore console.warn - console.warn = originalWarn; + console.debug = originalDebug; } }); + +test("assertNever - throws on an unreachable value", () => { + const unknownAction = { type: "unknown", data: "test" }; + + assert.throws( + // @ts-expect-error - we want to test assertNever + () => assertNever(unknownAction), + { + name: "NeverError", + value: { type: "unknown", data: "test" }, + }, + ); +}); diff --git a/src/store.ts b/src/store.ts index b88525a..1cad59b 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,47 +1,82 @@ -import { type AnySignal, signal } from "./signal.js"; +import { signal } from "./signal.js"; +import { type SendableSignal } from "./send.js"; + +/** Represents an fx that can be executed against a model state, yielding actions. */ +export type Fx = (state: () => Model) => AsyncGenerator; + +/** A no-op fx that yields nothing. */ +export async function* noFx(): AsyncGenerator { + // Yield nothing +} /** - * A tagged action. Convenience type for simple actions with a - * `type` discriminator and a `value`. + * A transaction is a value that represents a state update and optional fx. */ -export type TaggedAction = { - type: Type; - value: Value; +export type Tx = { + state: Model; + fx: Fx; }; -/** Convencience factory for creating an action with a `type` discriminator and `value` */ -export const action = ( - type: Type, - value: Value, -): TaggedAction => ({ - type, - value, -}); +/** Create a transaction */ +export const tx = ( + state: Model, + fx: Fx = noFx, +): Tx => ({ state, fx }); -export type Reducer = ( +export type Update = ( state: Model, action: Action, -) => Model; + context: Context, +) => Tx; -export type Send = (action: Action) => void; - -export type Store = AnySignal & { - send: Send; -}; +/** The signal returned by `store()`: a readable state with a `send` method. */ +export type StoreSignal = SendableSignal; /** * Create a signals-based store that updates through the provided `update` * reducer function. - * @arg update - The reducer function that updates the store state. + * @arg update - The reducer function that updates the store by returning + * a new state and optional effects. * @arg initial - The initial state of the store. + * @arg context - The context object passed to the reducer function (optional) * @returns A store object with a signal for the state and a send method. */ -export const store = ( - update: Reducer, +export function store( + update: Update, initial: Model, -): Store => { +): StoreSignal; +export function store( + update: Update, + initial: Model, + context: Context, +): StoreSignal; +export function store( + update: Update, + initial: Model, + context?: Context, +): StoreSignal { const $state = signal(initial); + const forkFx = async (fx: Fx) => { + const generator = fx(get); + while (true) { + try { + const { value, done } = await generator.next(); + + if (done) { + return; + } + + if (value != undefined) { + send(value); + } + } catch (e) { + console.error("Error in effect", e); + return; + } + } + }; + /** * Get the current state. * This method is hard-bound to the reducer so you can pass it around as a function. @@ -53,36 +88,42 @@ export const store = ( * This method is hard-bound to the reducer so you can pass it around as a function. */ const send = (action: Action) => { - const next = update($state.get(), action); - $state.set(next); + const { state, fx } = update($state.get(), action, context!); + $state.set(state); + forkFx(fx); }; return { get, send }; -}; +} -/** - * Transform a send function so that it tags actions on the way out. - * This can be useful for mapping actions from one component domain to another. - */ -export const forward = ( - send: (action: ActionA) => void, - tag: (action: ActionB) => ActionA, -) => - (action: ActionB): void => { - send(tag(action)); - }; +const alwaysLog = () => true; /** - * Convenience function for logging unknown actions in the default arm - * of a reducer. - * - * Because `action` is of type `never`, Typescript will show an error under - * this argument if the switch is not exhaustive. + * Wrap update function with logging. + * @param update - The update function to wrap. + * @param options - options object + * @param options.name - Name of the store in log messages. Defaults to "store". + * @param options.log - A function that returns `true` if logging should be enabled. + * @returns A new update function that logs state transitions. */ -export const updateUnknown = ( - state: Model, - action: never, -): Model => { - console.warn("Unknown action", action); - return state; +export const withLogging = ( + update: Update, + { + name = "store", + log = alwaysLog, + }: { + name?: string; + log?: () => boolean; + } = {}, +): Update => { + return (state: Model, action: Action, context: Context) => { + if (log()) { + console.debug("<-", name, action); + } + const result = update(state, action, context); + if (log()) { + console.debug("->", name, result.state); + } + return result; + }; };