/** * `mutation` — the service-level mutation constructor (ADR 0008): an * Effect-backed action with per-mutation state held in a labeled **atom**, an * **optimistic apply** that is **rolled back automatically on a typed failure**, * and query invalidation on success (through the {@link QueryCache} service). * Effect's typed error channel is the move no incumbent matches — the rollback * fires exactly when the effect fails, and the failure `E` lands, typed, in the * mutation's state. * * Built in a service's `make`, so the business policy (what is optimistic, what * reloads) lives with the service — and is testable as a plain Effect program, * no React, no DOM: * * ```ts * class Users extends Context.Service()('Users', { * make: Effect.gen(function* () { * const user = atom('user', { name: 'Ada' }); * const rename = yield* mutation('users/rename', (name: string) => api.rename(name), { * optimistic: (name) => { * const prev = user.value; * user.set({ ...prev, name }); * return () => user.set(prev); // rolled back on a typed failure * }, * invalidates: ['user'], // reload tagged queries on success * }); * return { user, rename }; * }), * }) {} * * // in a REC: users.rename.run('Grace'); const s = yield* users.rename.state; * ``` * * State is shared-by-default (one atom per constructed mutation, not per * component instance); per-entity mutations are one `mutation` per key — e.g. * constructed inside an `atomFamily`-style lookup. * * Defects are never swallowed: a mutation whose effect *dies* re-throws the * defect on a microtask (it does not land in the state's `error`, which is for * typed failures only); an interruption is silent. * * This module is React-free — a service holding a mutation stays server-safe * and unit-testable with no renderer. */ import * as Cause from 'effect/Cause'; import * as Effect from 'effect/Effect'; import * as Exit from 'effect/Exit'; import * as Option from 'effect/Option'; import type { ReadableAtom } from '#domain/protocol.ts'; import { QueryCache } from '#infrastructure/query-cache.ts'; import { atom } from '#infrastructure/reactivity-core.ts'; /** The observable state of a mutation — what `yield* m.state` reads. */ export interface MutationState { readonly status: 'idle' | 'pending' | 'success' | 'error'; /** The typed failure of the last run, if it failed. */ readonly error: E | undefined; /** The value of the last successful run. */ readonly data: A | undefined; } /** How a mutation is configured beyond its effect. */ export interface MutationOptions { /** * Apply an optimistic change immediately (before the effect settles) and * return a rollback thunk that restores the prior state. effract calls the * rollback if the effect fails, so the pre-optimistic state is restored * exactly. */ readonly optimistic?: (input: Input) => () => void; /** Run on success, with the value and the input (e.g. to reconcile local state). */ readonly onSuccess?: (value: A, input: Input) => void; /** Query tags to invalidate on success — matching queries reload. */ readonly invalidates?: ReadonlyArray; } /** * A mutation handle: `run` fires it, `state` is a reactive atom a REC reads * with `yield*` (re-rendering on every transition), `reset` returns to idle. * `state` is read-only to consumers — only the mutation itself writes it. */ export interface Mutation { readonly run: (input: Input) => void; readonly state: ReadableAtom, never, L>; readonly reset: () => void; } const IDLE = { status: 'idle', error: undefined, data: undefined } as const; /** * Construct a {@link Mutation} — label first, like `atom(label, initial)`; the * state atom is named `` `${label}.state` `` (what `RendersOn`, the devtools * trace, and the render ledger report). The effect's requirements `R` are * captured from the constructing context (the service's own `make`), and the * {@link QueryCache} requirement is provided automatically by `mount`. */ export const mutation = ( label: L, run: (input: Input) => Effect.Effect, options?: MutationOptions, ): Effect.Effect, never, QueryCache | R> => Effect.gen(function* () { const cache = yield* QueryCache; // Capture the constructing context: it satisfies the effect's `R` when the // mutation later runs from an event handler, outside any Effect program. const context = yield* Effect.context(); const runExit = Effect.runPromiseExitWith(context); const state = atom(`${label}.state`, IDLE as MutationState); return { run: (input: Input): void => { state.set({ status: 'pending', error: undefined, data: undefined }); const rollback = options?.optimistic?.(input); void runExit(run(input)).then((exit) => { if (Exit.isSuccess(exit)) { state.set({ status: 'success', error: undefined, data: exit.value }); options?.onSuccess?.(exit.value, input); for (const tag of options?.invalidates ?? []) { cache.invalidate(tag); } return; } // Failed: undo the optimistic change so the pre-optimistic state is restored. rollback?.(); const failure = Cause.findErrorOption(exit.cause); if (Option.isSome(failure)) { state.set({ status: 'error', error: failure.value, data: undefined }); return; } // No typed failure → a defect (or interruption). Defects are never // swallowed: re-throw so it reaches the app's error reporting. state.set({ status: 'error', error: undefined, data: undefined }); if (!Cause.hasInterruptsOnly(exit.cause)) { const squashed = Cause.squash(exit.cause); queueMicrotask(() => { throw squashed; }); } }); }, state, reset: () => state.set(IDLE as MutationState), }; });