From 74253350992d927167c155f7a65786d545be6f46 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 25 Mar 2026 16:37:06 +0300 Subject: [PATCH 01/19] Give store transactional effects Create plain reducer at reducer.js --- src/middleware/fx.ts | 12 ++--- src/middleware/logger.ts | 26 +++++----- src/middleware/scope.ts | 20 ++++---- src/reducer.ts | 89 ++++++++++++++++++++++++++++++++++ src/store.ts | 101 ++++++++++++++++++--------------------- 5 files changed, 165 insertions(+), 83 deletions(-) create mode 100644 src/reducer.ts diff --git a/src/middleware/fx.ts b/src/middleware/fx.ts index 6db6c55..bc38fa2 100644 --- a/src/middleware/fx.ts +++ b/src/middleware/fx.ts @@ -1,4 +1,4 @@ -import { type Store } from "../store.js"; +import { type ReducerSignal } from "../reducer.js"; import { peek } from "../signal.js"; export type Fx = ( @@ -52,18 +52,18 @@ const forkFx = async ( */ export function fx( fx: Fx, -): (store: Store) => Store; +): (store: ReducerSignal) => ReducerSignal; export function fx( fx: Fx, context: Context, -): (store: Store) => Store; +): (store: ReducerSignal) => ReducerSignal; export function fx( fx: Fx, context?: Context, -): (store: Store) => Store { +): (store: ReducerSignal) => ReducerSignal { return ( - { get, send }: Store, - ): Store => { + { get, send }: ReducerSignal, + ): ReducerSignal => { const peekState = () => peek(get); const sendWithFx = (action: Action) => { diff --git a/src/middleware/logger.ts b/src/middleware/logger.ts index 9e121bd..7a7d6aa 100644 --- a/src/middleware/logger.ts +++ b/src/middleware/logger.ts @@ -1,4 +1,4 @@ -import { type Store } from "../store.js"; +import { type ReducerSignal } from "../reducer.js"; import { peek } from "../signal.js"; /** @@ -18,17 +18,17 @@ export const logger = ({ 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)); - }; + ( + { get, send }: ReducerSignal, + ): ReducerSignal => { + const sendWithLogging = (action: Action) => { + if (log) console.log(`${prefix}<`, action); + send(action); + if (log) console.log(`${prefix}>`, peek(get)); + }; - return { - get, - send: sendWithLogging, + return { + get, + send: sendWithLogging, + }; }; -}; diff --git a/src/middleware/scope.ts b/src/middleware/scope.ts index 8cabe11..153d1dc 100644 --- a/src/middleware/scope.ts +++ b/src/middleware/scope.ts @@ -1,4 +1,4 @@ -import { forward, type Store } from "../store.js"; +import { forward, type ReducerSignal } from "../reducer.js"; import { computed } from "../signal.js"; /** @@ -19,13 +19,13 @@ 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, + ( + store: ReducerSignal, + ): ReducerSignal => { + const $state = computed(() => get(store.get())); + const send = forward(store.send, tag); + return { + get: () => $state.get(), + send, + }; }; -}; diff --git a/src/reducer.ts b/src/reducer.ts new file mode 100644 index 0000000..7981d2f --- /dev/null +++ b/src/reducer.ts @@ -0,0 +1,89 @@ +import { type AnySignal, signal } from "./signal.js"; + +/** + * A tagged action. Convenience type for simple actions with a + * `type` discriminator and a `value`. + */ +export type TaggedAction = { + type: Type; + value: Value; +}; + +/** Convencience factory for creating an action with a `type` discriminator and `value` */ +export const action = ( + type: Type, + value: Value, +): TaggedAction => ({ + type, + value, +}); + +export type Reducer = ( + state: Model, + action: Action, +) => Model; + +export type Send = (action: Action) => void; + +export type ReducerSignal = AnySignal & { + send: Send; +}; + +/** + * 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 }; +}; + + +/** + * 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)); + }; + +/** + * 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. + */ +export const updateUnknown = ( + state: Model, + action: never, +): Model => { + console.warn("Unknown action", action); + return state; +}; diff --git a/src/store.ts b/src/store.ts index b88525a..a986d9d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,47 +1,64 @@ -import { type AnySignal, signal } from "./signal.js"; +import { signal } from "./signal.js"; +import { type ReducerSignal } from "./reducer.js"; +export { type Send, forward, updateUnknown } from "./reducer.js"; /** - * 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 effects. */ -export type TaggedAction = { - type: Type; - value: Value; +export type Tx = { + state: Model; + effects?: AsyncGenerator; }; -/** 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, + effects?: AsyncGenerator, +): Tx => ({ state, effects }); -export type Reducer = ( +export type Update = ( state: Model, action: Action, -) => Model; - -export type Send = (action: Action) => void; + context: Context, +) => Tx; -export type Store = AnySignal & { - send: Send; -}; +export type Store = ReducerSignal; /** * 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; +export function store( + update: Update, initial: Model, -): Store => { + context: Context, +): Store; +export function store( + update: Update, + initial: Model, + context?: Context, +): Store { const $state = signal(initial); + const forkEffects = async (effects: AsyncGenerator) => { + while (true) { + const { value, done } = await effects.next(get()); + if (done) return; + if (value != undefined) { + send(value); + } + } + }; + /** * Get the current state. * This method is hard-bound to the reducer so you can pass it around as a function. @@ -53,36 +70,12 @@ 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, effects } = update($state.get(), action, context!); + $state.set(state); + if (effects) { + forkEffects(effects); + } }; 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)); - }; - -/** - * 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. - */ -export const updateUnknown = ( - state: Model, - action: never, -): Model => { - console.warn("Unknown action", action); - return state; -}; From 727aee3fc71b031c6900e49396d93fd72d2e2a01 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 25 Mar 2026 17:27:11 +0300 Subject: [PATCH 02/19] Have AI do a coherency pass --- README.md | 169 ++++++++++++++++------- package.json | 4 + src/iter.ts | 6 +- src/main.ts | 1 + src/middleware/fx.test.ts | 22 +-- src/middleware/scope.test.ts | 24 ++-- src/reducer.test.ts | 230 ++++++++++++++++++++++++++++++++ src/reducer.ts | 2 +- src/store.test.ts | 250 ++++++++++++++++++++--------------- src/store.ts | 50 +++++-- 10 files changed, 563 insertions(+), 195 deletions(-) create mode 100644 src/reducer.test.ts diff --git a/README.md b/README.md index 709fd09..4e3ea9b 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,18 @@ 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). -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 offers two approaches: + +- **Store** with transactional effects (Elm-style) — state updates and effects are returned together, enabling check-then-update-then-effect patterns. +- **Reducer** for pure state updates (Redux-style) — effects can be added separately via the `fx` middleware. + +Both return the same `ReducerSignal` interface, so all middleware works with either. ## 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. +- **Transactional effects**: State updates and side effects in a single transaction. +- **Middleware**: Enhance behavior via function composition. - **Minimal dependencies**: Uses only `signal-polyfill` library for maximum compatibility. ## Example @@ -16,7 +21,7 @@ At its core, Refrakt is just a signal defined with a reducer. But don't underest Here's a simple counter example using [Lit](https://lit.dev/) for UI. ```typescript -import { store } from "refrakt"; +import { store, tx } from "refrakt"; import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators.js'; import { SignalWatcher } from '@lit-labs/signals'; @@ -24,15 +29,15 @@ import { SignalWatcher } from '@lit-labs/signals'; type Model = { count: number }; type Action = { type: 'inc' } | { type: 'dec' }; -const updateCounter = (state: Model, action: Action) => { +const update = (state: Model, action: Action) => { switch (action.type) { - case 'inc': return { count: state.count + 1 }; - case 'dec': return { count: state.count - 1 }; - default: return state; + case 'inc': return tx({ count: state.count + 1 }); + case 'dec': return tx({ count: state.count - 1 }); + default: return tx(state); } }; -const counter = store(updateCounter, { count: 0 }); +const counter = store(update, { count: 0 }); @customElement('counter-app') class CounterApp extends SignalWatcher(LitElement) { @@ -50,43 +55,111 @@ class CounterApp extends SignalWatcher(LitElement) { ## Store -`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. +`store()` creates a signal updated via a transactional update function. The update function returns a `Tx` containing the new state and optional effects. ```typescript -import { store } from 'refrakt'; +import { store, tx, type Update } from 'refrakt'; + +type Model = { count: number, fetching: boolean }; + +type Action = + | { type: 'increment' } + | { type: 'fetch' } + | { type: 'fetch-complete', value: number }; + +const update: Update = (state, action) => { + switch (action.type) { + case 'increment': + return tx({ ...state, count: state.count + 1 }); + case 'fetch': + // State update and effect 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 tx(state); + } +}; + +const counterStore = store(update, { count: 0, fetching: false }); +counterStore.send({ type: 'increment' }); +``` + +`tx(state, effects?)` is a convenience factory for creating transactions. Effects are async generators that yield actions back to the store. + +### Transactional effects + +The key advantage of combining state updates with effects is **transactional** check-then-update-then-effect patterns. For example, preventing duplicate fetches: + +```typescript +case 'fetch': + // Already fetching? Do nothing. + if (state.fetching) { + return tx(state); + } + // Set flag AND issue effect atomically + return tx( + { ...state, fetching: true }, + (async function* () { + const data = await fetchData(); + yield { type: 'fetch-complete', value: data }; + })() + ); +``` + +Because the flag and effect are set in the same transaction, there's no window where a duplicate fetch can slip through. This is difficult to achieve when reducers and effects are separated, since effects run in parallel while state updates are sequential. + +### Context + +`store()` accepts an optional third `context` argument, passed to the update function on every action: + +```typescript +const update: Update = (state, action, services) => { + // ... +}; + +const myStore = store(update, initialState, services); +``` + +## Reducer + +`reducer()` creates a signal updated via a pure reducer function. It's the simpler option when you don't need transactional effects. + +```typescript +import { reducer, type Reducer } from 'refrakt'; type CounterAction = | { type: 'increment' } | { type: 'decrement' } | { type: 'set', value: number }; -type CounterModel = { count: number }; - -const update = (state: CounterModel, action: CounterAction) => { +const update: Reducer = (state, action) => { switch (action.type) { case 'increment': - return { count: state.count + 1 }; + return state + 1; case 'decrement': - return { count: state.count - 1 }; + return state - 1; case 'set': - return { count: action.value }; + return action.value; default: return state; } }; -const counterStore = store(update, { count: 0 }); +const counterStore = reducer(update, 0); -// Send actions to update state counterStore.send({ type: 'increment' }); -console.log(counterStore.get().count); // 1 +console.log(counterStore.get()); // 1 ``` -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. - -You can create multiple stores for different components, or create a single central store for your entire application state. It's up to you! - -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). +Effects can be added to a reducer via the `fx` middleware (see below). ## Signals @@ -118,15 +191,12 @@ 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); ``` - ## 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. +The `fx` middleware adds managed side effects to a `reducer`. Effects are modeled as async generators that yield actions back to the store. ```typescript type Fx = ( @@ -135,10 +205,10 @@ type Fx = ( ) => AsyncGenerator; // Yielded actions are sent back to store ``` -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. +The effect generator function is called for each 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 { reducer, pipe } from 'refrakt'; import { fx, type Fx } from 'refrakt/middleware/fx.js'; const fetchProfileFx: Fx = async function* (state, action) { @@ -162,7 +232,7 @@ const fetchProfileFx: Fx = async function* (state, action) // Apply middleware const appStore = pipe( - store(appReducer, initialState), + reducer(appReducer, initialState), fx(fetchProfileFx) ); @@ -206,7 +276,7 @@ const loginFx: Fx = async function* (state, act }; const appStore = pipe( - store(appReducer, initialState), + reducer(appReducer, initialState), fx(loginFx, services) ); ```` @@ -219,14 +289,14 @@ Because effects are just async generators, they can be easily composed and mappe ## Logger middleware -Logs all actions and state changes to the console: +Logs all actions and state changes to the console. Works with both `store` and `reducer`. ```typescript -import { store, pipe } from 'refrakt'; +import { reducer, pipe } from 'refrakt'; import { logger } from 'refrakt/middleware/logger.js'; const myStore = pipe( - store(update, initialState), + reducer(update, initialState), logger({ prefix: 'MyStore: ' }) ); ``` @@ -242,7 +312,7 @@ MyStore: > { count: 1 } 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. ```typescript -import { store, pipe } from 'refrakt'; +import { pipe } from 'refrakt'; import { scope } from 'refrakt/middleware/scope.js'; const childStore = pipe( @@ -265,7 +335,7 @@ 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 { store, tx, type Store } from "refrakt"; import { LitElement, html } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { watch } from '@lit-labs/signals'; @@ -315,36 +385,36 @@ Because scoped stores are indistinguishable from parent stores, you can replace ## Custom Middleware -Middleware are just functions of `(store: Store) => Store` that wrap the store, returning a new store with enhanced behavior. +Middleware are functions of `(store: ReducerSignal) => ReducerSignal` that wrap the store, returning a new store with enhanced behavior. They work with both `store` and `reducer` since both return a `ReducerSignal`. That means you can simply pass the store to a middleware function: ```ts const loggerMiddleware = logger(); -const myStore = loggerMiddleware(store(update, initial)); +const myStore = loggerMiddleware(reducer(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 { reducer, pipe } from 'refrakt'; import { fx } from 'refrakt/middleware/fx.js'; import { logger } from 'refrakt/middleware/logger.js'; const counterStore = pipe( - store(update, { count: 0 }), + reducer(update, { count: 0 }), logger({ prefix: 'Counter: ' }), - fx(mySaga) + fx(myEffects) ); ``` 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'; +import type { ReducerSignal } from 'refrakt'; const timingMiddleware = () => - (store: Store): Store => { + (store: ReducerSignal): ReducerSignal => { const timedSend = (action: Action) => { const start = performance.now(); store.send(action); @@ -360,16 +430,17 @@ const timingMiddleware = () => // Use with pipe const myStore = pipe( - store(update, initialState), + reducer(update, initialState), timingMiddleware() ); ``` ## 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 +- `action(type, value)` - Create tagged actions: `action('set', 42)` -> `{ type: 'set', value: 42 }` +- `forward(send, transform)` - Decorates send function so that it transforms actions before sending - `updateUnknown(state, action)` - Default handler for unknown actions (logs warning) +- `tx(state, effects?)` - Create a transaction with state and optional effects ## License diff --git a/package.json b/package.json index 34d9d4f..e33b2a3 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,10 @@ "types": "./dist/iter.d.ts", "import": "./dist/iter.js" }, + "./reducer.js": { + "types": "./dist/reducer.d.ts", + "import": "./dist/reducer.js" + }, "./pipe.js": { "types": "./dist/pipe.d.ts", "import": "./dist/pipe.js" diff --git a/src/iter.ts b/src/iter.ts index a085a36..3335252 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](); }; diff --git a/src/main.ts b/src/main.ts index 62a7c0e..94314ca 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,4 @@ export * from "./store.js"; export * from "./signal.js"; +export { reducer, type Reducer, type ReducerSignal } from "./reducer.js"; export { pipe } from "./pipe.js"; diff --git a/src/middleware/fx.test.ts b/src/middleware/fx.test.ts index e07e00e..88759d3 100644 --- a/src/middleware/fx.test.ts +++ b/src/middleware/fx.test.ts @@ -1,7 +1,7 @@ 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 { type Reducer, reducer } from "../reducer.js"; import { pipe } from "../pipe.js"; // Test types @@ -49,7 +49,7 @@ test("fx - handles simple async effects", async () => { }; const counterStore = pipe( - store(counterReducer, { count: 0, loading: false } as CounterState), + reducer(counterReducer, { count: 0, loading: false } as CounterState), fx(testFx), ); @@ -91,7 +91,7 @@ test("fx - handles multiple yielded actions", async () => { }; const counterStore = pipe( - store(counterReducer, { count: 0, loading: false }), + reducer(counterReducer, { count: 0, loading: false }), fx(testFx), ); @@ -133,7 +133,7 @@ test("fx - ignores actions that don't trigger effects", async () => { }; const counterStore = pipe( - store(counterReducer, { count: 0, loading: false }), + reducer(counterReducer, { count: 0, loading: false }), fx(testFx), ); @@ -171,7 +171,7 @@ test("fx - can access current state", async () => { }; const counterStore = pipe( - store(counterReducer, { count: 5, loading: false }), + reducer(counterReducer, { count: 5, loading: false }), fx(testFx), ); @@ -214,7 +214,7 @@ test("fx - handles errors gracefully", async () => { }; const counterStore = pipe( - store(counterReducer, { count: 0, loading: false }), + reducer(counterReducer, { count: 0, loading: false }), fx(testFx), ); @@ -250,7 +250,7 @@ test("fx - works with empty generator", async () => { }; const counterStore = pipe( - store(counterReducer, { count: 0, loading: false }), + reducer(counterReducer, { count: 0, loading: false }), fx(testFx), ); @@ -286,7 +286,7 @@ test("fx - preserves action order", async () => { }; const counterStore = pipe( - store(counterReducer, { count: 0, loading: false }), + reducer(counterReducer, { count: 0, loading: false }), fx(testFx), ); @@ -332,7 +332,7 @@ test("fx - can chain multiple async operations", async () => { }; const counterStore = pipe( - store(counterReducer, { count: 0, loading: false }), + reducer(counterReducer, { count: 0, loading: false }), fx(testFx), ); @@ -366,7 +366,7 @@ test("fx - runs concurrently for multiple actions", async () => { }; const counterStore = pipe( - store(counterReducer, { count: 0, loading: false }), + reducer(counterReducer, { count: 0, loading: false }), fx(testFx), ); @@ -410,7 +410,7 @@ test("fx - passes context to fx generator function", (_t, done) => { }; const testStore = pipe( - store(counterReducer, 0), + reducer(counterReducer, 0), fx(testFx, 10), ); diff --git a/src/middleware/scope.test.ts b/src/middleware/scope.test.ts index e246abe..9bc3219 100644 --- a/src/middleware/scope.test.ts +++ b/src/middleware/scope.test.ts @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { scope } from "./scope.js"; -import { type Reducer, store } from "../store.js"; +import { type Reducer, reducer } from "../reducer.js"; import { pipe } from "../pipe.js"; import { effect } from "../signal.js"; @@ -39,7 +39,7 @@ test("scope - creates scoped store with subset of parent state", () => { } }; - const parentStore = store(parentReducer, { + const parentStore = reducer(parentReducer, { counter: 0, name: "test", timestamp: 0, @@ -72,7 +72,7 @@ test("scope - maps child actions to parent actions", () => { } }; - const parentStore = store(parentReducer, { + const parentStore = reducer(parentReducer, { counter: 0, name: "test", timestamp: 0, @@ -115,7 +115,7 @@ test("scope - reflects parent state changes in scoped store", () => { } }; - const parentStore = store(parentReducer, { + const parentStore = reducer(parentReducer, { counter: 5, name: "test", timestamp: 0, @@ -151,7 +151,7 @@ test("scope - isolates child from unrelated parent state changes", () => { } }; - const parentStore = store(parentReducer, { + const parentStore = reducer(parentReducer, { counter: 0, name: "test", timestamp: 0, @@ -224,7 +224,7 @@ test("scope - works with complex state transformations", () => { } }; - const parentStore = store(complexReducer, { + const parentStore = reducer(complexReducer, { user: { id: 1, profile: { name: "Alice", age: 30 }, @@ -271,7 +271,7 @@ test("scope - works with primitive value scopes", () => { } }; - const parentStore = store(parentReducer, { + const parentStore = reducer(parentReducer, { counter: 0, name: "initial", timestamp: 0, @@ -334,7 +334,7 @@ test("scope - can be chained for nested scoping", () => { } }; - const rootStore = store(nestedReducer, { + const rootStore = reducer(nestedReducer, { level1: { level2: { level3: { value: 0 } } }, }); @@ -393,7 +393,7 @@ test("scope - works with effects and reactivity", async () => { } }; - const parentStore = store(parentReducer, { + const parentStore = reducer(parentReducer, { counter: 0, name: "test", timestamp: 0, @@ -446,7 +446,7 @@ test("scope - multiple scoped stores from same parent", () => { } }; - const parentStore = store(parentReducer, { + const parentStore = reducer(parentReducer, { counter: 10, name: "parent", timestamp: 0, @@ -517,7 +517,7 @@ test("scope - handles computed transformations", () => { } }; - const parentStore = store(todoReducer, { + const parentStore = reducer(todoReducer, { todos: [ { id: 1, text: "Task 1", completed: false }, { id: 2, text: "Task 2", completed: true }, @@ -561,7 +561,7 @@ test("scope - preserves referential equality for unchanged scopes", () => { } }; - const parentStore = store(parentReducer, { + const parentStore = reducer(parentReducer, { counter: 0, name: "test", timestamp: 0, diff --git a/src/reducer.test.ts b/src/reducer.test.ts new file mode 100644 index 0000000..3221e57 --- /dev/null +++ b/src/reducer.test.ts @@ -0,0 +1,230 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + reducer, + type Reducer, + action, + forward, + stepUnknown, +} from "./reducer.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 stepUnknown(state, 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 stepUnknown(state, 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 stepUnknown(state, 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("action - creates tagged action", () => { + const testAction = action("test", 42); + + assert.strictEqual(testAction.type, "test"); + assert.strictEqual(testAction.value, 42); +}); + +test("action - works with different value types", () => { + const stringAction = action("string", "hello"); + assert.strictEqual(stringAction.type, "string"); + assert.strictEqual(stringAction.value, "hello"); + + const objectAction = action("object", { key: "value" }); + assert.strictEqual(objectAction.type, "object"); + assert.deepStrictEqual(objectAction.value, { key: "value" }); + + const arrayAction = action("array", [1, 2, 3]); + assert.strictEqual(arrayAction.type, "array"); + assert.deepStrictEqual(arrayAction.value, [1, 2, 3]); +}); + +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"); +}); + +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)}`; + }; + + try { + const state = { count: 5 }; + const unknownAction = { type: "unknown", data: "test" }; + + // @ts-ignore - we want to test updateUnknown + const result = stepUnknown(state, unknownAction); + + assert.strictEqual(result, state); + assert.strictEqual( + warningMessage, + 'Unknown action {"type":"unknown","data":"test"}', + ); + } finally { + // Restore console.warn + console.warn = originalWarn; + } +}); diff --git a/src/reducer.ts b/src/reducer.ts index 7981d2f..30c933d 100644 --- a/src/reducer.ts +++ b/src/reducer.ts @@ -80,7 +80,7 @@ export const forward = ( * Because `action` is of type `never`, Typescript will show an error under * this argument if the switch is not exhaustive. */ -export const updateUnknown = ( +export const stepUnknown = ( state: Model, action: never, ): Model => { diff --git a/src/store.test.ts b/src/store.test.ts index 25aec4a..4c71e31 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -1,6 +1,9 @@ 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, forward, action, updateUnknown } from "./store.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 +12,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); } }; - 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); } }; - const counterStore = store(counterReducer, 0); + const counterStore = store(update, 0); counterStore.send({ type: "increment" }); assert.strictEqual(counterStore.get(), 1); @@ -80,79 +67,150 @@ 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); } }; - const todoStore = store(todoReducer, { todos: [], nextId: 1 }); + const myStore = store(update, 0); - assert.strictEqual(todoStore.get().todos.length, 0); + myStore.send({ type: "fetch" }); + assert.strictEqual(myStore.get(), 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); + await delay(20); - todoStore.send({ type: "add", text: "Walk dog" }); - assert.strictEqual(todoStore.get().todos.length, 2); - assert.strictEqual(todoStore.get().nextId, 3); + assert.strictEqual(myStore.get(), 42); +}); - todoStore.send({ type: "toggle", id: 1 }); - assert.strictEqual(todoStore.get().todos[0].completed, true); +test("store - effects can yield multiple actions", async () => { + type Action = + | { type: "start" } + | { type: "increment" }; + + 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 updateUnknown(state, action); + } + }; - todoStore.send({ type: "remove", id: 1 }); - assert.strictEqual(todoStore.get().todos.length, 1); - assert.strictEqual(todoStore.get().todos[0].text, "Walk dog"); + const myStore = store(update, 0); + myStore.send({ type: "start" }); + + await delay(20); + + assert.strictEqual(myStore.get(), 3); }); -test("action - creates tagged action", () => { - const testAction = action("test", 42); +test("store - transactional check-then-update-then-effect", async () => { + type Model = { + count: number; + fetching: boolean; + }; - assert.strictEqual(testAction.type, "test"); - assert.strictEqual(testAction.value, 42); + type Action = + | { type: "fetch" } + | { type: "set"; value: number }; + + 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 }); + default: + return updateUnknown(state, action); + } + }; + + const myStore = store(update, { count: 0, fetching: false }); + + // First fetch: sets fetching=true, starts effect + myStore.send({ type: "fetch" }); + assert.strictEqual(myStore.get().fetching, true); + + // Second fetch while first is in-flight: no-op (no duplicate effect) + myStore.send({ type: "fetch" }); + assert.strictEqual(myStore.get().fetching, true); + + await delay(20); + + assert.strictEqual(myStore.get().count, 42); + assert.strictEqual(myStore.get().fetching, false); }); -test("action - works with different value types", () => { - const stringAction = action("string", "hello"); - assert.strictEqual(stringAction.type, "string"); - assert.strictEqual(stringAction.value, "hello"); +test("store - context is passed to update function", () => { + type Context = { multiplier: number }; - const objectAction = action("object", { key: "value" }); - assert.strictEqual(objectAction.type, "object"); - assert.deepStrictEqual(objectAction.value, { key: "value" }); + 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 myStore = store(update, 0, { multiplier: 10 }); - const arrayAction = action("array", [1, 2, 3]); - assert.strictEqual(arrayAction.type, "array"); - assert.deepStrictEqual(arrayAction.value, [1, 2, 3]); + myStore.send({ type: "increment" }); + assert.strictEqual(myStore.get(), 10); + + myStore.send({ type: "decrement" }); + assert.strictEqual(myStore.get(), 0); +}); + +test("action - creates tagged action", () => { + const testAction = action("test", 42); + + assert.strictEqual(testAction.type, "test"); + assert.strictEqual(testAction.value, 42); }); test("forward - transforms actions", () => { @@ -174,33 +232,10 @@ test("forward - transforms actions", () => { 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"); -}); - 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)}`; }; @@ -218,7 +253,6 @@ test("updateUnknown - logs warning and returns state unchanged", () => { 'Unknown action {"type":"unknown","data":"test"}', ); } finally { - // Restore console.warn console.warn = originalWarn; } }); diff --git a/src/store.ts b/src/store.ts index a986d9d..a12bcca 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,19 +1,25 @@ import { signal } from "./signal.js"; import { type ReducerSignal } from "./reducer.js"; -export { type Send, forward, updateUnknown } from "./reducer.js"; +export { type Send, type TaggedAction, action, forward } from "./reducer.js"; + +export type Effects = AsyncGenerator; + +export async function* noEffects(): Effects { + // Yield nothing +} /** * A transaction is a value that represents a state update and optional effects. */ export type Tx = { state: Model; - effects?: AsyncGenerator; + effects: Effects; }; /** Create a transaction */ export const tx = ( state: Model, - effects?: AsyncGenerator, + effects: Effects = noEffects(), ): Tx => ({ state, effects }); export type Update = ( @@ -49,12 +55,21 @@ export function store( ): Store { const $state = signal(initial); - const forkEffects = async (effects: AsyncGenerator) => { + const forkEffects = async (effects: Effects) => { while (true) { - const { value, done } = await effects.next(get()); - if (done) return; - if (value != undefined) { - send(value); + try { + const { value, done } = await effects.next(get()); + + if (done) { + return + }; + + if (value != undefined) { + send(value); + } + } catch (e) { + console.warn("Error in effect", e); + return; } } }; @@ -72,10 +87,23 @@ export function store( const send = (action: Action) => { const { state, effects } = update($state.get(), action, context!); $state.set(state); - if (effects) { - forkEffects(effects); - } + forkEffects(effects); }; return { get, send }; }; + +/** + * 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. + */ +export const updateUnknown = ( + state: Model, + action: never, +): Tx => { + console.warn("Unknown action", action); + return tx(state); +}; From 620b2d6a3517a6b987366d3e9e35d847c91dcf7e Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 25 Mar 2026 21:14:37 +0300 Subject: [PATCH 03/19] Checkpoint - Remove middleware - Remove pipe - Add logging helpers for update fns - Factor out scope - Factor out shared types --- package.json | 24 +- src/effect.ts | 5 + src/main.ts | 9 +- src/middleware/fx.test.ts | 418 ------------------------- src/middleware/fx.ts | 82 ----- src/middleware/logger.ts | 34 -- src/middleware/scope.test.ts | 587 ----------------------------------- src/middleware/scope.ts | 31 -- src/pipe.test.ts | 48 --- src/pipe.ts | 326 ------------------- src/reducer.test.ts | 32 +- src/reducer.ts | 66 ++-- src/scope.ts | 34 ++ src/send.ts | 19 ++ src/store.test.ts | 14 +- src/store.ts | 62 ++-- 16 files changed, 158 insertions(+), 1633 deletions(-) create mode 100644 src/effect.ts delete mode 100644 src/middleware/fx.test.ts delete mode 100644 src/middleware/fx.ts delete mode 100644 src/middleware/logger.ts delete mode 100644 src/middleware/scope.test.ts delete mode 100644 src/middleware/scope.ts delete mode 100644 src/pipe.test.ts delete mode 100644 src/pipe.ts create mode 100644 src/scope.ts create mode 100644 src/send.ts diff --git a/package.json b/package.json index e33b2a3..4a98f8a 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,14 @@ "types": "./dist/signal.d.ts", "import": "./dist/signal.js" }, + "./send.js": { + "types": "./dist/send.d.ts", + "import": "./dist/send.js" + }, + "./effect.js": { + "types": "./dist/effect.d.ts", + "import": "./dist/effect.js" + }, "./iter.js": { "types": "./dist/iter.d.ts", "import": "./dist/iter.js" @@ -23,22 +31,6 @@ "./reducer.js": { "types": "./dist/reducer.d.ts", "import": "./dist/reducer.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" - }, - "./middleware/fx.js": { - "types": "./dist/middleware/fx.d.ts", - "import": "./dist/middleware/fx.js" - }, - "./middleware/scope.js": { - "types": "./dist/middleware/scope.d.ts", - "import": "./dist/middleware/scope.js" } }, "files": [ diff --git a/src/effect.ts b/src/effect.ts new file mode 100644 index 0000000..60534bd --- /dev/null +++ b/src/effect.ts @@ -0,0 +1,5 @@ +export type Effect = AsyncGenerator; + +export async function* none(): Effect { + // Yield nothing +} diff --git a/src/main.ts b/src/main.ts index 94314ca..1f31383 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ -export * from "./store.js"; -export * from "./signal.js"; -export { reducer, type Reducer, type ReducerSignal } from "./reducer.js"; -export { pipe } from "./pipe.js"; +export * as Signal from "./signal.js"; +export * as Reducer from "./reducer.js"; +export * as Store from "./store.js"; +export * as Effect from "./effect.js"; +export * as Send from "./send.js"; diff --git a/src/middleware/fx.test.ts b/src/middleware/fx.test.ts deleted file mode 100644 index 88759d3..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, reducer } from "../reducer.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( - reducer(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( - reducer(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( - reducer(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( - reducer(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( - reducer(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( - reducer(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( - reducer(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( - reducer(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( - reducer(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( - reducer(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 bc38fa2..0000000 --- a/src/middleware/fx.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { type ReducerSignal } from "../reducer.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: ReducerSignal) => ReducerSignal; -export function fx( - fx: Fx, - context: Context, -): (store: ReducerSignal) => ReducerSignal; -export function fx( - fx: Fx, - context?: Context, -): (store: ReducerSignal) => ReducerSignal { - return ( - { get, send }: ReducerSignal, - ): ReducerSignal => { - 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 7a7d6aa..0000000 --- a/src/middleware/logger.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { type ReducerSignal } from "../reducer.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 }: ReducerSignal, - ): ReducerSignal => { - 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 9bc3219..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, reducer } from "../reducer.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 = reducer(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 = reducer(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 = reducer(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 = reducer(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 = reducer(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 = reducer(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 = reducer(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 = reducer(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 = reducer(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 = reducer(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 = reducer(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 153d1dc..0000000 --- a/src/middleware/scope.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { forward, type ReducerSignal } from "../reducer.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: ReducerSignal, - ): ReducerSignal => { - const $state = computed(() => get(store.get())); - const send = forward(store.send, tag); - return { - get: () => $state.get(), - send, - }; - }; 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 index 3221e57..23dac50 100644 --- a/src/reducer.test.ts +++ b/src/reducer.test.ts @@ -3,9 +3,8 @@ import assert from "node:assert/strict"; import { reducer, type Reducer, - action, forward, - stepUnknown, + unknown, } from "./reducer.js"; // Test types for actions @@ -43,7 +42,7 @@ test("reducer - creates reducer with initial state", () => { case "add": return state + action.value; default: - return stepUnknown(state, action); + return unknown(state, action); } }; @@ -64,7 +63,7 @@ test("reducer - handles actions through send", () => { case "add": return state + action.value; default: - return stepUnknown(state, action); + return unknown(state, action); } }; @@ -114,7 +113,7 @@ test("reducer - works with complex state", () => { todos: state.todos.filter((todo) => todo.id !== action.id), }; default: - return stepUnknown(state, action); + return unknown(state, action); } }; @@ -140,27 +139,6 @@ test("reducer - works with complex state", () => { assert.strictEqual(todoStore.get().todos[0].text, "Walk dog"); }); -test("action - creates tagged action", () => { - const testAction = action("test", 42); - - assert.strictEqual(testAction.type, "test"); - assert.strictEqual(testAction.value, 42); -}); - -test("action - works with different value types", () => { - const stringAction = action("string", "hello"); - assert.strictEqual(stringAction.type, "string"); - assert.strictEqual(stringAction.value, "hello"); - - const objectAction = action("object", { key: "value" }); - assert.strictEqual(objectAction.type, "object"); - assert.deepStrictEqual(objectAction.value, { key: "value" }); - - const arrayAction = action("array", [1, 2, 3]); - assert.strictEqual(arrayAction.type, "array"); - assert.deepStrictEqual(arrayAction.value, [1, 2, 3]); -}); - test("forward - transforms actions", () => { const receivedActions: string[] = []; @@ -216,7 +194,7 @@ test("updateUnknown - logs warning and returns state unchanged", () => { const unknownAction = { type: "unknown", data: "test" }; // @ts-ignore - we want to test updateUnknown - const result = stepUnknown(state, unknownAction); + const result = unknown(state, unknownAction); assert.strictEqual(result, state); assert.strictEqual( diff --git a/src/reducer.ts b/src/reducer.ts index 30c933d..51f8767 100644 --- a/src/reducer.ts +++ b/src/reducer.ts @@ -1,34 +1,11 @@ -import { type AnySignal, signal } from "./signal.js"; - -/** - * A tagged action. Convenience type for simple actions with a - * `type` discriminator and a `value`. - */ -export type TaggedAction = { - type: Type; - value: Value; -}; - -/** Convencience factory for creating an action with a `type` discriminator and `value` */ -export const action = ( - type: Type, - value: Value, -): TaggedAction => ({ - type, - value, -}); +import { signal } from "./signal.js"; +import { type SendableSignal } from "./send.js"; export type Reducer = ( state: Model, action: Action, ) => Model; -export type Send = (action: Action) => void; - -export type ReducerSignal = AnySignal & { - send: Send; -}; - /** * Create a signals-based reducer that updates through the provided `step` * reducer function. @@ -39,7 +16,7 @@ export type ReducerSignal = AnySignal & { export const reducer = ( step: Reducer, initial: Model, -): ReducerSignal => { +): SendableSignal => { const $state = signal(initial); /** @@ -60,18 +37,37 @@ export const reducer = ( return { get, send }; }; +const alwaysLog = () => true; /** - * 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. + * Wrap update function with logging. + * @param update - The reduer 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 forward = ( - send: (action: ActionA) => void, - tag: (action: ActionB) => ActionA, -) => - (action: ActionB): void => { - send(tag(action)); +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; }; +} /** * Convenience function for logging unknown actions in the default arm @@ -80,7 +76,7 @@ export const forward = ( * Because `action` is of type `never`, Typescript will show an error under * this argument if the switch is not exhaustive. */ -export const stepUnknown = ( +export const unknown = ( state: Model, action: never, ): Model => { diff --git a/src/scope.ts b/src/scope.ts new file mode 100644 index 0000000..9199322 --- /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.ts b/src/send.ts new file mode 100644 index 0000000..b05665c --- /dev/null +++ b/src/send.ts @@ -0,0 +1,19 @@ +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/store.test.ts b/src/store.test.ts index 4c71e31..de8243f 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { store, tx, type Update, forward, action, updateUnknown } from "./store.js"; +import { store, tx, type Update, forward, action, unknown } from "./store.js"; // Helper function to create a promise that resolves after a delay const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -24,7 +24,7 @@ test("store - creates store with initial state", () => { case "add": return tx(state + action.value); default: - return updateUnknown(state, action); + return unknown(state, action); } }; @@ -45,7 +45,7 @@ test("store - handles actions through send", () => { case "add": return tx(state + action.value); default: - return updateUnknown(state, action); + return unknown(state, action); } }; @@ -85,7 +85,7 @@ test("store - transactional effects yield actions", async () => { case "set": return tx(action.value); default: - return updateUnknown(state, action); + return unknown(state, action); } }; @@ -119,7 +119,7 @@ test("store - effects can yield multiple actions", async () => { case "increment": return tx(state + 1); default: - return updateUnknown(state, action); + return unknown(state, action); } }; @@ -159,7 +159,7 @@ test("store - transactional check-then-update-then-effect", async () => { case "set": return tx({ ...state, count: action.value, fetching: false }); default: - return updateUnknown(state, action); + return unknown(state, action); } }; @@ -245,7 +245,7 @@ test("updateUnknown - logs warning and returns state unchanged", () => { const unknownAction = { type: "unknown", data: "test" }; // @ts-ignore - we want to test updateUnknown - const result = updateUnknown(state, unknownAction); + const result = unknown(state, unknownAction); assert.strictEqual(result, state); assert.strictEqual( diff --git a/src/store.ts b/src/store.ts index a12bcca..457093f 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,26 +1,20 @@ import { signal } from "./signal.js"; -import { type ReducerSignal } from "./reducer.js"; -export { type Send, type TaggedAction, action, forward } from "./reducer.js"; - -export type Effects = AsyncGenerator; - -export async function* noEffects(): Effects { - // Yield nothing -} +import { type SendableSignal } from "./send.js"; +import * as Effect from "./effect.js"; /** * A transaction is a value that represents a state update and optional effects. */ export type Tx = { state: Model; - effects: Effects; + effect: Effect.Effect; }; /** Create a transaction */ export const tx = ( state: Model, - effects: Effects = noEffects(), -): Tx => ({ state, effects }); + effect: Effect.Effect = Effect.none(), +): Tx => ({ state, effect }); export type Update = ( state: Model, @@ -28,7 +22,7 @@ export type Update = ( context: Context, ) => Tx; -export type Store = ReducerSignal; +export type Store = SendableSignal; /** * Create a signals-based store that updates through the provided `update` @@ -55,10 +49,10 @@ export function store( ): Store { const $state = signal(initial); - const forkEffects = async (effects: Effects) => { + const forkEffect = async (effect: Effect.Effect) => { while (true) { try { - const { value, done } = await effects.next(get()); + const { value, done } = await effect.next(get()); if (done) { return @@ -68,7 +62,7 @@ export function store( send(value); } } catch (e) { - console.warn("Error in effect", e); + console.error("Error in effect", e); return; } } @@ -85,14 +79,46 @@ export function store( * This method is hard-bound to the reducer so you can pass it around as a function. */ const send = (action: Action) => { - const { state, effects } = update($state.get(), action, context!); + const { state, effect } = update($state.get(), action, context!); $state.set(state); - forkEffects(effects); + forkEffect(effect); }; return { get, send }; }; +const alwaysLog = () => true; + +/** + * 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 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; + }; +}; + /** * Convenience function for logging unknown actions in the default arm * of a reducer. @@ -100,7 +126,7 @@ export function store( * Because `action` is of type `never`, Typescript will show an error under * this argument if the switch is not exhaustive. */ -export const updateUnknown = ( +export const unknown = ( state: Model, action: never, ): Tx => { From c8fd26f734ad6572ad81429188cbf844f4f0638e Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 25 Mar 2026 21:28:32 +0300 Subject: [PATCH 04/19] Update tests --- src/reducer.test.ts | 91 +++++++++++++++++++++++++++----------------- src/scope.test.ts | 93 +++++++++++++++++++++++++++++++++++++++++++++ src/send.test.ts | 44 +++++++++++++++++++++ src/store.test.ts | 76 ++++++++++++++++++++++++++---------- 4 files changed, 249 insertions(+), 55 deletions(-) create mode 100644 src/scope.test.ts create mode 100644 src/send.test.ts diff --git a/src/reducer.test.ts b/src/reducer.test.ts index 23dac50..3fd897e 100644 --- a/src/reducer.test.ts +++ b/src/reducer.test.ts @@ -3,8 +3,8 @@ import assert from "node:assert/strict"; import { reducer, type Reducer, - forward, unknown, + withLogging, } from "./reducer.js"; // Test types for actions @@ -139,48 +139,69 @@ test("reducer - works with complex state", () => { assert.strictEqual(todoStore.get().todos[0].text, "Walk dog"); }); -test("forward - transforms actions", () => { - const receivedActions: string[] = []; - - const parentSend = (action: string) => { - receivedActions.push(action); +test("withLogging - logs actions and state transitions", () => { + const logs: string[] = []; + const originalDebug = console.debug; + console.debug = (...args: unknown[]) => { + logs.push(args.map(String).join(" ")); }; - const childSend = forward( - parentSend, - (childAction: number) => `child:${childAction}`, - ); - - childSend(1); - childSend(2); - childSend(3); - - assert.deepStrictEqual(receivedActions, ["child:1", "child:2", "child:3"]); + 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("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); +test("withLogging - respects log predicate", () => { + const logs: string[] = []; + const originalDebug = console.debug; + console.debug = (...args: unknown[]) => { + logs.push(args.map(String).join(" ")); }; - 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"); + 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("updateUnknown - logs warning and returns state unchanged", () => { +test("unknown - logs warning and returns state unchanged", () => { const originalWarn = console.warn; let warningMessage = ""; 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/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/store.test.ts b/src/store.test.ts index de8243f..cfd4a3a 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { store, tx, type Update, forward, action, unknown } from "./store.js"; +import { store, tx, type Update, unknown, withLogging } from "./store.js"; // Helper function to create a promise that resolves after a delay const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -206,33 +206,69 @@ test("store - context is passed to update function", () => { assert.strictEqual(myStore.get(), 0); }); -test("action - creates tagged action", () => { - const testAction = action("test", 42); +test("withLogging - logs actions and state transitions", () => { + const logs: string[] = []; + const originalDebug = console.debug; + console.debug = (...args: unknown[]) => { + logs.push(args.map(String).join(" ")); + }; - assert.strictEqual(testAction.type, "test"); - assert.strictEqual(testAction.value, 42); -}); + try { + const update: Update = (state, action) => { + switch (action.type) { + case "increment": + return tx(state + 1); + case "decrement": + return tx(state - 1); + default: + return tx(state); + } + }; -test("forward - transforms actions", () => { - const receivedActions: string[] = []; + const logged = withLogging(update, { name: "counter" }); + const counterStore = store(logged, 0); - const parentSend = (action: string) => { - receivedActions.push(action); + 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(" ")); }; - const childSend = forward( - parentSend, - (childAction: number) => `child:${childAction}`, - ); + try { + const update: Update = (state, action) => { + switch (action.type) { + case "increment": + return tx(state + 1); + default: + return tx(state); + } + }; - childSend(1); - childSend(2); - childSend(3); + const logged = withLogging(update, { log: () => false }); + const counterStore = store(logged, 0); - assert.deepStrictEqual(receivedActions, ["child:1", "child:2", "child:3"]); + counterStore.send({ type: "increment" }); + assert.strictEqual(counterStore.get(), 1); + assert.strictEqual(logs.length, 0); + } finally { + console.debug = originalDebug; + } }); -test("updateUnknown - logs warning and returns state unchanged", () => { +test("unknown - logs warning and returns state unchanged", () => { const originalWarn = console.warn; let warningMessage = ""; @@ -247,7 +283,7 @@ test("updateUnknown - logs warning and returns state unchanged", () => { // @ts-ignore - we want to test updateUnknown const result = unknown(state, unknownAction); - assert.strictEqual(result, state); + assert.strictEqual(result.state, state); assert.strictEqual( warningMessage, 'Unknown action {"type":"unknown","data":"test"}', From e775ea742e21cd7c83af79c70e0930a26da9f857 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 25 Mar 2026 22:07:14 +0300 Subject: [PATCH 05/19] Update effect type --- README.md | 338 +++++++++++++++------------------------------- src/effect.ts | 5 +- src/store.test.ts | 14 +- src/store.ts | 7 +- 4 files changed, 121 insertions(+), 243 deletions(-) diff --git a/README.md b/README.md index 4e3ea9b..92f395c 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,14 @@ # 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). -Refrakt offers two approaches: - -- **Store** with transactional effects (Elm-style) — state updates and effects are returned together, enabling check-then-update-then-effect patterns. -- **Reducer** for pure state updates (Redux-style) — effects can be added separately via the `fx` middleware. - -Both return the same `ReducerSignal` interface, so all middleware works with either. +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. -- **Transactional effects**: State updates and side effects in a single transaction. -- **Middleware**: Enhance behavior via function composition. +- **Managed effects**: Elm-style transactional effect management. +- **Scoped stores**: Create child stores that project parent state and tag actions. - **Minimal dependencies**: Uses only `signal-polyfill` library for maximum compatibility. ## Example @@ -21,7 +16,7 @@ Both return the same `ReducerSignal` interface, so all middleware works with eit Here's a simple counter example using [Lit](https://lit.dev/) for UI. ```typescript -import { store, tx } from "refrakt"; +import { reducer } from "refrakt/reducer.js"; import { LitElement, html } from 'lit'; import { customElement } from 'lit/decorators.js'; import { SignalWatcher } from '@lit-labs/signals'; @@ -31,13 +26,13 @@ type Action = { type: 'inc' } | { type: 'dec' }; const update = (state: Model, action: Action) => { switch (action.type) { - case 'inc': return tx({ count: state.count + 1 }); - case 'dec': return tx({ count: state.count - 1 }); - default: return tx(state); + case 'inc': return { count: state.count + 1 }; + case 'dec': { count: state.count - 1 }; + default: return state; } }; -const counter = store(update, { count: 0 }); +const counter = reducer(update, { count: 0 }); @customElement('counter-app') class CounterApp extends SignalWatcher(LitElement) { @@ -53,12 +48,45 @@ class CounterApp extends SignalWatcher(LitElement) { } ``` +## Reducer + +`reducer()` creates a signal updated via a pure reducer function. It works like React's `useReducer()` hook, except it's a signal. + +```typescript +import { reducer } from 'refrakt/reducer.js'; + +type CounterAction = + | { 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 state; + } +}; + +const counterStore = reducer(update, 0); + +counterStore.send({ type: 'increment' }); +console.log(counterStore.get()); // 1 +``` + ## Store -`store()` creates a signal updated via a transactional update function. The update function returns a `Tx` containing the new state and optional effects. +`store()` creates a reduer-like signal with support for managed side-effects. Instead of the reducer returning the next state, store reducers return a transaction object (`Tx`) containing the next state *and* optional side-effects. + +Effects are modeled as async generators that yield zero or more actions over time. ```typescript -import { store, tx, type Update } from 'refrakt'; +import { store, tx, type Tx } from 'refrakt/store.js'; type Model = { count: number, fetching: boolean }; @@ -67,7 +95,7 @@ type Action = | { type: 'fetch' } | { type: 'fetch-complete', value: number }; -const update: Update = (state, action) => { +const update = (state: Model, action: Action): Tx => { switch (action.type) { case 'increment': return tx({ ...state, count: state.count + 1 }); @@ -75,11 +103,11 @@ const update: Update = (state, action) => { // State update and effect in a single transaction return tx( { ...state, fetching: true }, - (async function* () { + 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 }); @@ -96,7 +124,9 @@ counterStore.send({ type: 'increment' }); ### Transactional effects -The key advantage of combining state updates with effects is **transactional** check-then-update-then-effect patterns. For example, preventing duplicate fetches: +Why store? Why return a transaction? For simple effect cases, a reducer combined with a signal `effect()` may be enough. But when side-effects become complex, you might want a store. + +The key advantage is that store lets you implement **transactional** side effects. This lets you implement atomic check-then-update-then-effect patterns. For example, preventing duplicate fetches: ```typescript case 'fetch': @@ -107,14 +137,14 @@ case 'fetch': // Set flag AND issue effect atomically return tx( { ...state, fetching: true }, - (async function* () { + async function* () { const data = await fetchData(); yield { type: 'fetch-complete', value: data }; - })() + } ); ``` -Because the flag and effect are set in the same transaction, there's no window where a duplicate fetch can slip through. This is difficult to achieve when reducers and effects are separated, since effects run in parallel while state updates are sequential. +Because update runs sequentially and atomically, and the flag and effect are set during the same transaction, there is no window where a duplicate fetch can slip through. It is difficult to achieve this kind of atomic control when state and effects are separated. ### Context @@ -128,38 +158,7 @@ const update: Update = (state, action, services) => { const myStore = store(update, initialState, services); ``` -## Reducer - -`reducer()` creates a signal updated via a pure reducer function. It's the simpler option when you don't need transactional effects. - -```typescript -import { reducer, type Reducer } from 'refrakt'; - -type CounterAction = - | { type: 'increment' } - | { type: 'decrement' } - | { type: 'set', value: number }; - -const update: Reducer = (state, action) => { - switch (action.type) { - case 'increment': - return state + 1; - case 'decrement': - return state - 1; - case 'set': - return action.value; - default: - return state; - } -}; - -const counterStore = reducer(update, 0); - -counterStore.send({ type: 'increment' }); -console.log(counterStore.get()); // 1 -``` - -Effects can be added to a reducer via the `fx` middleware (see below). +This can be used to expose external services to the update function. ## Signals @@ -194,139 +193,64 @@ Because stores are just another signal, you can use `computed` to scope down sta const username = computed(() => myStore.get().account.profile.username); ``` -## Fx middleware - -The `fx` middleware adds managed side effects to a `reducer`. Effects are modeled as async generators that yield actions back to the store. - -```typescript -type Fx = ( - state: () => Model, // Get current state - action: Action // Action triggering this effect -) => AsyncGenerator; // Yielded actions are sent back to store -``` +## Logging -The effect generator function is called for each action sent to the store, allowing it to perform async work and yield back zero or more actions in response. +Both `store` and `reducer` provide a `withLogging` function that wraps an update/reducer function with debug logging. ```typescript -import { reducer, pipe } from 'refrakt'; -import { fx, type Fx } from 'refrakt/middleware/fx.js'; +import { store, tx, withLogging, type Update } from 'refrakt/store.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( - reducer(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, { name: 'myStore' }); +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( - reducer(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: - -- `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 - -## Logger middleware - -Logs all actions and state changes to the console. Works with both `store` and `reducer`. - -```typescript -import { reducer, pipe } from 'refrakt'; -import { logger } from 'refrakt/middleware/logger.js'; - -const myStore = pipe( - reducer(update, initialState), - logger({ prefix: 'MyStore: ' }) -); +const loggedStep = withLogging(step, { name: 'myReducer' }); +const myStore = reducer(loggedStep, initialState); ``` Example output: ``` -MyStore: < { type: 'increment' } -MyStore: > { count: 1 } +<- myStore { type: 'increment' } +-> myStore { count: 1 } ``` -## Scope middleware +You can also pass a `log` predicate to conditionally enable logging: -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. +```typescript +const loggedUpdate = withLogging(update, { + name: 'myStore', + log: () => import.meta.env.DEV, +}); +``` + +## Scope + +`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 { 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. @@ -335,7 +259,7 @@ Components can be initialized with their own store by default. This store can be ```typescript // child-component.ts -import { store, tx, type Store } from "refrakt"; +import { store, tx, type Store } from "refrakt/store.js"; import { LitElement, html } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { watch } from '@lit-labs/signals'; @@ -353,21 +277,18 @@ class ChildComponent extends LitElement { ```typescript // parent-component.ts -import { pipe } from "refrakt"; -import { scope } from "refrakt/middleware/scope.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') class ParentComponent extends LitElement { @@ -383,64 +304,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 functions of `(store: ReducerSignal) => ReducerSignal` that wrap the store, returning a new store with enhanced behavior. They work with both `store` and `reducer` since both return a `ReducerSignal`. +The `iter` submodule provides utility functions for working with async generators. These can be useful for merging and mapping effects between component domains. -That means you can simply pass the store to a middleware function: - -```ts -const loggerMiddleware = logger(); -const myStore = loggerMiddleware(reducer(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 { reducer, pipe } from 'refrakt'; -import { fx } from 'refrakt/middleware/fx.js'; -import { logger } from 'refrakt/middleware/logger.js'; - -const counterStore = pipe( - reducer(update, { count: 0 }), - logger({ prefix: 'Counter: ' }), - fx(myEffects) -); -``` - -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 { ReducerSignal } from 'refrakt'; - -const timingMiddleware = () => - (store: ReducerSignal): ReducerSignal => { - 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( - reducer(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 transforms actions before sending -- `updateUnknown(state, action)` - Default handler for unknown actions (logs warning) -- `tx(state, effects?)` - Create a transaction with state and optional effects +- `forward(send, tag)` - Transform a send function so that it tags actions on the way out (`refrakt/send.js`) +- `tx(state, effects?)` - Create a transaction with state and optional effects (`refrakt/store.js`) +- `unknown(state, action)` - Default handler for unknown actions in switch default arm; enforces exhaustive switches via `never` type (available in both `refrakt/store.js` and `refrakt/reducer.js`) ## License diff --git a/src/effect.ts b/src/effect.ts index 60534bd..b4f94fe 100644 --- a/src/effect.ts +++ b/src/effect.ts @@ -1,5 +1,6 @@ -export type Effect = AsyncGenerator; +/** Represents an effect that can be executed against a model state, yielding actions. */ +export type Effect = (state: () => Model) => AsyncGenerator; -export async function* none(): Effect { +export async function* none(): AsyncGenerator { // Yield nothing } diff --git a/src/store.test.ts b/src/store.test.ts index cfd4a3a..2280825 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -77,10 +77,10 @@ test("store - transactional effects yield actions", async () => { case "fetch": return tx( state, - (async function*() { + async function*() { await delay(10); yield { type: "set", value: 42 }; - })(), + }, ); case "set": return tx(action.value); @@ -109,12 +109,12 @@ test("store - effects can yield multiple actions", async () => { case "start": return tx( state, - (async function*() { + async function*() { yield { type: "increment" }; await delay(5); yield { type: "increment" }; yield { type: "increment" }; - })(), + }, ); case "increment": return tx(state + 1); @@ -151,13 +151,13 @@ test("store - transactional check-then-update-then-effect", async () => { // Update flag AND issue effect in a single transaction return tx( { ...state, fetching: true }, - (async function*() { + async function*() { await delay(10); yield { type: "set", value: 42 }; - })(), + }, ); case "set": - return tx({ ...state, count: action.value, fetching: false }); + return tx({ ...state, count: action.value, fetching: false } as Model); default: return unknown(state, action); } diff --git a/src/store.ts b/src/store.ts index 457093f..ed36c9b 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,4 +1,4 @@ -import { signal } from "./signal.js"; +import { peek, signal } from "./signal.js"; import { type SendableSignal } from "./send.js"; import * as Effect from "./effect.js"; @@ -13,7 +13,7 @@ export type Tx = { /** Create a transaction */ export const tx = ( state: Model, - effect: Effect.Effect = Effect.none(), + effect: Effect.Effect = Effect.none, ): Tx => ({ state, effect }); export type Update = ( @@ -50,9 +50,10 @@ export function store( const $state = signal(initial); const forkEffect = async (effect: Effect.Effect) => { + const generator = effect(get); while (true) { try { - const { value, done } = await effect.next(get()); + const { value, done } = await generator.next(); if (done) { return From b681268664b1e2313d58f55752bbe8aa9e8df6d9 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Wed, 25 Mar 2026 22:15:40 +0300 Subject: [PATCH 06/19] Update readme --- README.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 92f395c..5da41c0 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,9 @@ console.log(counterStore.get()); // 1 ## Store -`store()` creates a reduer-like signal with support for managed side-effects. Instead of the reducer returning the next state, store reducers return a transaction object (`Tx`) containing the next state *and* optional side-effects. +`store()` returns a signal looks exactly like the type returned by `reducer()`, but has additional support for managed side-effects. Instead of the reducer returning just the next state, a store reducer returns a transaction object containing the next state *and* optional side-effects. -Effects are modeled as async generators that yield zero or more actions over time. +Effects are modeled as async generator functions that yield zero or more actions over time. ```typescript import { store, tx, type Tx } from 'refrakt/store.js'; @@ -120,7 +120,19 @@ const counterStore = store(update, { count: 0, fetching: false }); counterStore.send({ type: 'increment' }); ``` -`tx(state, effects?)` is a convenience factory for creating transactions. Effects are async generators that yield actions back to the store. +`tx(state, effect?)` is a convenience factory for creating transactions. Effects are async generators that yield actions back to the store. + +Effect generator functions can also take an optional `state()` argument, which can be used to check in on the current state of the store as the generator progresses. + +```typescript +async function* (state: () => Model) { + await sleep(2000); + if (state().cancel) { + return; + } + yield { type: "msg", value: "hello world" } +} +``` ### Transactional effects From e7c0cda9972e712aa3a029d294cda157cb49e952 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 26 Mar 2026 08:01:59 +0300 Subject: [PATCH 07/19] Update README --- README.md | 47 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 5da41c0..eebabd9 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,7 @@ console.log(counterStore.get()); // 1 ## Store -`store()` returns a signal looks exactly like the type returned by `reducer()`, but has additional support for managed side-effects. Instead of the reducer returning just the next state, a store reducer returns a transaction object containing the next state *and* optional side-effects. - -Effects are modeled as async generator functions that yield zero or more actions over time. +`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. 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'; @@ -117,12 +115,20 @@ const update = (state: Model, action: Action): Tx => { }; const counterStore = store(update, { count: 0, fetching: false }); -counterStore.send({ type: 'increment' }); +counterStore.send({ type: 'fetch' }); // Sets `fetching` and kicks off effect generator +counterStore.get().fetching; // true ``` -`tx(state, effect?)` is a convenience factory for creating transactions. Effects are async generators that yield actions back to the store. +`tx(state, effect?)` offers a convenience function for creating transaction objects. Transactions are just plain objects with state and effect properties: -Effect generator functions can also take an optional `state()` argument, which can be used to check in on the current state of the store as the generator progresses. +```typescript +type Tx = { + state: Model; + effect: (state: () => Model): AsyncGenerator; +} +``` + +The effect generator function also receives a `state()` function, which can be used to check on the state of the store after time has ellapsed. ```typescript async function* (state: () => Model) { @@ -136,9 +142,25 @@ async function* (state: () => Model) { ### Transactional effects -Why store? Why return a transaction? For simple effect cases, a reducer combined with a signal `effect()` may be enough. But when side-effects become complex, you might want a store. +Why transactions? When do you need a store? For simple side-effects, a signal or reducer combined with `effect()` may be enough. This is 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 might want to reach for a store. The key advantage is that store lets you implement structured, **transactional** effects. -The key advantage is that store lets you implement **transactional** side effects. This lets you implement atomic check-then-update-then-effect patterns. For example, preventing duplicate fetches: +**Transactional effects** update in response to actions *during the same transaction as the state*. This means you can implement atomic check-then-update-then-effect patterns in response to actions. For example, preventing duplicate fetches: ```typescript case 'fetch': @@ -156,7 +178,7 @@ case 'fetch': ); ``` -Because update runs sequentially and atomically, and the flag and effect are set during the same transaction, there is no window where a duplicate fetch can slip through. It is difficult to achieve this kind of atomic control when state and effects are separated. +Because each update runs sequentially and atomically, and the flag and effect 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 when state and effects are separate. ### Context @@ -216,7 +238,7 @@ const update: Update = (state, action) => { // ... }; -const loggedUpdate = withLogging(update, { name: 'myStore' }); +const loggedUpdate = withLogging(update); const myStore = store(loggedUpdate, initialState); ``` @@ -233,15 +255,14 @@ const myStore = reducer(loggedStep, initialState); Example output: ``` -<- myStore { type: 'increment' } --> myStore { count: 1 } +<- store { type: 'increment' } +-> store { count: 1 } ``` You can also pass a `log` predicate to conditionally enable logging: ```typescript const loggedUpdate = withLogging(update, { - name: 'myStore', log: () => import.meta.env.DEV, }); ``` From 2cef99fdabfdcbf391501e156720eec23d0fe83a Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 26 Mar 2026 08:44:42 +0300 Subject: [PATCH 08/19] Add check command --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 4a98f8a..9d76f3b 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "scripts": { "build": "tsc", "prepublishOnly": "npm run build", - "test": "npm run build && node --test \"dist/**/*.test.js\"" + "test": "npm run build && node --test \"dist/**/*.test.js\"", + "check": "tsc --noEmit" }, "keywords": [ "signals", From aea684447f20c29218f02c7aede77af47dfa20e5 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 26 Mar 2026 08:52:18 +0300 Subject: [PATCH 09/19] Add linting, checking, formatting And run prettier --- README.md | 108 ++-- eslint.config.js | 16 + package-lock.json | 1200 ++++++++++++++++++++++++++++++++++++++++++- package.json | 9 +- src/effect.ts | 4 +- src/iter.test.ts | 27 +- src/iter.ts | 5 +- src/reducer.test.ts | 24 +- src/reducer.ts | 16 +- src/scope.ts | 6 +- src/send.ts | 9 +- src/signal.test.ts | 2 +- src/store.test.ts | 42 +- src/store.ts | 12 +- 14 files changed, 1339 insertions(+), 141 deletions(-) create mode 100644 eslint.config.js diff --git a/README.md b/README.md index eebabd9..e4a8e81 100644 --- a/README.md +++ b/README.md @@ -17,31 +17,35 @@ Here's a simple counter example using [Lit](https://lit.dev/) for UI. ```typescript import { reducer } from "refrakt/reducer.js"; -import { LitElement, html } from 'lit'; -import { customElement } from 'lit/decorators.js'; -import { SignalWatcher } from '@lit-labs/signals'; +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 update = (state: Model, action: Action) => { switch (action.type) { - case 'inc': return { count: state.count + 1 }; - case 'dec': { count: state.count - 1 }; - default: return state; + case "inc": + return { count: state.count + 1 }; + case "dec": { + count: state.count - 1; + } + default: + return state; } }; const counter = reducer(update, { count: 0 }); -@customElement('counter-app') +@customElement("counter-app") class CounterApp extends SignalWatcher(LitElement) { render() { return html`

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

- - + +
`; } @@ -53,20 +57,20 @@ class CounterApp extends SignalWatcher(LitElement) { `reducer()` creates a signal updated via a pure reducer function. It works like React's `useReducer()` hook, except it's a signal. ```typescript -import { reducer } from 'refrakt/reducer.js'; +import { reducer } from "refrakt/reducer.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': + case "increment": return state + 1; - case 'decrement': + case "decrement": return state - 1; - case 'set': + case "set": return action.value; default: return state; @@ -75,39 +79,36 @@ const update = (state: number, action: CounterAction): number => { const counterStore = reducer(update, 0); -counterStore.send({ type: 'increment' }); +counterStore.send({ type: "increment" }); console.log(counterStore.get()); // 1 ``` ## Store -`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. Effects are modeled as async generator functions that yield zero or more actions back to the store. +`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. 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 { store, tx, type Tx } from "refrakt/store.js"; -type Model = { count: number, fetching: boolean }; +type Model = { count: number; fetching: boolean }; type Action = - | { type: 'increment' } - | { type: 'fetch' } - | { type: 'fetch-complete', value: number }; + | { type: "increment" } + | { type: "fetch" } + | { type: "fetch-complete"; value: number }; const update = (state: Model, action: Action): Tx => { switch (action.type) { - case 'increment': + case "increment": return tx({ ...state, count: state.count + 1 }); - case 'fetch': + case "fetch": // State update and effect in a single transaction - return tx( - { ...state, fetching: true }, - 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, fetching: true }, 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 tx(state); @@ -115,7 +116,7 @@ const update = (state: Model, action: Action): Tx => { }; const counterStore = store(update, { count: 0, fetching: false }); -counterStore.send({ type: 'fetch' }); // Sets `fetching` and kicks off effect generator +counterStore.send({ type: "fetch" }); // Sets `fetching` and kicks off effect generator counterStore.get().fetching; // true ``` @@ -155,12 +156,12 @@ const state = reducer((state: Model, action: Action) => { // Fires every time state changes effect(() => { service.doSomething(state.get()); -}) +}); ``` However, when side-effects become sufficiently complex, you might want to reach for a store. The key advantage is that store lets you implement structured, **transactional** effects. -**Transactional effects** update in response to actions *during the same transaction as the state*. This means you can implement atomic check-then-update-then-effect patterns in response to actions. For example, preventing duplicate fetches: +**Transactional effects** update in response to actions _during the same transaction as the state_. This means you can implement atomic check-then-update-then-effect patterns in response to actions. For example, preventing duplicate fetches: ```typescript case 'fetch': @@ -199,7 +200,7 @@ This can be used to expose external services to the update function. 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); @@ -213,7 +214,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" @@ -232,7 +233,7 @@ const username = computed(() => myStore.get().account.profile.username); Both `store` and `reducer` provide a `withLogging` function that wraps an update/reducer function with debug logging. ```typescript -import { store, tx, withLogging, type Update } from 'refrakt/store.js'; +import { store, tx, withLogging, type Update } from "refrakt/store.js"; const update: Update = (state, action) => { // ... @@ -243,17 +244,18 @@ const myStore = store(loggedUpdate, initialState); ``` ```typescript -import { reducer, withLogging, type Reducer } from 'refrakt/reducer.js'; +import { reducer, withLogging, type Reducer } from "refrakt/reducer.js"; const step: Reducer = (state, action) => { // ... }; -const loggedStep = withLogging(step, { name: 'myReducer' }); +const loggedStep = withLogging(step, { name: "myReducer" }); const myStore = reducer(loggedStep, initialState); ``` Example output: + ``` <- store { type: 'increment' } -> store { count: 1 } @@ -272,7 +274,7 @@ const loggedUpdate = withLogging(update, { `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 { scope } from 'refrakt/scope.js'; +import { scope } from "refrakt/scope.js"; const childStore = scope({ store: parentStore, @@ -281,7 +283,7 @@ const childStore = scope({ // Tag child actions, transforming them into parent actions tag: (action: ChildAction): ParentAction => ({ type: "child", - action + action, }), }); ``` @@ -293,13 +295,13 @@ Components can be initialized with their own store by default. This store can be ```typescript // child-component.ts import { store, tx, type Store } from "refrakt/store.js"; -import { LitElement, html } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; -import { watch } from '@lit-labs/signals'; +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 }); @@ -311,8 +313,8 @@ class ChildComponent extends LitElement { ```typescript // parent-component.ts import { scope } from "refrakt/scope.js"; -import { LitElement, html } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { LitElement, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; import * as ChildComponent from "./child-component.js"; // ... @@ -323,12 +325,12 @@ const childStore = scope({ tag: (action: ChildAction): ParentAction => ({ type: "child", action }), }); -@customElement('parent-component') +@customElement("parent-component") class ParentComponent extends LitElement { render() { return html`
- +
`; } 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/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 9d76f3b..193bc26 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,9 @@ "build": "tsc", "prepublishOnly": "npm run build", "test": "npm run build && node --test \"dist/**/*.test.js\"", - "check": "tsc --noEmit" + "check": "tsc --noEmit", + "fmt": "prettier --write .", + "lint": "eslint ." }, "keywords": [ "signals", @@ -65,7 +67,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/effect.ts b/src/effect.ts index b4f94fe..d4ae164 100644 --- a/src/effect.ts +++ b/src/effect.ts @@ -1,5 +1,7 @@ /** Represents an effect that can be executed against a model state, yielding actions. */ -export type Effect = (state: () => Model) => AsyncGenerator; +export type Effect = ( + state: () => Model, +) => AsyncGenerator; export async function* none(): AsyncGenerator { // Yield nothing 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 3335252..3672feb 100644 --- a/src/iter.ts +++ b/src/iter.ts @@ -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/reducer.test.ts b/src/reducer.test.ts index 3fd897e..62c184e 100644 --- a/src/reducer.test.ts +++ b/src/reducer.test.ts @@ -1,11 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { - reducer, - type Reducer, - unknown, - withLogging, -} from "./reducer.js"; +import { reducer, type Reducer, unknown, withLogging } from "./reducer.js"; // Test types for actions type CounterAction = @@ -91,11 +86,14 @@ test("reducer - works with complex state", () => { case "add": return { ...state, - todos: [...state.todos, { - id: state.nextId, - text: action.text, - completed: false, - }], + todos: [ + ...state.todos, + { + id: state.nextId, + text: action.text, + completed: false, + }, + ], nextId: state.nextId + 1, }; case "toggle": @@ -104,7 +102,7 @@ test("reducer - works with complex state", () => { todos: state.todos.map((todo) => todo.id === action.id ? { ...todo, completed: !todo.completed } - : todo + : todo, ), }; case "remove": @@ -214,7 +212,7 @@ test("unknown - logs warning and returns state unchanged", () => { const state = { count: 5 }; const unknownAction = { type: "unknown", data: "test" }; - // @ts-ignore - we want to test updateUnknown + // @ts-expect-error - we want to test updateUnknown const result = unknown(state, unknownAction); assert.strictEqual(result, state); diff --git a/src/reducer.ts b/src/reducer.ts index 51f8767..1ca3d30 100644 --- a/src/reducer.ts +++ b/src/reducer.ts @@ -1,10 +1,7 @@ import { signal } from "./signal.js"; import { type SendableSignal } from "./send.js"; -export type Reducer = ( - state: Model, - action: Action, -) => Model; +export type Reducer = (state: Model, action: Action) => Model; /** * Create a signals-based reducer that updates through the provided `step` @@ -54,8 +51,8 @@ export const withLogging = ( log = alwaysLog, }: { name?: string; - log?: () => boolean - } = {} + log?: () => boolean; + } = {}, ): Reducer => { return (state: Model, action: Action) => { if (log()) { @@ -67,7 +64,7 @@ export const withLogging = ( } return next; }; -} +}; /** * Convenience function for logging unknown actions in the default arm @@ -76,10 +73,7 @@ export const withLogging = ( * Because `action` is of type `never`, Typescript will show an error under * this argument if the switch is not exhaustive. */ -export const unknown = ( - state: Model, - action: never, -): Model => { +export const unknown = (state: Model, action: never): Model => { console.warn("Unknown action", action); return state; }; diff --git a/src/scope.ts b/src/scope.ts index 9199322..003c363 100644 --- a/src/scope.ts +++ b/src/scope.ts @@ -21,9 +21,9 @@ export const scope = ({ get, tag, }: { - store: SendableSignal, - get: (state: ModelA) => ModelB, - tag: (action: ActionB) => ActionA, + store: SendableSignal; + get: (state: ModelA) => ModelB; + tag: (action: ActionB) => ActionA; }): SendableSignal => { const $state = computed(() => get(store.get())); const send = forward(store.send, tag); diff --git a/src/send.ts b/src/send.ts index b05665c..f12da61 100644 --- a/src/send.ts +++ b/src/send.ts @@ -10,10 +10,11 @@ export type SendableSignal = AnySignal & { * 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, -) => +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..63071c1 100644 --- a/src/signal.test.ts +++ b/src/signal.test.ts @@ -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/store.test.ts b/src/store.test.ts index 2280825..560b613 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -68,20 +68,15 @@ test("store - handles actions through send", () => { }); test("store - transactional effects yield actions", async () => { - type Action = - | { type: "fetch" } - | { type: "set"; value: number }; + type Action = { type: "fetch" } | { type: "set"; value: number }; const update: Update = (state, action) => { switch (action.type) { case "fetch": - return tx( - state, - async function*() { - await delay(10); - yield { type: "set", value: 42 }; - }, - ); + return tx(state, async function* () { + await delay(10); + yield { type: "set", value: 42 }; + }); case "set": return tx(action.value); default: @@ -100,22 +95,17 @@ test("store - transactional effects yield actions", async () => { }); test("store - effects can yield multiple actions", async () => { - type Action = - | { type: "start" } - | { type: "increment" }; + type Action = { type: "start" } | { type: "increment" }; 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" }; - }, - ); + return tx(state, async function* () { + yield { type: "increment" }; + await delay(5); + yield { type: "increment" }; + yield { type: "increment" }; + }); case "increment": return tx(state + 1); default: @@ -137,9 +127,7 @@ test("store - transactional check-then-update-then-effect", async () => { fetching: boolean; }; - type Action = - | { type: "fetch" } - | { type: "set"; value: number }; + type Action = { type: "fetch" } | { type: "set"; value: number }; const update: Update = (state, action) => { switch (action.type) { @@ -151,7 +139,7 @@ test("store - transactional check-then-update-then-effect", async () => { // Update flag AND issue effect in a single transaction return tx( { ...state, fetching: true }, - async function*() { + async function* () { await delay(10); yield { type: "set", value: 42 }; }, @@ -280,7 +268,7 @@ test("unknown - logs warning and returns state unchanged", () => { const state = { count: 5 }; const unknownAction = { type: "unknown", data: "test" }; - // @ts-ignore - we want to test updateUnknown + // @ts-expect-error - we want to test updateUnknown const result = unknown(state, unknownAction); assert.strictEqual(result.state, state); diff --git a/src/store.ts b/src/store.ts index ed36c9b..d6fcfac 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,4 +1,4 @@ -import { peek, signal } from "./signal.js"; +import { signal } from "./signal.js"; import { type SendableSignal } from "./send.js"; import * as Effect from "./effect.js"; @@ -56,8 +56,8 @@ export function store( const { value, done } = await generator.next(); if (done) { - return - }; + return; + } if (value != undefined) { send(value); @@ -86,7 +86,7 @@ export function store( }; return { get, send }; -}; +} const alwaysLog = () => true; @@ -105,8 +105,8 @@ export const withLogging = ( log = alwaysLog, }: { name?: string; - log?: () => boolean - } = {} + log?: () => boolean; + } = {}, ): Update => { return (state: Model, action: Action, context: Context) => { if (log()) { From 2027b99c0c29ff2c0695addff783366408e402b6 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 26 Mar 2026 08:56:03 +0300 Subject: [PATCH 10/19] Update workflows and prepublish checks --- .github/workflows/node.js.yml | 3 +++ package.json | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index dd96310..19232da 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -26,5 +26,8 @@ jobs: node-version: ${{ matrix.node-version }} cache: "npm" - run: npm ci + - run: npm run check + - run: npm run lint + - run: npm run check-fmt - run: npm run build --if-present - run: npm test diff --git a/package.json b/package.json index 193bc26..97d5ee1 100644 --- a/package.json +++ b/package.json @@ -43,11 +43,12 @@ ], "scripts": { "build": "tsc", - "prepublishOnly": "npm run build", + "prepublishOnly": "npm run check && npm run lint && npm run check-fmt && npm run build", "test": "npm run build && node --test \"dist/**/*.test.js\"", "check": "tsc --noEmit", "fmt": "prettier --write .", - "lint": "eslint ." + "lint": "eslint .", + "check-fmt": "prettier --check ." }, "keywords": [ "signals", From 394a4c7ed3c00ca8114bad7f5d2d69c4c4be1c94 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 26 Mar 2026 09:06:22 +0300 Subject: [PATCH 11/19] Add scope to exports --- package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package.json b/package.json index 97d5ee1..7829c6f 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,10 @@ "./reducer.js": { "types": "./dist/reducer.d.ts", "import": "./dist/reducer.js" + }, + "./scope.js": { + "types": "./dist/scope.d.ts", + "import": "./dist/scope.js" } }, "files": [ From 0c9a5638fa69e693b18fb28905c7e6cb837ab21e Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 26 Mar 2026 09:22:02 +0300 Subject: [PATCH 12/19] Effect -> Fx This distinguishes between Signal.effect() and Fx. --- README.md | 30 +++++++++++++++--------------- package.json | 6 +++--- src/effect.ts | 8 -------- src/fx.ts | 6 ++++++ src/main.ts | 2 +- src/store.ts | 18 +++++++++--------- 6 files changed, 34 insertions(+), 36 deletions(-) delete mode 100644 src/effect.ts create mode 100644 src/fx.ts diff --git a/README.md b/README.md index e4a8e81..702db6d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Refrakt is built around a simple concept: define a signal with a reducer, send a ## Features - **Fine-grained reactivity**: Built on top of TC39 signals. -- **Managed effects**: Elm-style transactional effect management. +- **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. @@ -85,7 +85,7 @@ console.log(counterStore.get()); // 1 ## Store -`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. Effects are modeled as async generator functions that yield zero or more actions back to the store. +`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"; @@ -102,7 +102,7 @@ const update = (state: Model, action: Action): Tx => { case "increment": return tx({ ...state, count: state.count + 1 }); case "fetch": - // State update and effect in a single transaction + // State update and fx in a single transaction return tx({ ...state, fetching: true }, function* () { const response = await fetch("/api/count"); const data = await response.json(); @@ -116,20 +116,20 @@ const update = (state: Model, action: Action): Tx => { }; const counterStore = store(update, { count: 0, fetching: false }); -counterStore.send({ type: "fetch" }); // Sets `fetching` and kicks off effect generator +counterStore.send({ type: "fetch" }); // Sets `fetching` and kicks off fx generator counterStore.get().fetching; // true ``` -`tx(state, effect?)` offers a convenience function for creating transaction objects. Transactions are just plain objects with state and effect properties: +`tx(state, fx?)` offers a convenience function for creating transaction objects. Transactions are just plain objects with state and fx properties: ```typescript type Tx = { state: Model; - effect: (state: () => Model): AsyncGenerator; + fx: (state: () => Model): AsyncGenerator; } ``` -The effect generator function also receives a `state()` function, which can be used to check on the state of the store after time has ellapsed. +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) { @@ -141,9 +141,9 @@ async function* (state: () => Model) { } ``` -### Transactional effects +### Transactional side-effects -Why transactions? When do you need a store? For simple side-effects, a signal or reducer combined with `effect()` may be enough. This is the equivalent of React's `useEffect()`: +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"; @@ -159,9 +159,9 @@ effect(() => { }); ``` -However, when side-effects become sufficiently complex, you might want to reach for a store. The key advantage is that store lets you implement structured, **transactional** effects. +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. -**Transactional effects** update in response to actions _during the same transaction as the state_. This means you can implement atomic check-then-update-then-effect patterns in response to actions. For example, preventing duplicate fetches: +**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': @@ -169,7 +169,7 @@ case 'fetch': if (state.fetching) { return tx(state); } - // Set flag AND issue effect atomically + // Set flag AND issue fx atomically return tx( { ...state, fetching: true }, async function* () { @@ -179,7 +179,7 @@ case 'fetch': ); ``` -Because each update runs sequentially and atomically, and the flag and effect 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 when state and effects are separate. +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 @@ -341,7 +341,7 @@ Because scoped stores are indistinguishable from parent stores, you can replace ## Async Iterator Utilities -The `iter` submodule provides utility functions for working with async generators. These can be useful for merging and mapping effects between component domains. +The `iter` submodule provides utility functions for working with async generators. These can be useful for merging and mapping fx between component domains. - `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 @@ -350,7 +350,7 @@ The `iter` submodule provides utility functions for working with async generator ## Utility Functions - `forward(send, tag)` - Transform a send function so that it tags actions on the way out (`refrakt/send.js`) -- `tx(state, effects?)` - Create a transaction with state and optional effects (`refrakt/store.js`) +- `tx(state, fx?)` - Create a transaction with state and optional fx (`refrakt/store.js`) - `unknown(state, action)` - Default handler for unknown actions in switch default arm; enforces exhaustive switches via `never` type (available in both `refrakt/store.js` and `refrakt/reducer.js`) ## License diff --git a/package.json b/package.json index 7829c6f..8904908 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,9 @@ "types": "./dist/send.d.ts", "import": "./dist/send.js" }, - "./effect.js": { - "types": "./dist/effect.d.ts", - "import": "./dist/effect.js" + "./fx.js": { + "types": "./dist/fx.d.ts", + "import": "./dist/fx.js" }, "./iter.js": { "types": "./dist/iter.d.ts", diff --git a/src/effect.ts b/src/effect.ts deleted file mode 100644 index d4ae164..0000000 --- a/src/effect.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** Represents an effect that can be executed against a model state, yielding actions. */ -export type Effect = ( - state: () => Model, -) => AsyncGenerator; - -export async function* none(): AsyncGenerator { - // Yield nothing -} diff --git a/src/fx.ts b/src/fx.ts new file mode 100644 index 0000000..277bef5 --- /dev/null +++ b/src/fx.ts @@ -0,0 +1,6 @@ +/** Represents an fx that can be executed against a model state, yielding actions. */ +export type Fx = (state: () => Model) => AsyncGenerator; + +export async function* none(): AsyncGenerator { + // Yield nothing +} diff --git a/src/main.ts b/src/main.ts index 1f31383..065a4f1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ export * as Signal from "./signal.js"; export * as Reducer from "./reducer.js"; export * as Store from "./store.js"; -export * as Effect from "./effect.js"; +export * as Fx from "./fx.js"; export * as Send from "./send.js"; diff --git a/src/store.ts b/src/store.ts index d6fcfac..0c5073c 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,20 +1,20 @@ import { signal } from "./signal.js"; import { type SendableSignal } from "./send.js"; -import * as Effect from "./effect.js"; +import * as Fx from "./fx.js"; /** - * A transaction is a value that represents a state update and optional effects. + * A transaction is a value that represents a state update and optional fx. */ export type Tx = { state: Model; - effect: Effect.Effect; + fx: Fx.Fx; }; /** Create a transaction */ export const tx = ( state: Model, - effect: Effect.Effect = Effect.none, -): Tx => ({ state, effect }); + fx: Fx.Fx = Fx.none, +): Tx => ({ state, fx }); export type Update = ( state: Model, @@ -49,8 +49,8 @@ export function store( ): Store { const $state = signal(initial); - const forkEffect = async (effect: Effect.Effect) => { - const generator = effect(get); + const forkFx = async (fx: Fx.Fx) => { + const generator = fx(get); while (true) { try { const { value, done } = await generator.next(); @@ -80,9 +80,9 @@ export function store( * This method is hard-bound to the reducer so you can pass it around as a function. */ const send = (action: Action) => { - const { state, effect } = update($state.get(), action, context!); + const { state, fx } = update($state.get(), action, context!); $state.set(state); - forkEffect(effect); + forkFx(fx); }; return { get, send }; From a6321dec99d3eec8a3136f20aa7ff326aad207b9 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 26 Mar 2026 09:27:15 +0300 Subject: [PATCH 13/19] Merge fx into store --- package.json | 4 ---- src/fx.ts | 6 ------ src/main.ts | 1 - src/store.ts | 15 +++++++++++---- 4 files changed, 11 insertions(+), 15 deletions(-) delete mode 100644 src/fx.ts diff --git a/package.json b/package.json index 8904908..b4cbcf4 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,6 @@ "types": "./dist/send.d.ts", "import": "./dist/send.js" }, - "./fx.js": { - "types": "./dist/fx.d.ts", - "import": "./dist/fx.js" - }, "./iter.js": { "types": "./dist/iter.d.ts", "import": "./dist/iter.js" diff --git a/src/fx.ts b/src/fx.ts deleted file mode 100644 index 277bef5..0000000 --- a/src/fx.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** Represents an fx that can be executed against a model state, yielding actions. */ -export type Fx = (state: () => Model) => AsyncGenerator; - -export async function* none(): AsyncGenerator { - // Yield nothing -} diff --git a/src/main.ts b/src/main.ts index 065a4f1..c6b98ae 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,4 @@ export * as Signal from "./signal.js"; export * as Reducer from "./reducer.js"; export * as Store from "./store.js"; -export * as Fx from "./fx.js"; export * as Send from "./send.js"; diff --git a/src/store.ts b/src/store.ts index 0c5073c..020ef49 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,19 +1,26 @@ import { signal } from "./signal.js"; import { type SendableSignal } from "./send.js"; -import * as Fx from "./fx.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 transaction is a value that represents a state update and optional fx. */ export type Tx = { state: Model; - fx: Fx.Fx; + fx: Fx; }; /** Create a transaction */ export const tx = ( state: Model, - fx: Fx.Fx = Fx.none, + fx: Fx = noFx, ): Tx => ({ state, fx }); export type Update = ( @@ -49,7 +56,7 @@ export function store( ): Store { const $state = signal(initial); - const forkFx = async (fx: Fx.Fx) => { + const forkFx = async (fx: Fx) => { const generator = fx(get); while (true) { try { From eef6db0764275efa5924ca792b9d910ba1adba76 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 26 Mar 2026 09:42:45 +0300 Subject: [PATCH 14/19] unknown -> unreachable --- src/reducer.test.ts | 32 ++++++++++++++++---------------- src/reducer.ts | 6 +++--- src/store.test.ts | 32 ++++++++++++++++---------------- src/store.ts | 6 +++--- 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/src/reducer.test.ts b/src/reducer.test.ts index 62c184e..49b81ee 100644 --- a/src/reducer.test.ts +++ b/src/reducer.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { reducer, type Reducer, unknown, withLogging } from "./reducer.js"; +import { reducer, type Reducer, unreachable, withLogging } from "./reducer.js"; // Test types for actions type CounterAction = @@ -37,7 +37,7 @@ test("reducer - creates reducer with initial state", () => { case "add": return state + action.value; default: - return unknown(state, action); + return unreachable(state, action); } }; @@ -58,7 +58,7 @@ test("reducer - handles actions through send", () => { case "add": return state + action.value; default: - return unknown(state, action); + return unreachable(state, action); } }; @@ -111,7 +111,7 @@ test("reducer - works with complex state", () => { todos: state.todos.filter((todo) => todo.id !== action.id), }; default: - return unknown(state, action); + return unreachable(state, action); } }; @@ -199,29 +199,29 @@ test("withLogging - respects log predicate", () => { } }); -test("unknown - logs warning and returns state unchanged", () => { - const originalWarn = console.warn; - let warningMessage = ""; +test("unreachable - logs error and returns state unchanged", () => { + const originalError = console.error; + let errorMessage = ""; - // Mock console.warn - console.warn = (msg: string, data: unknown) => { - warningMessage = `${msg} ${JSON.stringify(data)}`; + // Mock console.error + console.error = (msg: string, data: unknown) => { + errorMessage = `${msg} ${JSON.stringify(data)}`; }; try { const state = { count: 5 }; const unknownAction = { type: "unknown", data: "test" }; - // @ts-expect-error - we want to test updateUnknown - const result = unknown(state, unknownAction); + // @ts-expect-error - we want to test unreachable + const result = unreachable(state, unknownAction); assert.strictEqual(result, state); assert.strictEqual( - warningMessage, - 'Unknown action {"type":"unknown","data":"test"}', + errorMessage, + 'Unreachable action {"type":"unknown","data":"test"}', ); } finally { - // Restore console.warn - console.warn = originalWarn; + // Restore console.error + console.error = originalError; } }); diff --git a/src/reducer.ts b/src/reducer.ts index 1ca3d30..38acc33 100644 --- a/src/reducer.ts +++ b/src/reducer.ts @@ -67,13 +67,13 @@ export const withLogging = ( }; /** - * Convenience function for logging unknown actions in the default arm + * Convenience function for handling unreachable 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. */ -export const unknown = (state: Model, action: never): Model => { - console.warn("Unknown action", action); +export const unreachable = (state: Model, action: never): Model => { + console.error("Unreachable action", action); return state; }; diff --git a/src/store.test.ts b/src/store.test.ts index 560b613..57be666 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { store, tx, type Update, unknown, withLogging } from "./store.js"; +import { store, tx, type Update, unreachable, withLogging } from "./store.js"; // Helper function to create a promise that resolves after a delay const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -24,7 +24,7 @@ test("store - creates store with initial state", () => { case "add": return tx(state + action.value); default: - return unknown(state, action); + return unreachable(state, action); } }; @@ -45,7 +45,7 @@ test("store - handles actions through send", () => { case "add": return tx(state + action.value); default: - return unknown(state, action); + return unreachable(state, action); } }; @@ -80,7 +80,7 @@ test("store - transactional effects yield actions", async () => { case "set": return tx(action.value); default: - return unknown(state, action); + return unreachable(state, action); } }; @@ -109,7 +109,7 @@ test("store - effects can yield multiple actions", async () => { case "increment": return tx(state + 1); default: - return unknown(state, action); + return unreachable(state, action); } }; @@ -147,7 +147,7 @@ test("store - transactional check-then-update-then-effect", async () => { case "set": return tx({ ...state, count: action.value, fetching: false } as Model); default: - return unknown(state, action); + return unreachable(state, action); } }; @@ -256,27 +256,27 @@ test("withLogging - respects log predicate", () => { } }); -test("unknown - logs warning and returns state unchanged", () => { - const originalWarn = console.warn; - let warningMessage = ""; +test("unreachable - logs error and returns state unchanged", () => { + const originalError = console.error; + let errorMessage = ""; - console.warn = (msg: string, data: unknown) => { - warningMessage = `${msg} ${JSON.stringify(data)}`; + console.error = (msg: string, data: unknown) => { + errorMessage = `${msg} ${JSON.stringify(data)}`; }; try { const state = { count: 5 }; const unknownAction = { type: "unknown", data: "test" }; - // @ts-expect-error - we want to test updateUnknown - const result = unknown(state, unknownAction); + // @ts-expect-error - we want to test unreachable + const result = unreachable(state, unknownAction); assert.strictEqual(result.state, state); assert.strictEqual( - warningMessage, - 'Unknown action {"type":"unknown","data":"test"}', + errorMessage, + 'Unreachable action {"type":"unknown","data":"test"}', ); } finally { - console.warn = originalWarn; + console.error = originalError; } }); diff --git a/src/store.ts b/src/store.ts index 020ef49..982f280 100644 --- a/src/store.ts +++ b/src/store.ts @@ -128,16 +128,16 @@ export const withLogging = ( }; /** - * Convenience function for logging unknown actions in the default arm + * Convenience function for handling unreachable 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. */ -export const unknown = ( +export const unreachable = ( state: Model, action: never, ): Tx => { - console.warn("Unknown action", action); + console.error("Unreachable action", action); return tx(state); }; From fd1a335ad5c0aa1c5edd9327b4647b6e3f101fda Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 26 Mar 2026 10:25:13 +0300 Subject: [PATCH 15/19] Fix example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 702db6d..1872bf7 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ const update = (state: Model, action: Action): Tx => { return tx({ ...state, count: state.count + 1 }); case "fetch": // State update and fx in a single transaction - return tx({ ...state, fetching: true }, function* () { + 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 }; From 179db840c7b723f1da16e5dc17278c95aafb9c80 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 26 Mar 2026 10:48:42 +0300 Subject: [PATCH 16/19] Add skill and examples --- .claude/skills/refrakt/SKILL.md | 150 ++++++++++++++++++++++++++++++++ examples/simple-counter/main.ts | 31 +++++++ examples/stopwatch/main.ts | 73 ++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 .claude/skills/refrakt/SKILL.md create mode 100644 examples/simple-counter/main.ts create mode 100644 examples/stopwatch/main.ts diff --git a/.claude/skills/refrakt/SKILL.md b/.claude/skills/refrakt/SKILL.md new file mode 100644 index 0000000..50bf647 --- /dev/null +++ b/.claude/skills/refrakt/SKILL.md @@ -0,0 +1,150 @@ +--- +name: refrakt +description: How to use Refrakt signals-based state mangement library +--- + +Refrakt is a lightweight state management library built on TC39 signals. + +## Modules + +- `refrakt/signal.js` — signal primitives: `signal`, `computed`, `effect`, `peek` +- `refrakt/reducer.js` — pure reducer-based state: `reducer`, `withLogging`, `unreachable` +- `refrakt/store.js` — transactional state with effects: `store`, `tx`, `noFx`, `withLogging`, `unreachable` +- `refrakt/send.js` — action utilities: `forward` +- `refrakt/scope.js` — scoped child stores: `scope` +- `refrakt/iter.js` — async iterator utilities: `mergeAsync`, `sequenceAsync`, `mapAsync` + +## Reducer (pure state, no effects) + +Use `reducer` when you only need state transitions with no side effects. + +```ts +import { type Reducer, reducer, unreachable } from "refrakt/reducer.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 unreachable(state, 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, unreachable } from "refrakt/store.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 unreachable(state, 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, peek } 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 = peek(() => 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 `unreachable(state, action)` 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/examples/simple-counter/main.ts b/examples/simple-counter/main.ts new file mode 100644 index 0000000..5c3a553 --- /dev/null +++ b/examples/simple-counter/main.ts @@ -0,0 +1,31 @@ +/// A simple counter using Reducer +import { type Reducer, reducer, unreachable } from "refrakt/reducer.js"; +import { effect } from "refrakt/signal.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 unreachable(state, 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..6aa8617 --- /dev/null +++ b/examples/stopwatch/main.ts @@ -0,0 +1,73 @@ +/// 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, unreachable } from "refrakt/store.js"; +import { effect } from "refrakt/signal.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 unreachable(state, 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); From d954caf7f2c86d113332d718597fe94ed824999d Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 21 May 2026 14:17:04 +0300 Subject: [PATCH 17/19] Add assertNever --- README.md | 2 +- package.json | 4 ++++ src/main.ts | 1 + src/reducer.ts | 12 ------------ src/store.ts | 15 --------------- src/utils.ts | 8 ++++++++ 6 files changed, 14 insertions(+), 28 deletions(-) create mode 100644 src/utils.ts diff --git a/README.md b/README.md index 1872bf7..74b66ec 100644 --- a/README.md +++ b/README.md @@ -351,7 +351,7 @@ The `iter` submodule provides utility functions for working with async generator - `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`) -- `unknown(state, action)` - Default handler for unknown actions in switch default arm; enforces exhaustive switches via `never` type (available in both `refrakt/store.js` and `refrakt/reducer.js`) +- `assertNever(value)` - Enforces exhaustive switches via `never` type. Use in reducers to enforce exhaustive action handling (available in `refrakt/utils.js`) ## License diff --git a/package.json b/package.json index b4cbcf4..ed6cd4d 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,10 @@ "./scope.js": { "types": "./dist/scope.d.ts", "import": "./dist/scope.js" + }, + "./utils.js": { + "types": "./dist/utils.d.ts", + "import": "./dist/utils.js" } }, "files": [ diff --git a/src/main.ts b/src/main.ts index c6b98ae..96ca70f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,3 +2,4 @@ export * as Signal from "./signal.js"; export * as Reducer from "./reducer.js"; export * as Store from "./store.js"; export * as Send from "./send.js"; +export * as Utils from "./utils.js"; diff --git a/src/reducer.ts b/src/reducer.ts index 38acc33..eca3cc8 100644 --- a/src/reducer.ts +++ b/src/reducer.ts @@ -65,15 +65,3 @@ export const withLogging = ( return next; }; }; - -/** - * Convenience function for handling unreachable 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. - */ -export const unreachable = (state: Model, action: never): Model => { - console.error("Unreachable action", action); - return state; -}; diff --git a/src/store.ts b/src/store.ts index 982f280..54eb6cd 100644 --- a/src/store.ts +++ b/src/store.ts @@ -126,18 +126,3 @@ export const withLogging = ( return result; }; }; - -/** - * Convenience function for handling unreachable 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. - */ -export const unreachable = ( - state: Model, - action: never, -): Tx => { - console.error("Unreachable action", action); - return tx(state); -}; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..9545cfa --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,8 @@ +/** + * 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 Error(`Unreachable case: ${JSON.stringify(value)}`); +}; From 307fe0ccc195ba27ca75ee5cf05c716ff9ba97e3 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 21 May 2026 14:27:05 +0300 Subject: [PATCH 18/19] Update tests Also throw a NeverError, carrying value for debugging purposes. --- src/reducer.test.ts | 45 +++++++++++++++---------------------------- src/store.test.ts | 47 +++++++++++++++++---------------------------- src/utils.ts | 13 ++++++++++++- 3 files changed, 46 insertions(+), 59 deletions(-) diff --git a/src/reducer.test.ts b/src/reducer.test.ts index 49b81ee..0a5feaf 100644 --- a/src/reducer.test.ts +++ b/src/reducer.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { reducer, type Reducer, unreachable, withLogging } from "./reducer.js"; +import { reducer, type Reducer, withLogging } from "./reducer.js"; +import { assertNever } from "./utils.js"; // Test types for actions type CounterAction = @@ -37,7 +38,7 @@ test("reducer - creates reducer with initial state", () => { case "add": return state + action.value; default: - return unreachable(state, action); + return assertNever(action); } }; @@ -58,7 +59,7 @@ test("reducer - handles actions through send", () => { case "add": return state + action.value; default: - return unreachable(state, action); + return assertNever(action); } }; @@ -111,7 +112,7 @@ test("reducer - works with complex state", () => { todos: state.todos.filter((todo) => todo.id !== action.id), }; default: - return unreachable(state, action); + return assertNever(action); } }; @@ -199,29 +200,15 @@ test("withLogging - respects log predicate", () => { } }); -test("unreachable - logs error and returns state unchanged", () => { - const originalError = console.error; - let errorMessage = ""; - - // Mock console.error - console.error = (msg: string, data: unknown) => { - errorMessage = `${msg} ${JSON.stringify(data)}`; - }; - - try { - const state = { count: 5 }; - const unknownAction = { type: "unknown", data: "test" }; - - // @ts-expect-error - we want to test unreachable - const result = unreachable(state, unknownAction); - - assert.strictEqual(result, state); - assert.strictEqual( - errorMessage, - 'Unreachable action {"type":"unknown","data":"test"}', - ); - } finally { - // Restore console.error - console.error = originalError; - } +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.test.ts b/src/store.test.ts index 57be666..984b025 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { store, tx, type Update, unreachable, withLogging } from "./store.js"; +import { store, tx, type Update, withLogging } from "./store.js"; +import { assertNever } from "./utils.js"; // Helper function to create a promise that resolves after a delay const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -24,7 +25,7 @@ test("store - creates store with initial state", () => { case "add": return tx(state + action.value); default: - return unreachable(state, action); + return assertNever(action); } }; @@ -45,7 +46,7 @@ test("store - handles actions through send", () => { case "add": return tx(state + action.value); default: - return unreachable(state, action); + return assertNever(action); } }; @@ -80,7 +81,7 @@ test("store - transactional effects yield actions", async () => { case "set": return tx(action.value); default: - return unreachable(state, action); + return assertNever(action); } }; @@ -109,7 +110,7 @@ test("store - effects can yield multiple actions", async () => { case "increment": return tx(state + 1); default: - return unreachable(state, action); + return assertNever(action); } }; @@ -147,7 +148,7 @@ test("store - transactional check-then-update-then-effect", async () => { case "set": return tx({ ...state, count: action.value, fetching: false } as Model); default: - return unreachable(state, action); + return assertNever(action); } }; @@ -256,27 +257,15 @@ test("withLogging - respects log predicate", () => { } }); -test("unreachable - logs error and returns state unchanged", () => { - const originalError = console.error; - let errorMessage = ""; - - console.error = (msg: string, data: unknown) => { - errorMessage = `${msg} ${JSON.stringify(data)}`; - }; - - try { - const state = { count: 5 }; - const unknownAction = { type: "unknown", data: "test" }; - - // @ts-expect-error - we want to test unreachable - const result = unreachable(state, unknownAction); - - assert.strictEqual(result.state, state); - assert.strictEqual( - errorMessage, - 'Unreachable action {"type":"unknown","data":"test"}', - ); - } finally { - console.error = originalError; - } +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/utils.ts b/src/utils.ts index 9545cfa..e373cce 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +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 Error(`Unreachable case: ${JSON.stringify(value)}`); + throw new NeverError(`Unexpected value`, value); }; From f84e10af62e50b9ede556983b29632c206672c41 Mon Sep 17 00:00:00 2001 From: Gordon Brander Date: Thu, 21 May 2026 14:56:50 +0300 Subject: [PATCH 19/19] Clean up API --- .claude/skills/refrakt/SKILL.md | 26 ++++++++++------- .github/workflows/node.js.yml | 1 + README.md | 51 +++++++++++++++++++++++++-------- examples/simple-counter/main.ts | 5 ++-- examples/stopwatch/main.ts | 5 ++-- examples/tsconfig.json | 11 +++++++ package.json | 11 ++++--- src/main.ts | 30 +++++++++++++++---- src/{utils.ts => never.ts} | 0 src/reducer.test.ts | 2 +- src/reducer.ts | 7 +++-- src/signal.test.ts | 14 ++++----- src/signal.ts | 4 +-- src/store.test.ts | 2 +- src/store.ts | 9 +++--- 15 files changed, 125 insertions(+), 53 deletions(-) create mode 100644 examples/tsconfig.json rename src/{utils.ts => never.ts} (100%) diff --git a/.claude/skills/refrakt/SKILL.md b/.claude/skills/refrakt/SKILL.md index 50bf647..48edd6c 100644 --- a/.claude/skills/refrakt/SKILL.md +++ b/.claude/skills/refrakt/SKILL.md @@ -1,25 +1,28 @@ --- name: refrakt -description: How to use Refrakt signals-based state mangement library +description: How to use Refrakt signals-based state management library --- Refrakt is a lightweight state management library built on TC39 signals. ## Modules -- `refrakt/signal.js` — signal primitives: `signal`, `computed`, `effect`, `peek` -- `refrakt/reducer.js` — pure reducer-based state: `reducer`, `withLogging`, `unreachable` -- `refrakt/store.js` — transactional state with effects: `store`, `tx`, `noFx`, `withLogging`, `unreachable` +- `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, unreachable } from "refrakt/reducer.js"; +import { type Reducer, reducer } from "refrakt/reducer.js"; +import { assertNever } from "refrakt/never.js"; type Action = { type: "increment" } | { type: "decrement" }; @@ -30,7 +33,7 @@ const update: Reducer = (state, action) => { case "decrement": return state - 1; default: - return unreachable(state, action); + return assertNever(action); } }; @@ -44,7 +47,8 @@ counter.get(); // 1 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, unreachable } from "refrakt/store.js"; +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" }; @@ -68,7 +72,7 @@ const update: Update = (state, action) => { case "tick": return tx({ ...state, elapsed: state.elapsed + 1 } as Model); default: - return unreachable(state, action); + return assertNever(action); } }; @@ -101,7 +105,7 @@ const myStore = store(update, initialState, { api: myApi }); ## Signals ```ts -import { signal, computed, effect, peek } from "refrakt/signal.js"; +import { signal, computed, effect, untrack } from "refrakt/signal.js"; const count = signal(0); const doubled = computed(() => count.get() * 2); @@ -112,7 +116,7 @@ const cleanup = effect(() => { }); // Read without tracking -const value = peek(() => count.get()); +const value = untrack(() => count.get()); ``` ## Scope @@ -144,7 +148,7 @@ const sendChild = forward(parentStore.send, (action) => ({ ## Key patterns -- **Exhaustive switches**: Use `unreachable(state, action)` in the default arm. TypeScript will error if the switch isn't exhaustive. +- **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 19232da..b3d5cd8 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -27,6 +27,7 @@ jobs: 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 diff --git a/README.md b/README.md index 74b66ec..0d1ab7a 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,38 @@ Refrakt is built around a simple concept: define a signal with a reducer, send a - **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 { 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"; @@ -24,15 +50,14 @@ import { SignalWatcher } from "@lit-labs/signals"; type Model = { count: number }; type Action = { type: "inc" } | { type: "dec" }; -const update = (state: Model, action: Action) => { +const update = (state: Model, action: Action): Model => { switch (action.type) { case "inc": return { count: state.count + 1 }; - case "dec": { - count: state.count - 1; - } + case "dec": + return { count: state.count - 1 }; default: - return state; + return assertNever(action); } }; @@ -58,6 +83,7 @@ class CounterApp extends SignalWatcher(LitElement) { ```typescript import { reducer } from "refrakt/reducer.js"; +import { assertNever } from "refrakt/never.js"; type CounterAction = | { type: "increment" } @@ -73,7 +99,7 @@ const update = (state: number, action: CounterAction): number => { case "set": return action.value; default: - return state; + return assertNever(action); } }; @@ -89,6 +115,7 @@ console.log(counterStore.get()); // 1 ```typescript import { store, tx, type Tx } from "refrakt/store.js"; +import { assertNever } from "refrakt/never.js"; type Model = { count: number; fetching: boolean }; @@ -111,7 +138,7 @@ const update = (state: Model, action: Action): Tx => { case "fetch-complete": return tx({ ...state, count: action.value, fetching: false }); default: - return tx(state); + return assertNever(action); } }; @@ -125,8 +152,8 @@ counterStore.get().fetching; // true ```typescript type Tx = { state: Model; - fx: (state: () => Model): AsyncGenerator; -} + fx: (state: () => Model) => AsyncGenerator; +}; ``` 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. @@ -294,7 +321,7 @@ Components can be initialized with their own store by default. This store can be ```typescript // child-component.ts -import { store, tx, type Store } from "refrakt/store.js"; +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"; @@ -304,7 +331,7 @@ import { watch } from "@lit-labs/signals"; @customElement("child-component") class ChildComponent extends LitElement { @property({ attribute: false }) - store: Store = store(update, { count: 0 }); + store: StoreSignal = store(update, { count: 0 }); // ... } @@ -351,7 +378,7 @@ The `iter` submodule provides utility functions for working with async generator - `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/utils.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/examples/simple-counter/main.ts b/examples/simple-counter/main.ts index 5c3a553..d04ea7d 100644 --- a/examples/simple-counter/main.ts +++ b/examples/simple-counter/main.ts @@ -1,6 +1,7 @@ /// A simple counter using Reducer -import { type Reducer, reducer, unreachable } from "refrakt/reducer.js"; +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" }; @@ -13,7 +14,7 @@ const update: Reducer = (state, action) => { case "reset": return 0; default: - return unreachable(state, action); + return assertNever(action); } }; diff --git a/examples/stopwatch/main.ts b/examples/stopwatch/main.ts index 6aa8617..8abe70e 100644 --- a/examples/stopwatch/main.ts +++ b/examples/stopwatch/main.ts @@ -1,8 +1,9 @@ /// 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, unreachable } from "refrakt/store.js"; +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; @@ -42,7 +43,7 @@ const update: Update = (state, action) => { case "tick": return tx({ ...state, elapsed: state.elapsed + 1 } as Model); default: - return unreachable(state, action); + return assertNever(action); } }; 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.json b/package.json index ed6cd4d..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", @@ -32,9 +33,9 @@ "types": "./dist/scope.d.ts", "import": "./dist/scope.js" }, - "./utils.js": { - "types": "./dist/utils.d.ts", - "import": "./dist/utils.js" + "./never.js": { + "types": "./dist/never.d.ts", + "import": "./dist/never.js" } }, "files": [ @@ -46,10 +47,12 @@ "LICENSE-MIT" ], "scripts": { + "prebuild": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", "build": "tsc", - "prepublishOnly": "npm run check && npm run lint && npm run check-fmt && npm run build", + "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 ." diff --git a/src/main.ts b/src/main.ts index 96ca70f..e4278b9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,25 @@ -export * as Signal from "./signal.js"; -export * as Reducer from "./reducer.js"; -export * as Store from "./store.js"; -export * as Send from "./send.js"; -export * as Utils from "./utils.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/utils.ts b/src/never.ts similarity index 100% rename from src/utils.ts rename to src/never.ts diff --git a/src/reducer.test.ts b/src/reducer.test.ts index 0a5feaf..f1b7571 100644 --- a/src/reducer.test.ts +++ b/src/reducer.test.ts @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { reducer, type Reducer, withLogging } from "./reducer.js"; -import { assertNever } from "./utils.js"; +import { assertNever } from "./never.js"; // Test types for actions type CounterAction = diff --git a/src/reducer.ts b/src/reducer.ts index eca3cc8..a4739d3 100644 --- a/src/reducer.ts +++ b/src/reducer.ts @@ -3,6 +3,9 @@ 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. @@ -13,7 +16,7 @@ export type Reducer = (state: Model, action: Action) => Model; export const reducer = ( step: Reducer, initial: Model, -): SendableSignal => { +): ReducerSignal => { const $state = signal(initial); /** @@ -38,7 +41,7 @@ const alwaysLog = () => true; /** * Wrap update function with logging. - * @param update - The reduer function to wrap. + * @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. diff --git a/src/signal.test.ts b/src/signal.test.ts index 63071c1..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); 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 984b025..8c5b5a7 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { store, tx, type Update, withLogging } from "./store.js"; -import { assertNever } from "./utils.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)); diff --git a/src/store.ts b/src/store.ts index 54eb6cd..1cad59b 100644 --- a/src/store.ts +++ b/src/store.ts @@ -29,7 +29,8 @@ export type Update = ( context: Context, ) => Tx; -export type Store = SendableSignal; +/** 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` @@ -43,17 +44,17 @@ export type Store = SendableSignal; export function store( update: Update, initial: Model, -): Store; +): StoreSignal; export function store( update: Update, initial: Model, context: Context, -): Store; +): StoreSignal; export function store( update: Update, initial: Model, context?: Context, -): Store { +): StoreSignal { const $state = signal(initial); const forkFx = async (fx: Fx) => {