diff --git a/src/action.ts b/src/action.ts index 63d931f..90ac683 100644 --- a/src/action.ts +++ b/src/action.ts @@ -15,11 +15,45 @@ import { createFilterQueue } from "./queue.js"; import type { Action, ActionWithPayload, AnyAction } from "./types.js"; import type { ActionFnWithPayload } from "./types.js"; +/** + * Shared action signal used by `put`, `useActions`, and related helpers. + * + * @remarks + * This context stores an Effection Signal that acts as the central event bus + * for action dispatch. All actions flow through this signal, enabling the + * pub/sub system that powers thunks and endpoints. + * + * The signal is automatically set up when you create a store with {@link createStore}. + * + * @see {@link put} for emitting actions. + * @see {@link take} for receiving actions. + * @see {@link useActions} for subscribing to action streams. + */ export const ActionContext = createContext( "starfx:action", createSignal(), ); +/** + * Subscribe to action events that match `pattern`. + * + * @remarks + * Returns an Effection `Stream` that yields actions matching the provided pattern. + * The stream will replay previously queued items for new subscribers. + * + * This is a low-level primitive. For most use cases, prefer {@link take}, + * {@link takeEvery}, {@link takeLatest}, or {@link takeLeading}. + * + * @param pattern - Pattern to match against emitted actions. Can be: + * - A string action type (e.g., `"FETCH_USERS"`) + * - An array of action types (e.g., `["LOGIN", "LOGOUT"]`) + * - A predicate function `(action) => boolean` + * - `"*"` to match all actions + * @returns An Effection Stream that yields matching actions. + * + * @see {@link take} for taking a single action. + * @see {@link takeEvery} for handling every matching action. + */ export function useActions(pattern: ActionPattern): Stream { return { [Symbol.iterator]: function* () { @@ -31,6 +65,12 @@ export function useActions(pattern: ActionPattern): Stream { }; } +/** + * Emit one or more actions into the provided signal. + * + * @param param0.signal - The Effection Signal to send actions through. + * @param param0.action - An action or an array of actions. + */ export function emit({ signal, action, @@ -48,6 +88,35 @@ export function emit({ } } +/** + * Put an action into the global action signal. + * + * @remarks + * This is an Operation that must be yielded. It dispatches one or more + * actions through the action signal, making them available to all subscribers + * (thunks, endpoints, and custom watchers). + * + * This is the Operation-based equivalent of `store.dispatch()` for use inside + * Effection scopes and middleware. + * + * @param action - A single action or array of actions to dispatch. + * + * @example Single action + * ```ts + * function* myThunk(ctx, next) { + * yield* put({ type: 'USER_CLICKED', payload: { id: '123' } }); + * yield* next(); + * } + * ``` + * + * @example Multiple actions + * ```ts + * yield* put([ + * { type: 'LOADING_START' }, + * { type: 'FETCH_REQUESTED' }, + * ]); + * ``` + */ export function* put(action: AnyAction | AnyAction[]) { const signal = yield* ActionContext.expect(); return yield* lift(emit)({ @@ -56,6 +125,35 @@ export function* put(action: AnyAction | AnyAction[]) { }); } +/** + * Take the next matching action from the action stream. + * + * @remarks + * Blocks until an action matching the pattern is dispatched, then returns it. + * This is commonly used in supervisor loops to wait for specific events. + * + * @typeParam P - The expected payload type of the action. + * @param pattern - Pattern to match against emitted actions. Can be: + * - A string action type (e.g., `"FETCH_USERS"`) + * - An array of action types (e.g., `["LOGIN", "LOGOUT"]`) + * - A predicate function `(action) => boolean` + * - `"*"` to match all actions + * @returns The first action matching the pattern. + * + * @see {@link takeEvery} for handling every matching action. + * @see {@link takeLatest} for cancelling previous handlers. + * @see {@link takeLeading} for ignoring actions while busy. + * + * @example Basic usage + * ```ts + * function* watchLogin() { + * while (true) { + * const action = yield* take('LOGIN'); + * console.log('User logged in:', action.payload); + * } + * } + * ``` + */ export function take

( pattern: ActionPattern, ): Operation>; @@ -71,6 +169,31 @@ export function* take(pattern: ActionPattern): Operation { return result.value; } +/** + * Spawn a handler for each matching action concurrently. + * + * @remarks + * This is the default supervisor strategy for thunks and endpoints. Each + * dispatched action spawns a new concurrent task, allowing multiple instances + * to run simultaneously. + * + * @typeParam T - The return type of the handler operation. + * @param pattern - Pattern to match against actions. + * @param op - Operation to run for each matching action. + * + * @see {@link takeLatest} for cancelling previous handlers. + * @see {@link takeLeading} for ignoring actions while busy. + * + * @example + * ```ts + * function* watchFetch() { + * yield* takeEvery('FETCH_USERS', function* (action) { + * console.log('Fetching for:', action.payload); + * // Multiple fetches can run concurrently + * }); + * } + * ``` + */ export function* takeEvery( pattern: ActionPattern, op: (action: AnyAction) => Operation, @@ -82,6 +205,31 @@ export function* takeEvery( } } +/** + * Spawn a handler for each matching action but cancel the previous one if a new + * action arrives. + * + * @remarks + * Useful for search/autocomplete scenarios where only the most recent request + * matters. When a new action is dispatched, the previous handler is halted. + * + * @typeParam T - The return type of the handler operation. + * @param pattern - Pattern to match against actions. + * @param op - Operation to run for each matching action. + * + * @see {@link takeEvery} for concurrent handlers. + * @see {@link takeLeading} for ignoring actions while busy. + * + * @example + * ```ts + * const search = thunks.create('search', { supervisor: takeLatest }); + * + * // Rapid dispatches cancel previous searches + * store.dispatch(search('a')); // cancelled + * store.dispatch(search('ab')); // cancelled + * store.dispatch(search('abc')); // this one runs + * ``` + */ export function* takeLatest( pattern: ActionPattern, op: (action: AnyAction) => Operation, @@ -98,6 +246,31 @@ export function* takeLatest( } } +/** + * Sequentially handle matching actions, ensuring the handler finishes before + * processing the next one. + * + * @remarks + * Useful for preventing duplicate work or rate-limiting expensive + * operations. Actions dispatched while a handler is running are ignored. + * + * @typeParam T - The return type of the handler operation. + * @param pattern - Pattern to match against actions. + * @param op - Operation to run for each matching action. + * + * @see {@link takeEvery} for concurrent handlers. + * @see {@link takeLatest} for cancelling previous handlers. + * + * @example + * ```ts + * const submitForm = thunks.create('submit', { supervisor: takeLeading }); + * + * // Only the first click is processed + * store.dispatch(submitForm()); // runs + * store.dispatch(submitForm()); // ignored (first still running) + * store.dispatch(submitForm()); // ignored + * ``` + */ export function* takeLeading( pattern: ActionPattern, op: (action: AnyAction) => Operation, @@ -108,6 +281,26 @@ export function* takeLeading( } } +/** + * Wait until the provided predicate operation returns `true`. + * + * @remarks + * Polls the predicate on each dispatched action until it returns `true`. + * If the predicate is initially `true`, returns immediately. + * + * @param predicate - Operation returning a boolean. + * + * @example + * ```ts + * function* waitForUser(userId: string) { + * yield* waitFor(function* () { + * const user = yield* select(schema.users.selectById, { id: userId }); + * return user.id !== ''; + * }); + * // User now exists in state + * } + * ``` + */ export function* waitFor(predicate: () => Operation): Operation { const init = yield* predicate(); if (init) { @@ -123,6 +316,9 @@ export function* waitFor(predicate: () => Operation): Operation { } } +/** + * Extract the deterministic id from an action or action-creator. + */ export function getIdFromAction( action: ActionWithPayload<{ key: string }> | ActionFnWithPayload, ): string { @@ -131,6 +327,31 @@ export function getIdFromAction( export const API_ACTION_PREFIX = ""; +/** + * Create an action creator function with optional payload. + * + * @remarks + * Creates a simple action creator that returns a Flux Standard Action (FSA). + * The returned function has a `toString()` method that returns the action type, + * useful for pattern matching. + * + * @param actionType - The action type string (must be non-empty). + * @returns An action creator function. + * @throws {Error} If `actionType` is empty. + * + * @example Without payload + * ```ts + * const increment = createAction('INCREMENT'); + * store.dispatch(increment()); // { type: 'INCREMENT' } + * ``` + * + * @example With typed payload + * ```ts + * const setUser = createAction<{ id: string; name: string }>('SET_USER'); + * store.dispatch(setUser({ id: '1', name: 'Alice' })); + * // { type: 'SET_USER', payload: { id: '1', name: 'Alice' } } + * ``` + */ export function createAction(actionType: string): () => Action; export function createAction

( actionType: string, diff --git a/src/compose.ts b/src/compose.ts index fa0145d..5daecd4 100644 --- a/src/compose.ts +++ b/src/compose.ts @@ -1,15 +1,61 @@ import type { Operation } from "effection"; import type { Next } from "./types.js"; +/** + * Base context for middleware. Implementations may extend this with typed fields. + */ export interface BaseCtx { [key: string]: any; } +/** + * Middleware function shape used across the library. + */ export type BaseMiddleware = ( ctx: Ctx, next: Next, ) => Operation; +/** + * Compose an array of middleware into a single middleware function. + * + * @remarks + * This middleware system is similar to Koa's middleware pattern. Each middleware + * receives a context object and a `next` function. Calling `yield* next()` passes + * control to the next middleware in the stack. Code after `yield* next()` runs + * after all downstream middleware have completed. + * + * If a middleware does not call `next()`, the remaining middleware are skipped, + * providing "exit early" functionality. + * + * @typeParam Ctx - The context type passed through the middleware stack. + * @typeParam T - The return type of middleware functions. + * @param middleware - Array of middleware functions to compose. + * @returns A composed middleware function that executes the stack in order. + * @throws {TypeError} If `middleware` is not an array or contains non-functions. + * + * @see {@link https://koajs.com | Koa.js} for the inspiration behind this pattern. + * + * @example + * ```ts + * import { compose } from 'starfx'; + * + * const mdw = compose([ + * function* first(ctx, next) { + * console.log('1 - before'); + * yield* next(); + * console.log('1 - after'); + * }, + * function* second(ctx, next) { + * console.log('2 - before'); + * yield* next(); + * console.log('2 - after'); + * }, + * ]); + * + * // Output: 1 - before, 2 - before, 2 - after, 1 - after + * ``` + */ export function compose( middleware: BaseMiddleware[], ) { diff --git a/src/fx/parallel.ts b/src/fx/parallel.ts index 4638a0d..a94e395 100644 --- a/src/fx/parallel.ts +++ b/src/fx/parallel.ts @@ -8,54 +8,77 @@ export interface ParallelRet extends Operation[]> { } /** - * The goal of `parallel` is to make it easier to cooridnate multiple async - * operations in parallel, with different ways to receive completed tasks. + * Run multiple operations in parallel with flexible result handling. * - * All tasks are called with {@link safe} which means they will never - * throw an exception. Instead all tasks will return a Result object that - * the end development must evaluate in order to grab the value. + * @remarks + * The `parallel` function makes it easier to coordinate multiple async + * operations with different ways to receive completed tasks. * - * @example + * All tasks are wrapped with {@link safe}, so they will never throw an + * exception. Instead, all tasks return a {@link Result} that must be + * evaluated to access the value or error. + * + * The returned resource provides three ways to consume results: + * - **Await all**: `yield* task` waits for all operations to complete + * - **Immediate**: `task.immediate` channel yields results as they complete + * - **Sequence**: `task.sequence` channel yields results in original array order + * + * @typeParam T - The return type of operations. + * @typeParam TArgs - Argument types for operations. + * @param operations - Array of operation factories to run in parallel. + * @returns A resource that provides multiple ways to access results. + * + * @see {@link safe} for the error-handling wrapper. + * @see {@link each} from Effection for iterating over channels. + * + * @example Wait for all results * ```ts - * import { parallel } from "starfx"; + * import { parallel } from 'starfx'; * * function* run() { - * const task = yield* parallel([job1, job2]); - * // wait for all tasks to complete before moving to next yield point - * const results = yield* task; - * // job1 = results[0]; - * // job2 = results[1]; + * const task = yield* parallel([fetchUsers, fetchPosts, fetchComments]); + * + * // Wait for all tasks to complete + * const results = yield* task; + * // results[0] = fetchUsers result + * // results[1] = fetchPosts result + * // results[2] = fetchComments result + * + * for (const result of results) { + * if (result.ok) { + * console.log('Success:', result.value); + * } else { + * console.error('Error:', result.error); + * } + * } * } * ``` * - * Instead of waiting for all tasks to complete, we can instead loop over - * tasks as they arrive: - * - * @example + * @example Process results as they complete * ```ts + * import { each } from 'effection'; + * * function* run() { - * const task = yield* parallel([job1, job2]); - * for (const job of yield* each(task.immediate)) { - * // job2 completes first then it will be first in list - * console.log(job); - * yield* each.next(); - * } + * const task = yield* parallel([slowJob, fastJob]); + * + * // Results arrive in completion order (fastest first) + * for (const result of yield* each(task.immediate)) { + * console.log('Completed:', result); + * yield* each.next(); + * } * } * ``` * - * Or we can instead loop over tasks in order of the array provided to - * parallel: - * - * @example + * @example Process results in original order * ```ts * function* run() { - * const task = yield* parallel([job1, job2]); - * for (const job of yield* each(task.sequence)) { - * // job1 then job2 will be returned regardless of when the jobs - * // complete - * console.log(job); - * yield* each.next(); - * } + * const task = yield* parallel([job1, job2, job3]); + * + * // Results arrive in array order regardless of completion time + * for (const result of yield* each(task.sequence)) { + * console.log('Result:', result); + * yield* each.next(); + * } * } * ``` */ diff --git a/src/fx/race.ts b/src/fx/race.ts index a5633b1..2de3d60 100644 --- a/src/fx/race.ts +++ b/src/fx/race.ts @@ -5,6 +5,51 @@ interface OpMap { [key: string]: (...args: TArgs) => Operation; } +/** + * Race multiple named operations and return the winner's result. + * + * @remarks + * Each operation in `opMap` starts concurrently. When one operation + * completes first, all others are halted and the function resolves + * with a result map containing the winner's value. + * + * This is useful for implementing timeouts, cancellation patterns, + * or "first response wins" scenarios. + * + * @typeParam T - The return type of operations. + * @param opMap - Map of named operation factories. + * @returns An operation resolving to the result map with the winning value. + * + * @example Timeout pattern + * ```ts + * import { raceMap, sleep } from 'starfx'; + * + * function* fetchWithTimeout() { + * const result = yield* raceMap({ + * data: () => fetchData(), + * timeout: () => sleep(5000), + * }); + * + * if (result.data) { + * return result.data; + * } + * throw new Error('Request timed out'); + * } + * ``` + * + * @example First response wins + * ```ts + * function* fetchFromMultipleSources() { + * const result = yield* raceMap({ + * primary: () => fetchFromPrimary(), + * fallback: () => fetchFromFallback(), + * }); + * + * // Use whichever responded first + * return result.primary ?? result.fallback; + * } + * ``` + */ export function raceMap(opMap: OpMap): Operation<{ [K in keyof OpMap]: OpMap[K] extends (...args: any[]) => any ? ReturnType diff --git a/src/fx/replay-signal.ts b/src/fx/replay-signal.ts index 61056b3..202c576 100644 --- a/src/fx/replay-signal.ts +++ b/src/fx/replay-signal.ts @@ -1,6 +1,13 @@ import type { Resolve, Subscription } from "effection"; import { action, resource } from "effection"; +/** + * Create a durable publish-subscribe signal with replay semantics. + * + * @typeParam T - Value type sent to subscribers. + * @typeParam TClose - Optional close value type. + * @returns A resource-style `subscribe` plus `send` and `close` helpers. New subscribers will receive previously sent items (replayed). + */ export function createReplaySignal() { const subscribers = new Set>(); // single shared durable queue storage diff --git a/src/fx/request.ts b/src/fx/request.ts index 84396df..24e5557 100644 --- a/src/fx/request.ts +++ b/src/fx/request.ts @@ -1,5 +1,12 @@ import { type Operation, until, useAbortSignal } from "effection"; +/** + * Perform a fetch request using Effection's `until` and an abort signal. + * + * @param url - URL or Request to fetch. + * @param opts - Optional `RequestInit` options. + * @returns An Effection Operation resolving to the `Response`. + */ export function* request( url: string | URL | Request, opts?: RequestInit, @@ -9,6 +16,12 @@ export function* request( return response; } +/** + * Helper to parse a `Response` JSON body as an Effection Operation. + * + * @param response - The fetch Response to parse. + * @returns An Operation resolving to the parsed JSON value. + */ export function* json(response: Response): Operation { return yield* until(response.json()); } diff --git a/src/fx/safe.ts b/src/fx/safe.ts index 363254a..9812ac7 100644 --- a/src/fx/safe.ts +++ b/src/fx/safe.ts @@ -2,20 +2,60 @@ import type { Operation, Result } from "effection"; import { Err, Ok, call } from "effection"; /** - * The goal of `safe` is to wrap Operations to prevent them from raising - * and error. The result of `safe` is always a {@link Result} type. + * Wrap an operation to prevent it from throwing exceptions. * - * @example + * @remarks + * The `safe` function ensures that operations never raise uncaught exceptions. + * Instead, any errors are captured and returned as an `Err` result, while + * successful values are wrapped in an `Ok` result. + * + * This is essential for building robust async flows where you want to handle + * errors explicitly rather than relying on try/catch blocks. + * + * @typeParam T - The success value type. + * @typeParam TArgs - Argument types for the operation. + * @param operator - Operation factory to wrap. + * @returns An operation yielding a {@link Result} containing either the value or error. + * + * @see {@link parallel} which uses `safe` internally. + * @see {@link Result}, {@link Ok}, {@link Err} from Effection. + * + * @example Basic usage * ```ts - * import { safe } from "starfx"; + * import { safe } from 'starfx'; * * function* run() { - * const results = yield* safe(fetch("api.com")); - * if (result.ok) { - * console.log(result.value); - * } else { - * console.error(result.error); - * } + * const result = yield* safe(() => fetch('https://api.example.com/users')); + * + * if (result.ok) { + * console.log('Response:', result.value); + * } else { + * console.error('Failed:', result.error.message); + * } + * } + * ``` + * + * @example With async operations + * ```ts + * function* fetchUser(id: string) { + * const result = yield* safe(function* () { + * const response = yield* until(fetch(`/users/${id}`)); + * if (!response.ok) throw new Error('Not found'); + * return yield* until(response.json()); + * }); + * + * return result; // Result + * } + * ``` + * + * @example Chaining with Result + * ```ts + * function* pipeline() { + * const userResult = yield* safe(() => fetchUser('123')); + * if (!userResult.ok) return userResult; + * + * const postsResult = yield* safe(() => fetchPosts(userResult.value.id)); + * return postsResult; * } * ``` */ diff --git a/src/fx/supervisor.ts b/src/fx/supervisor.ts index 2278050..9a046ce 100644 --- a/src/fx/supervisor.ts +++ b/src/fx/supervisor.ts @@ -3,6 +3,23 @@ import { API_ACTION_PREFIX, put } from "../action.js"; import { parallel } from "./parallel.js"; import { safe } from "./safe.js"; +/** + * Default exponential backoff strategy used by {@link supervise}. + * + * @remarks + * Uses exponential backoff with base 2: 20ms, 40ms, 80ms, ... up to ~10 seconds. + * Returns a negative value when max attempts are exceeded, signaling to stop. + * + * @param attempt - Current attempt count (1-based). + * @param max - Maximum attempts before giving up (default: 10). + * @returns Milliseconds to wait before next attempt, or negative to stop. + * + * @example Custom max attempts + * ```ts + * const backoff = (attempt: number) => superviseBackoff(attempt, 5); + * const supervised = supervise(myOperation, backoff); + * ``` + */ export function superviseBackoff(attempt: number, max = 10): number { if (attempt > max) return -1; // 20ms, 40ms, 80ms, 160ms, 320ms, 640ms, 1280ms, 2560ms, 5120ms, 10240ms @@ -10,11 +27,49 @@ export function superviseBackoff(attempt: number, max = 10): number { } /** - * supvervise will watch whatever {@link Operation} is provided - * and it will automatically try to restart it when it exists. By - * default it uses a backoff pressure mechanism so if there is an - * error simply calling the {@link Operation} then it will exponentially - * wait longer until attempting to restart and eventually give up. + * Create a supervised operation that restarts on failure with backoff. + * + * @remarks + * Supervisor tasks monitor operations and manage their health. When the + * supervised operation fails, the supervisor waits according to the backoff + * strategy before restarting it. This pattern is inspired by Erlang's + * supervisor trees. + * + * On failure, an action is emitted with the error details, useful for + * debugging and monitoring. + * + * @typeParam T - The return type of the operation. + * @typeParam TArgs - Argument types for the operation. + * @param op - Operation factory to supervise. + * @param backoff - Backoff function returning milliseconds to wait, or negative to stop. + * Defaults to {@link superviseBackoff}. + * @returns An operation factory that supervises the original operation. + * + * @see {@link superviseBackoff} for the default backoff strategy. + * @see {@link keepAlive} for supervising multiple operations. + * @see {@link https://www.erlang.org/doc/design_principles/des_princ | Erlang supervisors} + * + * @example Basic supervision + * ```ts + * import { supervise } from 'starfx'; + * + * function* unstableTask() { + * // We want this to run forever, restarting on failure + * // but it may fail intermittently + * } + * + * const supervised = supervise(unstableTask); + * yield* supervised(); // Automatically restarts on failure + * ``` + * + * @example Custom backoff + * ```ts + * // Linear backoff: 1s, 2s, 3s, ... + * const linearBackoff = (attempt: number) => + * attempt > 5 ? -1 : attempt * 1000; + * + * const supervised = supervise(myTask, linearBackoff); + * ``` */ export function supervise( op: (...args: TArgs) => Operation, @@ -45,8 +100,34 @@ export function supervise( } /** - * keepAlive accepts a list of operations and calls them all with - * {@link supervise} + * Supervise multiple operations concurrently, keeping them all alive. + * + * @remarks + * Wraps each operation with {@link supervise} and runs them in parallel. + * Useful for starting multiple background services that should all stay + * running (e.g., WebSocket connections, polling tasks, etc.). + * + * @typeParam T - The return type of operations. + * @typeParam TArgs - Argument types for operations. + * @param ops - Array of operation factories to supervise. + * @param backoff - Optional custom backoff function. + * @returns An array of supervision results wrapped in {@link Result}. + * + * @see {@link supervise} for individual operation supervision. + * @see {@link parallel} for non-supervised parallel execution. + * + * @example Keep multiple services alive + * ```ts + * import { keepAlive } from 'starfx'; + * + * function* startApp() { + * yield* keepAlive([ + * websocketConnection, + * heartbeatPing, + * backgroundSync, + * ]); + * } + * ``` */ export function* keepAlive( ops: ((...args: TArgs) => Operation)[], diff --git a/src/matcher.ts b/src/matcher.ts index 38c2bf4..d11b7b1 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -13,6 +13,11 @@ type SubPattern = | Predicate | StringableActionCreator | ActionType; +/** + * A `Pattern` can be an action type string, an action creator, a predicate + * function, or an array containing any of those. It is used to match actions + * in listeners and middleware. + */ export type Pattern = SubPattern | SubPattern[]; type ActionSubPattern = | GuardPredicate @@ -37,6 +42,12 @@ function isActionCreator(fn: any): boolean { return !!fn && fn._starfx === true; } +/** + * Build a predicate that returns `true` when an action matches `pattern`. + * + * @param pattern - A string, action-creator, predicate, or array of those. + * @returns A predicate function accepting an action and returning a boolean. + */ export function matcher(pattern: ActionPattern): Predicate { if (pattern === "*") { return (input) => !!input; diff --git a/src/mdw/fetch.ts b/src/mdw/fetch.ts index 8e50724..b71de99 100644 --- a/src/mdw/fetch.ts +++ b/src/mdw/fetch.ts @@ -5,8 +5,30 @@ import { isObject, noop } from "../query/util.js"; import type { Next } from "../types.js"; /** - * This middleware converts the name provided to {@link createApi} - * into `url` and `method` for the fetch request. + * Parse URL and HTTP method from the action name. + * + * @remarks + * This middleware extracts the URL pattern and HTTP method from the action name + * provided to {@link createApi}. The format is: `/path [METHOD]` + * + * It also performs URL parameter substitution, replacing `:param` placeholders + * with values from `ctx.payload`. + * + * Supported HTTP methods: GET, HEAD, POST, PUT, DELETE, CONNECT, OPTIONS, TRACE, PATCH + * + * This middleware is included in {@link mdw.api}. + * + * @typeParam Ctx - The fetch context type. + * + * @example How names are parsed + * ```ts + * // Name: '/users [GET]' -> url: '/users', method: 'GET' + * const fetchUsers = api.get('/users'); + * + * // Name: '/users/:id [GET]' with payload { id: '123' } + * // -> url: '/users/123', method: 'GET' + * const fetchUser = api.get<{ id: string }>('/users/:id'); + * ``` */ export function* nameParser( ctx: Ctx, @@ -56,8 +78,26 @@ export function* nameParser( } /** - * Automatically sets `content-type` to `application/json` when - * that header is not already present. + * Set default Content-Type header to application/json. + * + * @remarks + * If `ctx.request` exists and doesn't have a `Content-Type` header set, + * this middleware adds `Content-Type: application/json`. + * + * This is useful as a default for JSON APIs but can be overridden by + * setting the header explicitly in your endpoint middleware. + * + * @typeParam CurCtx - The fetch context type. + * + * @example Override the default + * ```ts + * const uploadFile = api.post('/upload', function* (ctx, next) { + * ctx.request = ctx.req({ + * headers: { 'Content-Type': 'multipart/form-data' }, + * }); + * yield* next(); + * }); + * ``` */ export function* headers( ctx: CurCtx, @@ -79,11 +119,28 @@ export function* headers( } /** - * This middleware takes the `ctx.response` and sets `ctx.json` to the body representation - * requested. It uses the `ctx.bodyType` property to determine how to represent the body. - * The default is set to `json` which calls `Response.json()`. + * Parse the fetch response body and set `ctx.json`. * - * @example + * @remarks + * Takes `ctx.response` and parses its body according to `ctx.bodyType` + * (default: 'json'). The result is stored in `ctx.json` as a {@link Result}: + * - `{ ok: true, value: data }` if response is OK and parsing succeeds + * - `{ ok: false, error: data }` if response is not OK + * - `{ ok: false, error: { message } }` if parsing fails + * + * Special case: HTTP 204 (No Content) returns an empty object. + * + * Change `ctx.bodyType` to use different Response methods: + * - 'json' -> `Response.json()` + * - 'text' -> `Response.text()` + * - 'blob' -> `Response.blob()` + * - etc. + * + * This middleware is part of {@link mdw.fetch}. + * + * @typeParam CurCtx - The fetch context type. + * + * @example Change body type * ```ts * const fetchUsers = api.get('/users', function*(ctx, next) { * ctx.bodyType = 'text'; // calls Response.text(); @@ -153,15 +210,32 @@ export function composeUrl( } /** - * If there's a slug inside the ctx.name (which is the URL segement in this case) - * and there is *not* a corresponding truthy value in the payload, then that means - * the user has an empty value (e.g. empty string) which means we want to abort the - * fetch request. + * Validate URL parameters against payload values. * - * e.g. `ctx.name = "/apps/:id"` with `payload = { id: '' }` + * @remarks + * Checks if the URL pattern contains parameter placeholders (`:param`) and + * validates that corresponding payload values are truthy. If a required + * parameter has a falsy value (empty string, null, undefined), the request + * is aborted early with an error in `ctx.json`. * - * Ideally the action wouldn't have been dispatched at all but that is *not* a - * gaurantee we can make here. + * This prevents accidental requests to URLs like `/users/undefined` when + * required data isn't available yet. + * + * This middleware is part of {@link mdw.fetch}. + * + * @typeParam CurCtx - The fetch context type. + * + * @example Automatic validation + * ```ts + * const fetchUser = api.get<{ id: string }>('/users/:id'); + * + * // This works + * dispatch(fetchUser({ id: '123' })); + * + * // This bails early with error (no network request) + * dispatch(fetchUser({ id: '' })); + * // ctx.json = { ok: false, error: 'found :id in endpoint name...' } + * ``` */ export function* payload( ctx: CurCtx, diff --git a/src/mdw/query.ts b/src/mdw/query.ts index 91bcf0d..1505191 100644 --- a/src/mdw/query.ts +++ b/src/mdw/query.ts @@ -17,15 +17,27 @@ import * as fetchMdw from "./fetch.js"; export * from "./fetch.js"; /** - * This middleware will catch any errors in the pipeline - * and `console.error` the context object. + * Error-catching middleware that logs exceptions and sets `ctx.result`. * - * You are highly encouraged to ditch this middleware if you need something - * more custom. + * @remarks + * Wraps the entire middleware pipeline in error handling. If any downstream + * middleware throws, the error is caught, logged to console with context, + * and an `error:query` action is dispatched. * - * It also sets `ctx.result` which informs us whether the entire - * middleware pipeline succeeded or not. Think the `.catch()` case for - * `window.fetch`. + * Sets `ctx.result` to a {@link Result} indicating pipeline success/failure. + * This is analogous to `.catch()` for `window.fetch`. + * + * You are encouraged to replace this middleware if you need custom error + * handling (e.g., error reporting services, different logging). + * + * @typeParam Ctx - The thunk context type. + * + * @example + * ```ts + * const thunks = createThunks(); + * thunks.use(mdw.err); // Catches and logs errors + * thunks.use(thunks.routes()); + * ``` */ export function* err(ctx: Ctx, next: Next) { ctx.result = yield* safe(next); @@ -43,19 +55,29 @@ export function* err(ctx: Ctx, next: Next) { } /** - * This middleware allows the user to override the default key provided - * to every pipeline function and instead use whatever they want. + * Middleware that allows overriding the default action key. + * + * @remarks + * By default, the `key` is a hash of the action type and payload. This + * middleware lets you set a custom key by modifying `ctx.key` in an earlier + * middleware or the thunk handler. + * + * Custom keys are useful when you need different cache entries for the + * same payload, or when integrating with external systems that expect + * specific identifiers. + * + * @typeParam Ctx - The thunk context type. * * @example * ```ts - * import { createPipe } from 'starfx'; + * const thunks = createThunks(); + * thunks.use(mdw.customKey); * - * const thunk = createPipe(); - * thunk.use(customKey); - * - * const doit = thunk.create('some-action', function*(ctx, next) { - * ctx.key = 'something-i-want'; - * }) + * const fetch = thunks.create('fetch', function* (ctx, next) { + * // Override the default key + * ctx.key = `custom-${ctx.payload.id}`; + * yield* next(); + * }); * ``` */ export function* customKey( @@ -75,8 +97,21 @@ export function* customKey( } /** - * This middleware sets up the context object with some values that are - * necessary for {@link createApi} to work properly. + * Initialize API context properties required by {@link createApi}. + * + * @remarks + * Sets up the following context properties if not already present: + * - `ctx.req()` - Helper function to merge request options + * - `ctx.request` - The fetch Request object + * - `ctx.response` - The fetch Response (initially null) + * - `ctx.json` - The parsed JSON response as a Result + * - `ctx.actions` - Array of actions to batch dispatch + * - `ctx.bodyType` - Response body parsing method (default: 'json') + * + * This middleware is included in {@link mdw.api} and is required for + * API endpoints to function properly. + * + * @typeParam Ctx - The API context type. */ export function* queryCtx(ctx: Ctx, next: Next) { if (!ctx.req) { @@ -92,12 +127,27 @@ export function* queryCtx(ctx: Ctx, next: Next) { } /** - * This middleware will take the result of `ctx.actions` and dispatch them - * as a single batch. + * Batch dispatch accumulated actions after middleware completes. + * + * @remarks + * Collects actions added to `ctx.actions` array during the middleware + * pipeline and dispatches them as a single batch at the end. This improves + * performance by reducing the number of store updates. + * + * Add actions to `ctx.actions` anywhere in the pipeline, and this middleware + * will dispatch them all at once after downstream middleware completes. * - * @remarks This is useful because sometimes there are a lot of actions that need dispatched - * within the pipeline of the middleware and instead of dispatching them serially this - * improves performance by only hitting the reducers once. + * This middleware is included in {@link mdw.api}. + * + * @example + * ```ts + * function* myMiddleware(ctx, next) { + * ctx.actions.push({ type: 'USER_LOADED', payload: user }); + * ctx.actions.push({ type: 'STATS_UPDATED', payload: stats }); + * yield* next(); + * // Both actions dispatched as a batch after this + * } + * ``` */ export function* actions(ctx: { actions: AnyAction[] }, next: Next) { if (!ctx.actions) ctx.actions = []; @@ -107,8 +157,28 @@ export function* actions(ctx: { actions: AnyAction[] }, next: Next) { } /** - * This middleware will add `performance.now()` before and after your - * middleware pipeline. + * Measure pipeline execution time with `performance.now()`. + * + * @remarks + * Records the start time before calling `next()` and calculates the elapsed + * time after all downstream middleware complete. The result is stored in + * `ctx.performance` (milliseconds). + * + * Useful for debugging slow endpoints or monitoring API performance. + * + * @typeParam Ctx - The context type with `performance` property. + * + * @example + * ```ts + * const api = createApi(); + * api.use(mdw.perf); + * api.use(api.routes()); + * + * const fetchUsers = api.get('/users', function* (ctx, next) { + * yield* next(); + * console.log(`Request took ${ctx.performance}ms`); + * }); + * ``` */ export function* perf(ctx: Ctx, next: Next) { const t0 = performance.now(); @@ -118,9 +188,32 @@ export function* perf(ctx: Ctx, next: Next) { } /** - * This middleware is a composition of other middleware required to - * use `window.fetch` {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API} - * with {@link createApi} + * Composed middleware for making fetch requests with {@link createApi}. + * + * @remarks + * This middleware composes several fetch-related middlewares: + * - {@link composeUrl} - Prepends `baseUrl` to request URLs + * - {@link payload} - Validates URL parameters against payload + * - {@link request} - Executes the actual `fetch()` call + * - {@link json} - Parses the response body + * + * Use this as the final middleware in your API stack to handle the + * actual HTTP request. + * + * @param options - Configuration options. + * @param options.baseUrl - Base URL to prepend to all requests (default: ''). + * @returns A composed middleware function. + * + * @see {@link mdw.api} for the full recommended middleware stack. + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API | Fetch API} + * + * @example + * ```ts + * const api = createApi(); + * api.use(mdw.api({ schema })); + * api.use(api.routes()); + * api.use(mdw.fetch({ baseUrl: 'https://api.example.com' })); + * ``` */ export function fetch( { @@ -138,7 +231,34 @@ export function fetch( } /** - * This middleware will only be activated if predicate is true. + * Conditionally execute middleware based on a predicate. + * + * @remarks + * Wraps another middleware and only executes it if the predicate returns `true`. + * If the predicate returns `false`, control passes directly to `next()`. + * + * The predicate can be synchronous or an async operation. + * + * @typeParam Ctx - The API context type. + * @param predicate - Function or operation that returns whether to run the middleware. + * @returns A middleware wrapper function. + * + * @example Skip middleware for certain requests + * ```ts + * const skipForAdmin = mdw.predicate( + * (ctx) => !ctx.payload.isAdmin + * ); + * + * api.use(skipForAdmin(mdw.rateLimit())); + * ``` + * + * @example Async predicate + * ```ts + * const onlyWhenOnline = mdw.predicate(function* (ctx) { + * const isOnline = yield* select(selectIsOnline); + * return isOnline; + * }); + * ``` */ export function predicate( predicate: ((ctx: Ctx) => boolean) | ((ctx: Ctx) => () => Operation), diff --git a/src/mdw/store.ts b/src/mdw/store.ts index afdf1a6..e9a2dcc 100644 --- a/src/mdw/store.ts +++ b/src/mdw/store.ts @@ -30,19 +30,37 @@ function isErrorLike(err: unknown): err is ErrorLike { } /** - * This middleware is a composition of many middleware used to faciliate - * the {@link createApi}. - * - * It is not required, however, it is battle-tested and highly recommended. - * - * List of mdw: - * - {@link mdw.err} - * - {@link mdw.actions} - * - {@link mdw.queryCtx} - * - {@link mdw.customKey} - * - {@link mdw.nameParser} - * - {@link mdw.loaderApi} - * - {@link mdw.cache} + * Composed middleware stack recommended for {@link createApi}. + * + * @remarks + * This middleware composes the standard middleware needed for API endpoints: + * - {@link mdw.err} - Error catching and logging + * - {@link mdw.actions} - Batch action dispatch + * - {@link mdw.queryCtx} - Initialize API context + * - {@link mdw.customKey} - Custom key support + * - {@link mdw.nameParser} - Parse URL and method from action name + * - {@link mdw.loaderApi} - Automatic loader state tracking + * - {@link mdw.cache} - Response caching + * + * This provides a battle-tested, production-ready setup for API requests. + * + * @typeParam Ctx - The API context type. + * @typeParam S - The store state type. + * @param props - Configuration options. + * @param props.schema - The schema containing `loaders` and `cache` slices. + * @param props.errorFn - Optional custom function to extract error messages. + * @returns A composed middleware function. + * + * @see {@link createApi} for creating API endpoints. + * @see {@link mdw.fetch} for the fetch middleware to add after this. + * + * @example Standard setup + * ```ts + * const api = createApi(); + * api.use(mdw.api({ schema })); + * api.use(api.routes()); + * api.use(mdw.fetch({ baseUrl: 'https://api.example.com' })); + * ``` */ export function api( props: ApiMdwProps, @@ -59,8 +77,34 @@ export function api( } /** - * This middleware will automatically cache any data found inside `ctx.json` - * which is where we store JSON data from the {@link mdw.fetch} middleware. + * Automatically cache API response data. + * + * @remarks + * When `ctx.cache` is truthy (set by `api.cache()` or manually), this + * middleware stores the response JSON in the `cache` slice keyed by `ctx.key`. + * + * Before the request, it loads any existing cached data into `ctx.cacheData`. + * After the request, if caching is enabled, it stores the new response. + * + * This middleware is included in {@link mdw.api}. + * + * @typeParam Ctx - The API context type. + * @param schema - Object containing the `cache` table slice. + * @returns A caching middleware function. + * + * @see {@link useCache} for React hook that reads cached data. + * + * @example Enable caching for an endpoint + * ```ts + * // Method 1: Using api.cache() helper + * const fetchUsers = api.get('/users', api.cache()); + * + * // Method 2: Manually enable in middleware + * const fetchUsers = api.get('/users', function* (ctx, next) { + * ctx.cache = true; + * yield* next(); + * }); + * ``` */ export function cache(schema: { cache: TableOutput; @@ -81,7 +125,38 @@ export function cache(schema: { } /** - * This middleware will track the status of a middleware fn + * Track thunk execution status with loaders. + * + * @remarks + * Automatically updates loader state for the thunk: + * - Sets `loading` status when the thunk starts + * - Sets `success` status when it completes normally + * - Sets `error` status with message if it throws + * + * Loaders are tracked by both `ctx.name` (the thunk name) and `ctx.key` + * (the unique action key including payload hash). + * + * Use this middleware for thunks that don't make HTTP requests but still + * need loading state tracking. For API endpoints, use {@link mdw.loaderApi} + * instead (included in {@link mdw.api}). + * + * @typeParam M - The loader metadata type. + * @param schema - Object containing the `loaders` slice. + * @returns A loader tracking middleware function. + * + * @example + * ```ts + * const thunks = createThunks(); + * thunks.use(mdw.loader({ loaders: schema.loaders })); + * thunks.use(thunks.routes()); + * + * const processData = thunks.create('process', function* (ctx, next) { + * // Loader automatically set to 'loading' + * yield* doExpensiveWork(); + * yield* next(); + * // Loader automatically set to 'success' + * }); + * ``` */ export function loader(schema: { loaders: LoaderOutput; @@ -148,7 +223,29 @@ function defaultErrorFn(ctx: Ctx) { } /** - * This middleware will track the status of a fetch request. + * Track API request status with loaders. + * + * @remarks + * Similar to {@link mdw.loader} but designed for API endpoints. Uses + * `ctx.response.ok` to determine success/error status instead of + * try/catch alone. + * + * Status transitions: + * - `loading` when request starts + * - `success` when `ctx.response.ok` is true + * - `error` when `ctx.response.ok` is false or an exception occurs + * - Reset to previous state if no response is set + * + * You can customize the error message extraction by providing an `errorFn`. + * + * This middleware is included in {@link mdw.api}. + * + * @typeParam Ctx - The API context type. + * @typeParam S - The store state type. + * @param props - Configuration options. + * @param props.schema - Object containing the `loaders` slice. + * @param props.errorFn - Custom function to extract error message from context. + * @returns A loader tracking middleware function. */ export function loaderApi< Ctx extends ApiCtx = ApiCtx, diff --git a/src/query/api.ts b/src/query/api.ts index f03ec25..d719a05 100644 --- a/src/query/api.ts +++ b/src/query/api.ts @@ -5,25 +5,85 @@ import type { ThunksApi } from "./thunk.js"; import type { ApiCtx, ApiRequest } from "./types.js"; /** - * Creates a middleware thunksline for HTTP requests. + * Creates a middleware pipeline for HTTP requests. * * @remarks - * It uses {@link createThunks} under the hood. + * An API is a specialized thunk system designed to manage HTTP requests. It provides: + * - HTTP method helpers (`.get()`, `.post()`, `.put()`, `.patch()`, `.delete()`, etc.) + * - A router that maps action names to URL patterns + * - Automatic request/response handling via middleware + * - Built-in caching support with `api.cache()` * - * @example + * The action name becomes the URL pattern, with support for URL parameters + * (e.g., `/users/:id`). Empty parameters cause the request to bail early. + * + * Uses {@link createThunks} under the hood. + * + * @typeParam Ctx - The context type extending {@link ApiCtx}. + * @param baseThunk - Optional base thunks instance to extend. + * @returns A {@link QueryApi} with HTTP method helpers and middleware registration. + * + * @see {@link createThunks} for the underlying thunk system. + * @see {@link mdw.api} for the recommended middleware stack. + * @see {@link mdw.fetch} for the fetch implementation. + * + * @example Basic setup * ```ts - * import { createApi, mdw } from 'starfx'; + * import { createApi, createStore, mdw } from 'starfx'; + * import { schema, initialState } from './schema'; * * const api = createApi(); - * api.use(mdw.api()); + * api.use(mdw.api({ schema })); * api.use(api.routes()); - * api.use(mdw.fetch({ baseUrl: 'https://api.com' })); + * api.use(mdw.fetch({ baseUrl: 'https://api.example.com' })); * - * const fetchUsers = api.get('/users', function*(ctx, next) { - * yield next(); - * }); + * // GET request with automatic caching + * export const fetchUsers = api.get('/users', api.cache()); + * + * // POST request with payload + * export const createUser = api.post<{ name: string }>( + * '/users', + * function* (ctx, next) { + * ctx.request = ctx.req({ + * body: JSON.stringify({ name: ctx.payload.name }), + * }); + * yield* next(); + * } + * ); + * + * const store = createStore({ initialState }); + * store.run(api.register); * * store.dispatch(fetchUsers()); + * store.dispatch(createUser({ name: 'Alice' })); + * ``` + * + * @example URL parameters + * ```ts + * // Parameters are extracted from payload + * const fetchUser = api.get<{ id: string }>('/users/:id'); + * store.dispatch(fetchUser({ id: '123' })); + * // Makes GET request to /users/123 + * ``` + * + * @example Response typing + * ```ts + * interface User { id: string; name: string; } + * interface ApiError { message: string; } + * + * const fetchUsers = api.get( + * '/users', + * function* (ctx, next) { + * yield* next(); + * if (ctx.json.ok) { + * // ctx.json.value is User[] + * console.log(ctx.json.value); + * } else { + * // ctx.json.error is ApiError + * console.error(ctx.json.error.message); + * } + * } + * ); * ``` */ export function createApi( diff --git a/src/query/create-key.ts b/src/query/create-key.ts index f16b2e1..30b84ee 100644 --- a/src/query/create-key.ts +++ b/src/query/create-key.ts @@ -31,7 +31,16 @@ const tinySimpleHash = (s: string) => { }; /** - * This function used to set `ctx.key` + * Create a deterministic key for an action based on its `name` and `payload`. + * + * @remarks + * The payload is deep-sorted and hashed so that semantically equivalent + * payload objects produce the same key string. This key is used to identify + * loader and cache entries in the store. + * + * @param name - Action endpoint name (e.g. '/users/:id'). + * @param payload - Optional payload object used to generate a stable hash. + * @returns A string key in the form `name|hash` when payload is provided, otherwise `name`. */ export const createKey = (name: string, payload?: any) => { const normJsonString = diff --git a/src/query/index.ts b/src/query/index.ts index 49a838d..ba2b3b6 100644 --- a/src/query/index.ts +++ b/src/query/index.ts @@ -7,6 +7,6 @@ export * from "./create-key.js"; export { createThunks, type ThunksApi }; /** - * @deprecated Use {@link createThunks} instead; + * @deprecated Use {@link createThunks} instead. This alias will be removed in a future version. */ export const createPipe = createThunks; diff --git a/src/query/thunk.ts b/src/query/thunk.ts index 82d8120..def36bb 100644 --- a/src/query/thunk.ts +++ b/src/query/thunk.ts @@ -28,11 +28,42 @@ import type { ThunkCtx, } from "./types.js"; +/** + * API for creating and managing thunk-style actions. + * + * @remarks + * Use {@link createThunks} (or {@link createApi}) to obtain an instance of this API. + * The ThunksApi provides: + * - `use()` for registering middleware + * - `create()` for creating typed action creators with associated handlers + * - `routes()` for the middleware router + * - `register()` for connecting to the store + * - `manage()` for handling and exposing Effection resources + * + * Each created thunk is an action creator that can be dispatched to trigger + * its middleware handler. + * + * @typeParam Ctx - The context type extending {@link ThunkCtx}. + * + * @see {@link createThunks} for creating a ThunksApi instance. + * @see {@link createApi} for HTTP-specific thunks. + */ export interface ThunksApi { + /** Register a middleware function into the pipeline. */ use: (fn: Middleware) => void; + /** Returns a middleware function that routes to action-specific middleware. */ routes: () => Middleware; + /** Register the thunks with the current store scope. */ register: () => Operation; + /** Reset any dynamically bound middleware for created actions. */ reset: () => void; + /** + * Start and expose an Effection resource within the store scope. + * + * @param name - unique name for the resource Context + * @param resource - an Effection Operation (usually created with `resource(...)`) + * @returns a `Context` that can `get()` or `expect()` + */ manage: ( name: string, resource: Operation, @@ -99,37 +130,104 @@ export interface ThunksApi { type Visors = (scope: Scope) => () => Operation; /** - * Creates a middleware pipeline. + * Creates a middleware pipeline for thunks. * * @remarks - * This middleware pipeline is almost exactly like koa's middleware system. - * See {@link https://koajs.com} + * Thunks are the foundational processing units in starfx. They have access to all + * dispatched actions, the global state, and the full power of structured concurrency. * - * @example + * Think of thunks as micro-controllers that can: + * - Update state (the only place where state mutations should occur) + * - Coordinate async operations with Effection + * - Call other thunks for composition + * + * The middleware system is similar to Koa/Express. Each middleware receives + * a context (`ctx`) and a `next` function. Calling `yield* next()` passes + * control to the next middleware. Not calling `next()` exits early. + * + * Every thunk requires a unique name/id which enables: + * - Better traceability and debugging + * - Naming convention abstractions (e.g., API routers) + * - Deterministic action types + * + * @typeParam Ctx - The context type extending {@link ThunkCtx}. + * @param options - Configuration options. + * @param options.supervisor - Default supervisor strategy (default: `takeEvery`). + * @returns A {@link ThunksApi} for creating and managing thunks. + * + * @see {@link https://koajs.com | Koa.js} for the middleware pattern inspiration. + * @see {@link createApi} for HTTP-specific thunks. + * @see {@link takeEvery}, {@link takeLatest}, {@link takeLeading} for supervisor strategies. + * + * @example Basic setup * ```ts - * import { createThunks } from 'starfx'; + * import { createThunks, mdw } from 'starfx'; + * + * const thunks = createThunks(); + * // Catch and log errors + * thunks.use(mdw.err); + * // Route to action-specific middleware + * thunks.use(thunks.routes()); + * + * const log = thunks.create('log', function* (ctx, next) { + * console.log('Message:', ctx.payload); + * yield* next(); + * }); * + * store.dispatch(log('Hello world')); + * ``` + * + * @example Middleware execution order + * ```ts * const thunks = createThunks(); + * * thunks.use(function* (ctx, next) { - * console.log('beginning'); + * console.log('1 - before'); * yield* next(); - * console.log('end'); + * console.log('4 - after'); * }); + * * thunks.use(thunks.routes()); * - * const doit = thunks.create('do-something', function*(ctx, next) { - * console.log('middle'); + * const doit = thunks.create('doit', function* (ctx, next) { + * console.log('2 - handler before'); * yield* next(); - * console.log('middle end'); + * console.log('3 - handler after'); * }); * - * // ... - * * store.dispatch(doit()); - * // beginning - * // middle - * // middle end - * // end + * // Output: 1 - before, 2 - handler before, 3 - handler after, 4 - after + * ``` + * + * @example Custom supervisor + * ```ts + * import { takeLatest, takeLeading } from 'starfx'; + * + * // Search with debounce-like behavior + * const search = thunks.create('search', { supervisor: takeLatest }); + * + * // Prevent duplicate submissions + * const submitForm = thunks.create('submit', { supervisor: takeLeading }); + * ``` + * + * @example Type-safe payload + * ```ts + * interface CreateUserPayload { + * name: string; + * email: string; + * } + * + * const createUser = thunks.create( + * 'create-user', + * function* (ctx, next) { + * // ctx.payload is typed as CreateUserPayload + * const { name, email } = ctx.payload; + * yield* next(); + * } + * ); + * + * createUser({ name: 'Alice', email: 'alice@example.com' }); // OK + * createUser({ name: 'Bob' }); // Type error: missing email * ``` */ export function createThunks>( diff --git a/src/query/types.ts b/src/query/types.ts index bb8f674..05169a7 100644 --- a/src/query/types.ts +++ b/src/query/types.ts @@ -10,22 +10,36 @@ import type { export type IfAny = 0 extends 1 & T ? Y : N; +/** + * Context provided to thunk middleware and action handlers. + */ export interface ThunkCtx

extends Payload

{ + /** Action name string */ name: string; + /** Deterministic key for this invocation */ key: string; + /** The dispatched action */ action: ActionWithPayload>; + /** The action creator function for this thunk */ actionFn: IfAny< P, CreateAction, CreateActionWithPayload, P> >; + /** Operation result placeholder */ result: Result; } +/** + * Thunk context that may be associated with a loader. + */ export interface ThunkCtxWLoader extends ThunkCtx { loader: Omit, "id"> | null; } +/** + * Extended thunk context including loader instance state. + */ export interface LoaderCtx

extends ThunkCtx

{ loader: Partial | null; } @@ -46,6 +60,9 @@ export type RequiredApiRequest = { headers: HeadersInit; } & Partial; +/** + * Context shape used when performing fetch requests. + */ export interface FetchCtx

extends ThunkCtx

{ request: ApiRequest | null; req: (r?: ApiRequest) => RequiredApiRequest; @@ -61,6 +78,9 @@ export interface FetchJsonCtx

extends FetchCtx

, FetchJson {} +/** + * Full API context made available to mdw/api and endpoint handlers. + */ export interface ApiCtx extends FetchJsonCtx { actions: Action[]; @@ -79,6 +99,9 @@ export interface PerfCtx

extends ThunkCtx

{ performance: number; } +/** + * A middleware function for thunks. + */ export type Middleware = ( ctx: Ctx, next: Next, @@ -87,6 +110,9 @@ export type MiddlewareCo = | Middleware | Middleware[]; +/** + * Middleware fn type specialized for HTTP API contexts. + */ export type MiddlewareApi = ( ctx: Ctx, next: Next, @@ -95,6 +121,9 @@ export type MiddlewareApiCo = | Middleware | Middleware[]; +/** + * Payload carried with each created action. + */ export interface CreateActionPayload

{ name: string; key: string; diff --git a/src/queue.ts b/src/queue.ts index 6f4af9c..2d30426 100644 --- a/src/queue.ts +++ b/src/queue.ts @@ -1,5 +1,13 @@ import { createQueue } from "effection"; +/** + * Create a queue that only accepts values matching `predicate`. + * + * @typeParam T - The item type. + * @typeParam TClose - The optional close value type. + * @param predicate - Predicate called for each value before enqueueing. + * @returns A queue with the same API as Effection's `createQueue`, but filtered. + */ export function createFilterQueue(predicate: (v: T) => boolean) { const queue = createQueue(); diff --git a/src/react.ts b/src/react.ts index 4e7eedc..848b9a7 100644 --- a/src/react.ts +++ b/src/react.ts @@ -51,6 +51,39 @@ export interface UseCacheResult const SchemaContext = createContext | null>(null); +/** + * React Provider to wire the `FxStore` and schema into React context. + * + * @remarks + * Wrap your application with this provider to make hooks like + * {@link useSchema}, {@link useStore}, {@link useLoader}, {@link useApi}, + * {@link useQuery}, and {@link useCache} available to all descendants. + * + * This provider integrates with react-redux internally, so standard Redux + * DevTools will work with your starfx store. + * + * @param props - Provider props. + * @param props.store - The {@link FxStore} instance created by {@link createStore}. + * @param props.schema - The schema created by {@link createSchema}. + * @param props.children - React children to render. + * + * @see {@link createStore} for creating the store. + * @see {@link createSchema} for creating the schema. + * + * @example + * ```tsx + * import { Provider } from 'starfx/react'; + * import { store, schema } from './store'; + * + * function App() { + * return ( + * + * + * + * ); + * } + * ``` + */ export function Provider({ store, schema, @@ -75,25 +108,44 @@ export function useStore() { } /** - * useLoader will take an action creator or action itself and return the associated - * loader for it. + * Get the loader state for an action creator or action. * - * @returns the loader object for an action creator or action + * @remarks + * Loaders track the lifecycle of thunks and endpoints (idle, loading, success, error). + * This hook subscribes to the loader state and triggers re-renders when it changes. * - * @example - * ```ts + * The returned {@link LoaderState} includes convenience booleans: + * - `isIdle` - Initial state, never run + * - `isLoading` - Currently executing + * - `isSuccess` - Completed successfully + * - `isError` - Completed with an error + * - `isInitialLoading` - Loading AND never succeeded before + * + * @typeParam S - The state shape (inferred from schema). + * @param action - The action creator or dispatched action to track. + * @returns The {@link LoaderState} for the action. + * + * @see {@link useApi} for combining loader with trigger function. + * @see {@link useQuery} for auto-triggering on mount. + * + * @example With action creator + * ```tsx * import { useLoader } from 'starfx/react'; * - * import { api } from './api'; + * function UserStatus() { + * const loader = useLoader(fetchUsers()); * - * const fetchUsers = api.get('/users', function*() { - * // ... - * }); + * if (loader.isLoading) return ; + * if (loader.isError) return ; + * return

Users loaded!
; + * } + * ``` * - * const View = () => { - * const loader = useLoader(fetchUsers); - * // or: const loader = useLoader(fetchUsers()); - * return
{loader.isLoader ? 'Loading ...' : 'Done!'}
+ * @example With dispatched action (tracks specific call) + * ```tsx + * function UserDetail({ id }: { id: string }) { + * const loader = useLoader(fetchUser({ id })); + * // Tracks this specific fetchUser call by its payload * } * ``` */ @@ -106,29 +158,55 @@ export function useLoader( } /** - * useApi will take an action creator or action itself and fetch - * the associated loader and create a `trigger` function that you can call - * later in your react component. + * Get loader state and a trigger function for an action. * - * This hook will *not* fetch the data for you because it does not know how to fetch - * data from your redux state. + * @remarks + * Combines {@link useLoader} with a `trigger` function for dispatching the action. + * Does NOT automatically fetch data - use `trigger()` to initiate the request. * - * @example - * ```ts + * For automatic fetching on mount, use {@link useQuery} instead. + * + * @typeParam P - The payload type for the action. + * @typeParam A - The action type. + * @param action - The action creator or dispatched action. + * @returns An object with loader state and `trigger` function. + * + * @see {@link useQuery} for auto-triggering on mount. + * @see {@link useCache} for auto-triggering with cached data. + * @see {@link useLoaderSuccess} for success callbacks. + * + * @example Manual trigger + * ```tsx * import { useApi } from 'starfx/react'; * - * import { api } from './api'; + * function CreateUserForm() { + * const { isLoading, trigger } = useApi(createUser); * - * const fetchUsers = api.get('/users', function*() { - * // ... - * }); + * const handleSubmit = (data: FormData) => { + * trigger({ name: data.get('name') }); + * }; * - * const View = () => { + * return ( + *
+ * + * + *
+ * ); + * } + * ``` + * + * @example Fetch on mount with useEffect + * ```tsx + * function UsersList() { * const { isLoading, trigger } = useApi(fetchUsers); + * * useEffect(() => { * trigger(); * }, []); - * return
{isLoading ? : 'Loading' : 'Done!'}
+ * + * return isLoading ? : ; * } * ``` */ @@ -155,21 +233,43 @@ export function useApi(action: any): any { } /** - * useQuery uses {@link useApi} and automatically calls `useApi().trigger()` + * Auto-triggering version of {@link useApi}. * - * @example - * ```ts + * @remarks + * Automatically dispatches the action on mount and when the action's `key` changes. + * This is useful for "fetch on render" patterns. + * + * The action is re-triggered when `action.payload.key` changes, which is a hash + * of the action name and payload. This means changing the payload (e.g., a user ID) + * will trigger a new fetch. + * + * @typeParam P - The payload type for the action. + * @typeParam A - The action type. + * @param action - The dispatched action to execute. + * @returns An object with loader state and `trigger` function. + * + * @see {@link useApi} for manual triggering. + * @see {@link useCache} for auto-triggering with cached data. + * + * @example Basic usage + * ```tsx * import { useQuery } from 'starfx/react'; * - * import { api } from './api'; + * function UsersList() { + * const { isLoading, isError, message } = useQuery(fetchUsers); * - * const fetchUsers = api.get('/users', function*() { - * // ... - * }); + * if (isLoading) return ; + * if (isError) return ; + * return ; + * } + * ``` * - * const View = () => { - * const { isLoading } = useQuery(fetchUsers); - * return
{isLoading ? : 'Loading' : 'Done!'}
+ * @example With parameters (re-fetches on change) + * ```tsx + * function UserDetail({ userId }: { userId: string }) { + * // Re-fetches when userId changes + * const { isLoading } = useQuery(fetchUser({ id: userId })); + * // ... * } * ``` */ @@ -184,20 +284,58 @@ export function useQuery

>( } /** - * useCache uses {@link useQuery} and automatically selects the cached data associated - * with the action creator or action provided. + * Auto-fetch with cached data selection. * - * @example - * ```ts - * import { useCache } from 'starfx/react'; + * @remarks + * Combines {@link useQuery} with automatic selection of cached response data. + * The endpoint must use `api.cache()` middleware to populate the cache. + * + * This is the most convenient hook for "fetch and display" patterns where + * you want the raw API response data. * - * import { api } from './api'; + * @typeParam P - The payload type for the action. + * @typeParam ApiSuccess - The expected success response type. + * @param action - The dispatched action with cache enabled. + * @returns An object with loader state, `trigger` function, and `data`. * - * const fetchUsers = api.get('/users', api.cache()); + * @see {@link useQuery} for queries without cache selection. + * @see {@link useApi} for manual triggering. + * + * @example Basic usage + * ```tsx + * import { useCache } from 'starfx/react'; * - * const View = () => { + * // Endpoint with caching enabled + * const fetchUsers = api.get('/users', api.cache()); + * + * function UsersList() { * const { isLoading, data } = useCache(fetchUsers()); - * return

{isLoading ? : 'Loading' : data.length}
+ * + * if (isLoading && !data) return ; + * + * return ( + *
    + * {data?.map(user => ( + *
  • {user.name}
  • + * ))} + *
+ * ); + * } + * ``` + * + * @example With typed response + * ```tsx + * interface User { + * id: string; + * name: string; + * email: string; + * } + * + * const fetchUser = api.get<{ id: string }, User>('/users/:id', api.cache()); + * + * function UserProfile({ id }: { id: string }) { + * const { data, isError, message } = useCache(fetchUser({ id })); + * // data is typed as User | null * } * ``` */ @@ -212,31 +350,48 @@ export function useCache

( } /** - * useLoaderSuccess will activate the callback provided when the loader transitions - * from some state to success. + * Execute a callback when a loader transitions to success state. * - * @example - * ```ts - * import { useLoaderSuccess, useApi } from 'starfx/react'; + * @remarks + * Watches the loader's status and fires the callback when it changes from + * any non-success state to "success". Useful for side effects like navigation, + * showing toasts, or resetting forms after successful operations. * - * import { api } from './api'; + * @param cur - The loader state to watch (from {@link useLoader} or {@link useApi}). + * @param success - Callback to execute on success transition. * - * const createUser = api.post('/users', function*(ctx, next) { - * // ... - * }); + * @example Navigate after form submission + * ```tsx + * import { useApi, useLoaderSuccess } from 'starfx/react'; + * import { useNavigate } from 'react-router-dom'; + * + * function CreateUserForm() { + * const navigate = useNavigate(); + * const { trigger, ...loader } = useApi(createUser); + * + * useLoaderSuccess(loader, () => { + * // Navigate to user list after successful creation + * navigate('/users'); + * }); * - * const View = () => { - * const { loader, trigger } = useApi(createUser); - * const onSubmit = () => { - * trigger({ name: 'bob' }); - * }; + * const handleSubmit = (data: FormData) => { + * trigger({ name: data.get('name') }); + * }; * - * useLoaderSuccess(loader, () => { - * // success! - * // Use this callback to navigate to another view - * }); + * return

...
; + * } + * ``` + * + * @example Show success toast + * ```tsx + * function DeleteButton({ id }: { id: string }) { + * const { trigger, ...loader } = useApi(deleteUser); * - * return + * useLoaderSuccess(loader, () => { + * toast.success('User deleted successfully'); + * }); + * + * return ; * } * ``` */ @@ -262,6 +417,56 @@ function Loading({ text }: { text: string }) { return h("div", null, text); } +/** + * Delay rendering until persistence rehydration completes. + * + * @remarks + * When using state persistence, the store needs to be rehydrated from storage + * before rendering the app. This component shows a loading state until the + * persistence loader (identified by `PERSIST_LOADER_ID`) reaches success. + * + * If rehydration fails, the error message is displayed. + * + * @param props - Component props. + * @param props.children - Elements to render after successful rehydration. + * @param props.loading - Optional element to show while rehydrating (default: "Loading"). + * + * @see {@link createPersistor} for setting up persistence. + * @see {@link PERSIST_LOADER_ID} for the internal loader ID. + * + * @example Basic usage + * ```tsx + * import { PersistGate, Provider } from 'starfx/react'; + * + * function App() { + * return ( + * + * }> + * + * + * + * + * + * ); + * } + * ``` + * + * @example Custom loading component + * ```tsx + * function CustomLoader() { + * return ( + *
+ * + *

Restoring your session...

+ *
+ * ); + * } + * + * }> + * + * + * ``` + */ export function PersistGate({ children, loading = h(Loading), diff --git a/src/store/batch.ts b/src/store/batch.ts index 461b3c5..ced776c 100644 --- a/src/store/batch.ts +++ b/src/store/batch.ts @@ -2,6 +2,29 @@ import { action } from "effection"; import type { AnyState, Next } from "../types.js"; import type { UpdaterCtx } from "./types.js"; +/** + * Create middleware that batches store update notifications. + * + * @remarks + * By default, every store update triggers immediate listener notifications. + * This middleware defers notifications until the provided `queue` function + * fires its callback, allowing multiple updates to be batched together. + * + * This is useful for integrating with React's batching mechanisms or other + * frameworks that benefit from grouped updates. + * + * @typeParam S - Root store state type. + * @param queue - Function that receives a `send` callback. Call `send()` when + * batched updates should notify listeners. + * @returns A store middleware Operation. + * + * @example With setTimeout (debounce-like) + * ```ts + * const batchMdw = createBatchMdw((send) => { + * setTimeout(send, 16); // ~60fps + * }); + * ``` + */ export function createBatchMdw( queue: (send: () => void) => void, ) { diff --git a/src/store/context.ts b/src/store/context.ts index ed69fe7..1267fdd 100644 --- a/src/store/context.ts +++ b/src/store/context.ts @@ -2,8 +2,19 @@ import { type Channel, createChannel, createContext } from "effection"; import type { AnyState } from "../types.js"; import type { FxStore } from "./types.js"; +/** + * Channel used to notify that the store update sequence completed. + * + * Consumers may `StoreUpdateContext.expect()` this context to access store lifecycle notifications through the channel. + */ export const StoreUpdateContext = createContext>( "starfx:store:update", createChannel(), ); + +/** + * Context that holds the active `FxStore` for the current scope. + * + * Use `StoreContext.expect()` within operations to access the store instance. + */ export const StoreContext = createContext>("starfx:store"); diff --git a/src/store/fx.ts b/src/store/fx.ts index 7c14574..469b73b 100644 --- a/src/store/fx.ts +++ b/src/store/fx.ts @@ -7,6 +7,47 @@ import { StoreContext } from "./context.js"; import type { LoaderOutput } from "./slice/loaders.js"; import type { FxStore, StoreUpdater, UpdaterCtx } from "./types.js"; +/** + * Apply a store updater within the current store context. + * + * @remarks + * This is one of three ways to update state in starfx. The recommended approach + * is to use `schema.update()` which provides full type safety. This function is + * more generic and requires manual type annotation. + * + * Updater functions receive an `immer` draft and can mutate it directly. + * Any mutations are captured and applied immutably to the real state. + * + * @typeParam S - Root state shape. + * @param updater - Updater function or array of updaters to apply. + * @returns The update context produced by the store. + * + * @see {@link https://immerjs.github.io/immer/update-patterns | Immer update patterns} + * + * @example Basic counter increment + * ```ts + * function* inc() { + * yield* updateStore((state) => { + * state.counter += 1; + * }); + * } + * ``` + * + * @example Using schema updater helpers + * ```ts + * function* addUser(user: User) { + * yield* updateStore(schema.users.add({ [user.id]: user })); + * } + * ``` + * + * @example Batch multiple updates + * ```ts + * yield* updateStore([ + * schema.users.add({ [user.id]: user }), + * schema.loaders.success({ id: 'fetch-user' }), + * ]); + * ``` + */ export function* updateStore( updater: StoreUpdater | StoreUpdater[], ): Operation> { @@ -17,6 +58,46 @@ export function* updateStore( return ctx; } +/** + * Evaluate a selector against the current store state. + * + * @remarks + * Selectors are functions that derive data from the store state. They encapsulate + * logic for looking up specific values and can be memoized using `createSelector` + * from reselect (re-exported by starfx). + * + * This is an Operation that must be yielded inside an Effection scope (typically a thunk/api). + * + * @typeParam S - The state shape. + * @typeParam R - The return type of the selector. + * @typeParam P - Optional parameter type for parameterized selectors. + * @param selectorFn - Selector function to evaluate. + * @param p - Optional parameter passed to the selector. + * @returns The result of calling the selector with current state. + * + * @see {@link createSelector} for memoized selectors. + * + * @example Basic selector usage + * ```ts + * // return an array of users + * const users = yield* select(schema.users.selectTableAsList); + * ``` + * + * @example Parameterized selector + * ```ts + * // return a single user by id + * const user = yield* select(schema.users.selectById, { id: '1' }); + * ``` + * + * @example With custom selector + * ```ts + * const selectActiveUsers = createSelector( + * schema.users.selectTableAsList, + * (users) => users.filter(u => u.isActive) + * ); + * const activeUsers = yield* select(selectActiveUsers); + * ``` + */ export function select(selectorFn: (s: S) => R): Operation; export function select( selectorFn: (s: S, p: P) => R, @@ -30,6 +111,37 @@ export function* select( return selectorFn(store.getState() as S, p); } +/** + * Wait for a loader associated with `action` to enter a terminal state + * (`success` or `error`). + * + * @remarks + * Loaders are "status trackers" that monitor the lifecycle of thunks and + * endpoints. They track loading, success, and error states along with + * timestamps and optional metadata. + * + * This function polls the loader state on every action until it reaches + * a terminal state (success or error). + * + * @typeParam M - The loader metadata shape. + * @param loaders - The loader slice instance from your schema. + * @param action - The action or action-creator which identifies the loader. + * @returns The final {@link LoaderState} with helper booleans (`isSuccess`, `isError`, etc.). + * + * @see {@link waitForLoaders} for waiting on multiple loaders. + * @see {@link LoaderState} for the shape of the returned state. + * + * @example + * ```ts + * // wait until the loader for `fetchUsers()` completes + * const loader = yield* waitForLoader(schema.loaders, fetchUsers()); + * if (loader.isSuccess) { + * console.log('Users fetched successfully'); + * } else if (loader.isError) { + * console.error('Failed:', loader.message); + * } + * ``` + */ export function* waitForLoader( loaders: LoaderOutput, action: ThunkAction | ActionFnWithPayload, @@ -52,6 +164,19 @@ export function* waitForLoader( } } +/** + * Wait for multiple loaders associated with `actions` to reach a terminal state. + * + * @example + * ```ts + * const results = yield* waitForLoaders(schema.loaders, [fetchUser(), fetchPosts()]); + * for (const res of results) { + * if (res.ok) { + * // res.value is a LoaderState + * } + * } + * ``` + */ export function* waitForLoaders( loaders: LoaderOutput, actions: (ThunkAction | ActionFnWithPayload)[], @@ -61,6 +186,21 @@ export function* waitForLoaders( return yield* group; } +/** + * Produce a helper that wraps an operation with loader start/success/error updates. + * + * @param loader - Loader slice instance used to mark start/success/error. + * + * @example + * ```ts + * const track = createTracker(schema.loaders); + * const trackedOp = track('my-id')(function* () { + * return yield* safe(() => someAsyncOp()); + * }); + * const result = yield* trackedOp; + * if (result.ok) { // result.value is the operation Result } + * ``` + */ export function createTracker>( loader: LoaderOutput, ) { diff --git a/src/store/persist.ts b/src/store/persist.ts index 0465420..a5126fc 100644 --- a/src/store/persist.ts +++ b/src/store/persist.ts @@ -3,6 +3,9 @@ import type { AnyState, Next } from "../types.js"; import { select, updateStore } from "./fx.js"; import type { UpdaterCtx } from "./types.js"; +/** + * Loader id used internally by the persistence system to track rehydration status. + */ export const PERSIST_LOADER_ID = "@@starfx/persist"; export interface PersistAdapter { @@ -24,6 +27,12 @@ interface TransformFunctions { out(s: Partial): Partial; } +/** + * Create a transform object for persistence that can alter the shape of the + * state when saving or loading. + * + * @returns An object with `.in` and `.out` transformer functions. + */ export function createTransform() { const transformers: TransformFunctions = { in: (currentState: Partial): Partial => currentState, @@ -73,6 +82,48 @@ export function shallowReconciler( return { ...original, ...persisted }; } +/** + * Create a persistor for state rehydration from storage. + * + * @remarks + * The persistor provides a `rehydrate` operation that: + * 1. Reads persisted state from the adapter + * 2. Applies optional `transform.out` to the loaded data + * 3. Merges with current state using the reconciler + * 4. Updates the store with the merged state + * + * The persistence system uses a special loader (`PERSIST_LOADER_ID`) to + * track rehydration status, which {@link PersistGate} uses to delay + * rendering until rehydration completes. + * + * @typeParam S - The state shape. + * @param options - Persistor configuration. + * @param options.adapter - Storage adapter (e.g., localStorage, AsyncStorage). + * @param options.key - Storage key for persisted data (default: 'starfx'). + * @param options.reconciler - Function to merge original and rehydrated state. + * @param options.allowlist - Keys to persist (empty = persist entire state). + * @param options.transform - Optional transformers for inbound/outbound shapes. + * @returns Persistor properties including the `rehydrate` operation. + * + * @see {@link createLocalStorageAdapter} for browser storage. + * @see {@link PersistGate} for React integration. + * @see {@link shallowReconciler} for the default merge strategy. + * + * @example Basic setup + * ```ts + * import { createPersistor, createLocalStorageAdapter } from 'starfx'; + * + * const persistor = createPersistor({ + * adapter: createLocalStorageAdapter(), + * key: 'my-app', + * allowlist: ['users', 'settings'], + * reconciler: shallowReconciler, + * }); + * + * // In your app initialization + * store.run(persistor.rehydrate); + * ``` + */ export function createPersistor({ adapter, key = "starfx", @@ -117,6 +168,13 @@ export function createPersistor({ }; } +/** + * Middleware that persists the store state after each update. + * + * @remarks + * Applies an optional inbound transform and either persists the entire state + * (when `allowlist` is empty) or only the listed keys. + */ export function persistStoreMdw({ allowlist, adapter, diff --git a/src/store/run.ts b/src/store/run.ts index cec1c45..5939517 100644 --- a/src/store/run.ts +++ b/src/store/run.ts @@ -1,6 +1,17 @@ import type { Operation, Result, Scope, Task } from "effection"; import { parallel, safe } from "../fx/index.js"; +/** + * Bind a `run` helper to an Effection `Scope`. + * + * @remarks + * The returned `run` function accepts either a single operation factory or an + * array of operation factories and returns an Effection `Task` that resolves + * to the operation result(s). When given an array, operations are executed in parallel. + * + * @param scope - The Effection scope used to run tasks. + * @returns A `run` function bound to the provided `scope`. + */ export function createRun(scope: Scope) { function run(op: (() => Operation)[]): Task[]>; function run(op: () => Operation): Task>; diff --git a/src/store/schema.ts b/src/store/schema.ts index 18cf5ba..75ba7a9 100644 --- a/src/store/schema.ts +++ b/src/store/schema.ts @@ -5,6 +5,72 @@ import type { FxMap, FxSchema, StoreUpdater } from "./types.js"; const defaultSchema = (): O => ({ cache: slice.table(), loaders: slice.loaders() }) as O; +/** + * Creates a schema object and initial state from slice factories. + * + * @remarks + * A schema defines the shape of your application state and provides reusable + * state management utilities. It is composed of "slices" that each represent + * a piece of state with associated update and query helpers. + * + * By default, `createSchema` requires `cache` and `loaders` slices which are + * used internally by starfx middleware and supervisors. These slices enable + * powerful features like automatic request caching and loader state tracking. + * + * Returns a tuple of `[schema, initialState]` where: + * - `schema` contains all slice helpers/selectors plus an `update()` method + * - `initialState` is the combined initial state from all slices + * + * @typeParam O - Map of slice factory functions. + * @typeParam S - Inferred state shape from the slices. + * @param slices - A map of slice factory functions. Defaults to a schema + * containing `cache` and `loaders` slices. + * @returns A tuple of `[schema, initialState]`. + * + * @see {@link slice} for available slice types. + * @see {@link https://zod.dev | Zod} for the inspiration behind this API. + * + * @example Basic usage + * ```ts + * import { createSchema, slice } from 'starfx'; + * + * interface User { + * id: string; + * name: string; + * } + * + * const [schema, initialState] = createSchema({ + * cache: slice.table(), + * loaders: slice.loaders(), + * users: slice.table({ empty: { id: '', name: '' } }), + * counter: slice.num(0), + * settings: slice.obj({ theme: 'light', notifications: true }), + * }); + * + * type AppState = typeof initialState; + * ``` + * + * @example Using the schema + * ```ts + * const fetchUsers = api.get('/users', function* (ctx, next) { + * // do work before the request + * yield* next(); + * if (!ctx.json.ok) return; + * + * const users = ctx.json.value.reduce((acc, u) => { + * acc[u.id] = u; + * return acc; + * }, {}); + * + * // Type-safe state updates + * yield* schema.update(schema.users.add(users)); + * + * // Type-safe selectors + * const allUsers = yield* select(schema.users.selectTableAsList); + * const user = yield* select(schema.users.selectById, { id: '1' }); + * } + * ``` + */ export function createSchema< O extends FxMap, S extends { [key in keyof O]: ReturnType["initialState"] }, diff --git a/src/store/slice/any.ts b/src/store/slice/any.ts index 42a7d84..2f4dc46 100644 --- a/src/store/slice/any.ts +++ b/src/store/slice/any.ts @@ -9,6 +9,13 @@ export interface AnyOutput extends BaseSchema { select: (s: S) => V; } +/** + * Create a generic slice for any arbitrary value. + * + * @param name - The state key for this slice. + * @param initialState - The initial value for the slice. + * @returns An `AnyOutput` providing setter, reset, and selector helpers. + */ export function createAny({ name, initialState, @@ -32,6 +39,11 @@ export function createAny({ }; } +/** + * Shortcut to define an `any` slice for schema creation. + * + * @param initialState - The initial value for the slice. + */ export function any(initialState: V) { return (name: string) => createAny({ name, initialState }); } diff --git a/src/store/slice/loaders.ts b/src/store/slice/loaders.ts index 1f1f67e..e4a2b28 100644 --- a/src/store/slice/loaders.ts +++ b/src/store/slice/loaders.ts @@ -17,6 +17,28 @@ interface PropIds { const excludesFalse = (n?: T): n is T => Boolean(n); +/** + * Create a default loader item with sensible defaults. + * + * @remarks + * Returns a complete {@link LoaderItemState} with the following defaults: + * - `id`: empty string + * - `status`: 'idle' + * - `message`: empty string + * - `lastRun`: 0 (never run) + * - `lastSuccess`: 0 (never succeeded) + * - `meta`: empty object + * + * @typeParam M - Metadata shape stored on the loader. + * @param li - Partial fields to override the defaults. + * @returns A fully populated {@link LoaderItemState}. + * + * @example + * ```ts + * const loader = defaultLoaderItem({ id: 'fetch-users' }); + * // { id: 'fetch-users', status: 'idle', message: '', ... } + * ``` + */ export function defaultLoaderItem( li: Partial> = {}, ): LoaderItemState { @@ -31,6 +53,36 @@ export function defaultLoaderItem( }; } +/** + * Create a loader state with computed helper booleans. + * + * @remarks + * Extends {@link defaultLoaderItem} with convenience boolean properties: + * - `isIdle` - status === 'idle' + * - `isLoading` - status === 'loading' + * - `isSuccess` - status === 'success' + * - `isError` - status === 'error' + * - `isInitialLoading` - loading AND never succeeded (`lastSuccess === 0`) + * + * The `isInitialLoading` distinction is important: if data was successfully + * loaded before, showing a loading spinner on re-fetch may not be necessary. + * This lets you show stale data while refreshing. + * + * @typeParam M - Metadata shape stored on the loader. + * @param l - Partial loader fields to normalize. + * @returns A {@link LoaderState} with helper booleans. + * + * @example + * ```ts + * const loader = defaultLoader({ status: 'loading', lastSuccess: 0 }); + * loader.isLoading; // true + * loader.isInitialLoading; // true (first load) + * + * const reloader = defaultLoader({ status: 'loading', lastSuccess: Date.now() }); + * reloader.isLoading; // true + * reloader.isInitialLoading; // false (has succeeded before) + * ``` + */ export function defaultLoader( l: Partial> = {}, ): LoaderState { @@ -122,6 +174,15 @@ export interface LoaderOutput< const ts = () => new Date().getTime(); +/** + * Create a loader slice for tracking async loader state keyed by id. + * + * @typeParam M - Metadata shape stored on loader entries. + * @typeParam S - Root state shape. + * @param param0.name - The slice name to attach to the state. + * @param param0.initialState - Optional initial loader table. + * @returns A `LoaderOutput` exposing selectors and mutation helpers. + */ export const createLoaders = < M extends AnyState = AnyState, S extends AnyState = AnyState, @@ -180,6 +241,11 @@ export const createLoaders = < }; }; +/** + * Shortcut for declaring loader slices in schema definitions. + * + * @param initialState - Optional initial loader table. + */ export function loaders( initialState?: Record>, ) { diff --git a/src/store/slice/num.ts b/src/store/slice/num.ts index 1700a7c..c753787 100644 --- a/src/store/slice/num.ts +++ b/src/store/slice/num.ts @@ -11,6 +11,13 @@ export interface NumOutput extends BaseSchema { select: (s: S) => number; } +/** + * Create a numeric slice with helpers to increment/decrement/reset the value. + * + * @param name - The state key for this slice. + * @param initialState - Optional initial value (default: 0). + * @returns A `NumOutput` with numeric helpers and a selector. + */ export function createNum({ name, initialState = 0, @@ -44,6 +51,11 @@ export function createNum({ }; } +/** + * Shortcut to create a numeric slice for schema creation. + * + * @param initialState - Optional initial value for the slice. + */ export function num(initialState?: number) { return (name: string) => createNum({ name, initialState }); } diff --git a/src/store/slice/obj.ts b/src/store/slice/obj.ts index 9f5f020..5405925 100644 --- a/src/store/slice/obj.ts +++ b/src/store/slice/obj.ts @@ -11,6 +11,13 @@ export interface ObjOutput select: (s: S) => V; } +/** + * Create an object slice with update, set, and reset helpers. + * + * @param name - The state key for this slice. + * @param initialState - The initial object used for this slice. + * @returns An `ObjOutput` providing setters, partial updates, and a selector. + */ export function createObj({ name, initialState, @@ -39,6 +46,11 @@ export function createObj({ }; } +/** + * Shortcut to create an `obj` slice for schema creation. + * + * @param initialState - The initial object used for the slice. + */ export function obj(initialState: V) { return (name: string) => createObj({ name, initialState }); } diff --git a/src/store/slice/str.ts b/src/store/slice/str.ts index 62f03fb..1f3ffe0 100644 --- a/src/store/slice/str.ts +++ b/src/store/slice/str.ts @@ -10,6 +10,13 @@ export interface StrOutput select: (s: S) => string; } +/** + * Create a string slice with set/reset/select helpers. + * + * @param name - State key for this slice. + * @param initialState - Optional initial string value (defaults to empty string). + * @returns A `StrOutput` containing setters and selector helpers. + */ export function createStr({ name, initialState = "", @@ -33,6 +40,11 @@ export function createStr({ }; } +/** + * Shortcut for creating a `str` slice when building schema definitions. + * + * @param initialState - Optional initial string value. + */ export function str(initialState?: string) { return (name: string) => createStr({ name, initialState }); } diff --git a/src/store/slice/table.ts b/src/store/slice/table.ts index 9b3c7ee..6c00166 100644 --- a/src/store/slice/table.ts +++ b/src/store/slice/table.ts @@ -100,6 +100,75 @@ export interface TableOutput< selectByIds: (s: S, p: PropIds) => Entity[]; } +/** + * Create a table-style slice for entity storage (id -> entity map). + * + * @remarks + * The table slice mimics a database table where entities are stored in a + * `Record` structure. It provides: + * + * **Selectors:** + * - `selectTable` - Get the entire table object + * - `selectTableAsList` - Get all entities as an array + * - `selectById` - Get single entity by id (returns `empty` if not found) + * - `selectByIds` - Get multiple entities by ids + * + * **Updaters:** + * - `add` - Add or replace entities + * - `set` - Replace entire table + * - `remove` - Remove entities by ids + * - `patch` - Partially update entities + * - `merge` - Deep merge entities + * - `reset` - Reset to initial state + * + * **Empty value:** + * When `empty` is provided and `selectById` doesn't find an entity, it returns + * the empty value instead of `undefined`. This promotes safer code by providing + * stable assumptions about data shape (no optional chaining needed). + * + * @typeParam Entity - The entity type stored in the table. + * @typeParam S - The root state type. + * @param p - Table configuration. + * @param p.name - The state key for this table. + * @param p.initialState - Optional initial map of entities. + * @param p.empty - Optional empty entity (or factory) returned for missing lookups. + * @returns A {@link TableOutput} with selectors and mutation helpers. + * + * @see {@link https://bower.sh/death-by-thousand-existential-checks | Why empty values matter} + * @see {@link https://bower.sh/entity-factories | Entity factories pattern} + * + * @example Basic usage + * ```ts + * interface User { + * id: string; + * name: string; + * email: string; + * } + * + * const [schema, initialState] = createSchema({ + * users: slice.table({ empty: { id: '', name: '', email: '' } }), + * }); + * + * // Add users + * yield* schema.update( + * schema.users.add({ + * '1': { id: '1', name: 'Alice', email: 'alice@example.com' }, + * '2': { id: '2', name: 'Bob', email: 'bob@example.com' }, + * }) + * ); + * + * // Get user (returns empty if not found) + * const user = yield* select(schema.users.selectById, { id: '1' }); + * + * // Partial update + * yield* schema.update( + * schema.users.patch({ '1': { name: 'Alice Smith' } }) + * ); + * + * // Remove users + * yield* schema.update(schema.users.remove(['2'])); + * ``` + */ export function createTable< Entity extends AnyState = AnyState, S extends AnyState = AnyState, @@ -141,6 +210,7 @@ export function createTable< state[id] = entities[id]; }); }, + set: (entities) => (s) => { (s as any)[name] = entities; }, @@ -192,6 +262,13 @@ export function table< initialState?: Record; empty?: Entity | (() => Entity); }): (n: string) => TableOutput; +/** + * Shortcut for defining a `table` slice when building schema declarations. + * + * @param initialState - Optional initial entity map. + * @param empty - Optional empty entity or factory. + * @returns A factory function accepting the slice name. + */ export function table< Entity extends AnyState = AnyState, S extends AnyState = AnyState, diff --git a/src/store/store.ts b/src/store/store.ts index b905173..bb4947f 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -36,6 +36,51 @@ export interface CreateStore { export const IdContext = createContext("starfx:id", 0); +/** + * Creates a new FxStore instance for managing application state. + * + * @remarks + * The store wraps an Effection scope and provides state management primitives, + * listener registration, middleware application, and a `run` helper for + * executing operations within the store's scope. + * + * Unlike traditional Redux stores, this store does not use reducers. Instead, + * state updates are performed using `immer`-based updater functions that directly + * mutate a draft state. This design is inspired by the observation that reducers + * were originally created to ensure immutability, but with `immer` that concern + * is handled automatically. + * + * @typeParam S - The shape of the root state object. + * + * @param options - Store configuration object. + * @param options.initialState - The initial state for the store. + * @param options.scope - Optional Effection scope to use. If omitted, a new scope is created. + * @param options.middleware - Optional array of store middleware. + * @returns A fully configured {@link FxStore} instance. + * + * @see {@link createSchema} for creating the schema and initial state. + * @see {@link https://immerjs.github.io/immer/update-patterns | Immer update patterns} + * + * @example + * ```ts + * import { createSchema, createStore, slice } from "starfx"; + * + * interface User { + * id: string; + * name: string; + * } + * + * const [schema, initialState] = createSchema({ + * cache: slice.table(), + * loaders: slice.loaders(), + * users: slice.table(), + * }); + * + * const store = createStore({ initialState }); + * store.run(api.register); + * store.dispatch(fetchUsers()); + * ``` + */ export function createStore({ initialState, scope: initScope, diff --git a/src/store/types.ts b/src/store/types.ts index f54093a..ffcf76e 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -6,10 +6,19 @@ import type { createRun } from "./run.js"; import type { LoaderOutput } from "./slice/loaders.js"; import type { TableOutput } from "./slice/table.js"; +/** + * A function that applies mutations to the store state. + */ export type StoreUpdater = (s: S) => S | void; +/** + * Simple listener callback type used by `subscribe`. + */ export type Listener = () => void; +/** + * Context passed to store update middleware. + */ export interface UpdaterCtx extends BaseCtx { updater: StoreUpdater | StoreUpdater[]; patches: Patch[]; @@ -21,6 +30,9 @@ declare global { } } +/** + * Base description of a slice factory (schema output) used to build Fx schemas. + */ export interface BaseSchema { initialState: TOutput; schema: string; @@ -31,16 +43,25 @@ export type Output }> = { [key in keyof O]: O[key]["initialState"]; }; +/** + * Map of slice factories used when creating a schema via {@link createSchema}. + */ export interface FxMap { loaders: (s: string) => LoaderOutput; cache: (s: string) => TableOutput; [key: string]: (name: string) => BaseSchema; } +/** + * Generated schema type mapping slice factories to their runtime output helpers. + */ export type FxSchema = { [key in keyof O]: ReturnType; } & { update: FxStore["update"] }; +/** + * Runtime store instance exposing state, update, and effect helpers. + */ export interface FxStore { getScope: () => Scope; getState: () => S; @@ -54,6 +75,9 @@ export interface FxStore { [Symbol.observable]: () => any; } +/** + * Minimal shape of the generated `QueryState`. + */ export interface QueryState { cache: TableOutput["initialState"]; loaders: LoaderOutput["initialState"]; diff --git a/src/supervisor.ts b/src/supervisor.ts index f5b5f3f..7f9f719 100644 --- a/src/supervisor.ts +++ b/src/supervisor.ts @@ -8,6 +8,47 @@ const MS = 1000; const SECONDS = 1 * MS; const MINUTES = 60 * SECONDS; +/** + * Create a polling supervisor for periodic execution. + * + * @remarks + * When activated, calls the thunk or endpoint once, then repeats every + * `parentTimer` milliseconds until a cancellation action is dispatched. + * + * The timer can be overridden per-dispatch by including `timer` in the payload. + * Polling is cancelled when an action with the same type is dispatched again, + * or when an optional `cancelType` action is dispatched. + * + * @param parentTimer - Default interval between executions (default: 5 seconds). + * @param cancelType - Optional action type that cancels polling. + * @returns A supervisor function. + * + * @see {@link timer} for request throttling. + * + * @example Basic polling + * ```ts + * const fetchUsers = api.get('/users', { + * supervisor: poll(10 * 1000), // Poll every 10 seconds + * }); + * + * // Start polling + * dispatch(fetchUsers()); + * // fetch -> wait 10s -> fetch -> wait 10s -> ... + * + * // Stop polling (dispatching same action cancels previous) + * dispatch(fetchUsers()); + * ``` + * + * @example Custom timer per call + * ```ts + * const fetchStatus = api.get('/status', { + * supervisor: poll(), + * }); + * + * // Override timer for this specific call + * dispatch(fetchStatus({ timer: 30000 })); // Poll every 30 seconds + * ``` + */ export function poll(parentTimer: number = 5 * SECONDS, cancelType?: string) { return function* poller( actionType: string, @@ -36,13 +77,54 @@ export const clearTimers = createAction< >("clear-timers"); /** - * timer() will create a cache timer for each `key` inside - * of a starfx api endpoint. `key` is a hash of the action type and payload. + * Create a cache timer supervisor for API endpoints. + * + * @remarks + * The timer supervisor ensures that repeated calls to the same endpoint + * (with the same payload) are throttled. Once an endpoint is called, subsequent + * calls with the same `key` (hash of name + payload) are ignored until the + * timer expires. + * + * This is particularly useful for preventing duplicate API requests when: + * - Users rapidly click buttons + * - Components re-mount and re-fetch + * - Multiple components request the same data + * + * The `key` is a hash of the action type AND payload, so: + * - `fetchUser({ id: '1' })` and `fetchUser({ id: '2' })` have different timers + * - Only `fetchUser({ id: '1' })` calls within the timer window are throttled + * + * Use {@link clearTimers} to manually invalidate timers. + * + * @param timer - Cache duration in milliseconds (default: 5 minutes). + * @returns A supervisor function. + * + * @see {@link clearTimers} for manual cache invalidation. + * @see {@link poll} for periodic fetching. + * + * @example Basic usage + * ```ts + * const fetchUser = api.get('/users/:id', { + * supervisor: timer(60 * 1000), // 1 minute cache + * }); + * + * dispatch(fetchUser({ id: '1' })); // Makes request + * dispatch(fetchUser({ id: '1' })); // Ignored (within timer) + * dispatch(fetchUser({ id: '2' })); // Makes request (different key) + * // After 1 minute... + * dispatch(fetchUser({ id: '1' })); // Makes request again + * ``` + * + * @example Clear timer manually + * ```ts + * import { clearTimers } from 'starfx'; + * + * // Clear specific endpoint + * dispatch(clearTimers(fetchUser({ id: '1' }))); * - * Why do we want this? If we have an api endpoint to fetch a single app: `fetchApp({ id: 1 })` - * if we don't set a timer per key then all calls to `fetchApp` will be on a timer. - * So if we call `fetchApp({ id: 1 })` and then `fetchApp({ id: 2 })` if we use a normal - * cache timer then the second call will not send an http request. + * // Clear all timers + * dispatch(clearTimers('*')); + * ``` */ export function timer(timer: number = 5 * MINUTES) { return function* onTimer( diff --git a/src/types.ts b/src/types.ts index 7c603ad..61adbc0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,20 +1,78 @@ import type { Operation } from "effection"; +/** + * Function passed to middleware to advance to the next operation in the stack. + * + * @remarks + * Call `yield* next()` to pass control to the next middleware. Code after + * the yield point executes after all downstream middleware have completed. + * Not calling `next()` exits the middleware stack early. + * + * @example + * ```ts + * function* myMiddleware(ctx, next) { + * console.log('before'); + * yield* next(); // Call next middleware + * console.log('after'); + * } + * ``` + */ export type Next = () => Operation; +/** + * Identifier type used in table slices (string | number). + */ export type IdProp = string | number; + +/** + * Finite set of loader states used by query loaders. + */ export type LoadingStatus = "loading" | "success" | "error" | "idle"; + +/** + * Minimal state tracked for each loader instance (internal representation). + * + * @remarks + * This is the raw state stored in the loaders slice. For consumer-facing + * state with convenience booleans, see {@link LoaderState}. + * + * @typeParam M - Shape of the `meta` object for custom metadata. + */ export interface LoaderItemState< M extends Record = Record, > { + /** Unique loader id derived from action key/payload */ id: string; + /** Current loader status */ status: LoadingStatus; + /** Optional message for errors or info */ message: string; + /** Timestamp of the last run (ms since epoch) */ lastRun: number; + /** Timestamp of the last successful run */ lastSuccess: number; + /** Arbitrary metadata attached to the loader */ meta: M; } +/** + * Extended loader state with convenience boolean properties. + * + * @remarks + * This is the type returned by loader selectors and hooks. It extends + * {@link LoaderItemState} with computed booleans for easy status checking: + * + * - `isIdle` - Initial state, operation hasn't started + * - `isLoading` - Currently executing + * - `isSuccess` - Completed successfully + * - `isError` - Failed with an error + * - `isInitialLoading` - Loading AND has never succeeded before + * + * The `isInitialLoading` property is useful for showing loading UI only + * on the first fetch, while displaying stale data during refreshes. + * + * @typeParam M - Shape of the `meta` object for custom metadata. + */ export interface LoaderState extends LoaderItemState { isIdle: boolean; @@ -33,15 +91,47 @@ export interface Payload

{ payload: P; } +/** + * Basic action shape used throughout the library. + * + * @remarks + * Actions are plain objects with a `type` string that identifies the action. + * This follows the Flux Standard Action pattern. + * + * @see {@link AnyAction} for actions with optional payload/meta. + * @see {@link ActionWithPayload} for actions with typed payload. + */ export interface Action { + /** Action type string */ type: string; [extraProps: string]: any; } +/** + * An action creator with no payload. + */ export type ActionFn = () => { toString: () => string }; + +/** + * An action creator that accepts a payload. + */ export type ActionFnWithPayload

= (p: P) => { toString: () => string }; // https://github.com/redux-utilities/flux-standard-action +/** + * Flux Standard Action (FSA) compatible action type. + * + * @remarks + * Extends {@link Action} with optional FSA properties: + * - `payload` - The action's data payload + * - `meta` - Additional metadata + * - `error` - If true, `payload` is an Error object + * + * While not strictly required, keeping actions JSON serializable is + * highly recommended for debugging and time-travel features. + * + * @see {@link https://github.com/redux-utilities/flux-standard-action | FSA Spec} + */ export interface AnyAction extends Action { payload?: any; meta?: any; @@ -49,6 +139,9 @@ export interface AnyAction extends Action { [extraProps: string]: any; } +/** + * AnyAction with an explicitly typed `payload`. + */ export interface ActionWithPayload

extends AnyAction { payload: P; }