import type { Logger } from "pino"; /** Callback for state change listeners. */ export type StateChangeHandler = (key: string, newValue: T | undefined, oldValue: T | undefined) => void; /** * Sigil prefix for the reserved internal state namespace (design.md D20). * * Automation-scoped keys are `:`, and automation names * derive from kebab-case filenames — they can never begin with `$`. A public * caller can therefore never produce a key inside this namespace, which is * what makes the reservation enforceable rather than merely conventional. * * Internal consumers (room definitions, automation enabled flags) write * through {@link StateManager.setInternal} / {@link StateManager.deleteInternal}, * which bypass the rejection that {@link StateManager.set} and * {@link StateManager.delete} apply to every other caller. */ export declare const INTERNAL_STATE_PREFIX = "$internal:"; /** Returns true when `key` falls inside the reserved internal namespace. */ export declare function isReservedStateKey(key: string): boolean; /** * Options for the StateManager. */ export interface StateManagerOptions { /** * Whether to persist state to a JSON file on shutdown and * restore it on startup. * * @default true */ persist?: boolean; /** * Path to the JSON file for state persistence. * Only used when `persist` is true. * * @default "./state.json" */ filePath?: string; /** * Milliseconds to wait after a mutation before flushing a coalesced save to * disk. Multiple writes within one window produce a single save. `0` saves * on every mutation. Only used when `persist` is true. * * An abrupt process kill loses mutations from the current window — a * bounded trade-off against writing through on every `set()`, which is not * viable given `save()` rewrites and fsyncs the whole store (design.md D6, * R3). * * @default 1000 */ flushIntervalMs?: number; } /** * Generic state manager for sharing and persisting state across automations. * * Provides typed get/set/delete operations on an in-memory key-value store. * Supports change listeners that fire when a key is set or deleted, enabling * the `state` trigger type in automations. * * Optionally persists state to a JSON file on shutdown and restores it on * startup. * * @example * ```ts * // In an automation: * this.state.set("night_mode", true); * const isNight = this.state.get("night_mode"); * * // Typed with a default: * const count = this.state.get("motion_count", 0); * this.state.set("motion_count", count + 1); * * // Delete: * this.state.delete("temporary_flag"); * ``` */ export declare class StateManager { private readonly logger; private readonly store; private readonly listeners; /** Wildcard listeners that fire on any key change. */ private readonly globalListeners; private readonly persist; private readonly filePath; private readonly flushIntervalMs; /** Pending coalesced-save timer, or `null` when no save is scheduled. */ private flushTimer; /** * Chains every `save()` call (scheduled or explicit) so overlapping calls * never race on the same file — a scheduled flush and an explicit call from * graceful shutdown can otherwise land inside `atomicWrite()` concurrently. */ private saveChain; constructor(logger: Logger, options?: StateManagerOptions); /** * Get a value from the state store. * * @param key The state key * @param defaultValue Value to return if the key doesn't exist * @returns The stored value cast to T, or the default */ get(key: string, defaultValue?: T): T | undefined; /** * Set a value in the state store. * * Fires change listeners if the value actually changed, and schedules a * coalesced save when persistence is enabled. * * @param key The state key * @param value The value to store * @throws {Error} if `key` falls inside the reserved internal namespace * (design.md D20). Use {@link setInternal} for room and automation * enabled-flag writes. */ set(key: string, value: T): void; /** * Writes a key under the reserved internal namespace, bypassing the * rejection {@link set} applies to public callers. Used only by the room * and automation enabled-flag writers (design.md D20). * * Everything else about the write is unchanged — it is persisted, * coalesced by the flush interval, and delivered to change listeners like * any other key. * * @throws {Error} if `key` does not fall inside the reserved namespace — * this path exists for internal keys only. */ setInternal(key: string, value: T): void; private writeValue; /** * Delete a key from the state store. * * Fires change listeners if the key existed, and schedules a coalesced * save when persistence is enabled. * * @param key The state key * @returns true if the key existed and was deleted * @throws {Error} if `key` falls inside the reserved internal namespace. * Use {@link deleteInternal} for room and automation enabled-flag deletes. */ delete(key: string): boolean; /** * Deletes a key under the reserved internal namespace, bypassing the * rejection {@link delete} applies to public callers (design.md D20). * * @throws {Error} if `key` does not fall inside the reserved namespace. */ deleteInternal(key: string): boolean; private deleteValue; /** * Check if a key exists in the state store. */ has(key: string): boolean; /** * Get all keys in the state store, excluding reserved internal keys * (design.md D20). Every surface that lists or counts keys derives from * this method, so a count alongside a listing can never disagree with it. */ keys(): string[]; /** * Returns reserved internal keys starting with `prefix`, for an internal * consumer that owns a slice of the reserved namespace and needs to * enumerate it — for example the automation-enabled reaper discarding * preferences for automations no longer discovered (design.md D20, D30). * * The reserved namespace is otherwise unenumerable through the public API * by design; this exists for internal consumers only. * * @throws {Error} if `prefix` does not fall inside the reserved namespace. */ keysInternal(prefix: string): string[]; /** * Register a listener for changes to a specific key. * * @param key The state key to watch * @param handler Callback fired with (key, newValue, oldValue) */ /** Threshold for warning about potential listener leaks. */ private static readonly LISTENER_WARN_THRESHOLD; onChange(key: string, handler: StateChangeHandler): void; /** * Remove a listener for a specific key. */ offChange(key: string, handler: StateChangeHandler): void; /** * Register a listener for changes to any key. * * @param handler Callback fired with (key, newValue, oldValue) */ onAnyChange(handler: StateChangeHandler): void; /** * Remove a global change listener. */ offAnyChange(handler: StateChangeHandler): void; /** * Load persisted state from disk (if persistence is enabled). * Called by the engine on startup. */ load(): Promise; /** * Attempt to restore state from the backup file (`filePath` + `.bak`). * On failure the in-memory store is left empty and an error is logged. */ private recoverFromBackup; /** Load parsed key-value pairs into the store and log success. */ private restore; /** * Save current state to disk (if persistence is enabled). * * Concurrent calls — a scheduled coalesced flush racing an explicit call * from graceful shutdown — are serialized onto one chain so they never * open the same temp file at once. A failed save is logged and does not * prevent a later save from running (design.md R3; task 2.5). */ save(): Promise; private performSave; /** * Schedules a coalesced save after a mutation, when persistence is * enabled. Multiple writes inside one `flushIntervalMs` window produce a * single save — the first write in an idle period starts the timer, and * later writes ride along until it fires rather than restarting it, so a * sustained stream of writes still flushes at a bounded interval rather * than being deferred indefinitely (design.md D6). * * `flushIntervalMs: 0` saves on every mutation instead of scheduling. */ private scheduleFlush; /** * Cancels any pending coalesced save and performs one immediately. * * Called by the engine on graceful shutdown so mutations made inside the * current debounce window are not lost (design.md D6; task 2.3). */ flush(): Promise; /** * Durably write `contents` to `filePath` atomically. * * Writes to a temp file, `fsync`s it to disk, preserves any existing file as * a `.bak` backup (best-effort), then atomically renames the temp file over * `filePath`. An interrupted write can never leave `filePath` truncated. */ private atomicWrite; private notifyListeners; /** * Simple equality check. Uses JSON.stringify for objects/arrays, * strict equality for primitives. */ private isEqual; } //# sourceMappingURL=state-manager.d.ts.map