src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/state.ts
blob: c5621fe373c47d855b0655bf374b806ccdbd76bc (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
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;
}