/** * The pure reducer that models a Protect controller's state as a fold over a typed packet stream. * * This module is Layer 1 and the conceptual center of the library: {@link reduce} is `(state, event) -> state`, where `event` is a {@link TypedEvent} from the * classifier and `state` is an immutable {@link ProtectState}. Bootstrap is not a thing the library fetches and holds - it is the initial state the reducer starts * from (via the synthetic `bootstrapLoaded` event), then continuously advances. Periodic re-bootstrap is a permanent failsafe against the controller's occasionally * lossy event delivery, reconciled here so that a refresh which finds no drift produces no observable change at all. * * Three invariants make the observation layer above this cheap and correct: * * - **Immutability.** No function here mutates its input. Every step returns either the same reference (nothing changed) or a new one (something did). * - **Structural sharing.** A change rebuilds only the path from the root to the changed node; every untouched device, map, and nested object keeps its existing * reference. This is what makes `Object.is`-based change detection in selectors and observers both correct and nearly free. * - **No-op fidelity.** A patch, upsert, removal, or full re-bootstrap that changes nothing by *value* returns the same reference by *identity*. This is the * load-bearing property for "the 120-second refresh is invisible when nothing drifted." * * @module ProtectReducer */ import type { DeepPartial, ProtectCameraConfig, ProtectChimeConfig, ProtectFobConfig, ProtectLightConfig, ProtectNvrBootstrap, ProtectNvrConfig, ProtectNvrLiveviewConfig, ProtectNvrUserConfig, ProtectRelayConfig, ProtectRingtoneConfig, ProtectSensorConfig, ProtectViewerConfig } from "../types/index.ts"; import type { TypedEvent } from "./events.ts"; /** * The immutable snapshot of a controller's modeled state. Devices are normalized into `id -> config` maps for O(1) lookup; selectors derive arrays on demand and * memoize on map identity. `bootstrapId` is a monotonic counter incremented on every applied bootstrap - internal bookkeeping that guarantees a bootstrap always mints a * fresh state reference (the store's dispatch gate relies on this), surfaced for diagnostics but read by no device observer. * * There is deliberately no `lastEventAt` here. Channel liveness is intrinsic to the realtime channel, so the events-WebSocket watchdog reads its timestamp from the * `EventStream` (which clock-stamps every message), not from this state: a pure reducer cannot write a wall-clock field without minting a new state reference on every * packet, which would destroy the no-op/structural-sharing invariant the observation layer depends on. * * Beyond devices, the state carries session identity and the controller's read-only collections, all lifted from the bootstrap: `authUserId` (the authenticated session's * user id, a singleton like `nvr`), `users` (the controller's roster), `liveviews` (the saved camera layouts), and `ringtones` (the chime ringtone library), each * normalized like the device collections. These are real controller state, not connect-time constants - a user's permissions change, liveviews are edited, ringtones are * uploaded - so they must be reducer-derived and observable as they change. * * Two of those collections advance differently, and the distinction is deliberate. `user` and `liveview` are `StateModelKey`s, so they advance in realtime through the * generic upsert/patch/remove paths as a device (a change reflects within event latency), with the periodic bootstrap as the failsafe. `ringtones`, by contrast, is * reduced *bootstrap-only*: the controller broadcasts no `ringtone` realtime packet, so this map is written exclusively by `applyBootstrap` (never by `mapFieldFor`) and * is intentionally absent from `DeviceMapField`. That is the honest encoding of "reduced and observable, but not realtime-addressed" - see the two-axis note on * {@link StateModelKey}. */ export type ProtectState = Readonly<{ authUserId: string | null; bootstrapId: number; cameras: ReadonlyMap; chimes: ReadonlyMap; fobs: ReadonlyMap; lights: ReadonlyMap; liveviews: ReadonlyMap; nvr: ProtectNvrConfig | null; relays: ReadonlyMap; ringtones: ReadonlyMap; sensors: ReadonlyMap; users: ReadonlyMap; viewers: ReadonlyMap; }>; /** * Build the empty starting state. The reducer begins here and advances via `bootstrapLoaded`; every map is fresh, the NVR and `authUserId` are null, and the lone * counter `bootstrapId` is zero. * * @returns A new, empty {@link ProtectState}. * * @category Reducer */ export declare function createInitialState(): ProtectState; /** * Advance the state by one typed event. Pure: returns a new state when the event changes something, or the *same reference* when it does not. * * State transitions advance the model: `bootstrapLoaded` reconciles against a fresh bootstrap, `deviceAdded` upserts a full record, `devicePatched` deep-merges a * partial, `deviceRemoved` deletes. Activity signals (`motionDetected`, `smartDetect`, `tamperDetected`, `authDetected`, `doorbellRing`, `accessEvent`) * are occurrences rather than state - they reach consumers through the firehose, and the reducer returns the state unchanged for them. * * @param state - The current state. * @param event - The typed event to apply. * * @returns The next state, identical by reference to `state` when nothing changed. * * @throws {@link ProtectProtocolError} only if an unrecognized event kind reaches the reducer - a defect that the exhaustiveness guard converts from a silent * fall-through into a loud failure. * * @category Reducer */ export declare function reduce(state: ProtectState, event: TypedEvent): ProtectState; /** * Recursively merge a partial patch onto a device record. Objects merge by key; arrays and primitives replace wholesale; an explicit `null` clears a field. The * merge is value-aware: it returns the *same reference* when the patch changes nothing, and rebuilds only the nodes on the path to an actual change (every untouched * nested object keeps its reference). This is the structural-sharing engine the no-op fidelity invariant depends on. * * @param device - The current device record. * @param patch - The partial update to merge onto it. * * @returns The merged record, identical by reference to `device` when the patch changed nothing. * * @category Reducer */ export declare function applyDevicePatch(device: T, patch: DeepPartial): T; /** * Atomically reconcile the state against a fresh bootstrap. Each device present in the bootstrap is matched against the current state: an unchanged device keeps its * existing reference (so selectors and observers see no change), a changed device takes the new reference, and a device absent from the bootstrap is dropped. A map * with no net change keeps its own reference, so a refresh that finds zero drift produces zero observer notifications. `bootstrapId` always increments - it counts * applied bootstraps and guarantees each mints a fresh state reference for the store's dispatch gate, internal bookkeeping no device observer reads. * * @param state - The current state. * @param bootstrap - The freshly fetched bootstrap document. * * @returns The reconciled state. * * @category Reducer */ export declare function applyBootstrap(state: ProtectState, bootstrap: ProtectNvrBootstrap): ProtectState; //# sourceMappingURL=reducer.d.ts.map