UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

627 lines (566 loc) 21.3 kB
import type { State as AlephaState } from "../Alepha.ts"; import { AlephaError } from "../errors/AlephaError.ts"; import type { LoggerInterface } from "../interfaces/LoggerInterface.ts"; import { Atom, type AtomStatic, type TAtomObject, } from "../primitives/$atom.ts"; import { type AnyDep, Computed } from "../primitives/$computed.ts"; import { $inject } from "../primitives/$inject.ts"; import { AlsProvider, type StateScope } from "./AlsProvider.ts"; import { EventManager } from "./EventManager.ts"; import { JsonSchemaCodec } from "./JsonSchemaCodec.ts"; import { SchemaValidator } from "./SchemaValidator.ts"; import type { Static, TObject } from "./TypeProvider.ts"; export interface AtomWithValue { atom: Atom; value: unknown; } export class StateManager<State extends object = AlephaState> { protected readonly als = $inject(AlsProvider); protected readonly events = $inject(EventManager); protected readonly codec = $inject(JsonSchemaCodec); protected readonly validator = $inject(SchemaValidator); protected readonly atoms = new Map<keyof State, Atom>(); protected store: Partial<State> = {}; constructor(store: Partial<State> = {}) { this.store = store; } /** * Export all registered atoms as a plain object. * * @param scope - Controls which store layer to read from: * - `undefined` (default): Resolved value (walks fork tree then app store). * - `"current"`: Only the current ALS layer — intentionally does NOT walk * the parent chain so that SSR serialisation captures exactly what the * current request set, without leaking parent-fork state. * - `"app"`: Only the root (app-level) store. */ public exportAtoms(scope?: "current" | "app"): Record<string, unknown> { const exported: Record<string, unknown> = {}; if (scope === "current") { if (this.als?.exists()) { for (const atom of this.atoms.values()) { if (atom.options.serverOnly) { continue; } const value = this.als.get(atom.key, "current"); if (value !== undefined) { exported[atom.key as string] = value; } } } return exported; } for (const [key, atom] of this.atoms.entries()) { if (atom.options.serverOnly) { continue; } const value = this.get(atom, scope); if (value !== undefined) { exported[key as string] = value; } } return exported; } public getAtoms(context = true): Array<AtomWithValue> { const atoms: Array<AtomWithValue> = []; if (context && this.als?.exists()) { // for each this.atoms, check if key is present in als, if yes, add to new map for (const atom of this.atoms.values()) { const value = this.als.get(atom.key); if (value !== undefined) { atoms.push({ atom, value }); } } } else { for (const [key, atom] of this.atoms.entries()) { atoms.push({ atom, value: this.store[key] as unknown }); } } return atoms; } /** * Return the registered atom for a state key, if any. */ public getAtom(key: string): Atom | undefined { return this.atoms.get(key as keyof State); } /** * Every atom registered so far, whatever store layer (if any) currently * holds its value. * * This is the order-independent way to discover atoms. The * `"state:register"` event fires exactly once per key and `EventManager` * has no replay buffer, so a service instantiated after an atom registered * never hears about it — a real hazard, since `$module.register()` * registers `atoms[]` BEFORE it wires `imports[]` and injects `services[]`. * Services that act on atoms (e.g. the cookie persistence adapter) must * read this registry on demand rather than build their own map from the * event. */ public listAtoms(): Array<Atom> { return [...this.atoms.values()]; } public register(atom: Atom<any>): this { if ((atom as unknown) instanceof Computed) { throw new AlephaError( `Cannot register computed value "${(atom as unknown as Computed).key}" as an atom. ` + "Computed values are derived from their dependencies on every read — they are " + "never stored, serialized, hydrated, or persisted. Register its dependency atoms instead.", ); } const key = atom.key as keyof State; if (this.atoms.has(key)) { return this; } this.atoms.set(key, atom); // A value can land under an atom's key before the atom registers — an // SSR hydration payload, an `Alepha.create(seed)` value, or a // fork-scoped write (e.g. `$cookie` mirrors its value into the store // during `server:onRequest`, ahead of the atom's first access). `get()` // resolves ALS (request-scoped fork) layers before the app store, so // such a value must be decoded in the layer it physically lives in — // decoding only `this.store` would let a raw, unvalidated ALS value // permanently shadow the decoded default. const layer = this.als?.getLayer(key as string); if (layer) { this.decodeExisting(atom, key, layer); } // ...and, INDEPENDENTLY of that, the app-level store must always end up // holding the atom's declared default. These two concerns are not // exclusive: an atom whose first registration happens inside a fork that // already carries a value for its key (exactly what a cookie-seeded // request produces) would otherwise leave the app store empty forever, // so every later, cookie-less request — which sees no fork value either // — would resolve `undefined` instead of the default. One user's // cookied request would silently define what every cookie-less user // sees. if (key in this.store) { this.decodeExisting(atom, key, this.store as Record<string, any>); } else { this.seedDefault(atom, key); } this.bindWebStorage(atom); // Fire-and-forget, but `emit`'s executor runs sync hooks synchronously // before hitting its first `await` — so a sync "state:register" handler // (e.g. AtomCookiePersistence's lazy in-request cookie seed) completes // before this call returns. If a handler on this event is ever made // `async`, that guarantee silently breaks: `register()` returns before // the seed lands, so an SSR render started right after would see the // atom's default instead of the persisted value. // // This event is a "an atom just appeared, act on it NOW" signal — it is // NOT a discovery mechanism. It fires once, is never replayed, and an // atom may well register before the interested service even exists // (`$module.register()` registers `atoms[]` first, then `imports[]`, // then `services[]`). Consumers that need the full set of atoms must // read {@link listAtoms} instead. this.events ?.emit("state:register", { atom }, { catch: true }) .catch(() => null); return this; } /** * Install the atom's declared default into the app-level store. * * Deliberately bypasses {@link set}, for two reasons: * * 1. `set()` short-circuits when the new value equals the currently * *resolved* value (`prevValue === value` for non-objects) — inside a * fork that already holds the same primitive value, the app-store write * would simply never happen. * 2. A registration seed is initialisation, NOT a mutation, so it must not * emit `state:mutate`. Persistence adapters listen on that event: an * atom registering lazily mid-request would otherwise emit a mutation * carrying its *default*, and the cookie adapter would dutifully write * it back as a `Set-Cookie` — overwriting the very cookie the request * arrived with. * * A `undefined` default (only reachable for an optional schema) is not * written at all, so the key stays absent from the store — matching the * previous behaviour of `set(key, undefined)`. */ protected seedDefault(atom: Atom<any>, key: keyof State): void { const value = this.cloneDefault(atom); if (value === undefined) { return; } (this.store as Record<string, any>)[key as string] = value; } /** * A fresh, schema-validated copy of the atom's declared default. * * `atom.options.default` is a module-level object shared by every * container and every request in the process. Handing that exact reference * to the store would let an ordinary `store.mut(atom, s => ...)` mutate the * declaration itself, permanently and process-wide. Every other write path * round-trips through the validator (zod always returns a new object), so * these must too. */ protected cloneDefault(atom: Atom<any>): unknown { if (atom.options.default === undefined) { return undefined; } return this.validator.validate(atom.schema, atom.options.default); } /** * Decode a value that already exists under an atom's key at registration * time, in place, in whichever layer it physically lives (an ALS fork * layer or the app store) — never flattening a fork-scoped value into the * app-level store. * * On schema mismatch, falls back to the atom's default (written into that * same layer) and emits a warning: a bad seed silently reverting a whole * atom to its defaults should be visible, not indistinguishable from * "nothing was ever set". */ protected decodeExisting( atom: Atom<any>, key: keyof State, layer: Record<string, any>, ): void { const current = layer[key as string]; if (current === undefined) { return; } const result = this.validator.safeValidate(atom.schema, current); if (result.success) { layer[key as string] = result.data; return; } this.logger?.warn( `Atom "${String(key)}" received an invalid seed value at registration, falling back to its default.`, { issues: result.error.issues }, ); layer[key as string] = this.cloneDefault(atom); } /** * Web-storage persistence (localStorage / sessionStorage) for atoms * declared with `persist`. Best-effort: quota errors and privacy modes * are swallowed. Cookie persistence is NOT handled here — it needs the * HTTP request cycle and lives in `alepha/server/cookies` * (AtomCookiePersistence), which discovers its atoms through * {@link listAtoms}. Both adapters are therefore registration-order * independent: one `persist` option, one reliability contract. */ protected bindWebStorage(atom: Atom<any>): void { const persist = atom.options.persist; if (persist !== "localStorage" && persist !== "sessionStorage") { return; } const storage = this.getWebStorage(persist); if (!storage) { // Server side: web storage does not exist, so an SSR'd page cannot // render the persisted value. SSR apps must use `persist: "cookie"`. this.logger?.warn( `Atom "${atom.key}" uses ${persist} persistence, which is unavailable in this environment. Use persist: "cookie" for SSR apps.`, ); return; } const key = atom.key as keyof State; try { const raw = storage.getItem(atom.key); if (raw != null) { const result = this.validator.safeValidate( atom.schema, JSON.parse(raw), ); if (result.success) { // Persisted user state wins over the default and over any seed. this.store[key] = result.data as State[keyof State]; } else { this.safeRemoveItem(storage, atom.key); } } } catch { // `getWebStorage` only guards *resolving* the Storage object (e.g. // `window.localStorage`) — in the very privacy mode its own JSDoc // claims to handle, the `getItem`/`JSON.parse` call above can throw // AND `storage.removeItem` can throw too. Route the recovery through // the same best-effort guard so persistence genuinely never escapes // `register()` (and therefore never escapes a caller's `store.get`). this.safeRemoveItem(storage, atom.key); } this.events.on("state:mutate", ({ key: mutatedKey, value }) => { if (mutatedKey !== atom.key) { return; } try { if (value === undefined) { storage.removeItem(atom.key); } else { storage.setItem(atom.key, JSON.stringify(value)); } } catch { // best-effort persistence } }); } /** * Best-effort `Storage#removeItem`. Some privacy modes throw on every * Storage method, including `removeItem` — this must never escape a * recovery `catch`, or persistence stops being best-effort. */ protected safeRemoveItem(storage: Storage, key: string): void { try { storage.removeItem(key); } catch { // truly best-effort — nothing more we can do } } /** * Resolve a Web Storage area, or undefined when unavailable (server, * privacy mode that throws on access). */ protected getWebStorage( kind: "localStorage" | "sessionStorage", ): Storage | undefined { try { if (typeof window === "undefined") { return undefined; } return kind === "localStorage" ? window.localStorage : window.sessionStorage; } catch { return undefined; } } /** * Best-effort logger lookup. `StateManager` lives in `core`, which cannot * depend on the logger module, so this reads the `"alepha.logger"` state * key directly (set by `Alepha.create()`, see `Alepha.ts`) instead of * injecting a logger service. May be `undefined` during early boot, before * the logger is wired up — callers must stay null-safe. */ protected get logger(): LoggerInterface | undefined { return this.get("alepha.logger" as keyof State) as | LoggerInterface | undefined; } /** * Get a value from the state with proper typing. * * @param scope - Optional scope to control resolution: * - `undefined` (default): Walk up the fork tree from current layer to root, then fall back to the app store. * - `"current"`: Read only from the current fork layer (no tree walking). * - `"parent"`: Read only from the immediate parent fork layer. * - `"app"`: Read only from the root (app-level) store. */ public get<R>(target: Computed<R>, scope?: StateScope): R; public get<T extends TAtomObject>( target: Atom<T>, scope?: StateScope, ): Static<T>; public get<Key extends keyof State>( target: Key, scope?: StateScope, ): State[Key] | undefined; public get(target: string | object, scope?: StateScope): any { if (target instanceof Computed) { return target.compute((dep: AnyDep) => this.get(dep as any, scope)); } if (target instanceof Atom) { this.register(target); } const key = target instanceof Atom ? target.key : target; const store = this.store as Record<string, any>; if (scope === "app") { return store[key]; } return this.als?.exists() ? (this.als.get(key as string, scope) ?? (scope ? undefined : store[key])) : store[key]; } /** * Set a value in the state */ public set<T extends TAtomObject>( target: Atom<T>, value: AtomStatic<T>, options?: SetStateOptions, ): this; public set<Key extends keyof State>( target: Key, value: State[Key] | undefined, options?: SetStateOptions, ): this; public set(target: any, value: any, options?: SetStateOptions): this { if (target instanceof Computed) { throw new AlephaError( `Cannot set computed value "${target.key}". Mutate its dependencies instead.`, ); } if (target instanceof Atom) { this.register(target); } const key = target instanceof Atom ? target.key : target; const store = this.store as Record<string, any>; const atom = target instanceof Atom ? target : this.atoms.get(key as keyof State); if (atom && value !== undefined && options?.skipValidation !== true) { value = this.validator.validate(atom.schema, value); } const prevValue = this.get(key); if (prevValue === value && typeof value !== "object") { return this; } if (options?.skipContext !== true && this.als?.exists()) { this.als.set(key as string, value); } else { store[key] = value; } if (options?.skipEvents !== true) { this.events ?.emit( "state:mutate", { key: key as keyof AlephaState, value, prevValue }, { catch: true }, ) .catch(() => null); } return this; } /** * Reset an atom back to its declared default value. */ public reset<T extends TAtomObject>(atom: Atom<T>): this { this.register(atom); return this.set(atom, atom.options.default as AtomStatic<T>); } /** * Observe mutations of an atom, a computed value, or a raw state key * outside React. Returns an unsubscribe function. * * **`Computed` overload, server-side caveat.** A computed has no stored * value, so the watcher keeps the last computed result in a single `prev` * closure variable, created once at subscription time and shared by every * invocation. Request-scoped (fork) state is not: two concurrent requests * resolve different values for the same dependency atoms. So the * `prevValue` handed to the callback is "the value this watcher computed * last time it fired", which may well have been computed inside a * *different* request's fork. `value` is always correct (it is computed * fresh, inside the mutating context); only `prevValue` can cross request * boundaries. * * That is fine for `watch`'s intended use — app-level, non-React * observation of app-level state (React subscribes through `useComputed`, * which is per-component and browser-side, where there is only ever one * "request"). Do not build per-request logic on a computed's `prevValue` * on the server. */ public watch<T extends TAtomObject>( target: Atom<T>, callback: (value: Static<T>, prevValue: Static<T> | undefined) => void, ): () => void; public watch<R>( target: Computed<R>, callback: (value: R, prevValue: R | undefined) => void, ): () => void; public watch<Key extends keyof State>( target: Key, callback: ( value: State[Key] | undefined, prevValue: State[Key] | undefined, ) => void, ): () => void; public watch( target: any, callback: (value: any, prevValue: any) => void, ): () => void { if (target instanceof Computed) { const keys = new Set(target.keys()); let prev = this.get(target); return this.events.on("state:mutate", (ev) => { if (!keys.has(ev.key as string)) { return; } const next = this.get(target); const last = prev; prev = next; callback(next, last); }); } if (target instanceof Atom) { this.register(target); } const key = target instanceof Atom ? target.key : target; return this.events.on("state:mutate", (ev) => { if (ev.key === key) { callback(ev.value, ev.prevValue); } }); } /** * Mutate a value in the state. */ public mut<T extends TObject>( target: Atom<T>, mutator: (current: Static<T>) => Static<T>, ): this; public mut<Key extends keyof State>( target: Key, mutator: (current: State[Key] | undefined) => State[Key] | undefined, ): this; public mut(target: any, mutator: (current: any) => any): this { const current = this.get(target); const updated = mutator(current); return this.set(target, updated); } /** * Check if a key exists in the state. * Walks the ALS fork tree when inside a fork context. */ public has<Key extends keyof State>(key: Key): boolean { if (this.als?.has(key as string)) { return true; } return key in this.store; } /** * Delete a key from the state (set to undefined) */ public del<Key extends keyof State>(key: Key): this { return this.set(key, undefined); } /** * Push a value to an array in the state */ public push<Key extends keyof OnlyArray<State>>( key: Key, ...value: Array<NonNullable<State[Key]> extends Array<infer U> ? U : never> ): this { const current = (this.get(key) ?? []) as Array<any>; // default to empty array if (Array.isArray(current)) { this.set(key, [...current, ...value] as State[Key]); } return this; } /** * Clear all state */ public clear(): this { this.store = {}; return this; } /** * Get all keys that exist in the state */ public keys(): (keyof State)[] { return Object.keys(this.store) as (keyof State)[]; } } type OnlyArray<T extends object> = { [K in keyof T]: NonNullable<T[K]> extends Array<any> ? K : never; }; export interface SetStateOptions { skipContext?: boolean; skipEvents?: boolean; /** * Skip schema validation for this write. Internal escape hatch for * callers that have already validated the value (e.g. hydration). */ skipValidation?: boolean; }