@camstack/system 1.2.456 → 1.2.458

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 (123) hide show
  1. package/dist/addon-runner.js +259 -37
  2. package/dist/addon-runner.mjs +289 -67
  3. package/dist/{agent-https-fm4AR8XL.mjs → agent-https-CQA3W-3e.mjs} +2 -2
  4. package/dist/{agent-https-yjxdfJDA.js → agent-https-DOELEug8.js} +2 -2
  5. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  9. package/dist/builtins/alerts/alerts.addon.js +2 -2
  10. package/dist/builtins/alerts/alerts.addon.mjs +2 -2
  11. package/dist/builtins/autotrack/index.js +1 -1
  12. package/dist/builtins/autotrack/index.mjs +1 -1
  13. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2 -2
  14. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
  15. package/dist/builtins/calendars/calendars.addon.js +1 -1
  16. package/dist/builtins/calendars/calendars.addon.mjs +1 -1
  17. package/dist/builtins/camera-grid/index.js +1 -1
  18. package/dist/builtins/camera-grid/index.mjs +1 -1
  19. package/dist/builtins/composer/composer.addon.js +2 -2
  20. package/dist/builtins/composer/composer.addon.mjs +2 -2
  21. package/dist/builtins/console-logging/console-destination.d.ts +1 -2
  22. package/dist/builtins/console-logging/index.js +1 -4
  23. package/dist/builtins/console-logging/index.mjs +1 -4
  24. package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
  25. package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
  26. package/dist/builtins/device-history/capture/capture-engine.d.ts +6 -0
  27. package/dist/builtins/device-history/device-history.addon.d.ts +13 -0
  28. package/dist/builtins/device-history/device-history.addon.js +736 -315
  29. package/dist/builtins/device-history/device-history.addon.mjs +736 -315
  30. package/dist/builtins/device-history/ingest/producer-ingest.d.ts +11 -1
  31. package/dist/builtins/device-history/query/history-query.d.ts +35 -9
  32. package/dist/builtins/device-history/query/point-shape.d.ts +33 -0
  33. package/dist/builtins/device-history/retention/retention.d.ts +1 -1
  34. package/dist/builtins/device-history/rollup/rollup-engine.d.ts +2 -0
  35. package/dist/builtins/device-history/rollup/rollup-math.d.ts +6 -0
  36. package/dist/builtins/device-history/store/history-store.d.ts +7 -0
  37. package/dist/builtins/device-history/store/series-registry.d.ts +26 -1
  38. package/dist/builtins/device-manager/device-manager.addon.js +1 -1
  39. package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
  40. package/dist/builtins/hub-forwarder/hub-forwarder-destination.d.ts +1 -2
  41. package/dist/builtins/hub-forwarder/index.js +1 -4
  42. package/dist/builtins/hub-forwarder/index.mjs +1 -4
  43. package/dist/builtins/keychain/index.js +4 -4
  44. package/dist/builtins/keychain/index.mjs +4 -4
  45. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  46. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  47. package/dist/builtins/local-auth/local-auth.addon.js +3 -3
  48. package/dist/builtins/local-auth/local-auth.addon.mjs +3 -3
  49. package/dist/builtins/local-network/local-network.addon.js +2 -2
  50. package/dist/builtins/local-network/local-network.addon.mjs +2 -2
  51. package/dist/builtins/loki-logging/index.js +323 -7
  52. package/dist/builtins/loki-logging/index.mjs +323 -7
  53. package/dist/builtins/loki-logging/loki-destination.d.ts +20 -3
  54. package/dist/builtins/loki-logging/loki-read.d.ts +51 -0
  55. package/dist/builtins/native-metrics/native-metrics.addon.js +290 -112
  56. package/dist/builtins/native-metrics/native-metrics.addon.mjs +290 -112
  57. package/dist/builtins/native-metrics/node-metrics-history-recorder.d.ts +106 -35
  58. package/dist/builtins/platform-probe/coral-accelerators.d.ts +26 -9
  59. package/dist/builtins/platform-probe/index.js +11 -68
  60. package/dist/builtins/platform-probe/index.mjs +11 -68
  61. package/dist/builtins/record-entries/index.js +1 -1
  62. package/dist/builtins/record-entries/index.mjs +1 -1
  63. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  64. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  65. package/dist/builtins/snapshot/index.js +1 -1
  66. package/dist/builtins/snapshot/index.mjs +1 -1
  67. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  68. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  69. package/dist/builtins/sqlite-storage/index.js +1 -1
  70. package/dist/builtins/sqlite-storage/index.mjs +1 -1
  71. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +2 -2
  72. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +2 -2
  73. package/dist/builtins/storage-orchestrator/location-history-recorder.d.ts +76 -0
  74. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.d.ts +9 -0
  75. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +144 -1
  76. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +144 -1
  77. package/dist/builtins/system-config/system-config.addon.js +3 -3
  78. package/dist/builtins/system-config/system-config.addon.mjs +3 -3
  79. package/dist/builtins/winston-logging/index.js +165 -3
  80. package/dist/builtins/winston-logging/index.mjs +165 -3
  81. package/dist/builtins/winston-logging/log-file-cursor.d.ts +12 -0
  82. package/dist/builtins/winston-logging/log-file-index-store.d.ts +11 -0
  83. package/dist/builtins/winston-logging/log-file-index.d.ts +77 -0
  84. package/dist/builtins/winston-logging/log-file-read-client.d.ts +44 -0
  85. package/dist/builtins/winston-logging/log-file-read-protocol.d.ts +41 -0
  86. package/dist/builtins/winston-logging/log-file-read-worker.d.ts +1 -0
  87. package/dist/builtins/winston-logging/log-file-reader.d.ts +51 -0
  88. package/dist/builtins/winston-logging/winston-destination.d.ts +20 -2
  89. package/dist/{composition-sources-Ds0Vr6eD.mjs → composition-sources-BVguN7VV.mjs} +1 -1
  90. package/dist/{composition-sources-gB4coDJR.js → composition-sources-DURQNDJ4.js} +1 -1
  91. package/dist/{constants-i5oD_gLB.mjs → constants-FGjnsE2x.mjs} +1 -1
  92. package/dist/{constants-CavyKfHZ.js → constants-Njka8Cv6.js} +1 -1
  93. package/dist/coral-accelerators-Bi_c3Rft.js +175 -0
  94. package/dist/coral-accelerators-DcnZvctM.mjs +162 -0
  95. package/dist/{device-ca-bpoEr0zG.js → device-ca-BrVK_5s3.js} +1 -1
  96. package/dist/{device-ca-qz1Pmeu1.mjs → device-ca-lEaAQ9Xi.mjs} +1 -1
  97. package/dist/{dist-BpqA2CT0.js → dist-BZQjLPiz.js} +526 -57
  98. package/dist/{dist-BSF_C8W2.mjs → dist-B_GRPhnY.mjs} +509 -58
  99. package/dist/file-host-worker.js +1 -1
  100. package/dist/file-host-worker.mjs +1 -1
  101. package/dist/{hub-mdns-ciao-CHaGDzyz.mjs → hub-mdns-ciao-Bg2HfTwP.mjs} +2 -2
  102. package/dist/{hub-mdns-ciao-CZlZRxxA.js → hub-mdns-ciao-nce3TXs_.js} +2 -2
  103. package/dist/index.d.ts +1 -0
  104. package/dist/index.js +140 -48
  105. package/dist/index.mjs +134 -49
  106. package/dist/kernel/config-schema.d.ts +5 -0
  107. package/dist/kernel/hwaccel/hwaccel-resolver.d.ts +7 -3
  108. package/dist/kernel/moleculer/accessory-prune-gate.d.ts +55 -0
  109. package/dist/log-file-read-protocol-B7M3X3bT.mjs +785 -0
  110. package/dist/log-file-read-protocol-D0WcLbIV.js +817 -0
  111. package/dist/log-file-read-worker.js +64 -0
  112. package/dist/log-file-read-worker.mjs +64 -0
  113. package/dist/log-read-filter-Bi066jwU.js +134 -0
  114. package/dist/log-read-filter-D7Ev3zm-.mjs +93 -0
  115. package/dist/logging/log-manager.d.ts +10 -6
  116. package/dist/logging/log-read-filter.d.ts +43 -0
  117. package/dist/{phase6-declare-gate-n-IZRklX.js → phase6-declare-gate-Dag1_WR2.js} +1 -1
  118. package/dist/{phase6-declare-gate-B4yfsf2n.mjs → phase6-declare-gate-Lb-mrVvz.mjs} +1 -1
  119. package/dist/{restore-archive-BGUM4FKf.mjs → restore-archive-C7kLDI_0.mjs} +1 -1
  120. package/dist/{restore-archive-B4QG7XmX.js → restore-archive-FyfZ8YMC.js} +1 -1
  121. package/dist/{wal-maintenance-CNEs_I94.js → wal-maintenance-DfSIOj24.js} +0 -0
  122. package/dist/{wal-maintenance-AerF4XE6.mjs → wal-maintenance-Duejh7Lx.mjs} +0 -0
  123. package/package.json +1 -1
@@ -3,7 +3,239 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-BpqA2CT0.js");
6
+ const require_dist = require("../../dist-BZQjLPiz.js");
7
+ //#region src/builtins/device-history/time/bucket-time.ts
8
+ /**
9
+ * Bucket arithmetic for the three rollup tiers (D850, plan §4/§5.5).
10
+ *
11
+ * 5m and 1h buckets are aligned to UTC; a 1d bucket starts at HUB-LOCAL
12
+ * midnight, so a day is 23, 24 or 25 hours long across a DST change (operator
13
+ * answer Q4). Pure: the zone is the process zone (`TZ`), nothing is read from
14
+ * the environment here.
15
+ */
16
+ var FIVE_MINUTES_MS = 5 * 6e4;
17
+ var HOUR_MS = 60 * 6e4;
18
+ var DAY_MS = 24 * HOUR_MS;
19
+ function floorTo(ts, widthMs) {
20
+ return Math.floor(ts / widthMs) * widthMs;
21
+ }
22
+ /** Hub-local midnight at or before `ts`. */
23
+ function startOfLocalDay(ts) {
24
+ const d = new Date(ts);
25
+ d.setHours(0, 0, 0, 0);
26
+ return d.getTime();
27
+ }
28
+ /** The local midnight following the local midnight `dayStart`. */
29
+ function nextLocalDayStart(dayStart) {
30
+ const d = new Date(dayStart);
31
+ d.setDate(d.getDate() + 1);
32
+ d.setHours(0, 0, 0, 0);
33
+ return d.getTime();
34
+ }
35
+ /** Start of the `tier` bucket containing `ts`. */
36
+ function bucketStartOf(tier, ts) {
37
+ if (tier === "5m") return floorTo(ts, FIVE_MINUTES_MS);
38
+ if (tier === "1h") return floorTo(ts, HOUR_MS);
39
+ return startOfLocalDay(ts);
40
+ }
41
+ /** Exclusive end of the `tier` bucket that starts at `start`. */
42
+ function bucketEndOf(tier, start) {
43
+ if (tier === "5m") return start + FIVE_MINUTES_MS;
44
+ if (tier === "1h") return start + HOUR_MS;
45
+ return nextLocalDayStart(start);
46
+ }
47
+ /** Quiet time after a bucket closes before it is rolled up (late writers). */
48
+ var ROLLUP_GRACE_MS = {
49
+ "5m": 3e4,
50
+ "1h": 5 * 6e4,
51
+ "1d": 15 * 6e4
52
+ };
53
+ //#endregion
54
+ //#region src/builtins/device-history/retention/departure-tracker.ts
55
+ var DEPARTED_ABSENCE_MS = 7 * DAY_MS;
56
+ var DepartureTracker = class {
57
+ store;
58
+ registry;
59
+ logger;
60
+ missing = /* @__PURE__ */ new Map();
61
+ constructor(store, registry, logger) {
62
+ this.store = store;
63
+ this.registry = registry;
64
+ this.logger = logger;
65
+ }
66
+ async load() {
67
+ this.missing = await this.store.loadDepartures();
68
+ }
69
+ missingSince(deviceId) {
70
+ return this.missing.get(deviceId);
71
+ }
72
+ /** Forget a device's clock — its series were removed because it was DELETED (D865). */
73
+ async forget(deviceId) {
74
+ await this.store.clearMissing([deviceId]);
75
+ const next = new Map(this.missing);
76
+ next.delete(deviceId);
77
+ this.missing = next;
78
+ }
79
+ /** Feed one SUCCESSFUL listing. A failed read never reaches here. */
80
+ async observe(listed, now) {
81
+ const withSeries = new Set(this.registry.all().flatMap((s) => s.deviceId === null ? [] : [s.deviceId]));
82
+ const back = [...this.missing.keys()].filter((id) => listed.has(id));
83
+ const gone = [...withSeries].filter((id) => !listed.has(id) && !this.missing.has(id));
84
+ try {
85
+ await this.store.clearMissing(back);
86
+ await this.store.markMissing(gone, now);
87
+ } catch (err) {
88
+ this.logger.warn("device-history: departure clock not written", { meta: { error: err instanceof Error ? err.message : String(err) } });
89
+ return;
90
+ }
91
+ const next = new Map(this.missing);
92
+ for (const id of back) next.delete(id);
93
+ for (const id of gone) next.set(id, now);
94
+ this.missing = next;
95
+ }
96
+ };
97
+ //#endregion
98
+ //#region src/builtins/device-history/retention/retention.ts
99
+ var DEFAULT_RETENTION = {
100
+ rawDays: 7,
101
+ fiveMinuteDays: 30,
102
+ hourDays: 365,
103
+ dayDays: null
104
+ };
105
+ /** Cutoff per tier: rows strictly older are deleted. `null` = keep forever. */
106
+ function retentionCutoffs(policy, now) {
107
+ return {
108
+ raw: now - policy.rawDays * DAY_MS,
109
+ "5m": now - policy.fiveMinuteDays * DAY_MS,
110
+ "1h": now - policy.hourDays * DAY_MS,
111
+ "1d": policy.dayDays === null || policy.dayDays <= 0 ? null : now - policy.dayDays * DAY_MS
112
+ };
113
+ }
114
+ var TIERS = [
115
+ "raw",
116
+ "5m",
117
+ "1h",
118
+ "1d"
119
+ ];
120
+ var RETENTION_STEP_MS = {
121
+ raw: HOUR_MS,
122
+ "5m": HOUR_MS,
123
+ "1h": DAY_MS,
124
+ "1d": DAY_MS
125
+ };
126
+ var MAX_PURGES_PER_RUN = 20;
127
+ /** Width of one delete statement's time range, per tier. */
128
+ var SLICE_MS = {
129
+ raw: DAY_MS,
130
+ "5m": DAY_MS,
131
+ "1h": 30 * DAY_MS,
132
+ "1d": 30 * DAY_MS
133
+ };
134
+ /** A bucket's start may precede the series' first point by up to a day. */
135
+ var BUCKET_LEAD_MS = 2 * DAY_MS;
136
+ /** The short tiers hold nothing older than this unless the operator configured more. */
137
+ var SHORT_TIER_LOOKBACK_MS = 400 * DAY_MS;
138
+ var yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve));
139
+ var RetentionJob = class {
140
+ deps;
141
+ appliedUpTo = /* @__PURE__ */ new Map();
142
+ running = null;
143
+ constructor(deps) {
144
+ this.deps = deps;
145
+ }
146
+ run(policy, now) {
147
+ if (this.running !== null) return this.running;
148
+ const run = this.doRun(policy, now).finally(() => {
149
+ this.running = null;
150
+ });
151
+ this.running = run;
152
+ return run;
153
+ }
154
+ async doRun(policy, now) {
155
+ const { registry, counters, logger } = this.deps;
156
+ const cutoffs = retentionCutoffs(policy, now);
157
+ try {
158
+ for (const tier of TIERS) {
159
+ const cutoff = cutoffs[tier];
160
+ if (cutoff === null) continue;
161
+ for (const series of registry.all()) {
162
+ if (series.firstTs - 864e5 >= cutoff) continue;
163
+ if (tier === "raw" && registry.metric(series.key)?.ingest === "buckets") continue;
164
+ const marker = `${tier}:${series.id}`;
165
+ const applied = this.appliedUpTo.get(marker);
166
+ if (applied !== void 0 && cutoff - applied < RETENTION_STEP_MS[tier]) continue;
167
+ const lo = Math.max(0, applied === void 0 ? series.firstTs - BUCKET_LEAD_MS : applied - RETENTION_STEP_MS[tier]);
168
+ counters.addDeleted(tier, await this.deleteSliced(tier, series.id, lo, cutoff - 1));
169
+ this.appliedUpTo.set(marker, cutoff);
170
+ }
171
+ }
172
+ await this.purgeDeparted(now);
173
+ counters.retentionLastRunAt = now;
174
+ } catch (err) {
175
+ logger.warn("device-history: retention pass failed — retried at the next run", { meta: { error: err instanceof Error ? err.message : String(err) } });
176
+ }
177
+ }
178
+ /** Delete `[lo, hiInclusive]` of one tier in time slices, yielding between them. */
179
+ /** Drop the visit markers of a purged series. */
180
+ forgetSeries(seriesId) {
181
+ for (const tier of TIERS) this.appliedUpTo.delete(`${tier}:${seriesId}`);
182
+ }
183
+ async deleteSliced(tier, seriesId, lo, hiInclusive) {
184
+ const yieldNow = this.deps.yieldNow ?? yieldToEventLoop;
185
+ let deleted = 0;
186
+ for (let from = lo; from <= hiInclusive; from += SLICE_MS[tier]) {
187
+ const to = Math.min(from + SLICE_MS[tier] - 1, hiInclusive);
188
+ deleted += await this.deps.store.deleteRange(tier, seriesId, from, to);
189
+ await yieldNow();
190
+ }
191
+ return deleted;
192
+ }
193
+ /** Tier by tier in slices, then the series row (and whatever the slices missed). */
194
+ async purgeSeries(series, now) {
195
+ for (const tier of TIERS) {
196
+ const lo = tier === "raw" || tier === "5m" ? Math.max(series.firstTs - BUCKET_LEAD_MS, now - SHORT_TIER_LOOKBACK_MS) : series.firstTs - BUCKET_LEAD_MS;
197
+ await this.deleteSliced(tier, series.id, Math.max(0, lo), now + DAY_MS);
198
+ }
199
+ await this.deps.store.deleteSeries(series.id);
200
+ }
201
+ /**
202
+ * Delete every series of a DELETED device now (D865). Driven by a tombstone
203
+ * through the `device-removal-hook`, never by a listing: the week-long
204
+ * absence clock above remains the backstop for a device removed before
205
+ * tombstones existed.
206
+ */
207
+ async purgeDevice(deviceId, now) {
208
+ const { registry, counters } = this.deps;
209
+ let purged = 0;
210
+ for (const series of registry.all()) {
211
+ if (series.deviceId !== deviceId) continue;
212
+ await this.purgeSeries(series, now);
213
+ registry.remove(series.id);
214
+ counters.purgedSeries += 1;
215
+ purged += 1;
216
+ }
217
+ return purged;
218
+ }
219
+ async purgeDeparted(now) {
220
+ const { registry, departures, counters, logger } = this.deps;
221
+ let purged = 0;
222
+ for (const series of registry.all()) {
223
+ if (purged >= MAX_PURGES_PER_RUN) break;
224
+ if (series.deviceId === null) continue;
225
+ const since = departures.missingSince(series.deviceId);
226
+ if (since === void 0 || now - since < DEPARTED_ABSENCE_MS) continue;
227
+ await this.purgeSeries(series, now);
228
+ registry.remove(series.id);
229
+ counters.purgedSeries += 1;
230
+ purged += 1;
231
+ logger.info("device-history: purged the series of an unregistered device", {
232
+ tags: { deviceId: series.deviceId },
233
+ meta: { key: series.key }
234
+ });
235
+ }
236
+ }
237
+ };
238
+ //#endregion
7
239
  //#region src/builtins/device-history/store/series-registry.ts
8
240
  function subjectIdOf(subject) {
9
241
  return subject.kind === "device" ? `d:${subject.deviceId}` : `n:${subject.nodeId}`;
@@ -11,16 +243,29 @@ function subjectIdOf(subject) {
11
243
  function deviceIdOf(subject) {
12
244
  return subject.kind === "device" ? subject.deviceId : null;
13
245
  }
246
+ /** Every metric key is a cap field (`cap:`), an addon's series (`addon:`) or derived. */
247
+ function isMetricKey(key) {
248
+ return key.startsWith("cap:") || key.startsWith("addon:");
249
+ }
14
250
  function identityOf(key, subjectId, scope) {
15
251
  return `${key}\u0000${subjectId}\u0000${scope}`;
16
252
  }
17
253
  var LOAD_PAGE = 5e3;
254
+ /** Append what `next` names that `stored` lacks, in `next`'s order: an ordinal never moves. */
255
+ function mergeEnumValues(stored, next) {
256
+ if (stored === null) return next;
257
+ if (next === null) return stored;
258
+ const missing = next.filter((v) => !stored.includes(v));
259
+ return missing.length === 0 ? stored : [...stored, ...missing];
260
+ }
18
261
  var SeriesRegistry = class {
19
262
  store;
20
263
  metrics = /* @__PURE__ */ new Map();
21
264
  byIdentity = /* @__PURE__ */ new Map();
22
265
  byId = /* @__PURE__ */ new Map();
23
266
  inflight = /* @__PURE__ */ new Map();
267
+ /** Enum metrics whose value list grew in memory and is not stored yet. */
268
+ dirtyMetrics = /* @__PURE__ */ new Set();
24
269
  removeListeners = [];
25
270
  constructor(store) {
26
271
  this.store = store;
@@ -29,7 +274,10 @@ var SeriesRegistry = class {
29
274
  this.metrics.clear();
30
275
  this.byIdentity.clear();
31
276
  this.byId.clear();
32
- for (const m of await this.store.loadMetrics()) this.metrics.set(m.key, m);
277
+ const stray = [];
278
+ for (const m of await this.store.loadMetrics()) if (isMetricKey(m.key)) this.metrics.set(m.key, m);
279
+ else stray.push(m.key);
280
+ if (stray.length > 0) await this.store.deleteMetrics(stray);
33
281
  let afterId = 0;
34
282
  for (;;) {
35
283
  const page = await this.store.loadSeriesPage(afterId, LOAD_PAGE);
@@ -49,12 +297,48 @@ var SeriesRegistry = class {
49
297
  allMetrics() {
50
298
  return [...this.metrics.values()];
51
299
  }
52
- /** Persist and remember a metric; idempotent. */
300
+ /**
301
+ * Persist and remember a metric; idempotent. An `enum` value list is MERGED
302
+ * append-only with the stored one, so reordering a `z.enum` never changes
303
+ * what a stored ordinal means.
304
+ */
53
305
  async declareMetric(metric) {
54
306
  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);
307
+ const merged = {
308
+ ...metric,
309
+ values: mergeEnumValues(existing?.values ?? null, metric.values)
310
+ };
311
+ if (existing !== void 0 && sameMetric(existing, merged)) return;
312
+ await this.store.upsertMetric(merged);
313
+ this.metrics.set(metric.key, merged);
314
+ }
315
+ /**
316
+ * The ordinal of `value` in an `enum` metric's list, learning (appending) a
317
+ * value seen for the first time. `null` when the key is not an enum metric or
318
+ * its list is full ({@link MAX_ENUM_VALUES}). A learned value is stored by
319
+ * {@link persistDirtyMetrics} BEFORE any point that uses it.
320
+ */
321
+ ordinalOf(key, value) {
322
+ const metric = this.metrics.get(key);
323
+ if (metric === void 0 || metric.kind !== "enum") return null;
324
+ const values = metric.values ?? [];
325
+ const known = values.indexOf(value);
326
+ if (known >= 0) return known;
327
+ if (values.length >= 1024) return null;
328
+ this.metrics.set(key, {
329
+ ...metric,
330
+ values: [...values, value]
331
+ });
332
+ this.dirtyMetrics.add(key);
333
+ return values.length;
334
+ }
335
+ /** Store every value list that grew in memory. Throws (and keeps them dirty) on a failed write. */
336
+ async persistDirtyMetrics() {
337
+ for (const key of this.dirtyMetrics) {
338
+ const metric = this.metrics.get(key);
339
+ if (metric !== void 0) await this.store.upsertMetric(metric);
340
+ this.dirtyMetrics.delete(key);
341
+ }
58
342
  }
59
343
  find(key, subjectId, scope) {
60
344
  return this.byIdentity.get(identityOf(key, subjectId, scope));
@@ -119,7 +403,7 @@ var SeriesRegistry = class {
119
403
  }
120
404
  };
121
405
  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;
406
+ 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 && JSON.stringify(a.values) === JSON.stringify(b.values);
123
407
  }
124
408
  //#endregion
125
409
  //#region src/builtins/device-history/capture/capture-filter.ts
@@ -143,10 +427,10 @@ var DEADBAND_EPSILON = 1e-9;
143
427
  /** The capture floor of a field: its own `minIntervalMs`, else the kind's default. */
144
428
  function minIntervalOf(spec) {
145
429
  if (spec.minIntervalMs !== void 0) return spec.minIntervalMs;
146
- return spec.kind === "boolean" ? 0 : require_dist.HISTORY_DEFAULT_MIN_INTERVAL_MS;
430
+ return spec.kind === "boolean" || spec.kind === "enum" ? 0 : require_dist.HISTORY_DEFAULT_MIN_INTERVAL_MS;
147
431
  }
148
432
  function clearsDeadband(spec, last, value) {
149
- if (spec.kind === "boolean") return true;
433
+ if (spec.kind === "boolean" || spec.kind === "enum") return true;
150
434
  const delta = Math.abs(value - last);
151
435
  if (spec.deadband !== void 0 && delta < spec.deadband - DEADBAND_EPSILON) return false;
152
436
  if (spec.deadbandPct !== void 0) {
@@ -331,7 +615,8 @@ function catalogMetrics(catalog, now) {
331
615
  unit: spec.unit ?? null,
332
616
  displayName: spec.displayName ?? humanize(capName, path),
333
617
  ingest: "points",
334
- declaredAt: now
618
+ declaredAt: now,
619
+ values: spec.kind === "enum" ? spec.values ?? [] : null
335
620
  })));
336
621
  }
337
622
  /** Devices waiting on a type lookup; beyond this the oldest is dropped (and counted). */
@@ -380,11 +665,28 @@ var CaptureEngine = class {
380
665
  counters.skip("excluded-device-type");
381
666
  continue;
382
667
  }
383
- const value = readNumeric(slice, path, spec.kind);
668
+ const value = this.readValue(slice, capName, path, spec);
384
669
  if (value === null) continue;
385
670
  this.consider(deviceId, capName, path, spec, value, at, false);
386
671
  }
387
672
  }
673
+ /**
674
+ * The number a field contributes, or `null` (not a reading — never 0). An
675
+ * `enum` state becomes the ordinal of its value in the metric's list; a value
676
+ * the full list cannot take is refused and counted (`enum-overflow`).
677
+ */
678
+ readValue(slice, capName, path, spec) {
679
+ if (spec.skipWhenSet !== void 0) {
680
+ const gate = readPath(slice, spec.skipWhenSet);
681
+ if (gate !== void 0 && gate !== null) return null;
682
+ }
683
+ if (spec.kind !== "enum") return readNumeric(slice, path, spec.kind);
684
+ const raw = readPath(slice, path);
685
+ if (typeof raw !== "string" || raw.length === 0) return null;
686
+ const ordinal = this.deps.registry.ordinalOf(capSeriesKey(capName, path), raw);
687
+ if (ordinal === null) this.deps.counters.skip("enum-overflow");
688
+ return ordinal;
689
+ }
388
690
  consider(deviceId, capName, path, spec, value, at, reconciling) {
389
691
  const key = capSeriesKey(capName, path);
390
692
  const subject = {
@@ -490,7 +792,7 @@ var CaptureEngine = class {
490
792
  if (slice === void 0) continue;
491
793
  for (const [path, spec] of Object.entries(fields)) {
492
794
  if (spec.excludeDeviceTypes?.includes(type) === true) continue;
493
- const value = readNumeric(slice, path, spec.kind);
795
+ const value = this.readValue(slice, capName, path, spec);
494
796
  if (value === null) continue;
495
797
  this.consider(deviceId, capName, path, spec, value, now, true);
496
798
  }
@@ -573,6 +875,7 @@ var PointWriter = class {
573
875
  this.queue = [];
574
876
  const { registry, store, counters, logger, clock } = this.deps;
575
877
  try {
878
+ await registry.persistDirtyMetrics();
576
879
  const bySeries = /* @__PURE__ */ new Map();
577
880
  for (const point of batch) {
578
881
  const series = await registry.ensure(point.key, point.subject, point.scope, point.ts);
@@ -664,52 +967,27 @@ var PointWriter = class {
664
967
  }
665
968
  };
666
969
  //#endregion
667
- //#region src/builtins/device-history/time/bucket-time.ts
970
+ //#region src/builtins/device-history/ingest/producer-ingest.ts
971
+ /** One point per series per this many ms, sustained. */
972
+ var RECORD_MIN_INTERVAL_MS = 1e4;
668
973
  /**
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.
974
+ * The slot after accepting a point at `ts`. A point older than the newest
975
+ * accepted one (a late arrival) moves nothing. Otherwise the consumed slot is
976
+ * the later of the one that was due and `ts` itself: an early point uses the
977
+ * due slot (the grid holds, so slack never compounds into a higher rate), a
978
+ * late one restarts the grid at `ts` (no credit is banked for a gap).
675
979
  */
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);
980
+ function nextSlot(slot, ts) {
981
+ if (slot === void 0) return {
982
+ lastTs: ts,
983
+ nextDue: ts + RECORD_MIN_INTERVAL_MS
984
+ };
985
+ if (ts < slot.lastTs) return slot;
986
+ return {
987
+ lastTs: ts,
988
+ nextDue: Math.max(slot.nextDue, ts) + RECORD_MIN_INTERVAL_MS
989
+ };
706
990
  }
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
991
  function addRefusal(refusals, key, reason) {
714
992
  const byReason = refusals.get(key) ?? /* @__PURE__ */ new Map();
715
993
  byReason.set(reason, (byReason.get(reason) ?? 0) + 1);
@@ -727,7 +1005,7 @@ function toResult(accepted, refusals) {
727
1005
  }
728
1006
  var ProducerIngest = class {
729
1007
  deps;
730
- /** Last accepted point time per series, for the sustained-rate bound. */
1008
+ /** Rate state per series identity, for the sustained-rate bound. */
731
1009
  lastAccepted = /* @__PURE__ */ new Map();
732
1010
  constructor(deps) {
733
1011
  this.deps = deps;
@@ -748,7 +1026,8 @@ var ProducerIngest = class {
748
1026
  unit: input.unit ?? null,
749
1027
  displayName: input.displayName,
750
1028
  ingest: input.ingest,
751
- declaredAt: this.deps.clock()
1029
+ declaredAt: this.deps.clock(),
1030
+ values: null
752
1031
  };
753
1032
  await this.deps.registry.declareMetric(metric);
754
1033
  return { key };
@@ -785,13 +1064,13 @@ var ProducerIngest = class {
785
1064
  continue;
786
1065
  }
787
1066
  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) {
1067
+ const slot = this.lastAccepted.get(identity);
1068
+ if (slot !== void 0 && point.ts >= slot.lastTs && point.ts < slot.nextDue - 1e3) {
790
1069
  addRefusal(refusals, point.key, "rate");
791
1070
  counters.refuse("rate");
792
1071
  continue;
793
1072
  }
794
- this.lastAccepted.set(identity, Math.max(last ?? point.ts, point.ts));
1073
+ this.lastAccepted.set(identity, nextSlot(slot, point.ts));
795
1074
  this.deps.writer.enqueue({
796
1075
  key: point.key,
797
1076
  subject: point.subject,
@@ -835,7 +1114,8 @@ var ProducerIngest = class {
835
1114
  last: bucket.last,
836
1115
  lastTs: bucket.lastTs,
837
1116
  twSum: null,
838
- twMs: null
1117
+ twMs: null,
1118
+ states: null
839
1119
  });
840
1120
  }
841
1121
  await store.upsertRollups("5m", rows);
@@ -874,7 +1154,109 @@ var ProducerIngest = class {
874
1154
  }
875
1155
  };
876
1156
  //#endregion
1157
+ //#region src/builtins/device-history/query/point-shape.ts
1158
+ /** kWh in one W·ms: 1 kWh = 1 000 W × 3 600 000 ms. */
1159
+ var KWH_PER_WATT_MS = 1 / 36e8;
1160
+ /** Growth from `prev` to `cur`; a drop is a reset counted from zero. `fallback` when `prev` is unknown. */
1161
+ function counterDelta(prev, cur, fallback) {
1162
+ if (cur === null) return null;
1163
+ if (prev === null) return fallback;
1164
+ return cur >= prev ? cur - prev : cur;
1165
+ }
1166
+ /** The name of an ordinal, or `null` when the series is not an enum or the ordinal is unknown. */
1167
+ function stateName(meaning, ordinal) {
1168
+ if (meaning.kind !== "enum" || meaning.values === null || ordinal === null) return null;
1169
+ return meaning.values[ordinal] ?? null;
1170
+ }
1171
+ function namedStates(meaning, states) {
1172
+ if (meaning.kind !== "enum" || states === null) return null;
1173
+ const out = {};
1174
+ for (const [ordinal, ms] of Object.entries(states)) {
1175
+ const name = stateName(meaning, Number(ordinal));
1176
+ if (name !== null) out[name] = (out[name] ?? 0) + ms;
1177
+ }
1178
+ return out;
1179
+ }
1180
+ /** Raw points, oldest first; `prev` is the value of the point before the first one. */
1181
+ function rawPoints(rows, meaning, prev) {
1182
+ const isEnum = meaning.kind === "enum";
1183
+ let held = prev;
1184
+ return rows.map((r) => {
1185
+ const delta = meaning.kind === "counter" ? counterDelta(held, r.value, null) : null;
1186
+ held = r.value;
1187
+ return {
1188
+ t: r.ts,
1189
+ samples: 1,
1190
+ mean: isEnum ? null : r.value,
1191
+ twMean: null,
1192
+ min: r.value,
1193
+ max: r.value,
1194
+ last: r.value,
1195
+ delta,
1196
+ state: stateName(meaning, r.value),
1197
+ states: null
1198
+ };
1199
+ });
1200
+ }
1201
+ /** Rollup rows, oldest first; `prevLast` is the `last` of the bucket before the first one. */
1202
+ function rollupPoints(rows, meaning, prevLast) {
1203
+ const isEnum = meaning.kind === "enum";
1204
+ let held = prevLast;
1205
+ return rows.map((row) => {
1206
+ const delta = meaning.kind === "counter" ? counterDelta(held, row.last, row.last === null ? null : row.last - row.min) : null;
1207
+ if (row.last !== null) held = row.last;
1208
+ return {
1209
+ t: row.bucketStart,
1210
+ samples: row.samples,
1211
+ mean: isEnum || row.samples <= 0 ? null : row.sum / row.samples,
1212
+ twMean: !isEnum && row.twSum !== null && row.twMs !== null && row.twMs > 0 ? row.twSum / row.twMs : null,
1213
+ min: row.min,
1214
+ max: row.max,
1215
+ last: row.last,
1216
+ delta,
1217
+ state: stateName(meaning, row.last),
1218
+ states: namedStates(meaning, row.states)
1219
+ };
1220
+ });
1221
+ }
1222
+ /** Energy used in each bucket of a `watts` gauge: its time-weighted integral, in kWh. */
1223
+ function integratedEnergyPoints(rows) {
1224
+ return rows.map((row) => ({
1225
+ t: row.bucketStart,
1226
+ samples: row.samples,
1227
+ mean: null,
1228
+ twMean: null,
1229
+ min: row.min,
1230
+ max: row.max,
1231
+ last: null,
1232
+ delta: row.twSum === null ? null : row.twSum * KWH_PER_WATT_MS,
1233
+ state: null,
1234
+ states: null
1235
+ }));
1236
+ }
1237
+ //#endregion
877
1238
  //#region src/builtins/device-history/query/history-query.ts
1239
+ /**
1240
+ * Reads: `listSeries`, `query` and `queryMany` (plan §5.1).
1241
+ *
1242
+ * `auto` resolution picks the finest tier whose retention covers `from` and
1243
+ * whose point count fits `maxPoints`: a rollup tier fits when
1244
+ * `ceil(range / bucket) <= maxPoints`, raw fits when a read of `maxPoints + 1`
1245
+ * rows comes back short. The coarsest covering tier is the last resort and is
1246
+ * truncated to its newest `maxPoints` points (`truncated: true`) rather than
1247
+ * answering nothing. A series fed by closed buckets has NO raw rows, so raw is
1248
+ * never a candidate for it: an empty raw read "fits" every window.
1249
+ *
1250
+ * `before` is the last point strictly before the window in the chosen tier, so a
1251
+ * step chart can start at the value in force when the window opens — a quiet
1252
+ * bucket has no row, and the held value is otherwise invisible.
1253
+ *
1254
+ * {@link DEVICE_HISTORY_ENERGY_KEY} is computed here at read time, never stored:
1255
+ * the device's `kwhTotal` meter when it has one (per-bucket growth), else the
1256
+ * time-weighted integral of its `watts` (`tw_sum`, already in every rollup).
1257
+ * One place makes that choice, so two charts of one device cannot disagree, and
1258
+ * it covers every hour of history recorded before the key existed.
1259
+ */
878
1260
  var TIER_ORDER = [
879
1261
  "raw",
880
1262
  "5m",
@@ -888,46 +1270,101 @@ var BUCKET_MS = {
888
1270
  };
889
1271
  /** A key with more scopes than this is cut (a zone x class matrix, not a chart). */
890
1272
  var MAX_SCOPES = 50;
1273
+ /** The power-meter series the energy key is derived from, meter first. */
1274
+ var ENERGY_METER_KEY = capSeriesKey("power-meter", "kwhTotal");
1275
+ var ENERGY_POWER_KEY = capSeriesKey("power-meter", "watts");
891
1276
  function retentionMsOf(tier, policy) {
892
1277
  if (tier === "raw") return policy.rawDays * DAY_MS;
893
1278
  if (tier === "5m") return policy.fiveMinuteDays * DAY_MS;
894
1279
  if (tier === "1h") return policy.hourDays * DAY_MS;
895
1280
  return policy.dayDays === null || policy.dayDays <= 0 ? null : policy.dayDays * DAY_MS;
896
1281
  }
897
- function rawPoint(ts, value) {
1282
+ /** Rows arrive newest first and one over the bound; keep the newest `maxPoints`, oldest first. */
1283
+ function bounded(newestFirst, maxPoints) {
1284
+ const overflow = newestFirst.length > maxPoints;
898
1285
  return {
899
- t: ts,
900
- samples: 1,
901
- mean: value,
902
- twMean: null,
903
- min: value,
904
- max: value,
905
- last: value
1286
+ rows: (overflow ? newestFirst.slice(0, maxPoints) : newestFirst).toReversed(),
1287
+ overflow
906
1288
  };
907
1289
  }
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
- };
1290
+ function errorText(err) {
1291
+ return err instanceof Error ? err.message : String(err);
918
1292
  }
919
1293
  var HistoryQuery = class {
920
1294
  deps;
921
1295
  constructor(deps) {
922
1296
  this.deps = deps;
923
1297
  }
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);
1298
+ async query(input) {
1299
+ if (input.key === "derived:energy.kwh") return this.queryEnergy(input);
1300
+ const { registry } = this.deps;
1301
+ const wanted = this.seriesOf(input);
1302
+ const metric = registry.metric(input.key);
1303
+ const meaning = {
1304
+ kind: metric?.kind ?? "gauge",
1305
+ values: metric?.values ?? null
1306
+ };
1307
+ const { chosen, reads } = await this.choose(input, wanted, metric?.ingest === "buckets");
1308
+ const series = [];
1309
+ for (const [s, read] of reads) {
1310
+ const before = await this.before(chosen, s, input.from, meaning);
1311
+ const points = read.tier === "raw" ? rawPoints(read.rows, meaning, before?.value ?? null) : rollupPoints(read.rows, meaning, before?.value ?? null);
1312
+ series.push({
1313
+ scope: s.scope,
1314
+ before,
1315
+ points,
1316
+ derivedFrom: null
1317
+ });
1318
+ }
1319
+ return {
1320
+ resolution: chosen,
1321
+ series,
1322
+ truncated: reads.some(([, r]) => r.overflow)
1323
+ };
1324
+ }
1325
+ /** Several reads over one window; one failing item answers its error, not the batch's. */
1326
+ async queryMany(input) {
1327
+ const out = [];
1328
+ for (const item of input.items) {
1329
+ const subject = "deviceId" in item ? {
1330
+ kind: "device",
1331
+ deviceId: item.deviceId
1332
+ } : {
1333
+ kind: "node",
1334
+ nodeId: item.forNodeId
1335
+ };
1336
+ try {
1337
+ const result = await this.query({
1338
+ key: item.key,
1339
+ subject,
1340
+ ...item.scope !== void 0 ? { scope: item.scope } : {},
1341
+ from: input.from,
1342
+ to: input.to,
1343
+ resolution: input.resolution,
1344
+ maxPoints: input.maxPoints
1345
+ });
1346
+ out.push({
1347
+ ok: true,
1348
+ result
1349
+ });
1350
+ } catch (err) {
1351
+ out.push({
1352
+ ok: false,
1353
+ error: errorText(err)
1354
+ });
1355
+ }
1356
+ }
1357
+ return out;
1358
+ }
1359
+ seriesOf(input) {
1360
+ return this.deps.registry.forKeyAndSubject(input.key, subjectIdOf(input.subject)).filter((s) => input.scope === void 0 || s.scope === input.scope).toSorted((a, b) => a.scope.localeCompare(b.scope)).slice(0, MAX_SCOPES);
1361
+ }
1362
+ /** The tier to answer from and its reads. `noRaw`: the series has no raw rows to read. */
1363
+ async choose(input, wanted, noRaw) {
1364
+ const covering = this.coveringTiers(input.from);
1365
+ const candidates = noRaw ? withoutRaw(covering) : covering;
929
1366
  const forced = input.resolution === "auto" ? null : input.resolution;
930
- const order = forced === null ? candidates : [forced];
1367
+ const order = forced === null ? candidates : [noRaw && forced === "raw" ? "5m" : forced];
931
1368
  let chosen = order[order.length - 1] ?? "1d";
932
1369
  let reads = [];
933
1370
  for (const [index, tier] of order.entries()) {
@@ -938,16 +1375,9 @@ var HistoryQuery = class {
938
1375
  if (reads.some(([, r]) => r.overflow) && !last) continue;
939
1376
  break;
940
1377
  }
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
1378
  return {
948
- resolution: chosen,
949
- series,
950
- truncated: reads.some(([, r]) => r.overflow)
1379
+ chosen,
1380
+ reads
951
1381
  };
952
1382
  }
953
1383
  /** Tiers whose retention covers `from`, finest first; the coarsest is always present. */
@@ -969,35 +1399,79 @@ var HistoryQuery = class {
969
1399
  async readTier(tier, series, input) {
970
1400
  const { store } = this.deps;
971
1401
  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
- }
1402
+ if (tier === "raw") return {
1403
+ tier,
1404
+ ...bounded(await store.readRaw(series.id, input.from, input.to, limit, "desc"), input.maxPoints)
1405
+ };
976
1406
  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
1407
  return {
984
- points: (overflow ? newestFirst.slice(0, maxPoints) : newestFirst).toReversed(),
985
- overflow
1408
+ tier,
1409
+ ...bounded(await store.readRollups(tier, series.id, lower, input.to, limit, "desc"), input.maxPoints)
986
1410
  };
987
1411
  }
988
- async before(tier, series, from) {
1412
+ async before(tier, series, from, meaning) {
989
1413
  const { store } = this.deps;
990
1414
  if (tier === "raw") {
991
1415
  const p = await store.rawBefore(series.id, from);
992
1416
  return p === null ? null : {
993
1417
  t: p.ts,
994
- value: p.value
1418
+ value: p.value,
1419
+ state: stateName(meaning, p.value)
995
1420
  };
996
1421
  }
997
1422
  const row = await store.rollupBefore(tier, series.id, bucketStartOf(tier, from));
998
1423
  return row === null ? null : {
999
1424
  t: row.bucketStart,
1000
- value: row.last
1425
+ value: row.last,
1426
+ state: stateName(meaning, row.last)
1427
+ };
1428
+ }
1429
+ /** The series energy is derived from for a device: the meter when it has one, else its power. */
1430
+ energySource(subject) {
1431
+ if (subject.kind !== "device") return null;
1432
+ const subjectId = subjectIdOf(subject);
1433
+ const meter = this.deps.registry.find(ENERGY_METER_KEY, subjectId, "");
1434
+ if (meter !== void 0) return {
1435
+ series: meter,
1436
+ from: "meter"
1437
+ };
1438
+ const power = this.deps.registry.find(ENERGY_POWER_KEY, subjectId, "");
1439
+ return power === void 0 ? null : {
1440
+ series: power,
1441
+ from: "power"
1442
+ };
1443
+ }
1444
+ async queryEnergy(input) {
1445
+ const source = this.energySource(input.subject);
1446
+ if (source === null || input.scope !== void 0 && input.scope !== "") return {
1447
+ resolution: "5m",
1448
+ series: [],
1449
+ truncated: false
1450
+ };
1451
+ const { chosen, reads } = await this.choose(input, [source.series], true);
1452
+ const read = reads[0]?.[1];
1453
+ if (read === void 0 || read.tier === "raw") return {
1454
+ resolution: chosen,
1455
+ series: [],
1456
+ truncated: false
1457
+ };
1458
+ let points;
1459
+ if (source.from === "meter") {
1460
+ const prev = await this.deps.store.rollupBefore(read.tier, source.series.id, bucketStartOf(read.tier, input.from));
1461
+ points = rollupPoints(read.rows, {
1462
+ kind: "counter",
1463
+ values: null
1464
+ }, prev?.last ?? null);
1465
+ } else points = integratedEnergyPoints(read.rows);
1466
+ return {
1467
+ resolution: chosen,
1468
+ series: [{
1469
+ scope: "",
1470
+ before: null,
1471
+ points,
1472
+ derivedFrom: source.series.key
1473
+ }],
1474
+ truncated: read.overflow
1001
1475
  };
1002
1476
  }
1003
1477
  async listSeries(subject) {
@@ -1014,10 +1488,23 @@ var HistoryQuery = class {
1014
1488
  unit: await this.unitOf(subject, s, metric.unit, slices),
1015
1489
  displayName: metric.displayName,
1016
1490
  source: metric.source,
1491
+ values: metric.kind === "enum" ? metric.values ?? [] : null,
1017
1492
  firstTs: s.firstTs,
1018
1493
  lastTs: s.lastTs
1019
1494
  });
1020
1495
  }
1496
+ const energy = this.energySource(subject);
1497
+ if (energy !== null) infos.push({
1498
+ key: require_dist.DEVICE_HISTORY_ENERGY_KEY,
1499
+ scope: "",
1500
+ kind: "counter",
1501
+ unit: "kWh",
1502
+ displayName: "Energy",
1503
+ source: "derived",
1504
+ values: null,
1505
+ firstTs: energy.series.firstTs,
1506
+ lastTs: energy.series.lastTs
1507
+ });
1021
1508
  return infos.toSorted((a, b) => a.key.localeCompare(b.key) || a.scope.localeCompare(b.scope));
1022
1509
  }
1023
1510
  /** The metric's fixed unit, else the device's own (a `unitFrom` field), else unknown. */
@@ -1038,191 +1525,11 @@ var HistoryQuery = class {
1038
1525
  return this.deps.catalog[body.slice(0, dot)]?.[body.slice(dot + 1)];
1039
1526
  }
1040
1527
  };
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
- };
1528
+ /** Every covering tier but raw; the coarsest stays when it is the only one. */
1529
+ function withoutRaw(tiers) {
1530
+ const rollups = tiers.filter((t) => t !== "raw");
1531
+ return rollups.length > 0 ? rollups : ["5m"];
1101
1532
  }
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
1533
  //#endregion
1227
1534
  //#region src/builtins/device-history/store/history-schema.ts
1228
1535
  var HISTORY_NAMESPACE = "device-history";
@@ -1292,6 +1599,10 @@ function rollupDeclaration(collection) {
1292
1599
  {
1293
1600
  name: "tw_ms",
1294
1601
  type: "INTEGER"
1602
+ },
1603
+ {
1604
+ name: "state_ms",
1605
+ type: "TEXT"
1295
1606
  }
1296
1607
  ]
1297
1608
  };
@@ -1346,6 +1657,10 @@ var HISTORY_DECLARATIONS = [
1346
1657
  name: "declared_at",
1347
1658
  type: "INTEGER",
1348
1659
  notNull: true
1660
+ },
1661
+ {
1662
+ name: "enum_values",
1663
+ type: "TEXT"
1349
1664
  }
1350
1665
  ]
1351
1666
  },
@@ -1519,9 +1834,34 @@ function chunks(items, size) {
1519
1834
  return out;
1520
1835
  }
1521
1836
  function parseKind(value) {
1522
- if (value === "gauge" || value === "counter" || value === "boolean") return value;
1837
+ if (value === "gauge" || value === "counter" || value === "boolean" || value === "enum") return value;
1523
1838
  throw new Error(`history row: unknown kind "${value}"`);
1524
1839
  }
1840
+ /** A JSON TEXT column: the engine hands back the decoded value, or the text itself. */
1841
+ function jsonColumn(data, column) {
1842
+ const raw = data[column];
1843
+ if (raw === null || raw === void 0) return null;
1844
+ return typeof raw === "string" ? JSON.parse(raw) : raw;
1845
+ }
1846
+ /** A JSON column that must hold a string array, or nothing. */
1847
+ function parseValues(data) {
1848
+ const parsed = jsonColumn(data, "enum_values");
1849
+ if (parsed === null) return null;
1850
+ if (Array.isArray(parsed) && parsed.every((v) => typeof v === "string")) return parsed;
1851
+ throw new Error("history row: column \"enum_values\" is not a JSON string array");
1852
+ }
1853
+ /** A JSON column that must hold `{ "<ordinal>": ms }`, or nothing. */
1854
+ function parseStates(data) {
1855
+ const parsed = jsonColumn(data, "state_ms");
1856
+ if (parsed === null) return null;
1857
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("history row: column \"state_ms\" is not a JSON object");
1858
+ const out = {};
1859
+ for (const [k, v] of Object.entries(parsed)) {
1860
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new Error("history row: column \"state_ms\" holds a non-number");
1861
+ out[k] = v;
1862
+ }
1863
+ return out;
1864
+ }
1525
1865
  function parseMetric(data) {
1526
1866
  const source = readText(data, "source");
1527
1867
  const subjectKind = readText(data, "subject_kind");
@@ -1538,7 +1878,8 @@ function parseMetric(data) {
1538
1878
  unit: readTextOrNull(data, "unit"),
1539
1879
  displayName: readText(data, "display_name"),
1540
1880
  ingest,
1541
- declaredAt: readNumber(data, "declared_at")
1881
+ declaredAt: readNumber(data, "declared_at"),
1882
+ values: parseValues(data)
1542
1883
  };
1543
1884
  }
1544
1885
  function parseSeries(data) {
@@ -1563,7 +1904,8 @@ function parseRollup(data) {
1563
1904
  last: readNumberOrNull(data, "last"),
1564
1905
  lastTs: readNumberOrNull(data, "last_ts"),
1565
1906
  twSum: readNumberOrNull(data, "tw_sum"),
1566
- twMs: readNumberOrNull(data, "tw_ms")
1907
+ twMs: readNumberOrNull(data, "tw_ms"),
1908
+ states: parseStates(data)
1567
1909
  };
1568
1910
  }
1569
1911
  function rollupData(row) {
@@ -1577,7 +1919,8 @@ function rollupData(row) {
1577
1919
  last: row.last,
1578
1920
  last_ts: row.lastTs,
1579
1921
  tw_sum: row.twSum,
1580
- tw_ms: row.twMs
1922
+ tw_ms: row.twMs,
1923
+ state_ms: row.states === null ? null : JSON.stringify(row.states)
1581
1924
  };
1582
1925
  }
1583
1926
  function collectionOf(tier) {
@@ -1601,22 +1944,37 @@ var HistoryStore = class {
1601
1944
  filter: { limit: 2e4 }
1602
1945
  })).map((r) => parseMetric(r.data));
1603
1946
  }
1947
+ /**
1948
+ * Delete metric rows no lookup can reach: rows an earlier build wrote under an
1949
+ * engine-minted random key (every metric key is `cap:` / `addon:` prefixed).
1950
+ */
1951
+ async deleteMetrics(keys) {
1952
+ for (const batch of chunks(keys, WRITE_CHUNK)) await this.door.deleteWhere.mutate({
1953
+ namespace: HISTORY_NAMESPACE,
1954
+ collection: COLLECTIONS.metrics,
1955
+ filter: { whereIn: { key: [...batch] } }
1956
+ });
1957
+ }
1604
1958
  async upsertMetric(metric) {
1605
1959
  await this.door.upsertMany.mutate({
1606
1960
  namespace: HISTORY_NAMESPACE,
1607
1961
  collection: COLLECTIONS.metrics,
1608
1962
  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
- } }]
1963
+ records: [{
1964
+ id: metric.key,
1965
+ data: {
1966
+ key: metric.key,
1967
+ source: metric.source,
1968
+ owner_addon: metric.ownerAddon,
1969
+ subject_kind: metric.subjectKind,
1970
+ kind: metric.kind,
1971
+ unit: metric.unit,
1972
+ display_name: metric.displayName,
1973
+ ingest: metric.ingest,
1974
+ declared_at: metric.declaredAt,
1975
+ enum_values: metric.values === null ? null : JSON.stringify(metric.values)
1976
+ }
1977
+ }]
1620
1978
  });
1621
1979
  }
1622
1980
  /** Series with `id > afterId`, ascending — the boot load pages with this. */
@@ -1863,6 +2221,11 @@ var HistoryStore = class {
1863
2221
  * The held value also counts toward `min`/`max` when it was in force at the
1864
2222
  * start of a bucket, because it really was the series' value then.
1865
2223
  */
2224
+ function credit(states, value, ms) {
2225
+ if (states === null || ms <= 0) return;
2226
+ const key = String(value);
2227
+ states[key] = (states[key] ?? 0) + ms;
2228
+ }
1866
2229
  /** One row per 5-minute bucket that has at least one point. */
1867
2230
  function rawToFiveMinuteRows(input) {
1868
2231
  const rows = [];
@@ -1881,6 +2244,7 @@ function rawToFiveMinuteRows(input) {
1881
2244
  let max = Number.NEGATIVE_INFINITY;
1882
2245
  let twSum = 0;
1883
2246
  let twMs = 0;
2247
+ const states = input.trackStates === true ? {} : null;
1884
2248
  let cursor = bucketStart;
1885
2249
  let value = held;
1886
2250
  if (value !== null) {
@@ -1895,6 +2259,7 @@ function rawToFiveMinuteRows(input) {
1895
2259
  const dt = p.ts - cursor;
1896
2260
  twSum += value * dt;
1897
2261
  twMs += dt;
2262
+ credit(states, value, dt);
1898
2263
  }
1899
2264
  cursor = p.ts;
1900
2265
  value = p.value;
@@ -1908,6 +2273,7 @@ function rawToFiveMinuteRows(input) {
1908
2273
  if (value !== null && coverageEnd > cursor) {
1909
2274
  twSum += value * (coverageEnd - cursor);
1910
2275
  twMs += coverageEnd - cursor;
2276
+ credit(states, value, coverageEnd - cursor);
1911
2277
  }
1912
2278
  held = last.value;
1913
2279
  rows.push({
@@ -1920,7 +2286,8 @@ function rawToFiveMinuteRows(input) {
1920
2286
  last: last.value,
1921
2287
  lastTs: last.ts,
1922
2288
  twSum,
1923
- twMs
2289
+ twMs,
2290
+ states
1924
2291
  });
1925
2292
  }
1926
2293
  return rows;
@@ -1937,6 +2304,7 @@ function combineRows(input) {
1937
2304
  let max = Number.NEGATIVE_INFINITY;
1938
2305
  let twSum = 0;
1939
2306
  let twMs = 0;
2307
+ const states = input.trackStates === true ? {} : null;
1940
2308
  let held = input.carryIn;
1941
2309
  let cursor = input.windowStart;
1942
2310
  for (const row of rows) {
@@ -1948,6 +2316,7 @@ function combineRows(input) {
1948
2316
  if (hasTw && gap > 0 && held !== null) {
1949
2317
  twSum += held * gap;
1950
2318
  twMs += gap;
2319
+ credit(states, held, gap);
1951
2320
  if (held < min) min = held;
1952
2321
  if (held > max) max = held;
1953
2322
  }
@@ -1955,6 +2324,7 @@ function combineRows(input) {
1955
2324
  twSum += row.twSum;
1956
2325
  twMs += row.twMs;
1957
2326
  }
2327
+ if (states !== null && row.states !== null) for (const [state, ms] of Object.entries(row.states)) states[state] = (states[state] ?? 0) + ms;
1958
2328
  cursor = row.bucketStart + input.childWidthMs;
1959
2329
  held = row.last;
1960
2330
  }
@@ -1962,6 +2332,7 @@ function combineRows(input) {
1962
2332
  if (hasTw && held !== null && coverageEnd > cursor) {
1963
2333
  twSum += held * (coverageEnd - cursor);
1964
2334
  twMs += coverageEnd - cursor;
2335
+ credit(states, held, coverageEnd - cursor);
1965
2336
  }
1966
2337
  return {
1967
2338
  seriesId: input.seriesId,
@@ -1973,7 +2344,8 @@ function combineRows(input) {
1973
2344
  last: lastRow.last,
1974
2345
  lastTs: lastRow.lastTs,
1975
2346
  twSum: hasTw ? twSum : null,
1976
- twMs: hasTw ? twMs : null
2347
+ twMs: hasTw ? twMs : null,
2348
+ states
1977
2349
  };
1978
2350
  }
1979
2351
  /** How far back a boot rebuilds from raw and re-marks the upper tiers. */
@@ -2114,6 +2486,11 @@ var RollupEngine = class {
2114
2486
  }
2115
2487
  if (handled > 0) this.deps.counters.last1dAt = now;
2116
2488
  }
2489
+ /** An `enum` series keeps the time spent in each state in its rollups. */
2490
+ isEnum(seriesId) {
2491
+ const series = this.deps.registry.byIdOf(seriesId);
2492
+ return series !== void 0 && this.deps.registry.metric(series.key)?.kind === "enum";
2493
+ }
2117
2494
  /** Raw → 5m rows for buckets in `[from, toExclusive)`; writes them and marks their hours. */
2118
2495
  async recomputeFiveMinute(seriesId, from, toExclusive, horizon) {
2119
2496
  const { store } = this.deps;
@@ -2130,7 +2507,8 @@ var RollupEngine = class {
2130
2507
  seriesId,
2131
2508
  points,
2132
2509
  carryIn: await store.rawBefore(seriesId, from),
2133
- horizon
2510
+ horizon,
2511
+ trackStates: this.isEnum(seriesId)
2134
2512
  });
2135
2513
  if (rows.length === 0) return 0;
2136
2514
  await store.upsertRollups("5m", rows);
@@ -2154,7 +2532,8 @@ var RollupEngine = class {
2154
2532
  childWidthMs,
2155
2533
  rows: await store.readRollups(child, seriesId, windowStart, windowEnd - 1, READ_PAGE),
2156
2534
  carryIn: (await store.rollupBefore(child, seriesId, windowStart))?.last ?? null,
2157
- horizon: now
2535
+ horizon: now,
2536
+ trackStates: this.isEnum(seriesId)
2158
2537
  });
2159
2538
  if (combined === null) return;
2160
2539
  await store.upsertRollups(parent, [combined]);
@@ -2614,6 +2993,13 @@ var HistoryRuntime = class {
2614
2993
  * are hub capabilities, and a forked runner cannot call them from
2615
2994
  * `onInitialize`. Until then the provider answers "starting" rather than an
2616
2995
  * empty history (an unanswered query is not an empty one, D315).
2996
+ *
2997
+ * Readiness follows the STORE, not the registration: the provider registers at
2998
+ * `onInitialize`, but `ready` is emitted only once the runtime is open in
2999
+ * `onHubReachable` (`autoEmitReadiness` is off). Producers declare their
3000
+ * series on `ready`; the automatic emit at registration sent every one of
3001
+ * them into "device-history is starting" on each boot (`.50`, 1.2.607:
3002
+ * metrics-native and pipeline-analytics).
2617
3003
  */
2618
3004
  var DEFAULT_CONFIG = {
2619
3005
  rawDays: DEFAULT_RETENTION.rawDays,
@@ -2637,9 +3023,40 @@ var DeviceHistoryAddon = class extends require_dist.BaseAddon {
2637
3023
  runtime = null;
2638
3024
  unsubscribe = null;
2639
3025
  removalHook = null;
3026
+ /** This instance's readiness generation (the base class keeps its own private). */
3027
+ readinessGeneration = `device-history-${Date.now().toString(36)}`;
2640
3028
  constructor() {
2641
3029
  super({ ...DEFAULT_CONFIG });
2642
3030
  }
3031
+ /** `ready` is the store being open, emitted by {@link emitStoreReadiness}. */
3032
+ get autoEmitReadiness() {
3033
+ return false;
3034
+ }
3035
+ /** Readiness of both provided caps, on the bare node this runner belongs to. */
3036
+ emitStoreReadiness(state) {
3037
+ const ctx = this.ctxIfReady;
3038
+ if (ctx === null) return;
3039
+ const raw = ctx.kernel?.localNodeId ?? "hub";
3040
+ const nodeId = raw.includes("/") ? raw.split("/")[0] ?? raw : raw;
3041
+ for (const capName of [require_dist.deviceHistoryCapability.name, require_dist.deviceRemovalHookCapability.name]) try {
3042
+ require_dist.emitReadiness(ctx.eventBus, {
3043
+ capName,
3044
+ scope: {
3045
+ type: "node",
3046
+ nodeId
3047
+ },
3048
+ state,
3049
+ generation: this.readinessGeneration,
3050
+ sourceNodeId: nodeId
3051
+ });
3052
+ } catch (err) {
3053
+ ctx.logger.warn("device-history: readiness not emitted", { meta: {
3054
+ capName,
3055
+ state,
3056
+ error: err instanceof Error ? err.message : String(err)
3057
+ } });
3058
+ }
3059
+ }
2643
3060
  retention() {
2644
3061
  const c = this.config;
2645
3062
  return {
@@ -2661,7 +3078,7 @@ var DeviceHistoryAddon = class extends require_dist.BaseAddon {
2661
3078
  }),
2662
3079
  listNodeSeries: async (input) => this.requireRuntime().query.listSeries({
2663
3080
  kind: "node",
2664
- nodeId: input.nodeId
3081
+ nodeId: input.forNodeId
2665
3082
  }),
2666
3083
  query: async ({ deviceId, ...window }) => this.requireRuntime().query.query({
2667
3084
  ...window,
@@ -2670,13 +3087,14 @@ var DeviceHistoryAddon = class extends require_dist.BaseAddon {
2670
3087
  deviceId
2671
3088
  }
2672
3089
  }),
2673
- queryNode: async ({ nodeId, ...window }) => this.requireRuntime().query.query({
3090
+ queryNode: async ({ forNodeId, ...window }) => this.requireRuntime().query.query({
2674
3091
  ...window,
2675
3092
  subject: {
2676
3093
  kind: "node",
2677
- nodeId
3094
+ nodeId: forNodeId
2678
3095
  }
2679
3096
  }),
3097
+ queryMany: async (input) => ({ results: await this.requireRuntime().query.queryMany(input) }),
2680
3098
  declareSeries: async (input) => this.requireRuntime().ingest.declareSeries(callerOf(input), input),
2681
3099
  record: async (input) => this.requireRuntime().ingest.record(callerOf(input), input.points),
2682
3100
  recordBuckets: async (input) => this.requireRuntime().ingest.recordBuckets(callerOf(input), input.buckets),
@@ -2684,6 +3102,7 @@ var DeviceHistoryAddon = class extends require_dist.BaseAddon {
2684
3102
  setDeviceEnabled: async (input) => ({ enabled: await this.requireRuntime().setEnabled(input.deviceId, input.enabled) }),
2685
3103
  getStatus: async () => this.requireRuntime().status()
2686
3104
  };
3105
+ this.emitStoreReadiness("starting");
2687
3106
  this.ctx.logger.info("Initialized — device-history provider registered (store opens on hub link)");
2688
3107
  const removalHook = {
2689
3108
  onDeviceRemoved: async (input) => {
@@ -2760,6 +3179,7 @@ var DeviceHistoryAddon = class extends require_dist.BaseAddon {
2760
3179
  logger: this.ctx.logger,
2761
3180
  remove: (input) => removalHook.onDeviceRemoved(input)
2762
3181
  }));
3182
+ this.emitStoreReadiness("ready");
2763
3183
  this.ctx.logger.info("device-history: capturing", { meta: {
2764
3184
  series: runtime.status().series,
2765
3185
  metrics: runtime.status().metrics
@@ -2771,6 +3191,7 @@ var DeviceHistoryAddon = class extends require_dist.BaseAddon {
2771
3191
  await this.runtime?.stop();
2772
3192
  }
2773
3193
  async onShutdown() {
3194
+ this.emitStoreReadiness("down");
2774
3195
  await this.stopRuntime();
2775
3196
  this.runtime = null;
2776
3197
  }