/** * 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 guarantees 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 * property that makes "the 120-second refresh is invisible when nothing drifted" hold. * * @module ProtectReducer */ import type { DeepPartial, ProtectCameraConfig, ProtectChimeConfig, ProtectFobConfig, ProtectLightConfig, ProtectNvrBootstrap, ProtectNvrConfig, ProtectNvrLiveviewConfig, ProtectNvrUserConfig, ProtectRelayConfig, ProtectRingtoneConfig, ProtectSensorConfig, ProtectStateRecord, ProtectViewerConfig } from "../types/index.ts"; import type { StateModelKey, 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 guarantee 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; }>; /** * The state fields the realtime reducer path writes via mapFieldFor - the maps backed by a StateModelKey. The NVR is excluded (a singleton field, not a map); `ringtones` * is excluded because it is reduced bootstrap-only (no `ringtone` realtime packet), so applyBootstrap writes it directly rather than through withDeviceMap. The user and * liveview rosters are included alongside the devices, as both are realtime-addressed StateModelKeys. Exported so a downward consumer (the store's adoption scan) can * name the field it looks a pre-fold record up in, rather than re-deriving the mapping. */ export type DeviceMapField = "cameras" | "chimes" | "fobs" | "lights" | "liveviews" | "relays" | "sensors" | "users" | "viewers"; /** * The reduced model keys whose state is stored as a map (every StateModelKey except "nvr", which is the singleton). It lets mapFieldFor return a non-optional * DeviceMapField, so the impossible "model key with no map" case is unrepresentable rather than defended against at runtime. Exported alongside mapFieldFor and * MAP_BACKED_STATE_MODEL_KEYS as the single source of "which model keys are map-backed collections", shared by the store's adoption scan. */ export type MapBackedStateModelKey = Exclude; export declare const MAP_BACKED_STATE_MODEL_KEYS: readonly MapBackedStateModelKey[]; /** * 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 (`accessEvent`, `authDetected`, `buttonPressed`, `doorbellRing`, `motionDetected`, `smartDetect`, * `tamperDetected`) 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 guarantee 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; /** * Map a map-backed reduced model key to its backing state field. Total: every value of MapBackedStateModelKey enumerates here, and the exhaustiveness guard on the * switch's default arm fails to compile if a new map-backed category is added without updating this mapping. The NVR singleton is excluded from the input type by * construction, so the function returns a non-optional DeviceMapField - no defensive null-handling at the call sites. Exported so the store's adoption scan resolves * a model key to its pre-fold state field through this one mapping rather than a copy of it. * * @param modelKey - The map-backed reduced model key to resolve. * * @returns The state field that backs `modelKey`. * * @category Reducer */ export declare function mapFieldFor(modelKey: MapBackedStateModelKey): DeviceMapField; /** * The effective adoption fields the self-contradiction detector reads: `isAdopted`, `isAdoptedByOther`, and `nvrMac`, each resolved from the incoming record-or-patch * when it carries a value of the right runtime type, else from the stored record. This is the one place the loosely-typed adoption fields are narrowed at runtime, since * they widen when read off a partial patch under strict typing, so this reducer's normalization and the store's diagnostics scan can never disagree on what a merged * record's adoption fields would be. * * @category Reducer */ export interface AdoptionView { isAdopted: boolean | undefined; isAdoptedByOther: boolean | undefined; nvrMac: string | undefined; } /** * Build the merged {@link AdoptionView} for an optional stored record and the incoming record-or-patch. Consumed by both this reducer's normalization and the store's * adoption scan, so the two share one definition of a field's effective value. * * @param stored - The record already in state for this id, or undefined when there is none (a first add, or a bootstrap element). * @param incoming - The full record (add or bootstrap) or partial patch (update) arriving from the wire. * * @returns The merged adoption view. * * @category Reducer */ export declare function adoptionView(stored: ProtectStateRecord | undefined, incoming: ProtectStateRecord | DeepPartial): AdoptionView; /** * Whether an adoption view is the provably-false self-contradiction this normalization refuses: the device is adopted, claims to be adopted by another controller, and * names a non-empty owning-controller MAC that equals this controller's own non-empty MAC. "Another controller owns me" and "this controller owns me" cannot both hold, * so the record is nonsense the model declines to honor. Anything less - not adopted, not flagged, a missing or empty MAC on either side, or two different MACs (a real * foreign adoption) - is not the contradiction and returns false. * * @param view - The merged adoption view. * @param ownMac - This controller's own MAC (from the incoming bootstrap during a bootstrap, else the stored NVR record), or undefined before the first bootstrap. * * @returns `true` only for the self-contradictory shape. * * @category Reducer */ export declare function isAdoptionContradiction(view: AdoptionView, ownMac: string | undefined): boolean; /** * The one runtime-narrowed read of the `isAdoptedByOther` flag an incoming record-or-patch carries on its own: the boolean the wire explicitly asserts, or undefined when * the payload is silent on adoption. The merged {@link adoptionView} reads through it, and the store's scan reads it directly to tell an explicit adoption assertion * (which drives an engaged-set transition) from a patch that says nothing about adoption (which must leave the set steady). * * @param incoming - The full record or partial patch arriving from the wire. * * @returns The explicitly asserted flag, or undefined when absent. * * @category Reducer */ export declare function adoptedByOtherAssertion(incoming: ProtectStateRecord | DeepPartial): boolean | undefined; //# sourceMappingURL=reducer.d.ts.map