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