src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/state.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/state.ts')
-rw-r--r--src/state.ts90
1 files changed, 90 insertions, 0 deletions
diff --git a/src/state.ts b/src/state.ts
new file mode 100644
index 0000000..c5621fe
--- /dev/null
+++ b/src/state.ts
@@ -0,0 +1,90 @@
+import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+
+/**
+ * Small service-state file kept next to the Matter fabric storage, with one
+ * entry per commissioned node (keyed by node ID as a decimal string). Written
+ * atomically (temp file + rename) so a power loss mid-write never corrupts it.
+ */
+export interface NodeState {
+ lastSuccessfulConnection: string | null;
+ lastSuccessfulSync: string | null;
+ lastAttemptedSync: string | null;
+ lastError: string | null;
+}
+
+export interface ServiceState {
+ nodes: Record<string, NodeState>;
+}
+
+export const EMPTY_NODE_STATE: NodeState = {
+ lastSuccessfulConnection: null,
+ lastSuccessfulSync: null,
+ lastAttemptedSync: null,
+ lastError: null,
+};
+
+export function serviceStatePath(storagePath: string): string {
+ return join(storagePath, "service-state.json");
+}
+
+export function readServiceState(path: string): ServiceState {
+ let raw: string;
+ try {
+ raw = readFileSync(path, "utf8");
+ } catch {
+ return { nodes: {} };
+ }
+ try {
+ const parsed = JSON.parse(raw) as Partial<ServiceState>;
+ const nodes: Record<string, NodeState> = {};
+ if (typeof parsed.nodes === "object" && parsed.nodes !== null) {
+ for (const [nodeId, value] of Object.entries(parsed.nodes)) {
+ if (!/^\d+$/.test(nodeId) || typeof value !== "object" || value === null) continue;
+ const entry = value as Partial<NodeState>;
+ nodes[nodeId] = {
+ lastSuccessfulConnection: stringOrNull(entry.lastSuccessfulConnection),
+ lastSuccessfulSync: stringOrNull(entry.lastSuccessfulSync),
+ lastAttemptedSync: stringOrNull(entry.lastAttemptedSync),
+ lastError: stringOrNull(entry.lastError),
+ };
+ }
+ }
+ return { nodes };
+ } catch {
+ // A corrupt state file is not fatal; it only holds status metadata.
+ return { nodes: {} };
+ }
+}
+
+export function writeServiceState(path: string, state: ServiceState): void {
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
+ const temp = `${path}.tmp`;
+ writeFileSync(temp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
+ renameSync(temp, path);
+}
+
+export function nodeState(state: ServiceState, nodeId: bigint): NodeState {
+ return state.nodes[nodeId.toString()] ?? { ...EMPTY_NODE_STATE };
+}
+
+/** Merges a patch into one node's entry and persists the result. */
+export function updateNodeState(path: string, nodeId: bigint, patch: Partial<NodeState>): ServiceState {
+ const state = readServiceState(path);
+ const key = nodeId.toString();
+ state.nodes[key] = { ...(state.nodes[key] ?? EMPTY_NODE_STATE), ...patch };
+ writeServiceState(path, state);
+ return state;
+}
+
+/** Drops one node's entry (after decommissioning) and persists the result. */
+export function removeNodeState(path: string, nodeId: bigint): ServiceState {
+ const state = readServiceState(path);
+ delete state.nodes[nodeId.toString()];
+ writeServiceState(path, state);
+ return state;
+}
+
+function stringOrNull(value: unknown): string | null {
+ return typeof value === "string" ? value : null;
+}