@camstack/system 1.2.446 → 1.2.448

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/assets/device-history/icon.svg +4 -0
  2. package/dist/{agent-https-5DYAOJMO.js → agent-https-CMz4S56_.js} +2 -2
  3. package/dist/{agent-https-BWUEf6ev.mjs → agent-https-D6lRxHma.mjs} +2 -2
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  5. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  7. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  8. package/dist/builtins/alerts/alerts.addon.js +2 -2
  9. package/dist/builtins/alerts/alerts.addon.mjs +2 -2
  10. package/dist/builtins/autotrack/index.js +1 -1
  11. package/dist/builtins/autotrack/index.mjs +1 -1
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2 -2
  13. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
  14. package/dist/builtins/calendars/calendars.addon.js +1 -1
  15. package/dist/builtins/calendars/calendars.addon.mjs +1 -1
  16. package/dist/builtins/camera-grid/index.js +1 -1
  17. package/dist/builtins/camera-grid/index.mjs +1 -1
  18. package/dist/builtins/composer/composer.addon.js +2 -2
  19. package/dist/builtins/composer/composer.addon.mjs +2 -2
  20. package/dist/builtins/console-logging/index.js +1 -1
  21. package/dist/builtins/console-logging/index.mjs +1 -1
  22. package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
  23. package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
  24. package/dist/builtins/device-history/capture/capture-engine.d.ts +87 -0
  25. package/dist/builtins/device-history/capture/capture-filter.d.ts +37 -0
  26. package/dist/builtins/device-history/capture/device-directory.d.ts +47 -0
  27. package/dist/builtins/device-history/capture/field-path.d.ts +10 -0
  28. package/dist/builtins/device-history/capture/point-writer.d.ts +63 -0
  29. package/dist/builtins/device-history/device-history.addon.d.ts +38 -0
  30. package/dist/builtins/device-history/device-history.addon.js +2823 -0
  31. package/dist/builtins/device-history/device-history.addon.mjs +2816 -0
  32. package/dist/builtins/device-history/index.d.ts +3 -0
  33. package/dist/builtins/device-history/ingest/producer-ingest.d.ts +89 -0
  34. package/dist/builtins/device-history/query/history-query.d.ts +69 -0
  35. package/dist/builtins/device-history/retention/departure-tracker.d.ts +33 -0
  36. package/dist/builtins/device-history/retention/retention.d.ts +67 -0
  37. package/dist/builtins/device-history/rollup/rollup-engine.d.ts +72 -0
  38. package/dist/builtins/device-history/rollup/rollup-math.d.ts +44 -0
  39. package/dist/builtins/device-history/runtime/history-runtime.d.ts +93 -0
  40. package/dist/builtins/device-history/status/counters.d.ts +55 -0
  41. package/dist/builtins/device-history/store/history-door.d.ts +7 -0
  42. package/dist/builtins/device-history/store/history-schema.d.ts +26 -0
  43. package/dist/builtins/device-history/store/history-store.d.ts +86 -0
  44. package/dist/builtins/device-history/store/row-readers.d.ts +11 -0
  45. package/dist/builtins/device-history/store/series-registry.d.ts +39 -0
  46. package/dist/builtins/device-history/time/bucket-time.d.ts +25 -0
  47. package/dist/builtins/device-manager/device-manager.addon.js +1 -1
  48. package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
  49. package/dist/builtins/hub-forwarder/index.js +1 -1
  50. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  51. package/dist/builtins/keychain/index.js +4 -4
  52. package/dist/builtins/keychain/index.mjs +4 -4
  53. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  54. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  55. package/dist/builtins/local-auth/local-auth.addon.js +3 -3
  56. package/dist/builtins/local-auth/local-auth.addon.mjs +3 -3
  57. package/dist/builtins/local-network/local-network.addon.js +2 -2
  58. package/dist/builtins/local-network/local-network.addon.mjs +2 -2
  59. package/dist/builtins/loki-logging/index.js +1 -1
  60. package/dist/builtins/loki-logging/index.mjs +1 -1
  61. package/dist/builtins/native-metrics/container-memory-ring.d.ts +18 -0
  62. package/dist/builtins/native-metrics/load-series-config.d.ts +38 -1
  63. package/dist/builtins/native-metrics/load-series-reader.d.ts +3 -3
  64. package/dist/builtins/native-metrics/load-series-types.d.ts +14 -0
  65. package/dist/builtins/native-metrics/native-metrics.addon.d.ts +5 -7
  66. package/dist/builtins/native-metrics/native-metrics.addon.js +220 -974
  67. package/dist/builtins/native-metrics/native-metrics.addon.mjs +220 -974
  68. package/dist/builtins/native-metrics/node-load-ring.d.ts +3 -5
  69. package/dist/builtins/native-metrics/node-metrics-history-recorder.d.ts +70 -0
  70. package/dist/builtins/platform-probe/index.js +1 -1
  71. package/dist/builtins/platform-probe/index.mjs +1 -1
  72. package/dist/builtins/record-entries/index.js +1 -1
  73. package/dist/builtins/record-entries/index.mjs +1 -1
  74. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  75. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  76. package/dist/builtins/snapshot/index.js +1 -1
  77. package/dist/builtins/snapshot/index.mjs +1 -1
  78. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  79. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  80. package/dist/builtins/sqlite-storage/index.js +1 -1
  81. package/dist/builtins/sqlite-storage/index.mjs +1 -1
  82. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +2 -2
  83. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +2 -2
  84. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  85. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  86. package/dist/builtins/system-config/system-config.addon.js +3 -3
  87. package/dist/builtins/system-config/system-config.addon.mjs +3 -3
  88. package/dist/builtins/winston-logging/index.js +1 -1
  89. package/dist/builtins/winston-logging/index.mjs +1 -1
  90. package/dist/{composition-sources-BwIIltnZ.js → composition-sources-BQmhc3se.js} +1 -1
  91. package/dist/{composition-sources-BmC5jAUv.mjs → composition-sources-DzuruDVY.mjs} +1 -1
  92. package/dist/{constants-DA_tjzIk.js → constants-BPWA-r-Y.js} +1 -1
  93. package/dist/{constants-6XHGmEUF.mjs → constants-gzGZbnwO.mjs} +1 -1
  94. package/dist/{device-ca-CnbrgfmW.js → device-ca-75rt3U_U.js} +1 -1
  95. package/dist/{device-ca-Bh_ClxMO.mjs → device-ca-D05nqNXE.mjs} +1 -1
  96. package/dist/{dist-BkNfEJKa.js → dist-Ct88catN.js} +385 -1
  97. package/dist/{dist-CkZIrsAP.mjs → dist-DV2ll9Hi.mjs} +356 -2
  98. package/dist/file-host-worker.js +1 -1
  99. package/dist/file-host-worker.mjs +1 -1
  100. package/dist/{hub-mdns-ciao-CoyIUWHC.mjs → hub-mdns-ciao-BPOg2iyc.mjs} +2 -2
  101. package/dist/{hub-mdns-ciao-m5LIr4oA.js → hub-mdns-ciao-DAU1WttG.js} +2 -2
  102. package/dist/index.d.ts +1 -0
  103. package/dist/index.js +9 -7
  104. package/dist/index.mjs +9 -8
  105. package/dist/{phase6-declare-gate-BDWZuB2n.mjs → phase6-declare-gate-BJplW6tX.mjs} +1 -1
  106. package/dist/{phase6-declare-gate-Bl7KocEs.js → phase6-declare-gate-C6WqagkC.js} +1 -1
  107. package/dist/{restore-archive-B57xI5Ue.js → restore-archive-Cprdh3P9.js} +1 -1
  108. package/dist/{restore-archive-CMr4V7Fi.mjs → restore-archive-Jiz63mVB.mjs} +1 -1
  109. package/dist/{wal-maintenance-CuG1DUca.js → wal-maintenance-Bj3Z87Dx.js} +0 -0
  110. package/dist/{wal-maintenance-Bqm4zrNA.mjs → wal-maintenance-DTmqHKdi.mjs} +0 -0
  111. package/package.json +24 -1
  112. package/dist/builtins/native-metrics/container-memory-series.d.ts +0 -260
  113. package/dist/builtins/native-metrics/load-series-store.d.ts +0 -293
@@ -0,0 +1,2816 @@
1
+ import { Br as DeviceType, Gt as deviceHistoryCapability, Hr as BaseAddon, L as HISTORY_CATALOG, Zr as HISTORY_DEFAULT_MIN_INTERVAL_MS, ma as EventCategory, mt as armRemovalReconcile, qt as deviceRemovalHookCapability } from "../../dist-DV2ll9Hi.mjs";
2
+ //#region src/builtins/device-history/store/series-registry.ts
3
+ function subjectIdOf(subject) {
4
+ return subject.kind === "device" ? `d:${subject.deviceId}` : `n:${subject.nodeId}`;
5
+ }
6
+ function deviceIdOf(subject) {
7
+ return subject.kind === "device" ? subject.deviceId : null;
8
+ }
9
+ function identityOf(key, subjectId, scope) {
10
+ return `${key}\u0000${subjectId}\u0000${scope}`;
11
+ }
12
+ var LOAD_PAGE = 5e3;
13
+ var SeriesRegistry = class {
14
+ store;
15
+ metrics = /* @__PURE__ */ new Map();
16
+ byIdentity = /* @__PURE__ */ new Map();
17
+ byId = /* @__PURE__ */ new Map();
18
+ inflight = /* @__PURE__ */ new Map();
19
+ removeListeners = [];
20
+ constructor(store) {
21
+ this.store = store;
22
+ }
23
+ async load() {
24
+ this.metrics.clear();
25
+ this.byIdentity.clear();
26
+ this.byId.clear();
27
+ for (const m of await this.store.loadMetrics()) this.metrics.set(m.key, m);
28
+ let afterId = 0;
29
+ for (;;) {
30
+ const page = await this.store.loadSeriesPage(afterId, LOAD_PAGE);
31
+ for (const s of page) this.index(s);
32
+ const last = page[page.length - 1];
33
+ if (last === void 0 || page.length < LOAD_PAGE) break;
34
+ afterId = last.id;
35
+ }
36
+ }
37
+ index(series) {
38
+ this.byId.set(series.id, series);
39
+ this.byIdentity.set(identityOf(series.key, series.subject, series.scope), series);
40
+ }
41
+ metric(key) {
42
+ return this.metrics.get(key);
43
+ }
44
+ allMetrics() {
45
+ return [...this.metrics.values()];
46
+ }
47
+ /** Persist and remember a metric; idempotent. */
48
+ async declareMetric(metric) {
49
+ const existing = this.metrics.get(metric.key);
50
+ if (existing !== void 0 && sameMetric(existing, metric)) return;
51
+ await this.store.upsertMetric(metric);
52
+ this.metrics.set(metric.key, metric);
53
+ }
54
+ find(key, subjectId, scope) {
55
+ return this.byIdentity.get(identityOf(key, subjectId, scope));
56
+ }
57
+ byIdOf(id) {
58
+ return this.byId.get(id);
59
+ }
60
+ all() {
61
+ return [...this.byId.values()];
62
+ }
63
+ forSubject(subjectId) {
64
+ return this.all().filter((s) => s.subject === subjectId);
65
+ }
66
+ forKeyAndSubject(key, subjectId) {
67
+ return this.all().filter((s) => s.key === key && s.subject === subjectId);
68
+ }
69
+ /** The series, created on first sight. `ts` stamps `first_ts`/`last_ts` of a new one. */
70
+ async ensure(key, subject, scope, ts) {
71
+ const subjectId = subjectIdOf(subject);
72
+ const identity = identityOf(key, subjectId, scope);
73
+ const known = this.byIdentity.get(identity);
74
+ if (known !== void 0) return known;
75
+ const pending = this.inflight.get(identity);
76
+ if (pending !== void 0) return pending;
77
+ const created = this.create(key, subjectId, deviceIdOf(subject), scope, ts).finally(() => {
78
+ this.inflight.delete(identity);
79
+ });
80
+ this.inflight.set(identity, created);
81
+ return created;
82
+ }
83
+ async create(key, subjectId, deviceId, scope, ts) {
84
+ const stored = await this.store.findSeries(key, subjectId, scope) ?? await this.store.insertSeries({
85
+ key,
86
+ subject: subjectId,
87
+ deviceId,
88
+ scope,
89
+ ts
90
+ });
91
+ this.index(stored);
92
+ return stored;
93
+ }
94
+ /** Remember a widened span (the store write is the caller's). */
95
+ noteSpan(id, firstTs, lastTs) {
96
+ const current = this.byId.get(id);
97
+ if (current === void 0) return;
98
+ this.index({
99
+ ...current,
100
+ firstTs: Math.min(current.firstTs, firstTs),
101
+ lastTs: Math.max(current.lastTs, lastTs)
102
+ });
103
+ }
104
+ remove(id) {
105
+ const current = this.byId.get(id);
106
+ if (current === void 0) return;
107
+ this.byId.delete(id);
108
+ this.byIdentity.delete(identityOf(current.key, current.subject, current.scope));
109
+ for (const listener of this.removeListeners) listener(current);
110
+ }
111
+ /** Per-series state elsewhere (last recorded value, dirty marks, rate limits) is dropped through this. */
112
+ onRemove(listener) {
113
+ this.removeListeners.push(listener);
114
+ }
115
+ };
116
+ function sameMetric(a, b) {
117
+ return a.source === b.source && a.ownerAddon === b.ownerAddon && a.subjectKind === b.subjectKind && a.kind === b.kind && a.unit === b.unit && a.displayName === b.displayName && a.ingest === b.ingest;
118
+ }
119
+ //#endregion
120
+ //#region src/builtins/device-history/capture/capture-filter.ts
121
+ /**
122
+ * The per-point capture decision for a cap field (D850, plan §5.3). PURE.
123
+ *
124
+ * A point is written only when the value CHANGED from the last one recorded,
125
+ * by more than the field's deadband, and no sooner than its capture floor:
126
+ *
127
+ * - `unchanged` — the same value again; a change-only series stores nothing;
128
+ * - `deadband` — a change smaller than `deadband` (absolute) or
129
+ * `deadbandPct` (relative to the last recorded value); a slow
130
+ * drift is still caught, because the comparison is against
131
+ * the last RECORDED value, not the last seen one;
132
+ * - `defer` — changed, but inside `minIntervalMs` of the last recorded
133
+ * point. NOT dropped: the caller keeps it pending and emits it
134
+ * once the floor has passed, so a burst never loses its end.
135
+ */
136
+ /** Tolerance for floating-point steps such as 21.3 - 21.2 against a 0.1 deadband. */
137
+ var DEADBAND_EPSILON = 1e-9;
138
+ /** The capture floor of a field: its own `minIntervalMs`, else the kind's default. */
139
+ function minIntervalOf(spec) {
140
+ if (spec.minIntervalMs !== void 0) return spec.minIntervalMs;
141
+ return spec.kind === "boolean" ? 0 : HISTORY_DEFAULT_MIN_INTERVAL_MS;
142
+ }
143
+ function clearsDeadband(spec, last, value) {
144
+ if (spec.kind === "boolean") return true;
145
+ const delta = Math.abs(value - last);
146
+ if (spec.deadband !== void 0 && delta < spec.deadband - DEADBAND_EPSILON) return false;
147
+ if (spec.deadbandPct !== void 0) {
148
+ if (delta < Math.abs(last) * spec.deadbandPct / 100 - DEADBAND_EPSILON) return false;
149
+ }
150
+ return true;
151
+ }
152
+ function decideCapture(spec, recorded, value, now, options = {}) {
153
+ if (!Number.isFinite(value)) return {
154
+ action: "skip",
155
+ reason: "non-finite"
156
+ };
157
+ if (recorded === null) return { action: "emit" };
158
+ if (value === recorded.value) return {
159
+ action: "skip",
160
+ reason: "unchanged"
161
+ };
162
+ if (!clearsDeadband(spec, recorded.value, value)) return {
163
+ action: "skip",
164
+ reason: "deadband"
165
+ };
166
+ if (options.ignoreFloor !== true) {
167
+ const notBefore = recorded.ts + minIntervalOf(spec);
168
+ if (now < notBefore) return {
169
+ action: "defer",
170
+ notBefore
171
+ };
172
+ }
173
+ return { action: "emit" };
174
+ }
175
+ //#endregion
176
+ //#region src/builtins/device-history/capture/device-directory.ts
177
+ /**
178
+ * Which devices exist and what type they are — the "real devices only" gate
179
+ * (`hub` and `container` are not devices anyone charts) and the
180
+ * `excludeDeviceTypes` gate of a history field.
181
+ *
182
+ * Built from `deviceManager.listAll({ projection: 'slim' })` at boot and every
183
+ * reconcile, and per device (`getDevice`) the first time a state event names one
184
+ * the map does not know. A FAILED read changes nothing (D49): the previous map
185
+ * stays, and the failure is counted and logged, never read as "no devices".
186
+ */
187
+ function isRealDeviceType(type) {
188
+ return type !== DeviceType.Hub && type !== DeviceType.Container;
189
+ }
190
+ var DeviceDirectory = class {
191
+ source;
192
+ counters;
193
+ logger;
194
+ clock;
195
+ types = /* @__PURE__ */ new Map();
196
+ inflight = /* @__PURE__ */ new Map();
197
+ refreshInflight = null;
198
+ lastFullRefreshAt = null;
199
+ constructor(source, counters, logger, clock) {
200
+ this.source = source;
201
+ this.counters = counters;
202
+ this.logger = logger;
203
+ this.clock = clock;
204
+ }
205
+ typeOf(deviceId) {
206
+ return this.types.get(deviceId);
207
+ }
208
+ size() {
209
+ return this.types.size;
210
+ }
211
+ /** When the full listing last ANSWERED; `null` before the first answer. */
212
+ lastRefreshAt() {
213
+ return this.lastFullRefreshAt;
214
+ }
215
+ /** Ids in the directory now (the last full listing plus per-device lookups). */
216
+ listedIds() {
217
+ return new Set(this.types.keys());
218
+ }
219
+ knows(deviceId) {
220
+ return this.types.has(deviceId);
221
+ }
222
+ /** Replace the map from the full listing. Returns false (map untouched) when the read failed. */
223
+ refresh() {
224
+ if (this.refreshInflight !== null) return this.refreshInflight;
225
+ const run = this.doRefresh().finally(() => {
226
+ this.refreshInflight = null;
227
+ });
228
+ this.refreshInflight = run;
229
+ return run;
230
+ }
231
+ async doRefresh() {
232
+ try {
233
+ const all = await this.source.listAll();
234
+ this.types = new Map(all.map((d) => [d.id, d.type]));
235
+ this.lastFullRefreshAt = this.clock();
236
+ this.counters.directoryRefreshAt = this.lastFullRefreshAt;
237
+ return true;
238
+ } catch (err) {
239
+ this.logger.warn("device directory: listing failed — keeping the previous map", { meta: { error: err instanceof Error ? err.message : String(err) } });
240
+ return false;
241
+ }
242
+ }
243
+ /** The device's type, asking the hub once when the map does not know it. */
244
+ ensure(deviceId) {
245
+ const known = this.types.get(deviceId);
246
+ if (known !== void 0) return Promise.resolve(known);
247
+ const pending = this.inflight.get(deviceId);
248
+ if (pending !== void 0) return pending;
249
+ const run = this.lookup(deviceId).finally(() => {
250
+ this.inflight.delete(deviceId);
251
+ });
252
+ this.inflight.set(deviceId, run);
253
+ return run;
254
+ }
255
+ async lookup(deviceId) {
256
+ try {
257
+ const device = await this.source.getDevice(deviceId);
258
+ if (device === null) return void 0;
259
+ this.types.set(device.id, device.type);
260
+ return device.type;
261
+ } catch (err) {
262
+ this.logger.warn("device directory: lookup failed", {
263
+ tags: { deviceId },
264
+ meta: { error: err instanceof Error ? err.message : String(err) }
265
+ });
266
+ return;
267
+ }
268
+ }
269
+ };
270
+ //#endregion
271
+ //#region src/builtins/device-history/capture/field-path.ts
272
+ /**
273
+ * Reading a history field out of a cap slice. A catalog path is a top-level
274
+ * `runtimeState` key or a path into a FIXED object (`dustBin.level`); the
275
+ * catalog guard keeps it out of arrays and records, so a plain walk is enough.
276
+ */
277
+ function isRecord$1(value) {
278
+ return typeof value === "object" && value !== null && !Array.isArray(value);
279
+ }
280
+ /** The value at `path`, or `undefined` when any step is missing or not an object. */
281
+ function readPath(slice, path) {
282
+ let current = slice;
283
+ for (const step of path.split(".")) {
284
+ if (!isRecord$1(current)) return void 0;
285
+ current = current[step];
286
+ }
287
+ return current;
288
+ }
289
+ /**
290
+ * The number a field contributes: a finite number for a gauge/counter, 0/1 for
291
+ * a boolean field. Anything else (null, undefined, a string, NaN) is NOT a
292
+ * reading — `null` means unknown and must never become 0.
293
+ */
294
+ function readNumeric(slice, path, kind) {
295
+ const raw = readPath(slice, path);
296
+ if (kind === "boolean") {
297
+ if (typeof raw === "boolean") return raw ? 1 : 0;
298
+ return null;
299
+ }
300
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
301
+ return null;
302
+ }
303
+ /** A unit string a `unitFrom` sibling field states, or `null` when it states none. */
304
+ function readUnit(slice, unitFrom) {
305
+ const raw = readPath(slice, unitFrom);
306
+ return typeof raw === "string" && raw.length > 0 ? raw : null;
307
+ }
308
+ //#endregion
309
+ //#region src/builtins/device-history/capture/capture-engine.ts
310
+ var CAP_KEY_PREFIX = "cap:";
311
+ function capSeriesKey(capName, fieldPath) {
312
+ return `${CAP_KEY_PREFIX}${capName}.${fieldPath}`;
313
+ }
314
+ function humanize(capName, fieldPath) {
315
+ const field = fieldPath.replace(/([A-Z])/g, " $1").replace(/\./g, " ").toLowerCase();
316
+ return `${capName.replace(/-/g, " ")} ${field}`;
317
+ }
318
+ /** The metric rows of every catalog field (owner `device-history`). */
319
+ function catalogMetrics(catalog, now) {
320
+ return Object.entries(catalog).flatMap(([capName, fields]) => Object.entries(fields).map(([path, spec]) => ({
321
+ key: capSeriesKey(capName, path),
322
+ source: "cap",
323
+ ownerAddon: "device-history",
324
+ subjectKind: "device",
325
+ kind: spec.kind,
326
+ unit: spec.unit ?? null,
327
+ displayName: spec.displayName ?? humanize(capName, path),
328
+ ingest: "points",
329
+ declaredAt: now
330
+ })));
331
+ }
332
+ /** Devices waiting on a type lookup; beyond this the oldest is dropped (and counted). */
333
+ var MAX_PARKED_DEVICES = 5e3;
334
+ /** Seeding reads the newest raw row of each cap series, this many at once. */
335
+ var SEED_CONCURRENCY = 8;
336
+ var CaptureEngine = class {
337
+ deps;
338
+ recorded = /* @__PURE__ */ new Map();
339
+ pending = /* @__PURE__ */ new Map();
340
+ parked = /* @__PURE__ */ new Map();
341
+ constructor(deps) {
342
+ this.deps = deps;
343
+ }
344
+ /** Learn the last recorded value of every cap series from the store, so a restart repeats nothing. */
345
+ async seed() {
346
+ const { registry, store } = this.deps;
347
+ const capSeries = registry.all().filter((s) => s.key.startsWith(CAP_KEY_PREFIX));
348
+ for (let i = 0; i < capSeries.length; i += SEED_CONCURRENCY) await Promise.all(capSeries.slice(i, i + SEED_CONCURRENCY).map(async (series) => {
349
+ const newest = await store.rawBefore(series.id, Number.MAX_SAFE_INTEGER);
350
+ if (newest === null) return;
351
+ this.recorded.set(identityOf(series.key, series.subject, series.scope), newest);
352
+ }));
353
+ }
354
+ /** One `DeviceStateChanged`. Synchronous: the bus must never wait on us. */
355
+ handleState(deviceId, capName, slice, at) {
356
+ const fields = this.deps.catalog[capName];
357
+ if (fields === void 0) return;
358
+ const { counters, directory } = this.deps;
359
+ counters.eventsSeen += 1;
360
+ if (this.deps.optedOut().has(deviceId)) {
361
+ counters.skip("opted-out");
362
+ return;
363
+ }
364
+ const type = directory.typeOf(deviceId);
365
+ if (type === void 0) {
366
+ this.park(deviceId, capName, slice, at);
367
+ return;
368
+ }
369
+ if (!isRealDeviceType(type)) {
370
+ counters.skip("not-a-real-device");
371
+ return;
372
+ }
373
+ for (const [path, spec] of Object.entries(fields)) {
374
+ if (spec.excludeDeviceTypes?.includes(type) === true) {
375
+ counters.skip("excluded-device-type");
376
+ continue;
377
+ }
378
+ const value = readNumeric(slice, path, spec.kind);
379
+ if (value === null) continue;
380
+ this.consider(deviceId, capName, path, spec, value, at, false);
381
+ }
382
+ }
383
+ consider(deviceId, capName, path, spec, value, at, reconciling) {
384
+ const key = capSeriesKey(capName, path);
385
+ const subject = {
386
+ kind: "device",
387
+ deviceId
388
+ };
389
+ const identity = identityOf(key, `d:${deviceId}`, "");
390
+ const decision = decideCapture(spec, this.recorded.get(identity) ?? null, value, at, { ignoreFloor: reconciling });
391
+ if (decision.action === "emit") {
392
+ this.emit(identity, key, subject, value, at);
393
+ if (reconciling) this.deps.counters.reconciledPoints += 1;
394
+ return;
395
+ }
396
+ if (decision.action === "defer") {
397
+ this.pending.set(identity, {
398
+ key,
399
+ subject,
400
+ spec,
401
+ value
402
+ });
403
+ return;
404
+ }
405
+ this.pending.delete(identity);
406
+ this.deps.counters.skip(decision.reason);
407
+ }
408
+ emit(identity, key, subject, value, ts) {
409
+ this.recorded.set(identity, {
410
+ value,
411
+ ts
412
+ });
413
+ this.pending.delete(identity);
414
+ this.deps.counters.pointsEmitted += 1;
415
+ this.deps.writer.enqueue({
416
+ key,
417
+ subject,
418
+ scope: "",
419
+ ts,
420
+ value
421
+ });
422
+ }
423
+ /** Emit every pending change whose capture floor has passed, stamped where the floor ended. */
424
+ tickPending(now) {
425
+ for (const [identity, p] of this.pending) {
426
+ const recorded = this.recorded.get(identity);
427
+ const deviceId = p.subject.kind === "device" ? p.subject.deviceId : -1;
428
+ if (this.deps.optedOut().has(deviceId)) {
429
+ this.pending.delete(identity);
430
+ this.deps.counters.skip("opted-out");
431
+ continue;
432
+ }
433
+ const notBefore = (recorded?.ts ?? now) + minIntervalOf(p.spec);
434
+ if (now < notBefore) continue;
435
+ this.emit(identity, p.key, p.subject, p.value, notBefore);
436
+ this.deps.counters.trailingEmitted += 1;
437
+ }
438
+ }
439
+ park(deviceId, capName, slice, at) {
440
+ const existing = this.parked.get(deviceId);
441
+ if (existing !== void 0) {
442
+ existing.set(capName, {
443
+ capName,
444
+ slice,
445
+ at
446
+ });
447
+ return;
448
+ }
449
+ if (this.parked.size >= MAX_PARKED_DEVICES) {
450
+ const oldest = this.parked.keys().next();
451
+ if (oldest.done !== true) this.parked.delete(oldest.value);
452
+ this.deps.counters.skip("parked-overflow");
453
+ }
454
+ this.parked.set(deviceId, new Map([[capName, {
455
+ capName,
456
+ slice,
457
+ at
458
+ }]]));
459
+ this.deps.directory.ensure(deviceId).then((type) => {
460
+ const waiting = this.parked.get(deviceId);
461
+ this.parked.delete(deviceId);
462
+ if (waiting === void 0) return;
463
+ if (type === void 0) {
464
+ this.deps.counters.skip("unknown-device", waiting.size);
465
+ return;
466
+ }
467
+ for (const p of waiting.values()) this.handleState(deviceId, p.capName, p.slice, p.at);
468
+ });
469
+ }
470
+ /**
471
+ * Restore a missed change from the state mirror's whole snapshot: a field whose
472
+ * value differs from the last recorded one gets a point stamped `now`.
473
+ * Additive only — it never removes anything, and a snapshot that could not be
474
+ * read is the CALLER's failure to count (D49), not an empty snapshot.
475
+ */
476
+ reconcile(snapshots, now) {
477
+ const optedOut = this.deps.optedOut();
478
+ for (const [deviceKey, caps] of Object.entries(snapshots)) {
479
+ const deviceId = Number(deviceKey);
480
+ if (!Number.isInteger(deviceId) || optedOut.has(deviceId)) continue;
481
+ const type = this.deps.directory.typeOf(deviceId);
482
+ if (type === void 0 || !isRealDeviceType(type)) continue;
483
+ for (const [capName, fields] of Object.entries(this.deps.catalog)) {
484
+ const slice = caps[capName];
485
+ if (slice === void 0) continue;
486
+ for (const [path, spec] of Object.entries(fields)) {
487
+ if (spec.excludeDeviceTypes?.includes(type) === true) continue;
488
+ const value = readNumeric(slice, path, spec.kind);
489
+ if (value === null) continue;
490
+ this.consider(deviceId, capName, path, spec, value, now, true);
491
+ }
492
+ }
493
+ }
494
+ }
495
+ /**
496
+ * Forget what was "recorded" for points that never reached the store (dropped),
497
+ * so the next change-only comparison — and the next reconcile — starts from what
498
+ * is really there instead of leaving a hole until the value moves.
499
+ */
500
+ forget(points) {
501
+ for (const p of points) {
502
+ const identity = identityOf(p.key, subjectIdOf(p.subject), p.scope);
503
+ this.recorded.delete(identity);
504
+ this.pending.delete(identity);
505
+ }
506
+ }
507
+ /** A purged series leaves no state behind. */
508
+ forgetSeries(series) {
509
+ const identity = identityOf(series.key, series.subject, series.scope);
510
+ this.recorded.delete(identity);
511
+ this.pending.delete(identity);
512
+ }
513
+ /** For status and specs. */
514
+ pendingCount() {
515
+ return this.pending.size;
516
+ }
517
+ /** The last recorded state of a series, for specs. */
518
+ lastRecorded(key, subjectId) {
519
+ return this.recorded.get(identityOf(key, subjectId, ""));
520
+ }
521
+ };
522
+ //#endregion
523
+ //#region src/builtins/device-history/capture/point-writer.ts
524
+ var FLUSH_INTERVAL_MS = 1e4;
525
+ var MAX_BUFFER_AGE_MS = 6e4;
526
+ /** Beyond this many queued points the oldest are dropped. */
527
+ var MAX_QUEUE_POINTS = 1e5;
528
+ /** The share of the queue dropped at once when it is full. */
529
+ var QUEUE_DROP_FRACTION = .1;
530
+ /** Device ids named in a drop line, so a line stays short. */
531
+ var MAX_DEVICES_IN_DROP_LINE = 20;
532
+ /** Tagged per-device drop lines per event, and the quiet time between events that print them. */
533
+ var MAX_DEVICE_LINES_PER_DROP = 10;
534
+ var DEVICE_LINES_MIN_GAP_MS = 1e4;
535
+ var PointWriter = class {
536
+ deps;
537
+ queue = [];
538
+ flushing = null;
539
+ lastDeviceLinesAt = Number.NEGATIVE_INFINITY;
540
+ constructor(deps) {
541
+ this.deps = deps;
542
+ }
543
+ enqueue(point) {
544
+ this.queue.push({
545
+ ...point,
546
+ bufferedAt: this.deps.clock()
547
+ });
548
+ if (this.queue.length > 1e5) {
549
+ const count = Math.ceil(MAX_QUEUE_POINTS * QUEUE_DROP_FRACTION);
550
+ this.drop(this.queue.splice(0, count), "queue full");
551
+ }
552
+ }
553
+ depth() {
554
+ return this.queue.length;
555
+ }
556
+ /** One flush at a time; a call while one runs waits for it and returns. */
557
+ flush() {
558
+ if (this.flushing !== null) return this.flushing;
559
+ const run = this.doFlush().finally(() => {
560
+ this.flushing = null;
561
+ });
562
+ this.flushing = run;
563
+ return run;
564
+ }
565
+ async doFlush() {
566
+ const batch = this.queue;
567
+ if (batch.length === 0) return;
568
+ this.queue = [];
569
+ const { registry, store, counters, logger, clock } = this.deps;
570
+ try {
571
+ const bySeries = /* @__PURE__ */ new Map();
572
+ for (const point of batch) {
573
+ const series = await registry.ensure(point.key, point.subject, point.scope, point.ts);
574
+ const list = bySeries.get(series.id) ?? [];
575
+ list.push({
576
+ ts: point.ts,
577
+ value: point.value
578
+ });
579
+ bySeries.set(series.id, list);
580
+ }
581
+ const rows = [...bySeries].flatMap(([seriesId, points]) => points.map((p) => ({
582
+ seriesId,
583
+ ts: p.ts,
584
+ value: p.value
585
+ })));
586
+ await store.upsertRaw(rows);
587
+ await this.recordSpans(bySeries);
588
+ counters.pointsWritten += rows.length;
589
+ counters.addRawRows(rows.length);
590
+ counters.lastFlushAt = clock();
591
+ this.deps.onWritten([...bySeries].map(([seriesId, points]) => ({
592
+ seriesId,
593
+ timestamps: points.map((p) => p.ts)
594
+ })));
595
+ } catch (err) {
596
+ counters.flushFailures += 1;
597
+ logger.warn("device-history: raw flush failed — keeping the batch", { meta: {
598
+ points: batch.length,
599
+ error: err instanceof Error ? err.message : String(err)
600
+ } });
601
+ this.requeue(batch);
602
+ }
603
+ }
604
+ async recordSpans(bySeries) {
605
+ const { registry, store } = this.deps;
606
+ const touch = [];
607
+ for (const [seriesId, points] of bySeries) {
608
+ const current = registry.byIdOf(seriesId);
609
+ if (current === void 0) continue;
610
+ let minTs = Number.POSITIVE_INFINITY;
611
+ let maxTs = Number.NEGATIVE_INFINITY;
612
+ for (const p of points) {
613
+ if (p.ts < minTs) minTs = p.ts;
614
+ if (p.ts > maxTs) maxTs = p.ts;
615
+ }
616
+ if (minTs < current.firstTs) await store.widenSeriesStart(seriesId, minTs);
617
+ if (maxTs > current.lastTs) touch.push({
618
+ id: seriesId,
619
+ lastTs: maxTs
620
+ });
621
+ registry.noteSpan(seriesId, minTs, maxTs);
622
+ }
623
+ await store.touchSeries(touch);
624
+ }
625
+ requeue(batch) {
626
+ const now = this.deps.clock();
627
+ const keep = [];
628
+ const dropped = [];
629
+ for (const point of batch) if (now - point.bufferedAt > 6e4) dropped.push(point);
630
+ else keep.push(point);
631
+ this.queue = [...keep, ...this.queue];
632
+ if (dropped.length > 0) this.drop(dropped, `older than ${MAX_BUFFER_AGE_MS} ms`);
633
+ }
634
+ /** Account for, say, and report to the owner a set of points that will never be written. */
635
+ drop(dropped, reason) {
636
+ if (dropped.length === 0) return;
637
+ const { counters, logger, clock } = this.deps;
638
+ counters.pointsDropped += dropped.length;
639
+ const perDevice = /* @__PURE__ */ new Map();
640
+ for (const p of dropped) if (p.subject.kind === "device") perDevice.set(p.subject.deviceId, (perDevice.get(p.subject.deviceId) ?? 0) + 1);
641
+ logger.error("device-history: dropped points the store would not take", { meta: {
642
+ dropped: dropped.length,
643
+ reason,
644
+ maxAgeMs: MAX_BUFFER_AGE_MS,
645
+ deviceIds: [...perDevice.keys()].slice(0, MAX_DEVICES_IN_DROP_LINE)
646
+ } });
647
+ const now = clock();
648
+ if (now - this.lastDeviceLinesAt >= DEVICE_LINES_MIN_GAP_MS) {
649
+ this.lastDeviceLinesAt = now;
650
+ for (const [deviceId, count] of [...perDevice].slice(0, MAX_DEVICE_LINES_PER_DROP)) logger.warn("device-history: points dropped for this device", {
651
+ tags: { deviceId },
652
+ meta: {
653
+ dropped: count,
654
+ reason
655
+ }
656
+ });
657
+ }
658
+ this.deps.onDropped(dropped);
659
+ }
660
+ };
661
+ //#endregion
662
+ //#region src/builtins/device-history/time/bucket-time.ts
663
+ /**
664
+ * Bucket arithmetic for the three rollup tiers (D850, plan §4/§5.5).
665
+ *
666
+ * 5m and 1h buckets are aligned to UTC; a 1d bucket starts at HUB-LOCAL
667
+ * midnight, so a day is 23, 24 or 25 hours long across a DST change (operator
668
+ * answer Q4). Pure: the zone is the process zone (`TZ`), nothing is read from
669
+ * the environment here.
670
+ */
671
+ var FIVE_MINUTES_MS = 5 * 6e4;
672
+ var HOUR_MS = 60 * 6e4;
673
+ var DAY_MS = 24 * HOUR_MS;
674
+ function floorTo(ts, widthMs) {
675
+ return Math.floor(ts / widthMs) * widthMs;
676
+ }
677
+ /** Hub-local midnight at or before `ts`. */
678
+ function startOfLocalDay(ts) {
679
+ const d = new Date(ts);
680
+ d.setHours(0, 0, 0, 0);
681
+ return d.getTime();
682
+ }
683
+ /** The local midnight following the local midnight `dayStart`. */
684
+ function nextLocalDayStart(dayStart) {
685
+ const d = new Date(dayStart);
686
+ d.setDate(d.getDate() + 1);
687
+ d.setHours(0, 0, 0, 0);
688
+ return d.getTime();
689
+ }
690
+ /** Start of the `tier` bucket containing `ts`. */
691
+ function bucketStartOf(tier, ts) {
692
+ if (tier === "5m") return floorTo(ts, FIVE_MINUTES_MS);
693
+ if (tier === "1h") return floorTo(ts, HOUR_MS);
694
+ return startOfLocalDay(ts);
695
+ }
696
+ /** Exclusive end of the `tier` bucket that starts at `start`. */
697
+ function bucketEndOf(tier, start) {
698
+ if (tier === "5m") return start + FIVE_MINUTES_MS;
699
+ if (tier === "1h") return start + HOUR_MS;
700
+ return nextLocalDayStart(start);
701
+ }
702
+ /** Quiet time after a bucket closes before it is rolled up (late writers). */
703
+ var ROLLUP_GRACE_MS = {
704
+ "5m": 3e4,
705
+ "1h": 5 * 6e4,
706
+ "1d": 15 * 6e4
707
+ };
708
+ function addRefusal(refusals, key, reason) {
709
+ const byReason = refusals.get(key) ?? /* @__PURE__ */ new Map();
710
+ byReason.set(reason, (byReason.get(reason) ?? 0) + 1);
711
+ refusals.set(key, byReason);
712
+ }
713
+ function toResult(accepted, refusals) {
714
+ return {
715
+ accepted,
716
+ refused: [...refusals].flatMap(([key, byReason]) => [...byReason].map(([reason, count]) => ({
717
+ key,
718
+ reason,
719
+ count
720
+ })))
721
+ };
722
+ }
723
+ var ProducerIngest = class {
724
+ deps;
725
+ /** Last accepted point time per series, for the sustained-rate bound. */
726
+ lastAccepted = /* @__PURE__ */ new Map();
727
+ constructor(deps) {
728
+ this.deps = deps;
729
+ }
730
+ /** A purged series leaves no rate-limit state behind. */
731
+ forgetSeries(series) {
732
+ this.lastAccepted.delete(identityOf(series.key, series.subject, series.scope));
733
+ }
734
+ async declareSeries(callerAddonId, input) {
735
+ if (callerAddonId === null) throw new Error("declareSeries: the caller addon could not be identified");
736
+ const key = `addon:${callerAddonId}/${input.name}`;
737
+ const metric = {
738
+ key,
739
+ source: "addon",
740
+ ownerAddon: callerAddonId,
741
+ subjectKind: input.subjectKind,
742
+ kind: input.kind,
743
+ unit: input.unit ?? null,
744
+ displayName: input.displayName,
745
+ ingest: input.ingest,
746
+ declaredAt: this.deps.clock()
747
+ };
748
+ await this.deps.registry.declareMetric(metric);
749
+ return { key };
750
+ }
751
+ /** Why a write to `metric` by `caller` for this subject must be refused, or null. */
752
+ async refusalFor(callerAddonId, metric, ingest, subject) {
753
+ if (metric === void 0) return "undeclared";
754
+ if (callerAddonId === null || metric.ownerAddon !== callerAddonId) return "not-owner";
755
+ if (metric.ingest !== ingest) return "ingest-mismatch";
756
+ if (metric.subjectKind !== subject.kind) return "undeclared";
757
+ if (subject.kind === "device") {
758
+ if (this.deps.optedOut().has(subject.deviceId)) return "opted-out";
759
+ const type = await this.deps.directory.ensure(subject.deviceId);
760
+ if (type === void 0 || !isRealDeviceType(type)) return "not-a-real-device";
761
+ }
762
+ return null;
763
+ }
764
+ async record(callerAddonId, points) {
765
+ const { clock, counters } = this.deps;
766
+ const now = clock();
767
+ const { rawMs } = this.deps.retention();
768
+ const refusals = /* @__PURE__ */ new Map();
769
+ let accepted = 0;
770
+ for (const point of points) {
771
+ const reason = await this.refusalFor(callerAddonId, this.deps.registry.metric(point.key), "points", point.subject);
772
+ if (reason !== null) {
773
+ addRefusal(refusals, point.key, reason);
774
+ counters.refuse(reason);
775
+ continue;
776
+ }
777
+ if (!Number.isFinite(point.value) || !Number.isFinite(point.ts) || point.ts > now + 3e5 || point.ts < now - rawMs) {
778
+ addRefusal(refusals, point.key, "out-of-range");
779
+ counters.refuse("out-of-range");
780
+ continue;
781
+ }
782
+ const identity = identityOf(point.key, subjectIdOf(point.subject), point.scope);
783
+ const last = this.lastAccepted.get(identity);
784
+ if (last !== void 0 && point.ts >= last && point.ts - last < 1e4) {
785
+ addRefusal(refusals, point.key, "rate");
786
+ counters.refuse("rate");
787
+ continue;
788
+ }
789
+ this.lastAccepted.set(identity, Math.max(last ?? point.ts, point.ts));
790
+ this.deps.writer.enqueue({
791
+ key: point.key,
792
+ subject: point.subject,
793
+ scope: point.scope,
794
+ ts: point.ts,
795
+ value: point.value
796
+ });
797
+ accepted += 1;
798
+ }
799
+ counters.ingestAccepted += accepted;
800
+ return toResult(accepted, refusals);
801
+ }
802
+ async recordBuckets(callerAddonId, buckets) {
803
+ const { clock, counters, registry, store } = this.deps;
804
+ const now = clock();
805
+ const { fiveMinuteMs } = this.deps.retention();
806
+ const refusals = /* @__PURE__ */ new Map();
807
+ const rows = [];
808
+ for (const bucket of buckets) {
809
+ const reason = await this.refusalFor(callerAddonId, registry.metric(bucket.key), "buckets", bucket.subject);
810
+ if (reason !== null) {
811
+ addRefusal(refusals, bucket.key, reason);
812
+ counters.refuse(reason);
813
+ continue;
814
+ }
815
+ const aligned = bucket.bucketStart % FIVE_MINUTES_MS === 0;
816
+ const consistent = Number.isFinite(bucket.sum) && Number.isFinite(bucket.min) && Number.isFinite(bucket.max) && bucket.min <= bucket.max;
817
+ if (!aligned || !consistent || bucket.bucketStart > now + 3e5 || bucket.bucketStart < now - fiveMinuteMs) {
818
+ addRefusal(refusals, bucket.key, "out-of-range");
819
+ counters.refuse("out-of-range");
820
+ continue;
821
+ }
822
+ const series = await registry.ensure(bucket.key, bucket.subject, bucket.scope, bucket.bucketStart);
823
+ rows.push({
824
+ seriesId: series.id,
825
+ bucketStart: bucket.bucketStart,
826
+ samples: bucket.samples,
827
+ sum: bucket.sum,
828
+ min: bucket.min,
829
+ max: bucket.max,
830
+ last: bucket.last,
831
+ lastTs: bucket.lastTs,
832
+ twSum: null,
833
+ twMs: null
834
+ });
835
+ }
836
+ await store.upsertRollups("5m", rows);
837
+ const bySeries = /* @__PURE__ */ new Map();
838
+ for (const row of rows) bySeries.set(row.seriesId, [...bySeries.get(row.seriesId) ?? [], row.bucketStart]);
839
+ for (const [seriesId, starts] of bySeries) counters.noteRollupRows("5m", seriesId, starts.toSorted((a, b) => a - b));
840
+ await this.recordSpans(rows);
841
+ for (const row of rows) this.deps.onBucketWritten(row.seriesId, row.bucketStart);
842
+ counters.ingestAccepted += rows.length;
843
+ return toResult(rows.length, refusals);
844
+ }
845
+ /** Persist the widened span of every series a bucket batch touched, then remember it. */
846
+ async recordSpans(rows) {
847
+ const { registry, store } = this.deps;
848
+ const spans = /* @__PURE__ */ new Map();
849
+ for (const row of rows) {
850
+ const end = row.lastTs ?? row.bucketStart;
851
+ const span = spans.get(row.seriesId);
852
+ spans.set(row.seriesId, {
853
+ first: span === void 0 ? row.bucketStart : Math.min(span.first, row.bucketStart),
854
+ last: span === void 0 ? end : Math.max(span.last, end)
855
+ });
856
+ }
857
+ const touch = [];
858
+ for (const [id, span] of spans) {
859
+ const current = registry.byIdOf(id);
860
+ if (current === void 0) continue;
861
+ if (span.first < current.firstTs) await store.widenSeriesStart(id, span.first);
862
+ if (span.last > current.lastTs) touch.push({
863
+ id,
864
+ lastTs: span.last
865
+ });
866
+ registry.noteSpan(id, span.first, span.last);
867
+ }
868
+ await store.touchSeries(touch);
869
+ }
870
+ };
871
+ //#endregion
872
+ //#region src/builtins/device-history/query/history-query.ts
873
+ var TIER_ORDER = [
874
+ "raw",
875
+ "5m",
876
+ "1h",
877
+ "1d"
878
+ ];
879
+ var BUCKET_MS = {
880
+ "5m": FIVE_MINUTES_MS,
881
+ "1h": HOUR_MS,
882
+ "1d": DAY_MS
883
+ };
884
+ /** A key with more scopes than this is cut (a zone x class matrix, not a chart). */
885
+ var MAX_SCOPES = 50;
886
+ function retentionMsOf(tier, policy) {
887
+ if (tier === "raw") return policy.rawDays * DAY_MS;
888
+ if (tier === "5m") return policy.fiveMinuteDays * DAY_MS;
889
+ if (tier === "1h") return policy.hourDays * DAY_MS;
890
+ return policy.dayDays === null || policy.dayDays <= 0 ? null : policy.dayDays * DAY_MS;
891
+ }
892
+ function rawPoint(ts, value) {
893
+ return {
894
+ t: ts,
895
+ samples: 1,
896
+ mean: value,
897
+ twMean: null,
898
+ min: value,
899
+ max: value,
900
+ last: value
901
+ };
902
+ }
903
+ function rollupPoint(row) {
904
+ return {
905
+ t: row.bucketStart,
906
+ samples: row.samples,
907
+ mean: row.samples > 0 ? row.sum / row.samples : null,
908
+ twMean: row.twSum !== null && row.twMs !== null && row.twMs > 0 ? row.twSum / row.twMs : null,
909
+ min: row.min,
910
+ max: row.max,
911
+ last: row.last
912
+ };
913
+ }
914
+ var HistoryQuery = class {
915
+ deps;
916
+ constructor(deps) {
917
+ this.deps = deps;
918
+ }
919
+ async query(input) {
920
+ const { registry } = this.deps;
921
+ const subjectId = subjectIdOf(input.subject);
922
+ const wanted = registry.forKeyAndSubject(input.key, subjectId).filter((s) => input.scope === void 0 || s.scope === input.scope).toSorted((a, b) => a.scope.localeCompare(b.scope)).slice(0, MAX_SCOPES);
923
+ const candidates = this.coveringTiers(input.from);
924
+ const forced = input.resolution === "auto" ? null : input.resolution;
925
+ const order = forced === null ? candidates : [forced];
926
+ let chosen = order[order.length - 1] ?? "1d";
927
+ let reads = [];
928
+ for (const [index, tier] of order.entries()) {
929
+ const last = index === order.length - 1;
930
+ if (!this.fits(tier, input, wanted) && !last) continue;
931
+ chosen = tier;
932
+ reads = await Promise.all(wanted.map(async (s) => [s, await this.readTier(tier, s, input)]));
933
+ if (reads.some(([, r]) => r.overflow) && !last) continue;
934
+ break;
935
+ }
936
+ const series = [];
937
+ for (const [s, read] of reads) series.push({
938
+ scope: s.scope,
939
+ before: await this.before(chosen, s, input.from),
940
+ points: read.points
941
+ });
942
+ return {
943
+ resolution: chosen,
944
+ series,
945
+ truncated: reads.some(([, r]) => r.overflow)
946
+ };
947
+ }
948
+ /** Tiers whose retention covers `from`, finest first; the coarsest is always present. */
949
+ coveringTiers(from) {
950
+ const policy = this.deps.retention();
951
+ const now = this.deps.clock();
952
+ const covering = TIER_ORDER.filter((tier) => {
953
+ const ms = retentionMsOf(tier, policy);
954
+ return ms === null || from >= now - ms;
955
+ });
956
+ return covering.length > 0 ? covering : ["1d"];
957
+ }
958
+ /** Cheap fit test for rollup tiers; raw is decided by the read itself. */
959
+ fits(tier, input, series) {
960
+ if (tier === "raw" || series.length === 0) return true;
961
+ const width = BUCKET_MS[tier];
962
+ return Math.ceil((input.to - input.from) / width) <= input.maxPoints;
963
+ }
964
+ async readTier(tier, series, input) {
965
+ const { store } = this.deps;
966
+ const limit = input.maxPoints + 1;
967
+ if (tier === "raw") {
968
+ const rows = await store.readRaw(series.id, input.from, input.to, limit, "desc");
969
+ return this.shape(rows.map((r) => rawPoint(r.ts, r.value)), input.maxPoints);
970
+ }
971
+ const lower = bucketStartOf(tier, input.from);
972
+ const rows = await store.readRollups(tier, series.id, lower, input.to, limit, "desc");
973
+ return this.shape(rows.map(rollupPoint), input.maxPoints);
974
+ }
975
+ /** Rows arrive newest first and one over the bound; keep the newest `maxPoints`, oldest first. */
976
+ shape(newestFirst, maxPoints) {
977
+ const overflow = newestFirst.length > maxPoints;
978
+ return {
979
+ points: (overflow ? newestFirst.slice(0, maxPoints) : newestFirst).toReversed(),
980
+ overflow
981
+ };
982
+ }
983
+ async before(tier, series, from) {
984
+ const { store } = this.deps;
985
+ if (tier === "raw") {
986
+ const p = await store.rawBefore(series.id, from);
987
+ return p === null ? null : {
988
+ t: p.ts,
989
+ value: p.value
990
+ };
991
+ }
992
+ const row = await store.rollupBefore(tier, series.id, bucketStartOf(tier, from));
993
+ return row === null ? null : {
994
+ t: row.bucketStart,
995
+ value: row.last
996
+ };
997
+ }
998
+ async listSeries(subject) {
999
+ const { registry } = this.deps;
1000
+ const slices = /* @__PURE__ */ new Map();
1001
+ const infos = [];
1002
+ for (const s of registry.forSubject(subjectIdOf(subject))) {
1003
+ const metric = registry.metric(s.key);
1004
+ if (metric === void 0) continue;
1005
+ infos.push({
1006
+ key: s.key,
1007
+ scope: s.scope,
1008
+ kind: metric.kind,
1009
+ unit: await this.unitOf(subject, s, metric.unit, slices),
1010
+ displayName: metric.displayName,
1011
+ source: metric.source,
1012
+ firstTs: s.firstTs,
1013
+ lastTs: s.lastTs
1014
+ });
1015
+ }
1016
+ return infos.toSorted((a, b) => a.key.localeCompare(b.key) || a.scope.localeCompare(b.scope));
1017
+ }
1018
+ /** The metric's fixed unit, else the device's own (a `unitFrom` field), else unknown. */
1019
+ async unitOf(subject, series, fixed, slices) {
1020
+ if (fixed !== null || subject.kind !== "device" || !series.key.startsWith("cap:")) return fixed;
1021
+ const spec = this.specOf(series.key);
1022
+ if (spec?.unitFrom === void 0) return null;
1023
+ const capName = series.key.slice(CAP_KEY_PREFIX.length, series.key.indexOf("."));
1024
+ const pending = slices.get(capName) ?? this.deps.readCapSlice(subject.deviceId, capName).catch(() => null);
1025
+ slices.set(capName, pending);
1026
+ const slice = await pending;
1027
+ return slice === null ? null : readUnit(slice, spec.unitFrom);
1028
+ }
1029
+ specOf(key) {
1030
+ const body = key.slice(CAP_KEY_PREFIX.length);
1031
+ const dot = body.indexOf(".");
1032
+ if (dot < 0) return void 0;
1033
+ return this.deps.catalog[body.slice(0, dot)]?.[body.slice(dot + 1)];
1034
+ }
1035
+ };
1036
+ //#endregion
1037
+ //#region src/builtins/device-history/retention/departure-tracker.ts
1038
+ var DEPARTED_ABSENCE_MS = 7 * DAY_MS;
1039
+ var DepartureTracker = class {
1040
+ store;
1041
+ registry;
1042
+ logger;
1043
+ missing = /* @__PURE__ */ new Map();
1044
+ constructor(store, registry, logger) {
1045
+ this.store = store;
1046
+ this.registry = registry;
1047
+ this.logger = logger;
1048
+ }
1049
+ async load() {
1050
+ this.missing = await this.store.loadDepartures();
1051
+ }
1052
+ missingSince(deviceId) {
1053
+ return this.missing.get(deviceId);
1054
+ }
1055
+ /** Forget a device's clock — its series were removed because it was DELETED (D865). */
1056
+ async forget(deviceId) {
1057
+ await this.store.clearMissing([deviceId]);
1058
+ const next = new Map(this.missing);
1059
+ next.delete(deviceId);
1060
+ this.missing = next;
1061
+ }
1062
+ /** Feed one SUCCESSFUL listing. A failed read never reaches here. */
1063
+ async observe(listed, now) {
1064
+ const withSeries = new Set(this.registry.all().flatMap((s) => s.deviceId === null ? [] : [s.deviceId]));
1065
+ const back = [...this.missing.keys()].filter((id) => listed.has(id));
1066
+ const gone = [...withSeries].filter((id) => !listed.has(id) && !this.missing.has(id));
1067
+ try {
1068
+ await this.store.clearMissing(back);
1069
+ await this.store.markMissing(gone, now);
1070
+ } catch (err) {
1071
+ this.logger.warn("device-history: departure clock not written", { meta: { error: err instanceof Error ? err.message : String(err) } });
1072
+ return;
1073
+ }
1074
+ const next = new Map(this.missing);
1075
+ for (const id of back) next.delete(id);
1076
+ for (const id of gone) next.set(id, now);
1077
+ this.missing = next;
1078
+ }
1079
+ };
1080
+ //#endregion
1081
+ //#region src/builtins/device-history/retention/retention.ts
1082
+ var DEFAULT_RETENTION = {
1083
+ rawDays: 7,
1084
+ fiveMinuteDays: 14,
1085
+ hourDays: 365,
1086
+ dayDays: null
1087
+ };
1088
+ /** Cutoff per tier: rows strictly older are deleted. `null` = keep forever. */
1089
+ function retentionCutoffs(policy, now) {
1090
+ return {
1091
+ raw: now - policy.rawDays * DAY_MS,
1092
+ "5m": now - policy.fiveMinuteDays * DAY_MS,
1093
+ "1h": now - policy.hourDays * DAY_MS,
1094
+ "1d": policy.dayDays === null || policy.dayDays <= 0 ? null : now - policy.dayDays * DAY_MS
1095
+ };
1096
+ }
1097
+ var TIERS = [
1098
+ "raw",
1099
+ "5m",
1100
+ "1h",
1101
+ "1d"
1102
+ ];
1103
+ var RETENTION_STEP_MS = {
1104
+ raw: HOUR_MS,
1105
+ "5m": HOUR_MS,
1106
+ "1h": DAY_MS,
1107
+ "1d": DAY_MS
1108
+ };
1109
+ var MAX_PURGES_PER_RUN = 20;
1110
+ /** Width of one delete statement's time range, per tier. */
1111
+ var SLICE_MS = {
1112
+ raw: DAY_MS,
1113
+ "5m": DAY_MS,
1114
+ "1h": 30 * DAY_MS,
1115
+ "1d": 30 * DAY_MS
1116
+ };
1117
+ /** A bucket's start may precede the series' first point by up to a day. */
1118
+ var BUCKET_LEAD_MS = 2 * DAY_MS;
1119
+ /** The short tiers hold nothing older than this unless the operator configured more. */
1120
+ var SHORT_TIER_LOOKBACK_MS = 400 * DAY_MS;
1121
+ var yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve));
1122
+ var RetentionJob = class {
1123
+ deps;
1124
+ appliedUpTo = /* @__PURE__ */ new Map();
1125
+ running = null;
1126
+ constructor(deps) {
1127
+ this.deps = deps;
1128
+ }
1129
+ run(policy, now) {
1130
+ if (this.running !== null) return this.running;
1131
+ const run = this.doRun(policy, now).finally(() => {
1132
+ this.running = null;
1133
+ });
1134
+ this.running = run;
1135
+ return run;
1136
+ }
1137
+ async doRun(policy, now) {
1138
+ const { registry, counters, logger } = this.deps;
1139
+ const cutoffs = retentionCutoffs(policy, now);
1140
+ try {
1141
+ for (const tier of TIERS) {
1142
+ const cutoff = cutoffs[tier];
1143
+ if (cutoff === null) continue;
1144
+ for (const series of registry.all()) {
1145
+ if (series.firstTs - 864e5 >= cutoff) continue;
1146
+ if (tier === "raw" && registry.metric(series.key)?.ingest === "buckets") continue;
1147
+ const marker = `${tier}:${series.id}`;
1148
+ const applied = this.appliedUpTo.get(marker);
1149
+ if (applied !== void 0 && cutoff - applied < RETENTION_STEP_MS[tier]) continue;
1150
+ const lo = Math.max(0, applied === void 0 ? series.firstTs - BUCKET_LEAD_MS : applied - RETENTION_STEP_MS[tier]);
1151
+ counters.addDeleted(tier, await this.deleteSliced(tier, series.id, lo, cutoff - 1));
1152
+ this.appliedUpTo.set(marker, cutoff);
1153
+ }
1154
+ }
1155
+ await this.purgeDeparted(now);
1156
+ counters.retentionLastRunAt = now;
1157
+ } catch (err) {
1158
+ logger.warn("device-history: retention pass failed — retried at the next run", { meta: { error: err instanceof Error ? err.message : String(err) } });
1159
+ }
1160
+ }
1161
+ /** Delete `[lo, hiInclusive]` of one tier in time slices, yielding between them. */
1162
+ /** Drop the visit markers of a purged series. */
1163
+ forgetSeries(seriesId) {
1164
+ for (const tier of TIERS) this.appliedUpTo.delete(`${tier}:${seriesId}`);
1165
+ }
1166
+ async deleteSliced(tier, seriesId, lo, hiInclusive) {
1167
+ const yieldNow = this.deps.yieldNow ?? yieldToEventLoop;
1168
+ let deleted = 0;
1169
+ for (let from = lo; from <= hiInclusive; from += SLICE_MS[tier]) {
1170
+ const to = Math.min(from + SLICE_MS[tier] - 1, hiInclusive);
1171
+ deleted += await this.deps.store.deleteRange(tier, seriesId, from, to);
1172
+ await yieldNow();
1173
+ }
1174
+ return deleted;
1175
+ }
1176
+ /** Tier by tier in slices, then the series row (and whatever the slices missed). */
1177
+ async purgeSeries(series, now) {
1178
+ for (const tier of TIERS) {
1179
+ const lo = tier === "raw" || tier === "5m" ? Math.max(series.firstTs - BUCKET_LEAD_MS, now - SHORT_TIER_LOOKBACK_MS) : series.firstTs - BUCKET_LEAD_MS;
1180
+ await this.deleteSliced(tier, series.id, Math.max(0, lo), now + DAY_MS);
1181
+ }
1182
+ await this.deps.store.deleteSeries(series.id);
1183
+ }
1184
+ /**
1185
+ * Delete every series of a DELETED device now (D865). Driven by a tombstone
1186
+ * through the `device-removal-hook`, never by a listing: the week-long
1187
+ * absence clock above remains the backstop for a device removed before
1188
+ * tombstones existed.
1189
+ */
1190
+ async purgeDevice(deviceId, now) {
1191
+ const { registry, counters } = this.deps;
1192
+ let purged = 0;
1193
+ for (const series of registry.all()) {
1194
+ if (series.deviceId !== deviceId) continue;
1195
+ await this.purgeSeries(series, now);
1196
+ registry.remove(series.id);
1197
+ counters.purgedSeries += 1;
1198
+ purged += 1;
1199
+ }
1200
+ return purged;
1201
+ }
1202
+ async purgeDeparted(now) {
1203
+ const { registry, departures, counters, logger } = this.deps;
1204
+ let purged = 0;
1205
+ for (const series of registry.all()) {
1206
+ if (purged >= MAX_PURGES_PER_RUN) break;
1207
+ if (series.deviceId === null) continue;
1208
+ const since = departures.missingSince(series.deviceId);
1209
+ if (since === void 0 || now - since < DEPARTED_ABSENCE_MS) continue;
1210
+ await this.purgeSeries(series, now);
1211
+ registry.remove(series.id);
1212
+ counters.purgedSeries += 1;
1213
+ purged += 1;
1214
+ logger.info("device-history: purged the series of an unregistered device", {
1215
+ tags: { deviceId: series.deviceId },
1216
+ meta: { key: series.key }
1217
+ });
1218
+ }
1219
+ }
1220
+ };
1221
+ //#endregion
1222
+ //#region src/builtins/device-history/store/history-schema.ts
1223
+ var HISTORY_NAMESPACE = "device-history";
1224
+ var COLLECTIONS = {
1225
+ metrics: "metrics",
1226
+ series: "series",
1227
+ raw: "raw",
1228
+ rollup5m: "rollup-5m",
1229
+ rollup1h: "rollup-1h",
1230
+ rollup1d: "rollup-1d",
1231
+ optOuts: "device-opt-outs",
1232
+ departures: "device-departures"
1233
+ };
1234
+ function rollupCollectionOf(tier) {
1235
+ if (tier === "5m") return COLLECTIONS.rollup5m;
1236
+ if (tier === "1h") return COLLECTIONS.rollup1h;
1237
+ return COLLECTIONS.rollup1d;
1238
+ }
1239
+ function rollupDeclaration(collection) {
1240
+ return {
1241
+ namespace: HISTORY_NAMESPACE,
1242
+ collection,
1243
+ database: "history",
1244
+ primaryKey: ["series_id", "bucket_start"],
1245
+ withoutRowid: true,
1246
+ columns: [
1247
+ {
1248
+ name: "series_id",
1249
+ type: "INTEGER"
1250
+ },
1251
+ {
1252
+ name: "bucket_start",
1253
+ type: "INTEGER"
1254
+ },
1255
+ {
1256
+ name: "samples",
1257
+ type: "INTEGER",
1258
+ notNull: true
1259
+ },
1260
+ {
1261
+ name: "sum",
1262
+ type: "REAL",
1263
+ notNull: true
1264
+ },
1265
+ {
1266
+ name: "min",
1267
+ type: "REAL",
1268
+ notNull: true
1269
+ },
1270
+ {
1271
+ name: "max",
1272
+ type: "REAL",
1273
+ notNull: true
1274
+ },
1275
+ {
1276
+ name: "last",
1277
+ type: "REAL"
1278
+ },
1279
+ {
1280
+ name: "last_ts",
1281
+ type: "INTEGER"
1282
+ },
1283
+ {
1284
+ name: "tw_sum",
1285
+ type: "REAL"
1286
+ },
1287
+ {
1288
+ name: "tw_ms",
1289
+ type: "INTEGER"
1290
+ }
1291
+ ]
1292
+ };
1293
+ }
1294
+ var HISTORY_DECLARATIONS = [
1295
+ {
1296
+ namespace: HISTORY_NAMESPACE,
1297
+ collection: COLLECTIONS.metrics,
1298
+ database: "history",
1299
+ columns: [
1300
+ {
1301
+ name: "key",
1302
+ type: "TEXT",
1303
+ primaryKey: true,
1304
+ notNull: true
1305
+ },
1306
+ {
1307
+ name: "source",
1308
+ type: "TEXT",
1309
+ notNull: true
1310
+ },
1311
+ {
1312
+ name: "owner_addon",
1313
+ type: "TEXT",
1314
+ notNull: true
1315
+ },
1316
+ {
1317
+ name: "subject_kind",
1318
+ type: "TEXT",
1319
+ notNull: true
1320
+ },
1321
+ {
1322
+ name: "kind",
1323
+ type: "TEXT",
1324
+ notNull: true
1325
+ },
1326
+ {
1327
+ name: "unit",
1328
+ type: "TEXT"
1329
+ },
1330
+ {
1331
+ name: "display_name",
1332
+ type: "TEXT",
1333
+ notNull: true
1334
+ },
1335
+ {
1336
+ name: "ingest",
1337
+ type: "TEXT",
1338
+ notNull: true
1339
+ },
1340
+ {
1341
+ name: "declared_at",
1342
+ type: "INTEGER",
1343
+ notNull: true
1344
+ }
1345
+ ]
1346
+ },
1347
+ {
1348
+ namespace: HISTORY_NAMESPACE,
1349
+ collection: COLLECTIONS.series,
1350
+ database: "history",
1351
+ columns: [
1352
+ {
1353
+ name: "id",
1354
+ type: "INTEGER",
1355
+ primaryKey: true
1356
+ },
1357
+ {
1358
+ name: "key",
1359
+ type: "TEXT",
1360
+ notNull: true
1361
+ },
1362
+ {
1363
+ name: "subject",
1364
+ type: "TEXT",
1365
+ notNull: true
1366
+ },
1367
+ {
1368
+ name: "device_id",
1369
+ type: "INTEGER"
1370
+ },
1371
+ {
1372
+ name: "scope",
1373
+ type: "TEXT",
1374
+ notNull: true,
1375
+ defaultValue: ""
1376
+ },
1377
+ {
1378
+ name: "first_ts",
1379
+ type: "INTEGER",
1380
+ notNull: true
1381
+ },
1382
+ {
1383
+ name: "last_ts",
1384
+ type: "INTEGER",
1385
+ notNull: true
1386
+ }
1387
+ ],
1388
+ indexes: [{
1389
+ name: "idx_dh_series_identity",
1390
+ columns: [
1391
+ "key",
1392
+ "subject",
1393
+ "scope"
1394
+ ],
1395
+ unique: true
1396
+ }, {
1397
+ name: "idx_dh_series_device",
1398
+ columns: ["device_id"]
1399
+ }]
1400
+ },
1401
+ {
1402
+ namespace: HISTORY_NAMESPACE,
1403
+ collection: COLLECTIONS.raw,
1404
+ database: "history",
1405
+ primaryKey: ["series_id", "ts"],
1406
+ withoutRowid: true,
1407
+ columns: [
1408
+ {
1409
+ name: "series_id",
1410
+ type: "INTEGER"
1411
+ },
1412
+ {
1413
+ name: "ts",
1414
+ type: "INTEGER"
1415
+ },
1416
+ {
1417
+ name: "value",
1418
+ type: "REAL",
1419
+ notNull: true
1420
+ }
1421
+ ]
1422
+ },
1423
+ rollupDeclaration(COLLECTIONS.rollup5m),
1424
+ rollupDeclaration(COLLECTIONS.rollup1h),
1425
+ rollupDeclaration(COLLECTIONS.rollup1d),
1426
+ {
1427
+ namespace: HISTORY_NAMESPACE,
1428
+ collection: COLLECTIONS.optOuts,
1429
+ columns: [
1430
+ {
1431
+ name: "id",
1432
+ type: "INTEGER",
1433
+ primaryKey: true
1434
+ },
1435
+ {
1436
+ name: "device_id",
1437
+ type: "INTEGER",
1438
+ notNull: true
1439
+ },
1440
+ {
1441
+ name: "opted_out_at",
1442
+ type: "INTEGER",
1443
+ notNull: true
1444
+ }
1445
+ ],
1446
+ indexes: [{
1447
+ name: "idx_dh_opt_outs_device",
1448
+ columns: ["device_id"],
1449
+ unique: true
1450
+ }]
1451
+ },
1452
+ {
1453
+ namespace: HISTORY_NAMESPACE,
1454
+ collection: COLLECTIONS.departures,
1455
+ columns: [
1456
+ {
1457
+ name: "id",
1458
+ type: "INTEGER",
1459
+ primaryKey: true
1460
+ },
1461
+ {
1462
+ name: "device_id",
1463
+ type: "INTEGER",
1464
+ notNull: true
1465
+ },
1466
+ {
1467
+ name: "missing_since",
1468
+ type: "INTEGER",
1469
+ notNull: true
1470
+ }
1471
+ ],
1472
+ indexes: [{
1473
+ name: "idx_dh_departures_device",
1474
+ columns: ["device_id"],
1475
+ unique: true
1476
+ }]
1477
+ }
1478
+ ];
1479
+ //#endregion
1480
+ //#region src/builtins/device-history/store/row-readers.ts
1481
+ function readNumber(data, column) {
1482
+ const v = data[column];
1483
+ if (typeof v === "number" && Number.isFinite(v)) return v;
1484
+ throw new Error(`history row: column "${column}" is not a number (${typeof v})`);
1485
+ }
1486
+ function readNumberOrNull(data, column) {
1487
+ const v = data[column];
1488
+ if (v === null || v === void 0) return null;
1489
+ if (typeof v === "number" && Number.isFinite(v)) return v;
1490
+ throw new Error(`history row: column "${column}" is not a number or null (${typeof v})`);
1491
+ }
1492
+ function readText(data, column) {
1493
+ const v = data[column];
1494
+ if (typeof v === "string") return v;
1495
+ throw new Error(`history row: column "${column}" is not a string (${typeof v})`);
1496
+ }
1497
+ function readTextOrNull(data, column) {
1498
+ const v = data[column];
1499
+ if (v === null || v === void 0) return null;
1500
+ if (typeof v === "string") return v;
1501
+ throw new Error(`history row: column "${column}" is not a string or null (${typeof v})`);
1502
+ }
1503
+ //#endregion
1504
+ //#region src/builtins/device-history/store/history-store.ts
1505
+ /** Rows per `upsertMany` call: bounds one transaction on the history thread. */
1506
+ var WRITE_CHUNK = 1e3;
1507
+ /** Operations per `transact`. */
1508
+ var TRANSACT_CHUNK = 200;
1509
+ /** Rows per read page. */
1510
+ var READ_PAGE = 5e3;
1511
+ function chunks(items, size) {
1512
+ const out = [];
1513
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
1514
+ return out;
1515
+ }
1516
+ function parseKind(value) {
1517
+ if (value === "gauge" || value === "counter" || value === "boolean") return value;
1518
+ throw new Error(`history row: unknown kind "${value}"`);
1519
+ }
1520
+ function parseMetric(data) {
1521
+ const source = readText(data, "source");
1522
+ const subjectKind = readText(data, "subject_kind");
1523
+ const ingest = readText(data, "ingest");
1524
+ if (source !== "cap" && source !== "addon") throw new Error(`history row: unknown source "${source}"`);
1525
+ if (subjectKind !== "device" && subjectKind !== "node") throw new Error(`history row: unknown subject_kind "${subjectKind}"`);
1526
+ if (ingest !== "points" && ingest !== "buckets") throw new Error(`history row: unknown ingest "${ingest}"`);
1527
+ return {
1528
+ key: readText(data, "key"),
1529
+ source,
1530
+ ownerAddon: readText(data, "owner_addon"),
1531
+ subjectKind,
1532
+ kind: parseKind(readText(data, "kind")),
1533
+ unit: readTextOrNull(data, "unit"),
1534
+ displayName: readText(data, "display_name"),
1535
+ ingest,
1536
+ declaredAt: readNumber(data, "declared_at")
1537
+ };
1538
+ }
1539
+ function parseSeries(data) {
1540
+ return {
1541
+ id: readNumber(data, "id"),
1542
+ key: readText(data, "key"),
1543
+ subject: readText(data, "subject"),
1544
+ deviceId: readNumberOrNull(data, "device_id"),
1545
+ scope: readText(data, "scope"),
1546
+ firstTs: readNumber(data, "first_ts"),
1547
+ lastTs: readNumber(data, "last_ts")
1548
+ };
1549
+ }
1550
+ function parseRollup(data) {
1551
+ return {
1552
+ seriesId: readNumber(data, "series_id"),
1553
+ bucketStart: readNumber(data, "bucket_start"),
1554
+ samples: readNumber(data, "samples"),
1555
+ sum: readNumber(data, "sum"),
1556
+ min: readNumber(data, "min"),
1557
+ max: readNumber(data, "max"),
1558
+ last: readNumberOrNull(data, "last"),
1559
+ lastTs: readNumberOrNull(data, "last_ts"),
1560
+ twSum: readNumberOrNull(data, "tw_sum"),
1561
+ twMs: readNumberOrNull(data, "tw_ms")
1562
+ };
1563
+ }
1564
+ function rollupData(row) {
1565
+ return {
1566
+ series_id: row.seriesId,
1567
+ bucket_start: row.bucketStart,
1568
+ samples: row.samples,
1569
+ sum: row.sum,
1570
+ min: row.min,
1571
+ max: row.max,
1572
+ last: row.last,
1573
+ last_ts: row.lastTs,
1574
+ tw_sum: row.twSum,
1575
+ tw_ms: row.twMs
1576
+ };
1577
+ }
1578
+ function collectionOf(tier) {
1579
+ return tier === "raw" ? COLLECTIONS.raw : rollupCollectionOf(tier);
1580
+ }
1581
+ function orderColumnOf(tier) {
1582
+ return tier === "raw" ? "ts" : "bucket_start";
1583
+ }
1584
+ var HistoryStore = class {
1585
+ door;
1586
+ constructor(door) {
1587
+ this.door = door;
1588
+ }
1589
+ async declare() {
1590
+ for (const declaration of HISTORY_DECLARATIONS) await this.door.declareCollection.mutate(declaration);
1591
+ }
1592
+ async loadMetrics() {
1593
+ return (await this.door.query.query({
1594
+ namespace: HISTORY_NAMESPACE,
1595
+ collection: COLLECTIONS.metrics,
1596
+ filter: { limit: 2e4 }
1597
+ })).map((r) => parseMetric(r.data));
1598
+ }
1599
+ async upsertMetric(metric) {
1600
+ await this.door.upsertMany.mutate({
1601
+ namespace: HISTORY_NAMESPACE,
1602
+ collection: COLLECTIONS.metrics,
1603
+ conflict: ["key"],
1604
+ records: [{ data: {
1605
+ key: metric.key,
1606
+ source: metric.source,
1607
+ owner_addon: metric.ownerAddon,
1608
+ subject_kind: metric.subjectKind,
1609
+ kind: metric.kind,
1610
+ unit: metric.unit,
1611
+ display_name: metric.displayName,
1612
+ ingest: metric.ingest,
1613
+ declared_at: metric.declaredAt
1614
+ } }]
1615
+ });
1616
+ }
1617
+ /** Series with `id > afterId`, ascending — the boot load pages with this. */
1618
+ async loadSeriesPage(afterId, limit) {
1619
+ return (await this.door.query.query({
1620
+ namespace: HISTORY_NAMESPACE,
1621
+ collection: COLLECTIONS.series,
1622
+ filter: {
1623
+ whereBetween: { id: [afterId + 1, Number.MAX_SAFE_INTEGER] },
1624
+ orderBy: {
1625
+ field: "id",
1626
+ direction: "asc"
1627
+ },
1628
+ limit
1629
+ }
1630
+ })).map((r) => parseSeries(r.data));
1631
+ }
1632
+ async findSeries(key, subject, scope) {
1633
+ const first = (await this.door.query.query({
1634
+ namespace: HISTORY_NAMESPACE,
1635
+ collection: COLLECTIONS.series,
1636
+ filter: {
1637
+ where: {
1638
+ key,
1639
+ subject,
1640
+ scope
1641
+ },
1642
+ limit: 1
1643
+ }
1644
+ }))[0];
1645
+ return first === void 0 ? null : parseSeries(first.data);
1646
+ }
1647
+ async insertSeries(series) {
1648
+ const { id } = await this.door.insert.mutate({
1649
+ namespace: HISTORY_NAMESPACE,
1650
+ collection: COLLECTIONS.series,
1651
+ record: { data: {
1652
+ key: series.key,
1653
+ subject: series.subject,
1654
+ device_id: series.deviceId,
1655
+ scope: series.scope,
1656
+ first_ts: series.ts,
1657
+ last_ts: series.ts
1658
+ } }
1659
+ });
1660
+ return {
1661
+ id: typeof id === "number" ? id : Number(id),
1662
+ key: series.key,
1663
+ subject: series.subject,
1664
+ deviceId: series.deviceId,
1665
+ scope: series.scope,
1666
+ firstTs: series.ts,
1667
+ lastTs: series.ts
1668
+ };
1669
+ }
1670
+ async touchSeries(updates) {
1671
+ for (const batch of chunks(updates, TRANSACT_CHUNK)) await this.door.transact.mutate({
1672
+ namespace: HISTORY_NAMESPACE,
1673
+ ops: batch.map((u) => ({
1674
+ op: "update",
1675
+ collection: COLLECTIONS.series,
1676
+ id: String(u.id),
1677
+ data: { last_ts: u.lastTs }
1678
+ }))
1679
+ });
1680
+ }
1681
+ async widenSeriesStart(id, firstTs) {
1682
+ await this.door.update.mutate({
1683
+ namespace: HISTORY_NAMESPACE,
1684
+ collection: COLLECTIONS.series,
1685
+ id: String(id),
1686
+ data: { first_ts: firstTs }
1687
+ });
1688
+ }
1689
+ async upsertRaw(rows) {
1690
+ for (const batch of chunks(rows, WRITE_CHUNK)) await this.door.upsertMany.mutate({
1691
+ namespace: HISTORY_NAMESPACE,
1692
+ collection: COLLECTIONS.raw,
1693
+ conflict: ["series_id", "ts"],
1694
+ records: batch.map((r) => ({ data: {
1695
+ series_id: r.seriesId,
1696
+ ts: r.ts,
1697
+ value: r.value
1698
+ } }))
1699
+ });
1700
+ }
1701
+ /** Points of a series in `[from, toInclusive]`, ascending, at most `limit`. */
1702
+ async readRaw(seriesId, from, toInclusive, limit, direction = "asc") {
1703
+ return (await this.door.query.query({
1704
+ namespace: HISTORY_NAMESPACE,
1705
+ collection: COLLECTIONS.raw,
1706
+ filter: {
1707
+ where: { series_id: seriesId },
1708
+ whereBetween: { ts: [from, toInclusive] },
1709
+ orderBy: {
1710
+ field: "ts",
1711
+ direction
1712
+ },
1713
+ limit
1714
+ }
1715
+ })).map((r) => ({
1716
+ ts: readNumber(r.data, "ts"),
1717
+ value: readNumber(r.data, "value")
1718
+ }));
1719
+ }
1720
+ /** The newest raw point strictly before `ts`, if any. */
1721
+ async rawBefore(seriesId, ts) {
1722
+ return (await this.readRaw(seriesId, 0, ts - 1, 1, "desc"))[0] ?? null;
1723
+ }
1724
+ async upsertRollups(tier, rows) {
1725
+ for (const batch of chunks(rows, WRITE_CHUNK)) await this.door.upsertMany.mutate({
1726
+ namespace: HISTORY_NAMESPACE,
1727
+ collection: rollupCollectionOf(tier),
1728
+ conflict: ["series_id", "bucket_start"],
1729
+ records: batch.map((r) => ({ data: rollupData(r) }))
1730
+ });
1731
+ }
1732
+ async readRollups(tier, seriesId, from, toInclusive, limit, direction = "asc") {
1733
+ return (await this.door.query.query({
1734
+ namespace: HISTORY_NAMESPACE,
1735
+ collection: rollupCollectionOf(tier),
1736
+ filter: {
1737
+ where: { series_id: seriesId },
1738
+ whereBetween: { bucket_start: [from, toInclusive] },
1739
+ orderBy: {
1740
+ field: "bucket_start",
1741
+ direction
1742
+ },
1743
+ limit
1744
+ }
1745
+ })).map((r) => parseRollup(r.data));
1746
+ }
1747
+ /** The newest rollup row of `tier` strictly before `bucketStart`, if any. */
1748
+ async rollupBefore(tier, seriesId, bucketStart) {
1749
+ return (await this.readRollups(tier, seriesId, 0, bucketStart - 1, 1, "desc"))[0] ?? null;
1750
+ }
1751
+ /** Delete one series' rows of a tier with the order column in `[lo, hiInclusive]`. */
1752
+ async deleteRange(tier, seriesId, lo, hiInclusive) {
1753
+ const { deleted } = await this.door.deleteWhere.mutate({
1754
+ namespace: HISTORY_NAMESPACE,
1755
+ collection: collectionOf(tier),
1756
+ filter: {
1757
+ where: { series_id: seriesId },
1758
+ whereBetween: { [orderColumnOf(tier)]: [lo, hiInclusive] }
1759
+ }
1760
+ });
1761
+ return deleted;
1762
+ }
1763
+ /** Delete every row of one series, in every tier, then the series row. */
1764
+ async deleteSeries(seriesId) {
1765
+ for (const tier of [
1766
+ "raw",
1767
+ "5m",
1768
+ "1h",
1769
+ "1d"
1770
+ ]) await this.door.deleteWhere.mutate({
1771
+ namespace: HISTORY_NAMESPACE,
1772
+ collection: collectionOf(tier),
1773
+ filter: { where: { series_id: seriesId } }
1774
+ });
1775
+ await this.door.delete.mutate({
1776
+ namespace: HISTORY_NAMESPACE,
1777
+ collection: COLLECTIONS.series,
1778
+ key: String(seriesId)
1779
+ });
1780
+ }
1781
+ async countRows(tier) {
1782
+ return this.door.count.query({
1783
+ namespace: HISTORY_NAMESPACE,
1784
+ collection: collectionOf(tier)
1785
+ });
1786
+ }
1787
+ async loadOptOuts() {
1788
+ const rows = await this.door.query.query({
1789
+ namespace: HISTORY_NAMESPACE,
1790
+ collection: COLLECTIONS.optOuts,
1791
+ filter: { limit: 2e4 }
1792
+ });
1793
+ return new Set(rows.map((r) => readNumber(r.data, "device_id")));
1794
+ }
1795
+ async setOptOut(deviceId, at) {
1796
+ await this.door.upsertMany.mutate({
1797
+ namespace: HISTORY_NAMESPACE,
1798
+ collection: COLLECTIONS.optOuts,
1799
+ conflict: ["device_id"],
1800
+ records: [{ data: {
1801
+ device_id: deviceId,
1802
+ opted_out_at: at
1803
+ } }]
1804
+ });
1805
+ }
1806
+ async loadDepartures() {
1807
+ const rows = await this.door.query.query({
1808
+ namespace: HISTORY_NAMESPACE,
1809
+ collection: COLLECTIONS.departures,
1810
+ filter: { limit: 2e4 }
1811
+ });
1812
+ return new Map(rows.map((r) => [readNumber(r.data, "device_id"), readNumber(r.data, "missing_since")]));
1813
+ }
1814
+ async markMissing(deviceIds, at) {
1815
+ for (const batch of chunks(deviceIds, WRITE_CHUNK)) await this.door.upsertMany.mutate({
1816
+ namespace: HISTORY_NAMESPACE,
1817
+ collection: COLLECTIONS.departures,
1818
+ conflict: ["device_id"],
1819
+ records: batch.map((id) => ({ data: {
1820
+ device_id: id,
1821
+ missing_since: at
1822
+ } }))
1823
+ });
1824
+ }
1825
+ async clearMissing(deviceIds) {
1826
+ for (const batch of chunks(deviceIds, WRITE_CHUNK)) await this.door.deleteWhere.mutate({
1827
+ namespace: HISTORY_NAMESPACE,
1828
+ collection: COLLECTIONS.departures,
1829
+ filter: { whereIn: { device_id: [...batch] } }
1830
+ });
1831
+ }
1832
+ async clearOptOut(deviceId) {
1833
+ await this.door.deleteWhere.mutate({
1834
+ namespace: HISTORY_NAMESPACE,
1835
+ collection: COLLECTIONS.optOuts,
1836
+ filter: { where: { device_id: deviceId } }
1837
+ });
1838
+ }
1839
+ };
1840
+ //#endregion
1841
+ //#region src/builtins/device-history/rollup/rollup-math.ts
1842
+ /**
1843
+ * Rollup arithmetic (D850, plan §4/§5.5). PURE: rows in, rows out.
1844
+ *
1845
+ * Two operations, both a RECOMPUTE from the lower tier — never an increment —
1846
+ * so running one twice, or after a late write, gives the same answer:
1847
+ *
1848
+ * - {@link rawToFiveMinuteRows}: change-only raw points into 5-minute rows,
1849
+ * with the time-weighted sum `tw_sum` (Σ value·ms) a change-only gauge
1850
+ * needs: 4m59s at 50 and 1s at 100 is a time-weighted mean of ~50, not 75.
1851
+ * - {@link combineRows}: rows of the child tier into one row of the next.
1852
+ *
1853
+ * Held values. A change-only series writes a point only when the value
1854
+ * changes, so a quiet bucket has NO row and the value in force across it is
1855
+ * the last one before. Both operations take that value in (`carryIn`) and
1856
+ * credit the gap with it; without that, an hour that sat at 10 for 55 minutes
1857
+ * and jumped to 100 in the last five would be averaged over those five alone.
1858
+ * The held value also counts toward `min`/`max` when it was in force at the
1859
+ * start of a bucket, because it really was the series' value then.
1860
+ */
1861
+ /** One row per 5-minute bucket that has at least one point. */
1862
+ function rawToFiveMinuteRows(input) {
1863
+ const rows = [];
1864
+ const { points, seriesId, horizon } = input;
1865
+ let held = input.carryIn === null ? null : input.carryIn.value;
1866
+ let i = 0;
1867
+ while (i < points.length) {
1868
+ const first = points[i];
1869
+ if (first === void 0) break;
1870
+ const bucketStart = floorTo(first.ts, FIVE_MINUTES_MS);
1871
+ const bucketEnd = bucketStart + FIVE_MINUTES_MS;
1872
+ const coverageEnd = Math.min(bucketEnd, Math.max(horizon, bucketStart));
1873
+ let samples = 0;
1874
+ let sum = 0;
1875
+ let min = Number.POSITIVE_INFINITY;
1876
+ let max = Number.NEGATIVE_INFINITY;
1877
+ let twSum = 0;
1878
+ let twMs = 0;
1879
+ let cursor = bucketStart;
1880
+ let value = held;
1881
+ if (value !== null) {
1882
+ min = value;
1883
+ max = value;
1884
+ }
1885
+ let last = first;
1886
+ while (i < points.length) {
1887
+ const p = points[i];
1888
+ if (p === void 0 || p.ts >= bucketEnd) break;
1889
+ if (value !== null) {
1890
+ const dt = p.ts - cursor;
1891
+ twSum += value * dt;
1892
+ twMs += dt;
1893
+ }
1894
+ cursor = p.ts;
1895
+ value = p.value;
1896
+ samples += 1;
1897
+ sum += p.value;
1898
+ if (p.value < min) min = p.value;
1899
+ if (p.value > max) max = p.value;
1900
+ last = p;
1901
+ i += 1;
1902
+ }
1903
+ if (value !== null && coverageEnd > cursor) {
1904
+ twSum += value * (coverageEnd - cursor);
1905
+ twMs += coverageEnd - cursor;
1906
+ }
1907
+ held = last.value;
1908
+ rows.push({
1909
+ seriesId,
1910
+ bucketStart,
1911
+ samples,
1912
+ sum,
1913
+ min,
1914
+ max,
1915
+ last: last.value,
1916
+ lastTs: last.ts,
1917
+ twSum,
1918
+ twMs
1919
+ });
1920
+ }
1921
+ return rows;
1922
+ }
1923
+ /** One row of the next tier from the child rows of its window; `null` when there are none. */
1924
+ function combineRows(input) {
1925
+ const { rows } = input;
1926
+ const lastRow = rows[rows.length - 1];
1927
+ if (lastRow === void 0) return null;
1928
+ const hasTw = rows.some((r) => r.twSum !== null && r.twMs !== null);
1929
+ let samples = 0;
1930
+ let sum = 0;
1931
+ let min = Number.POSITIVE_INFINITY;
1932
+ let max = Number.NEGATIVE_INFINITY;
1933
+ let twSum = 0;
1934
+ let twMs = 0;
1935
+ let held = input.carryIn;
1936
+ let cursor = input.windowStart;
1937
+ for (const row of rows) {
1938
+ samples += row.samples;
1939
+ sum += row.sum;
1940
+ if (row.min < min) min = row.min;
1941
+ if (row.max > max) max = row.max;
1942
+ const gap = row.bucketStart - cursor;
1943
+ if (hasTw && gap > 0 && held !== null) {
1944
+ twSum += held * gap;
1945
+ twMs += gap;
1946
+ if (held < min) min = held;
1947
+ if (held > max) max = held;
1948
+ }
1949
+ if (row.twSum !== null && row.twMs !== null) {
1950
+ twSum += row.twSum;
1951
+ twMs += row.twMs;
1952
+ }
1953
+ cursor = row.bucketStart + input.childWidthMs;
1954
+ held = row.last;
1955
+ }
1956
+ const coverageEnd = Math.min(input.windowEnd, input.horizon);
1957
+ if (hasTw && held !== null && coverageEnd > cursor) {
1958
+ twSum += held * (coverageEnd - cursor);
1959
+ twMs += coverageEnd - cursor;
1960
+ }
1961
+ return {
1962
+ seriesId: input.seriesId,
1963
+ bucketStart: input.windowStart,
1964
+ samples,
1965
+ sum,
1966
+ min,
1967
+ max,
1968
+ last: lastRow.last,
1969
+ lastTs: lastRow.lastTs,
1970
+ twSum: hasTw ? twSum : null,
1971
+ twMs: hasTw ? twMs : null
1972
+ };
1973
+ }
1974
+ /** How far back a boot rebuilds from raw and re-marks the upper tiers. */
1975
+ var BOOT_WINDOW_MS = 3 * HOUR_MS;
1976
+ var markVersion = 0;
1977
+ function mark(sets, seriesId, bucketStart) {
1978
+ const map = sets.get(seriesId) ?? /* @__PURE__ */ new Map();
1979
+ markVersion += 1;
1980
+ map.set(bucketStart, markVersion);
1981
+ sets.set(seriesId, map);
1982
+ }
1983
+ /**
1984
+ * Clear what a recompute handled — but only a mark that was not RENEWED while the
1985
+ * recompute awaited: a late write that re-marked the bucket mid-flight carries a newer
1986
+ * version and must be computed again, not erased with the old mark.
1987
+ */
1988
+ function settle(sets, seriesId, handled) {
1989
+ const map = sets.get(seriesId);
1990
+ if (map === void 0) return;
1991
+ for (const { bucket, version } of handled) if (map.get(bucket) === version) map.delete(bucket);
1992
+ if (map.size === 0) sets.delete(seriesId);
1993
+ }
1994
+ /** Buckets further apart than this are recomputed as separate ranges, not one long read. */
1995
+ var MAX_RANGE_GAP_MS = HOUR_MS;
1996
+ /** `[from, toExclusive)` ranges covering sorted 5-minute bucket starts, split at long gaps. */
1997
+ function clusterRanges(sorted) {
1998
+ const ranges = [];
1999
+ for (const start of sorted) {
2000
+ const current = ranges[ranges.length - 1];
2001
+ if (current !== void 0 && start - current[1] <= MAX_RANGE_GAP_MS) current[1] = start + FIVE_MINUTES_MS;
2002
+ else ranges.push([start, start + FIVE_MINUTES_MS]);
2003
+ }
2004
+ return ranges;
2005
+ }
2006
+ var RollupEngine = class {
2007
+ deps;
2008
+ dirty5m = /* @__PURE__ */ new Map();
2009
+ dirty1h = /* @__PURE__ */ new Map();
2010
+ dirty1d = /* @__PURE__ */ new Map();
2011
+ ticking = null;
2012
+ constructor(deps) {
2013
+ this.deps = deps;
2014
+ }
2015
+ dirtyCounts() {
2016
+ const count = (sets) => [...sets.values()].reduce((n, set) => n + set.size, 0);
2017
+ return {
2018
+ m5: count(this.dirty5m),
2019
+ h1: count(this.dirty1h),
2020
+ d1: count(this.dirty1d)
2021
+ };
2022
+ }
2023
+ /** Raw points landed: their 5-minute buckets (and a late bucket's successor) are dirty. */
2024
+ markPoints(written) {
2025
+ const now = this.deps.clock();
2026
+ for (const { seriesId, timestamps } of written) for (const ts of timestamps) {
2027
+ const bucket = floorTo(ts, FIVE_MINUTES_MS);
2028
+ mark(this.dirty5m, seriesId, bucket);
2029
+ if (bucket + 3e5 + ROLLUP_GRACE_MS["5m"] <= now) mark(this.dirty5m, seriesId, bucket + FIVE_MINUTES_MS);
2030
+ }
2031
+ }
2032
+ /** A 5m row landed (computed, or a producer bucket): its hour is dirty. */
2033
+ markFiveMinuteRow(seriesId, bucketStart) {
2034
+ mark(this.dirty1h, seriesId, bucketStartOf("1h", bucketStart));
2035
+ }
2036
+ /** One pass over the three tiers. Single-flight; errors are counted and the work stays dirty. */
2037
+ tick() {
2038
+ if (this.ticking !== null) return this.ticking;
2039
+ const run = this.doTick().finally(() => {
2040
+ this.ticking = null;
2041
+ });
2042
+ this.ticking = run;
2043
+ return run;
2044
+ }
2045
+ async doTick() {
2046
+ const now = this.deps.clock();
2047
+ await this.guarded("5m", () => this.tickFiveMinute(now));
2048
+ await this.guarded("1h", () => this.tickHour(now));
2049
+ await this.guarded("1d", () => this.tickDay(now));
2050
+ }
2051
+ async guarded(tier, run) {
2052
+ try {
2053
+ await run();
2054
+ } catch (err) {
2055
+ this.deps.counters.rollupErrors += 1;
2056
+ this.deps.logger.warn(`device-history: ${tier} rollup pass failed — work stays dirty`, { meta: { error: err instanceof Error ? err.message : String(err) } });
2057
+ }
2058
+ }
2059
+ closedBuckets(map, tier, now) {
2060
+ return [...map].filter(([start]) => bucketEndOf(tier, start) + ROLLUP_GRACE_MS[tier] <= now).map(([bucket, version]) => ({
2061
+ bucket,
2062
+ version
2063
+ })).toSorted((a, b) => a.bucket - b.bucket);
2064
+ }
2065
+ /** Forget everything held for a series that was purged. */
2066
+ forgetSeries(seriesId) {
2067
+ this.dirty5m.delete(seriesId);
2068
+ this.dirty1h.delete(seriesId);
2069
+ this.dirty1d.delete(seriesId);
2070
+ this.deps.counters.forgetSeries(seriesId);
2071
+ }
2072
+ async tickFiveMinute(now) {
2073
+ let handled = 0;
2074
+ for (const [seriesId, set] of this.dirty5m) {
2075
+ if (handled >= 400) break;
2076
+ const closed = this.closedBuckets(set, "5m", now);
2077
+ if (closed.length === 0) continue;
2078
+ handled += 1;
2079
+ for (const [from, to] of clusterRanges(closed.map((c) => c.bucket))) await this.recomputeFiveMinute(seriesId, from, to, now);
2080
+ settle(this.dirty5m, seriesId, closed);
2081
+ }
2082
+ if (handled > 0) this.deps.counters.last5mAt = now;
2083
+ }
2084
+ async tickHour(now) {
2085
+ let handled = 0;
2086
+ for (const [seriesId, set] of this.dirty1h) {
2087
+ if (handled >= 400) break;
2088
+ const closed = this.closedBuckets(set, "1h", now);
2089
+ if (closed.length === 0) continue;
2090
+ handled += 1;
2091
+ for (const hour of closed) {
2092
+ await this.recomputeHour(seriesId, hour.bucket, now);
2093
+ settle(this.dirty1h, seriesId, [hour]);
2094
+ }
2095
+ }
2096
+ if (handled > 0) this.deps.counters.last1hAt = now;
2097
+ }
2098
+ async tickDay(now) {
2099
+ let handled = 0;
2100
+ for (const [seriesId, set] of this.dirty1d) {
2101
+ if (handled >= 400) break;
2102
+ const closed = this.closedBuckets(set, "1d", now);
2103
+ if (closed.length === 0) continue;
2104
+ handled += 1;
2105
+ for (const day of closed) {
2106
+ await this.recomputeDay(seriesId, day.bucket, now);
2107
+ settle(this.dirty1d, seriesId, [day]);
2108
+ }
2109
+ }
2110
+ if (handled > 0) this.deps.counters.last1dAt = now;
2111
+ }
2112
+ /** Raw → 5m rows for buckets in `[from, toExclusive)`; writes them and marks their hours. */
2113
+ async recomputeFiveMinute(seriesId, from, toExclusive, horizon) {
2114
+ const { store } = this.deps;
2115
+ const points = [];
2116
+ let cursor = from;
2117
+ for (;;) {
2118
+ const page = await store.readRaw(seriesId, cursor, toExclusive - 1, READ_PAGE);
2119
+ points.push(...page);
2120
+ const last = page[page.length - 1];
2121
+ if (last === void 0 || page.length < 5e3) break;
2122
+ cursor = last.ts + 1;
2123
+ }
2124
+ const rows = rawToFiveMinuteRows({
2125
+ seriesId,
2126
+ points,
2127
+ carryIn: await store.rawBefore(seriesId, from),
2128
+ horizon
2129
+ });
2130
+ if (rows.length === 0) return 0;
2131
+ await store.upsertRollups("5m", rows);
2132
+ this.deps.counters.noteRollupRows("5m", seriesId, rows.map((r) => r.bucketStart));
2133
+ for (const row of rows) this.markFiveMinuteRow(seriesId, row.bucketStart);
2134
+ return rows.length;
2135
+ }
2136
+ async recomputeHour(seriesId, hour, now) {
2137
+ await this.recomputeFrom("5m", "1h", FIVE_MINUTES_MS, seriesId, hour, now);
2138
+ }
2139
+ async recomputeDay(seriesId, day, now) {
2140
+ await this.recomputeFrom("1h", "1d", HOUR_MS, seriesId, day, now);
2141
+ }
2142
+ async recomputeFrom(child, parent, childWidthMs, seriesId, windowStart, now) {
2143
+ const { store } = this.deps;
2144
+ const windowEnd = bucketEndOf(parent, windowStart);
2145
+ const combined = combineRows({
2146
+ seriesId,
2147
+ windowStart,
2148
+ windowEnd,
2149
+ childWidthMs,
2150
+ rows: await store.readRollups(child, seriesId, windowStart, windowEnd - 1, READ_PAGE),
2151
+ carryIn: (await store.rollupBefore(child, seriesId, windowStart))?.last ?? null,
2152
+ horizon: now
2153
+ });
2154
+ if (combined === null) return;
2155
+ await store.upsertRollups(parent, [combined]);
2156
+ this.deps.counters.noteRollupRows(parent, seriesId, [combined.bucketStart]);
2157
+ if (parent === "1h") mark(this.dirty1d, seriesId, bucketStartOf("1d", windowStart));
2158
+ }
2159
+ /**
2160
+ * After a restart the dirty sets are gone. Rebuild the recent window from raw
2161
+ * (the still-open bucket included, time-weighted up to `now`), then mark the
2162
+ * 1h / 1d buckets of that window dirty so the next ticks recompute them.
2163
+ */
2164
+ async bootRecompute(now) {
2165
+ const windowStart = floorTo(now - BOOT_WINDOW_MS, FIVE_MINUTES_MS);
2166
+ const openBucket = floorTo(now, FIVE_MINUTES_MS);
2167
+ let series = 0;
2168
+ for (const s of this.deps.registry.all()) {
2169
+ if (s.lastTs < windowStart) continue;
2170
+ const metric = this.deps.registry.metric(s.key);
2171
+ series += 1;
2172
+ if (metric?.ingest === "points") {
2173
+ await this.recomputeFiveMinute(s.id, windowStart, openBucket + FIVE_MINUTES_MS, now);
2174
+ mark(this.dirty5m, s.id, openBucket);
2175
+ mark(this.dirty5m, s.id, openBucket - FIVE_MINUTES_MS);
2176
+ }
2177
+ for (let hour = floorTo(windowStart, HOUR_MS); hour <= now; hour += HOUR_MS) mark(this.dirty1h, s.id, hour);
2178
+ mark(this.dirty1d, s.id, bucketStartOf("1d", windowStart));
2179
+ mark(this.dirty1d, s.id, bucketStartOf("1d", now));
2180
+ }
2181
+ return series;
2182
+ }
2183
+ };
2184
+ //#endregion
2185
+ //#region src/builtins/device-history/status/counters.ts
2186
+ function deltas(now, before) {
2187
+ const out = {};
2188
+ for (const [k, v] of now) {
2189
+ const d = v - (before.get(k) ?? 0);
2190
+ if (d > 0) out[k] = d;
2191
+ }
2192
+ return out;
2193
+ }
2194
+ var HistoryCounters = class {
2195
+ eventsSeen = 0;
2196
+ pointsEmitted = 0;
2197
+ pointsWritten = 0;
2198
+ pointsDropped = 0;
2199
+ trailingEmitted = 0;
2200
+ reconciledPoints = 0;
2201
+ reconcileRuns = 0;
2202
+ reconcileFailures = 0;
2203
+ lastReconcileAt = null;
2204
+ lastFlushAt = null;
2205
+ flushFailures = 0;
2206
+ ingestAccepted = 0;
2207
+ rollupErrors = 0;
2208
+ last5mAt = null;
2209
+ last1hAt = null;
2210
+ last1dAt = null;
2211
+ retentionLastRunAt = null;
2212
+ purgedSeries = 0;
2213
+ directoryRefreshAt = null;
2214
+ skipped = /* @__PURE__ */ new Map();
2215
+ refused = /* @__PURE__ */ new Map();
2216
+ deleted = /* @__PURE__ */ new Map();
2217
+ reported = {
2218
+ skipped: /* @__PURE__ */ new Map(),
2219
+ refused: /* @__PURE__ */ new Map(),
2220
+ deleted: /* @__PURE__ */ new Map(),
2221
+ flushFailures: 0
2222
+ };
2223
+ /** Net rows written minus deleted per tier since boot — the estimate behind `getStatus` row counts. */
2224
+ rowDelta = /* @__PURE__ */ new Map();
2225
+ /** Newest bucket seen per (tier, series): a row beyond it is NEW, a rewrite of an older one is not. */
2226
+ newestBucket = /* @__PURE__ */ new Map();
2227
+ skip(reason, count = 1) {
2228
+ this.skipped.set(reason, (this.skipped.get(reason) ?? 0) + count);
2229
+ }
2230
+ refuse(reason, count = 1) {
2231
+ this.refused.set(reason, (this.refused.get(reason) ?? 0) + count);
2232
+ }
2233
+ addDeleted(tier, count) {
2234
+ if (count <= 0) return;
2235
+ this.deleted.set(tier, (this.deleted.get(tier) ?? 0) + count);
2236
+ if (tier === "raw" || tier === "5m" || tier === "1h" || tier === "1d") this.rowDelta.set(tier, (this.rowDelta.get(tier) ?? 0) - count);
2237
+ }
2238
+ addRawRows(count) {
2239
+ this.rowDelta.set("raw", (this.rowDelta.get("raw") ?? 0) + count);
2240
+ }
2241
+ /** Rollup rows written: only a bucket newer than the series' newest known is counted as a new row. */
2242
+ noteRollupRows(tier, seriesId, bucketStarts) {
2243
+ const marker = `${tier}:${seriesId}`;
2244
+ let newest = this.newestBucket.get(marker) ?? Number.NEGATIVE_INFINITY;
2245
+ let added = 0;
2246
+ for (const b of bucketStarts) if (b > newest) {
2247
+ added += 1;
2248
+ newest = b;
2249
+ }
2250
+ this.newestBucket.set(marker, newest);
2251
+ if (added > 0) this.rowDelta.set(tier, (this.rowDelta.get(tier) ?? 0) + added);
2252
+ }
2253
+ rowDeltaOf(tier) {
2254
+ return this.rowDelta.get(tier) ?? 0;
2255
+ }
2256
+ forgetSeries(seriesId) {
2257
+ for (const tier of [
2258
+ "5m",
2259
+ "1h",
2260
+ "1d"
2261
+ ]) this.newestBucket.delete(`${tier}:${seriesId}`);
2262
+ }
2263
+ skippedRecord() {
2264
+ return Object.fromEntries(this.skipped);
2265
+ }
2266
+ refusedRecord() {
2267
+ return Object.fromEntries(this.refused);
2268
+ }
2269
+ deletedRecord() {
2270
+ return Object.fromEntries(this.deleted);
2271
+ }
2272
+ /** What was skipped, refused, deleted or failed since the last call; `null` when nothing. */
2273
+ summarySinceLast() {
2274
+ const summary = {
2275
+ skipped: deltas(this.skipped, this.reported.skipped),
2276
+ refused: deltas(this.refused, this.reported.refused),
2277
+ deleted: deltas(this.deleted, this.reported.deleted),
2278
+ flushFailures: this.flushFailures - this.reported.flushFailures
2279
+ };
2280
+ this.reported = {
2281
+ skipped: new Map(this.skipped),
2282
+ refused: new Map(this.refused),
2283
+ deleted: new Map(this.deleted),
2284
+ flushFailures: this.flushFailures
2285
+ };
2286
+ return Object.keys(summary.skipped).length === 0 && Object.keys(summary.refused).length === 0 && Object.keys(summary.deleted).length === 0 && summary.flushFailures === 0 ? null : summary;
2287
+ }
2288
+ };
2289
+ //#endregion
2290
+ //#region src/builtins/device-history/runtime/history-runtime.ts
2291
+ var ROLLUP_INTERVAL_MS = 6e4;
2292
+ var RECONCILE_INTERVAL_MS = 5 * 6e4;
2293
+ var RETENTION_INTERVAL_MS = 10 * 6e4;
2294
+ /**
2295
+ * ONE exact row count per boot (a `COUNT(*)` is a full scan on the history thread), taken late so
2296
+ * boot is not spent scanning the raw table. After it the figures move with the writes and deletes
2297
+ * the addon itself makes — an estimate, labelled so in the docs.
2298
+ */
2299
+ var FIRST_COUNT_DELAY_MS = 9e4;
2300
+ /** Per-row size estimates behind `approxBytes` (WITHOUT ROWID rows incl. page overhead). */
2301
+ var APPROX_BYTES_PER_ROW = {
2302
+ raw: 24,
2303
+ "5m": 72,
2304
+ "1h": 72,
2305
+ "1d": 72
2306
+ };
2307
+ var HistoryRuntime = class {
2308
+ deps;
2309
+ counters = new HistoryCounters();
2310
+ store;
2311
+ registry;
2312
+ directory;
2313
+ rollup;
2314
+ writer;
2315
+ capture;
2316
+ ingest;
2317
+ query;
2318
+ retentionJob;
2319
+ departures;
2320
+ optedOutDevices = /* @__PURE__ */ new Set();
2321
+ tierCounts = /* @__PURE__ */ new Map();
2322
+ startedAt;
2323
+ timers = [];
2324
+ countTimeout = null;
2325
+ ready = false;
2326
+ constructor(deps) {
2327
+ this.deps = deps;
2328
+ const { logger, clock, catalog } = deps;
2329
+ this.startedAt = clock();
2330
+ this.store = new HistoryStore(deps.door);
2331
+ this.registry = new SeriesRegistry(this.store);
2332
+ this.directory = new DeviceDirectory(deps.deviceSource, this.counters, logger, clock);
2333
+ this.rollup = new RollupEngine({
2334
+ store: this.store,
2335
+ registry: this.registry,
2336
+ counters: this.counters,
2337
+ logger,
2338
+ clock
2339
+ });
2340
+ this.writer = new PointWriter({
2341
+ registry: this.registry,
2342
+ store: this.store,
2343
+ counters: this.counters,
2344
+ logger,
2345
+ clock,
2346
+ onWritten: (written) => this.rollup.markPoints(written),
2347
+ onDropped: (dropped) => this.capture.forget(dropped)
2348
+ });
2349
+ const optedOut = () => this.optedOutDevices;
2350
+ this.capture = new CaptureEngine({
2351
+ catalog,
2352
+ registry: this.registry,
2353
+ store: this.store,
2354
+ directory: this.directory,
2355
+ writer: this.writer,
2356
+ optedOut,
2357
+ counters: this.counters,
2358
+ logger,
2359
+ clock
2360
+ });
2361
+ this.ingest = new ProducerIngest({
2362
+ registry: this.registry,
2363
+ store: this.store,
2364
+ directory: this.directory,
2365
+ writer: this.writer,
2366
+ counters: this.counters,
2367
+ logger,
2368
+ clock,
2369
+ optedOut,
2370
+ retention: () => {
2371
+ const policy = deps.retention();
2372
+ return {
2373
+ rawMs: policy.rawDays * DAY_MS,
2374
+ fiveMinuteMs: policy.fiveMinuteDays * DAY_MS
2375
+ };
2376
+ },
2377
+ onBucketWritten: (seriesId, bucketStart) => this.rollup.markFiveMinuteRow(seriesId, bucketStart)
2378
+ });
2379
+ this.query = new HistoryQuery({
2380
+ registry: this.registry,
2381
+ store: this.store,
2382
+ clock,
2383
+ retention: deps.retention,
2384
+ catalog,
2385
+ readCapSlice: deps.readCapSlice
2386
+ });
2387
+ this.departures = new DepartureTracker(this.store, this.registry, logger);
2388
+ this.registry.onRemove((series) => {
2389
+ this.capture.forgetSeries(series);
2390
+ this.ingest.forgetSeries(series);
2391
+ this.rollup.forgetSeries(series.id);
2392
+ this.retentionJob.forgetSeries(series.id);
2393
+ });
2394
+ this.retentionJob = new RetentionJob({
2395
+ store: this.store,
2396
+ registry: this.registry,
2397
+ departures: this.departures,
2398
+ counters: this.counters,
2399
+ logger
2400
+ });
2401
+ }
2402
+ async prepare() {
2403
+ await this.store.declare();
2404
+ await this.registry.load();
2405
+ for (const metric of catalogMetrics(this.deps.catalog, this.deps.clock())) await this.registry.declareMetric(metric);
2406
+ this.optedOutDevices = await this.store.loadOptOuts();
2407
+ await this.departures.load();
2408
+ await this.directory.refresh();
2409
+ await this.capture.seed();
2410
+ }
2411
+ async finishBoot() {
2412
+ const series = await this.rollup.bootRecompute(this.deps.clock());
2413
+ this.deps.logger.info("device-history: recent rollup window rebuilt", { meta: { series } });
2414
+ await this.reconcile();
2415
+ }
2416
+ startTimers() {
2417
+ const every = (ms, run) => {
2418
+ const timer = setInterval(() => {
2419
+ run().catch((err) => {
2420
+ this.deps.logger.warn("device-history: scheduled pass threw", { meta: { error: err instanceof Error ? err.message : String(err) } });
2421
+ });
2422
+ }, ms);
2423
+ timer.unref();
2424
+ this.timers.push(timer);
2425
+ };
2426
+ every(FLUSH_INTERVAL_MS, () => this.flushNow());
2427
+ every(ROLLUP_INTERVAL_MS, async () => {
2428
+ await this.rollup.tick();
2429
+ this.reportWindow();
2430
+ });
2431
+ every(RECONCILE_INTERVAL_MS, async () => {
2432
+ await this.reconcile();
2433
+ this.reportWindow();
2434
+ });
2435
+ every(RETENTION_INTERVAL_MS, async () => {
2436
+ await this.retentionJob.run(this.deps.retention(), this.deps.clock());
2437
+ this.reportWindow();
2438
+ });
2439
+ this.countTimeout = setTimeout(() => {
2440
+ this.refreshCounts().catch(() => void 0);
2441
+ }, FIRST_COUNT_DELAY_MS);
2442
+ this.countTimeout.unref();
2443
+ }
2444
+ async stop() {
2445
+ for (const t of this.timers) clearInterval(t);
2446
+ this.timers = [];
2447
+ if (this.countTimeout !== null) clearTimeout(this.countTimeout);
2448
+ this.countTimeout = null;
2449
+ await this.writer.flush();
2450
+ }
2451
+ /** Emit pending trailing points, then write the buffer. */
2452
+ async flushNow() {
2453
+ this.capture.tickPending(this.deps.clock());
2454
+ await this.writer.flush();
2455
+ }
2456
+ /**
2457
+ * Restore changes a dropped event missed. A FAILED read changes nothing (D49):
2458
+ * it is counted and logged, and the next pass tries again.
2459
+ */
2460
+ async reconcile() {
2461
+ const { logger, clock } = this.deps;
2462
+ if (await this.directory.refresh()) await this.departures.observe(this.directory.listedIds(), clock());
2463
+ let snapshots;
2464
+ try {
2465
+ snapshots = await this.deps.readSnapshots();
2466
+ } catch (err) {
2467
+ this.counters.reconcileFailures += 1;
2468
+ logger.warn("device-history: reconcile read failed — nothing changed", { meta: { error: err instanceof Error ? err.message : String(err) } });
2469
+ return;
2470
+ }
2471
+ const now = clock();
2472
+ this.capture.reconcile(snapshots, now);
2473
+ this.counters.reconcileRuns += 1;
2474
+ this.counters.lastReconcileAt = now;
2475
+ this.ready = true;
2476
+ }
2477
+ async refreshCounts() {
2478
+ for (const tier of [
2479
+ "raw",
2480
+ "5m",
2481
+ "1h",
2482
+ "1d"
2483
+ ]) {
2484
+ const deltaAtCount = this.counters.rowDeltaOf(tier);
2485
+ const rows = await this.store.countRows(tier);
2486
+ this.tierCounts.set(tier, {
2487
+ rows,
2488
+ countedAt: this.deps.clock(),
2489
+ deltaAtCount
2490
+ });
2491
+ }
2492
+ }
2493
+ /**
2494
+ * ONE aggregated INFO naming what was skipped, refused, deleted or failed since the
2495
+ * previous report (D391) — the counts a per-event line would drown. Silent when
2496
+ * nothing happened.
2497
+ */
2498
+ reportWindow() {
2499
+ const summary = this.counters.summarySinceLast();
2500
+ if (summary === null) return;
2501
+ this.deps.logger.info("device-history: since the last report", { meta: { ...summary } });
2502
+ }
2503
+ isEnabled(deviceId) {
2504
+ return !this.optedOutDevices.has(deviceId);
2505
+ }
2506
+ async setEnabled(deviceId, enabled) {
2507
+ if (enabled) {
2508
+ await this.store.clearOptOut(deviceId);
2509
+ this.optedOutDevices = new Set([...this.optedOutDevices].filter((id) => id !== deviceId));
2510
+ } else {
2511
+ await this.store.setOptOut(deviceId, this.deps.clock());
2512
+ this.optedOutDevices = new Set([...this.optedOutDevices, deviceId]);
2513
+ }
2514
+ return enabled;
2515
+ }
2516
+ /** Delete the history of a deleted device: its series, opt-out and absence clock. */
2517
+ async removeDevice(deviceId) {
2518
+ const series = await this.retentionJob.purgeDevice(deviceId, this.deps.clock());
2519
+ await this.store.clearOptOut(deviceId);
2520
+ await this.departures.forget(deviceId);
2521
+ this.optedOutDevices = new Set([...this.optedOutDevices].filter((id) => id !== deviceId));
2522
+ return { series };
2523
+ }
2524
+ /** Series held per device, for the admin clean-up listing. */
2525
+ seriesByDevice() {
2526
+ const out = /* @__PURE__ */ new Map();
2527
+ for (const s of this.registry.all()) {
2528
+ if (s.deviceId === null) continue;
2529
+ out.set(s.deviceId, (out.get(s.deviceId) ?? 0) + 1);
2530
+ }
2531
+ return out;
2532
+ }
2533
+ status() {
2534
+ const c = this.counters;
2535
+ const dirty = this.rollup.dirtyCounts();
2536
+ return {
2537
+ startedAt: this.startedAt,
2538
+ ready: this.ready,
2539
+ series: this.registry.all().length,
2540
+ metrics: this.registry.allMetrics().length,
2541
+ optedOutDevices: this.optedOutDevices.size,
2542
+ tiers: [
2543
+ "raw",
2544
+ "5m",
2545
+ "1h",
2546
+ "1d"
2547
+ ].map((tier) => {
2548
+ const count = this.tierCounts.get(tier);
2549
+ const rows = count === void 0 ? null : Math.max(0, count.rows + this.counters.rowDeltaOf(tier) - count.deltaAtCount);
2550
+ return {
2551
+ tier,
2552
+ rows,
2553
+ approxBytes: rows === null ? null : rows * APPROX_BYTES_PER_ROW[tier],
2554
+ countedAt: count?.countedAt ?? null
2555
+ };
2556
+ }),
2557
+ capture: {
2558
+ eventsSeen: c.eventsSeen,
2559
+ pointsEmitted: c.pointsEmitted,
2560
+ pointsWritten: c.pointsWritten,
2561
+ pointsDropped: c.pointsDropped,
2562
+ bufferDepth: this.writer.depth(),
2563
+ trailingEmitted: c.trailingEmitted,
2564
+ reconciledPoints: c.reconciledPoints,
2565
+ reconcileRuns: c.reconcileRuns,
2566
+ reconcileFailures: c.reconcileFailures,
2567
+ lastReconcileAt: c.lastReconcileAt,
2568
+ lastFlushAt: c.lastFlushAt,
2569
+ flushFailures: c.flushFailures,
2570
+ skipped: c.skippedRecord()
2571
+ },
2572
+ ingest: {
2573
+ accepted: c.ingestAccepted,
2574
+ refused: c.refusedRecord()
2575
+ },
2576
+ rollup: {
2577
+ last5mAt: c.last5mAt,
2578
+ last1hAt: c.last1hAt,
2579
+ last1dAt: c.last1dAt,
2580
+ errors: c.rollupErrors,
2581
+ dirty5m: dirty.m5,
2582
+ dirty1h: dirty.h1,
2583
+ dirty1d: dirty.d1
2584
+ },
2585
+ retention: {
2586
+ lastRunAt: c.retentionLastRunAt,
2587
+ deleted: c.deletedRecord(),
2588
+ purgedSeries: c.purgedSeries
2589
+ },
2590
+ directory: {
2591
+ devices: this.directory.size(),
2592
+ lastRefreshAt: this.directory.lastRefreshAt()
2593
+ }
2594
+ };
2595
+ }
2596
+ };
2597
+ //#endregion
2598
+ //#region src/builtins/device-history/device-history.addon.ts
2599
+ /**
2600
+ * device-history — the ONE mechanism for numeric history (D850). Hub-only, its
2601
+ * own runner (not grouped, D29): it writes through the settings door into
2602
+ * `history.db`, a file with its own serving thread (D851), so a slow rollup can
2603
+ * never queue behind — or in front of — configuration reads.
2604
+ *
2605
+ * This shell only wires: the capability provider, the bus subscription, the
2606
+ * timers and the retention settings. The engine is {@link HistoryRuntime}.
2607
+ *
2608
+ * Boot waits for `onHubReachable` — the settings door and the device manager
2609
+ * are hub capabilities, and a forked runner cannot call them from
2610
+ * `onInitialize`. Until then the provider answers "starting" rather than an
2611
+ * empty history (an unanswered query is not an empty one, D315).
2612
+ */
2613
+ var DEFAULT_CONFIG = {
2614
+ rawDays: DEFAULT_RETENTION.rawDays,
2615
+ fiveMinuteDays: DEFAULT_RETENTION.fiveMinuteDays,
2616
+ hourDays: DEFAULT_RETENTION.hourDays,
2617
+ dayDays: 0
2618
+ };
2619
+ function isRecord(value) {
2620
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2621
+ }
2622
+ /** The caller the transport stamped (never in an input schema); `null` when unstamped. */
2623
+ function callerOf(input) {
2624
+ if (!isRecord(input)) return null;
2625
+ const raw = input["callerAddonId"];
2626
+ return typeof raw === "string" && raw.length > 0 ? raw : null;
2627
+ }
2628
+ function positiveOr(value, fallback) {
2629
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
2630
+ }
2631
+ var DeviceHistoryAddon = class extends BaseAddon {
2632
+ runtime = null;
2633
+ unsubscribe = null;
2634
+ removalHook = null;
2635
+ constructor() {
2636
+ super({ ...DEFAULT_CONFIG });
2637
+ }
2638
+ retention() {
2639
+ const c = this.config;
2640
+ return {
2641
+ rawDays: positiveOr(c.rawDays, DEFAULT_RETENTION.rawDays),
2642
+ fiveMinuteDays: positiveOr(c.fiveMinuteDays, DEFAULT_RETENTION.fiveMinuteDays),
2643
+ hourDays: positiveOr(c.hourDays, DEFAULT_RETENTION.hourDays),
2644
+ dayDays: positiveOr(c.dayDays, 0) > 0 ? c.dayDays : null
2645
+ };
2646
+ }
2647
+ requireRuntime() {
2648
+ if (this.runtime === null) throw new Error("device-history is starting — the history store is not open yet");
2649
+ return this.runtime;
2650
+ }
2651
+ async onInitialize() {
2652
+ const provider = {
2653
+ listSeries: async (input) => this.requireRuntime().query.listSeries({
2654
+ kind: "device",
2655
+ deviceId: input.deviceId
2656
+ }),
2657
+ listNodeSeries: async (input) => this.requireRuntime().query.listSeries({
2658
+ kind: "node",
2659
+ nodeId: input.nodeId
2660
+ }),
2661
+ query: async ({ deviceId, ...window }) => this.requireRuntime().query.query({
2662
+ ...window,
2663
+ subject: {
2664
+ kind: "device",
2665
+ deviceId
2666
+ }
2667
+ }),
2668
+ queryNode: async ({ nodeId, ...window }) => this.requireRuntime().query.query({
2669
+ ...window,
2670
+ subject: {
2671
+ kind: "node",
2672
+ nodeId
2673
+ }
2674
+ }),
2675
+ declareSeries: async (input) => this.requireRuntime().ingest.declareSeries(callerOf(input), input),
2676
+ record: async (input) => this.requireRuntime().ingest.record(callerOf(input), input.points),
2677
+ recordBuckets: async (input) => this.requireRuntime().ingest.recordBuckets(callerOf(input), input.buckets),
2678
+ getDeviceEnabled: async (input) => ({ enabled: this.requireRuntime().isEnabled(input.deviceId) }),
2679
+ setDeviceEnabled: async (input) => ({ enabled: await this.requireRuntime().setEnabled(input.deviceId, input.enabled) }),
2680
+ getStatus: async () => this.requireRuntime().status()
2681
+ };
2682
+ this.ctx.logger.info("Initialized — device-history provider registered (store opens on hub link)");
2683
+ const removalHook = {
2684
+ onDeviceRemoved: async (input) => {
2685
+ const { series } = await this.requireRuntime().removeDevice(input.deviceId);
2686
+ if (series > 0) this.ctx.logger.info("device-history: removed the history of a deleted device", {
2687
+ tags: { deviceId: input.deviceId },
2688
+ meta: {
2689
+ counts: { series },
2690
+ deletedAt: input.deletedAt
2691
+ }
2692
+ });
2693
+ return { counts: { series } };
2694
+ },
2695
+ listDeviceData: async () => [...this.requireRuntime().seriesByDevice()].map(([deviceId, count]) => ({
2696
+ store: "series",
2697
+ deviceId,
2698
+ count
2699
+ }))
2700
+ };
2701
+ this.removalHook = removalHook;
2702
+ return [{
2703
+ capability: deviceHistoryCapability,
2704
+ provider
2705
+ }, {
2706
+ capability: deviceRemovalHookCapability,
2707
+ provider: removalHook
2708
+ }];
2709
+ }
2710
+ async onHubReachable() {
2711
+ const api = this.ctx.api;
2712
+ const runtime = new HistoryRuntime({
2713
+ door: api.settingsStore,
2714
+ catalog: HISTORY_CATALOG,
2715
+ deviceSource: {
2716
+ listAll: async () => {
2717
+ return (await api.deviceManager.listAll.query({ projection: "slim" })).map((d) => ({
2718
+ id: d.id,
2719
+ type: d.type
2720
+ }));
2721
+ },
2722
+ getDevice: async (deviceId) => {
2723
+ const d = await api.deviceManager.getDevice.query({ deviceId });
2724
+ return d === null ? null : {
2725
+ id: d.id,
2726
+ type: d.type
2727
+ };
2728
+ }
2729
+ },
2730
+ readSnapshots: async () => api.deviceState.getAllSnapshots.query({}),
2731
+ readCapSlice: (deviceId, capName) => api.deviceState.getCapSlice.query({
2732
+ deviceId,
2733
+ capName
2734
+ }),
2735
+ retention: () => this.retention(),
2736
+ logger: this.ctx.logger,
2737
+ clock: () => Date.now()
2738
+ });
2739
+ await runtime.prepare();
2740
+ this.unsubscribe = this.ctx.eventBus.subscribe({ category: EventCategory.DeviceStateChanged }, (event) => {
2741
+ const data = event.data;
2742
+ if (!isRecord(data)) return;
2743
+ const { deviceId, capName, slice } = data;
2744
+ if (typeof deviceId !== "number" || typeof capName !== "string" || !isRecord(slice)) return;
2745
+ const at = event.timestamp instanceof Date ? event.timestamp.getTime() : Date.now();
2746
+ runtime.capture.handleState(deviceId, capName, slice, at);
2747
+ });
2748
+ this.ctx.addDisposer(async () => this.stopRuntime());
2749
+ this.runtime = runtime;
2750
+ await runtime.finishBoot();
2751
+ runtime.startTimers();
2752
+ const removalHook = this.removalHook;
2753
+ if (removalHook !== null) this.ctx.addDisposer(armRemovalReconcile({
2754
+ api: this.ctx.api,
2755
+ logger: this.ctx.logger,
2756
+ remove: (input) => removalHook.onDeviceRemoved(input)
2757
+ }));
2758
+ this.ctx.logger.info("device-history: capturing", { meta: {
2759
+ series: runtime.status().series,
2760
+ metrics: runtime.status().metrics
2761
+ } });
2762
+ }
2763
+ async stopRuntime() {
2764
+ this.unsubscribe?.();
2765
+ this.unsubscribe = null;
2766
+ await this.runtime?.stop();
2767
+ }
2768
+ async onShutdown() {
2769
+ await this.stopRuntime();
2770
+ this.runtime = null;
2771
+ }
2772
+ globalSettingsSchema() {
2773
+ return this.schema({ sections: [{
2774
+ id: "retention",
2775
+ title: "Retention",
2776
+ description: "How long each tier is kept. The raw points feed the 5-minute rollup; the 1-day tier is kept forever unless a number of days is set.",
2777
+ columns: 2,
2778
+ fields: [
2779
+ {
2780
+ type: "number",
2781
+ key: "rawDays",
2782
+ label: "Raw points (days)",
2783
+ min: 1,
2784
+ max: 60,
2785
+ step: 1
2786
+ },
2787
+ {
2788
+ type: "number",
2789
+ key: "fiveMinuteDays",
2790
+ label: "5-minute rollup (days)",
2791
+ min: 1,
2792
+ max: 365,
2793
+ step: 1
2794
+ },
2795
+ {
2796
+ type: "number",
2797
+ key: "hourDays",
2798
+ label: "1-hour rollup (days)",
2799
+ min: 7,
2800
+ max: 3650,
2801
+ step: 1
2802
+ },
2803
+ {
2804
+ type: "number",
2805
+ key: "dayDays",
2806
+ label: "1-day rollup (days, 0 = forever)",
2807
+ min: 0,
2808
+ max: 36500,
2809
+ step: 1
2810
+ }
2811
+ ]
2812
+ }] });
2813
+ }
2814
+ };
2815
+ //#endregion
2816
+ export { DeviceHistoryAddon, DeviceHistoryAddon as default, callerOf };