@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
@@ -1,4 +1,236 @@
1
- import { $r as BaseAddon, $t as deviceHistoryCapability, St as armRemovalReconcile, Ta as EventCategory, Zr as DeviceType, ci as HISTORY_DEFAULT_MIN_INTERVAL_MS, tn as deviceRemovalHookCapability, z as HISTORY_CATALOG } from "../../dist-BSF_C8W2.mjs";
1
+ import { B as HISTORY_CATALOG, Ji as emitReadiness, O as DEVICE_HISTORY_ENERGY_KEY, Oa as EventCategory, Tt as armRemovalReconcile, di as HISTORY_DEFAULT_MIN_INTERVAL_MS, ei as DeviceType, in as deviceRemovalHookCapability, ni as BaseAddon, nn as deviceHistoryCapability } from "../../dist-B_GRPhnY.mjs";
2
+ //#region src/builtins/device-history/time/bucket-time.ts
3
+ /**
4
+ * Bucket arithmetic for the three rollup tiers (D850, plan §4/§5.5).
5
+ *
6
+ * 5m and 1h buckets are aligned to UTC; a 1d bucket starts at HUB-LOCAL
7
+ * midnight, so a day is 23, 24 or 25 hours long across a DST change (operator
8
+ * answer Q4). Pure: the zone is the process zone (`TZ`), nothing is read from
9
+ * the environment here.
10
+ */
11
+ var FIVE_MINUTES_MS = 5 * 6e4;
12
+ var HOUR_MS = 60 * 6e4;
13
+ var DAY_MS = 24 * HOUR_MS;
14
+ function floorTo(ts, widthMs) {
15
+ return Math.floor(ts / widthMs) * widthMs;
16
+ }
17
+ /** Hub-local midnight at or before `ts`. */
18
+ function startOfLocalDay(ts) {
19
+ const d = new Date(ts);
20
+ d.setHours(0, 0, 0, 0);
21
+ return d.getTime();
22
+ }
23
+ /** The local midnight following the local midnight `dayStart`. */
24
+ function nextLocalDayStart(dayStart) {
25
+ const d = new Date(dayStart);
26
+ d.setDate(d.getDate() + 1);
27
+ d.setHours(0, 0, 0, 0);
28
+ return d.getTime();
29
+ }
30
+ /** Start of the `tier` bucket containing `ts`. */
31
+ function bucketStartOf(tier, ts) {
32
+ if (tier === "5m") return floorTo(ts, FIVE_MINUTES_MS);
33
+ if (tier === "1h") return floorTo(ts, HOUR_MS);
34
+ return startOfLocalDay(ts);
35
+ }
36
+ /** Exclusive end of the `tier` bucket that starts at `start`. */
37
+ function bucketEndOf(tier, start) {
38
+ if (tier === "5m") return start + FIVE_MINUTES_MS;
39
+ if (tier === "1h") return start + HOUR_MS;
40
+ return nextLocalDayStart(start);
41
+ }
42
+ /** Quiet time after a bucket closes before it is rolled up (late writers). */
43
+ var ROLLUP_GRACE_MS = {
44
+ "5m": 3e4,
45
+ "1h": 5 * 6e4,
46
+ "1d": 15 * 6e4
47
+ };
48
+ //#endregion
49
+ //#region src/builtins/device-history/retention/departure-tracker.ts
50
+ var DEPARTED_ABSENCE_MS = 7 * DAY_MS;
51
+ var DepartureTracker = class {
52
+ store;
53
+ registry;
54
+ logger;
55
+ missing = /* @__PURE__ */ new Map();
56
+ constructor(store, registry, logger) {
57
+ this.store = store;
58
+ this.registry = registry;
59
+ this.logger = logger;
60
+ }
61
+ async load() {
62
+ this.missing = await this.store.loadDepartures();
63
+ }
64
+ missingSince(deviceId) {
65
+ return this.missing.get(deviceId);
66
+ }
67
+ /** Forget a device's clock — its series were removed because it was DELETED (D865). */
68
+ async forget(deviceId) {
69
+ await this.store.clearMissing([deviceId]);
70
+ const next = new Map(this.missing);
71
+ next.delete(deviceId);
72
+ this.missing = next;
73
+ }
74
+ /** Feed one SUCCESSFUL listing. A failed read never reaches here. */
75
+ async observe(listed, now) {
76
+ const withSeries = new Set(this.registry.all().flatMap((s) => s.deviceId === null ? [] : [s.deviceId]));
77
+ const back = [...this.missing.keys()].filter((id) => listed.has(id));
78
+ const gone = [...withSeries].filter((id) => !listed.has(id) && !this.missing.has(id));
79
+ try {
80
+ await this.store.clearMissing(back);
81
+ await this.store.markMissing(gone, now);
82
+ } catch (err) {
83
+ this.logger.warn("device-history: departure clock not written", { meta: { error: err instanceof Error ? err.message : String(err) } });
84
+ return;
85
+ }
86
+ const next = new Map(this.missing);
87
+ for (const id of back) next.delete(id);
88
+ for (const id of gone) next.set(id, now);
89
+ this.missing = next;
90
+ }
91
+ };
92
+ //#endregion
93
+ //#region src/builtins/device-history/retention/retention.ts
94
+ var DEFAULT_RETENTION = {
95
+ rawDays: 7,
96
+ fiveMinuteDays: 30,
97
+ hourDays: 365,
98
+ dayDays: null
99
+ };
100
+ /** Cutoff per tier: rows strictly older are deleted. `null` = keep forever. */
101
+ function retentionCutoffs(policy, now) {
102
+ return {
103
+ raw: now - policy.rawDays * DAY_MS,
104
+ "5m": now - policy.fiveMinuteDays * DAY_MS,
105
+ "1h": now - policy.hourDays * DAY_MS,
106
+ "1d": policy.dayDays === null || policy.dayDays <= 0 ? null : now - policy.dayDays * DAY_MS
107
+ };
108
+ }
109
+ var TIERS = [
110
+ "raw",
111
+ "5m",
112
+ "1h",
113
+ "1d"
114
+ ];
115
+ var RETENTION_STEP_MS = {
116
+ raw: HOUR_MS,
117
+ "5m": HOUR_MS,
118
+ "1h": DAY_MS,
119
+ "1d": DAY_MS
120
+ };
121
+ var MAX_PURGES_PER_RUN = 20;
122
+ /** Width of one delete statement's time range, per tier. */
123
+ var SLICE_MS = {
124
+ raw: DAY_MS,
125
+ "5m": DAY_MS,
126
+ "1h": 30 * DAY_MS,
127
+ "1d": 30 * DAY_MS
128
+ };
129
+ /** A bucket's start may precede the series' first point by up to a day. */
130
+ var BUCKET_LEAD_MS = 2 * DAY_MS;
131
+ /** The short tiers hold nothing older than this unless the operator configured more. */
132
+ var SHORT_TIER_LOOKBACK_MS = 400 * DAY_MS;
133
+ var yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve));
134
+ var RetentionJob = class {
135
+ deps;
136
+ appliedUpTo = /* @__PURE__ */ new Map();
137
+ running = null;
138
+ constructor(deps) {
139
+ this.deps = deps;
140
+ }
141
+ run(policy, now) {
142
+ if (this.running !== null) return this.running;
143
+ const run = this.doRun(policy, now).finally(() => {
144
+ this.running = null;
145
+ });
146
+ this.running = run;
147
+ return run;
148
+ }
149
+ async doRun(policy, now) {
150
+ const { registry, counters, logger } = this.deps;
151
+ const cutoffs = retentionCutoffs(policy, now);
152
+ try {
153
+ for (const tier of TIERS) {
154
+ const cutoff = cutoffs[tier];
155
+ if (cutoff === null) continue;
156
+ for (const series of registry.all()) {
157
+ if (series.firstTs - 864e5 >= cutoff) continue;
158
+ if (tier === "raw" && registry.metric(series.key)?.ingest === "buckets") continue;
159
+ const marker = `${tier}:${series.id}`;
160
+ const applied = this.appliedUpTo.get(marker);
161
+ if (applied !== void 0 && cutoff - applied < RETENTION_STEP_MS[tier]) continue;
162
+ const lo = Math.max(0, applied === void 0 ? series.firstTs - BUCKET_LEAD_MS : applied - RETENTION_STEP_MS[tier]);
163
+ counters.addDeleted(tier, await this.deleteSliced(tier, series.id, lo, cutoff - 1));
164
+ this.appliedUpTo.set(marker, cutoff);
165
+ }
166
+ }
167
+ await this.purgeDeparted(now);
168
+ counters.retentionLastRunAt = now;
169
+ } catch (err) {
170
+ logger.warn("device-history: retention pass failed — retried at the next run", { meta: { error: err instanceof Error ? err.message : String(err) } });
171
+ }
172
+ }
173
+ /** Delete `[lo, hiInclusive]` of one tier in time slices, yielding between them. */
174
+ /** Drop the visit markers of a purged series. */
175
+ forgetSeries(seriesId) {
176
+ for (const tier of TIERS) this.appliedUpTo.delete(`${tier}:${seriesId}`);
177
+ }
178
+ async deleteSliced(tier, seriesId, lo, hiInclusive) {
179
+ const yieldNow = this.deps.yieldNow ?? yieldToEventLoop;
180
+ let deleted = 0;
181
+ for (let from = lo; from <= hiInclusive; from += SLICE_MS[tier]) {
182
+ const to = Math.min(from + SLICE_MS[tier] - 1, hiInclusive);
183
+ deleted += await this.deps.store.deleteRange(tier, seriesId, from, to);
184
+ await yieldNow();
185
+ }
186
+ return deleted;
187
+ }
188
+ /** Tier by tier in slices, then the series row (and whatever the slices missed). */
189
+ async purgeSeries(series, now) {
190
+ for (const tier of TIERS) {
191
+ const lo = tier === "raw" || tier === "5m" ? Math.max(series.firstTs - BUCKET_LEAD_MS, now - SHORT_TIER_LOOKBACK_MS) : series.firstTs - BUCKET_LEAD_MS;
192
+ await this.deleteSliced(tier, series.id, Math.max(0, lo), now + DAY_MS);
193
+ }
194
+ await this.deps.store.deleteSeries(series.id);
195
+ }
196
+ /**
197
+ * Delete every series of a DELETED device now (D865). Driven by a tombstone
198
+ * through the `device-removal-hook`, never by a listing: the week-long
199
+ * absence clock above remains the backstop for a device removed before
200
+ * tombstones existed.
201
+ */
202
+ async purgeDevice(deviceId, now) {
203
+ const { registry, counters } = this.deps;
204
+ let purged = 0;
205
+ for (const series of registry.all()) {
206
+ if (series.deviceId !== deviceId) continue;
207
+ await this.purgeSeries(series, now);
208
+ registry.remove(series.id);
209
+ counters.purgedSeries += 1;
210
+ purged += 1;
211
+ }
212
+ return purged;
213
+ }
214
+ async purgeDeparted(now) {
215
+ const { registry, departures, counters, logger } = this.deps;
216
+ let purged = 0;
217
+ for (const series of registry.all()) {
218
+ if (purged >= MAX_PURGES_PER_RUN) break;
219
+ if (series.deviceId === null) continue;
220
+ const since = departures.missingSince(series.deviceId);
221
+ if (since === void 0 || now - since < DEPARTED_ABSENCE_MS) continue;
222
+ await this.purgeSeries(series, now);
223
+ registry.remove(series.id);
224
+ counters.purgedSeries += 1;
225
+ purged += 1;
226
+ logger.info("device-history: purged the series of an unregistered device", {
227
+ tags: { deviceId: series.deviceId },
228
+ meta: { key: series.key }
229
+ });
230
+ }
231
+ }
232
+ };
233
+ //#endregion
2
234
  //#region src/builtins/device-history/store/series-registry.ts
3
235
  function subjectIdOf(subject) {
4
236
  return subject.kind === "device" ? `d:${subject.deviceId}` : `n:${subject.nodeId}`;
@@ -6,16 +238,29 @@ function subjectIdOf(subject) {
6
238
  function deviceIdOf(subject) {
7
239
  return subject.kind === "device" ? subject.deviceId : null;
8
240
  }
241
+ /** Every metric key is a cap field (`cap:`), an addon's series (`addon:`) or derived. */
242
+ function isMetricKey(key) {
243
+ return key.startsWith("cap:") || key.startsWith("addon:");
244
+ }
9
245
  function identityOf(key, subjectId, scope) {
10
246
  return `${key}\u0000${subjectId}\u0000${scope}`;
11
247
  }
12
248
  var LOAD_PAGE = 5e3;
249
+ /** Append what `next` names that `stored` lacks, in `next`'s order: an ordinal never moves. */
250
+ function mergeEnumValues(stored, next) {
251
+ if (stored === null) return next;
252
+ if (next === null) return stored;
253
+ const missing = next.filter((v) => !stored.includes(v));
254
+ return missing.length === 0 ? stored : [...stored, ...missing];
255
+ }
13
256
  var SeriesRegistry = class {
14
257
  store;
15
258
  metrics = /* @__PURE__ */ new Map();
16
259
  byIdentity = /* @__PURE__ */ new Map();
17
260
  byId = /* @__PURE__ */ new Map();
18
261
  inflight = /* @__PURE__ */ new Map();
262
+ /** Enum metrics whose value list grew in memory and is not stored yet. */
263
+ dirtyMetrics = /* @__PURE__ */ new Set();
19
264
  removeListeners = [];
20
265
  constructor(store) {
21
266
  this.store = store;
@@ -24,7 +269,10 @@ var SeriesRegistry = class {
24
269
  this.metrics.clear();
25
270
  this.byIdentity.clear();
26
271
  this.byId.clear();
27
- for (const m of await this.store.loadMetrics()) this.metrics.set(m.key, m);
272
+ const stray = [];
273
+ for (const m of await this.store.loadMetrics()) if (isMetricKey(m.key)) this.metrics.set(m.key, m);
274
+ else stray.push(m.key);
275
+ if (stray.length > 0) await this.store.deleteMetrics(stray);
28
276
  let afterId = 0;
29
277
  for (;;) {
30
278
  const page = await this.store.loadSeriesPage(afterId, LOAD_PAGE);
@@ -44,12 +292,48 @@ var SeriesRegistry = class {
44
292
  allMetrics() {
45
293
  return [...this.metrics.values()];
46
294
  }
47
- /** Persist and remember a metric; idempotent. */
295
+ /**
296
+ * Persist and remember a metric; idempotent. An `enum` value list is MERGED
297
+ * append-only with the stored one, so reordering a `z.enum` never changes
298
+ * what a stored ordinal means.
299
+ */
48
300
  async declareMetric(metric) {
49
301
  const existing = this.metrics.get(metric.key);
50
- if (existing !== void 0 && sameMetric(existing, metric)) return;
51
- await this.store.upsertMetric(metric);
52
- this.metrics.set(metric.key, metric);
302
+ const merged = {
303
+ ...metric,
304
+ values: mergeEnumValues(existing?.values ?? null, metric.values)
305
+ };
306
+ if (existing !== void 0 && sameMetric(existing, merged)) return;
307
+ await this.store.upsertMetric(merged);
308
+ this.metrics.set(metric.key, merged);
309
+ }
310
+ /**
311
+ * The ordinal of `value` in an `enum` metric's list, learning (appending) a
312
+ * value seen for the first time. `null` when the key is not an enum metric or
313
+ * its list is full ({@link MAX_ENUM_VALUES}). A learned value is stored by
314
+ * {@link persistDirtyMetrics} BEFORE any point that uses it.
315
+ */
316
+ ordinalOf(key, value) {
317
+ const metric = this.metrics.get(key);
318
+ if (metric === void 0 || metric.kind !== "enum") return null;
319
+ const values = metric.values ?? [];
320
+ const known = values.indexOf(value);
321
+ if (known >= 0) return known;
322
+ if (values.length >= 1024) return null;
323
+ this.metrics.set(key, {
324
+ ...metric,
325
+ values: [...values, value]
326
+ });
327
+ this.dirtyMetrics.add(key);
328
+ return values.length;
329
+ }
330
+ /** Store every value list that grew in memory. Throws (and keeps them dirty) on a failed write. */
331
+ async persistDirtyMetrics() {
332
+ for (const key of this.dirtyMetrics) {
333
+ const metric = this.metrics.get(key);
334
+ if (metric !== void 0) await this.store.upsertMetric(metric);
335
+ this.dirtyMetrics.delete(key);
336
+ }
53
337
  }
54
338
  find(key, subjectId, scope) {
55
339
  return this.byIdentity.get(identityOf(key, subjectId, scope));
@@ -114,7 +398,7 @@ var SeriesRegistry = class {
114
398
  }
115
399
  };
116
400
  function sameMetric(a, b) {
117
- return a.source === b.source && a.ownerAddon === b.ownerAddon && a.subjectKind === b.subjectKind && a.kind === b.kind && a.unit === b.unit && a.displayName === b.displayName && a.ingest === b.ingest;
401
+ 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);
118
402
  }
119
403
  //#endregion
120
404
  //#region src/builtins/device-history/capture/capture-filter.ts
@@ -138,10 +422,10 @@ var DEADBAND_EPSILON = 1e-9;
138
422
  /** The capture floor of a field: its own `minIntervalMs`, else the kind's default. */
139
423
  function minIntervalOf(spec) {
140
424
  if (spec.minIntervalMs !== void 0) return spec.minIntervalMs;
141
- return spec.kind === "boolean" ? 0 : HISTORY_DEFAULT_MIN_INTERVAL_MS;
425
+ return spec.kind === "boolean" || spec.kind === "enum" ? 0 : HISTORY_DEFAULT_MIN_INTERVAL_MS;
142
426
  }
143
427
  function clearsDeadband(spec, last, value) {
144
- if (spec.kind === "boolean") return true;
428
+ if (spec.kind === "boolean" || spec.kind === "enum") return true;
145
429
  const delta = Math.abs(value - last);
146
430
  if (spec.deadband !== void 0 && delta < spec.deadband - DEADBAND_EPSILON) return false;
147
431
  if (spec.deadbandPct !== void 0) {
@@ -326,7 +610,8 @@ function catalogMetrics(catalog, now) {
326
610
  unit: spec.unit ?? null,
327
611
  displayName: spec.displayName ?? humanize(capName, path),
328
612
  ingest: "points",
329
- declaredAt: now
613
+ declaredAt: now,
614
+ values: spec.kind === "enum" ? spec.values ?? [] : null
330
615
  })));
331
616
  }
332
617
  /** Devices waiting on a type lookup; beyond this the oldest is dropped (and counted). */
@@ -375,11 +660,28 @@ var CaptureEngine = class {
375
660
  counters.skip("excluded-device-type");
376
661
  continue;
377
662
  }
378
- const value = readNumeric(slice, path, spec.kind);
663
+ const value = this.readValue(slice, capName, path, spec);
379
664
  if (value === null) continue;
380
665
  this.consider(deviceId, capName, path, spec, value, at, false);
381
666
  }
382
667
  }
668
+ /**
669
+ * The number a field contributes, or `null` (not a reading — never 0). An
670
+ * `enum` state becomes the ordinal of its value in the metric's list; a value
671
+ * the full list cannot take is refused and counted (`enum-overflow`).
672
+ */
673
+ readValue(slice, capName, path, spec) {
674
+ if (spec.skipWhenSet !== void 0) {
675
+ const gate = readPath(slice, spec.skipWhenSet);
676
+ if (gate !== void 0 && gate !== null) return null;
677
+ }
678
+ if (spec.kind !== "enum") return readNumeric(slice, path, spec.kind);
679
+ const raw = readPath(slice, path);
680
+ if (typeof raw !== "string" || raw.length === 0) return null;
681
+ const ordinal = this.deps.registry.ordinalOf(capSeriesKey(capName, path), raw);
682
+ if (ordinal === null) this.deps.counters.skip("enum-overflow");
683
+ return ordinal;
684
+ }
383
685
  consider(deviceId, capName, path, spec, value, at, reconciling) {
384
686
  const key = capSeriesKey(capName, path);
385
687
  const subject = {
@@ -485,7 +787,7 @@ var CaptureEngine = class {
485
787
  if (slice === void 0) continue;
486
788
  for (const [path, spec] of Object.entries(fields)) {
487
789
  if (spec.excludeDeviceTypes?.includes(type) === true) continue;
488
- const value = readNumeric(slice, path, spec.kind);
790
+ const value = this.readValue(slice, capName, path, spec);
489
791
  if (value === null) continue;
490
792
  this.consider(deviceId, capName, path, spec, value, now, true);
491
793
  }
@@ -568,6 +870,7 @@ var PointWriter = class {
568
870
  this.queue = [];
569
871
  const { registry, store, counters, logger, clock } = this.deps;
570
872
  try {
873
+ await registry.persistDirtyMetrics();
571
874
  const bySeries = /* @__PURE__ */ new Map();
572
875
  for (const point of batch) {
573
876
  const series = await registry.ensure(point.key, point.subject, point.scope, point.ts);
@@ -659,52 +962,27 @@ var PointWriter = class {
659
962
  }
660
963
  };
661
964
  //#endregion
662
- //#region src/builtins/device-history/time/bucket-time.ts
965
+ //#region src/builtins/device-history/ingest/producer-ingest.ts
966
+ /** One point per series per this many ms, sustained. */
967
+ var RECORD_MIN_INTERVAL_MS = 1e4;
663
968
  /**
664
- * Bucket arithmetic for the three rollup tiers (D850, plan §4/§5.5).
665
- *
666
- * 5m and 1h buckets are aligned to UTC; a 1d bucket starts at HUB-LOCAL
667
- * midnight, so a day is 23, 24 or 25 hours long across a DST change (operator
668
- * answer Q4). Pure: the zone is the process zone (`TZ`), nothing is read from
669
- * the environment here.
969
+ * The slot after accepting a point at `ts`. A point older than the newest
970
+ * accepted one (a late arrival) moves nothing. Otherwise the consumed slot is
971
+ * the later of the one that was due and `ts` itself: an early point uses the
972
+ * due slot (the grid holds, so slack never compounds into a higher rate), a
973
+ * late one restarts the grid at `ts` (no credit is banked for a gap).
670
974
  */
671
- var FIVE_MINUTES_MS = 5 * 6e4;
672
- var HOUR_MS = 60 * 6e4;
673
- var DAY_MS = 24 * HOUR_MS;
674
- function floorTo(ts, widthMs) {
675
- return Math.floor(ts / widthMs) * widthMs;
676
- }
677
- /** Hub-local midnight at or before `ts`. */
678
- function startOfLocalDay(ts) {
679
- const d = new Date(ts);
680
- d.setHours(0, 0, 0, 0);
681
- return d.getTime();
682
- }
683
- /** The local midnight following the local midnight `dayStart`. */
684
- function nextLocalDayStart(dayStart) {
685
- const d = new Date(dayStart);
686
- d.setDate(d.getDate() + 1);
687
- d.setHours(0, 0, 0, 0);
688
- return d.getTime();
689
- }
690
- /** Start of the `tier` bucket containing `ts`. */
691
- function bucketStartOf(tier, ts) {
692
- if (tier === "5m") return floorTo(ts, FIVE_MINUTES_MS);
693
- if (tier === "1h") return floorTo(ts, HOUR_MS);
694
- return startOfLocalDay(ts);
695
- }
696
- /** Exclusive end of the `tier` bucket that starts at `start`. */
697
- function bucketEndOf(tier, start) {
698
- if (tier === "5m") return start + FIVE_MINUTES_MS;
699
- if (tier === "1h") return start + HOUR_MS;
700
- return nextLocalDayStart(start);
975
+ function nextSlot(slot, ts) {
976
+ if (slot === void 0) return {
977
+ lastTs: ts,
978
+ nextDue: ts + RECORD_MIN_INTERVAL_MS
979
+ };
980
+ if (ts < slot.lastTs) return slot;
981
+ return {
982
+ lastTs: ts,
983
+ nextDue: Math.max(slot.nextDue, ts) + RECORD_MIN_INTERVAL_MS
984
+ };
701
985
  }
702
- /** Quiet time after a bucket closes before it is rolled up (late writers). */
703
- var ROLLUP_GRACE_MS = {
704
- "5m": 3e4,
705
- "1h": 5 * 6e4,
706
- "1d": 15 * 6e4
707
- };
708
986
  function addRefusal(refusals, key, reason) {
709
987
  const byReason = refusals.get(key) ?? /* @__PURE__ */ new Map();
710
988
  byReason.set(reason, (byReason.get(reason) ?? 0) + 1);
@@ -722,7 +1000,7 @@ function toResult(accepted, refusals) {
722
1000
  }
723
1001
  var ProducerIngest = class {
724
1002
  deps;
725
- /** Last accepted point time per series, for the sustained-rate bound. */
1003
+ /** Rate state per series identity, for the sustained-rate bound. */
726
1004
  lastAccepted = /* @__PURE__ */ new Map();
727
1005
  constructor(deps) {
728
1006
  this.deps = deps;
@@ -743,7 +1021,8 @@ var ProducerIngest = class {
743
1021
  unit: input.unit ?? null,
744
1022
  displayName: input.displayName,
745
1023
  ingest: input.ingest,
746
- declaredAt: this.deps.clock()
1024
+ declaredAt: this.deps.clock(),
1025
+ values: null
747
1026
  };
748
1027
  await this.deps.registry.declareMetric(metric);
749
1028
  return { key };
@@ -780,13 +1059,13 @@ var ProducerIngest = class {
780
1059
  continue;
781
1060
  }
782
1061
  const identity = identityOf(point.key, subjectIdOf(point.subject), point.scope);
783
- const last = this.lastAccepted.get(identity);
784
- if (last !== void 0 && point.ts >= last && point.ts - last < 1e4) {
1062
+ const slot = this.lastAccepted.get(identity);
1063
+ if (slot !== void 0 && point.ts >= slot.lastTs && point.ts < slot.nextDue - 1e3) {
785
1064
  addRefusal(refusals, point.key, "rate");
786
1065
  counters.refuse("rate");
787
1066
  continue;
788
1067
  }
789
- this.lastAccepted.set(identity, Math.max(last ?? point.ts, point.ts));
1068
+ this.lastAccepted.set(identity, nextSlot(slot, point.ts));
790
1069
  this.deps.writer.enqueue({
791
1070
  key: point.key,
792
1071
  subject: point.subject,
@@ -830,7 +1109,8 @@ var ProducerIngest = class {
830
1109
  last: bucket.last,
831
1110
  lastTs: bucket.lastTs,
832
1111
  twSum: null,
833
- twMs: null
1112
+ twMs: null,
1113
+ states: null
834
1114
  });
835
1115
  }
836
1116
  await store.upsertRollups("5m", rows);
@@ -869,7 +1149,109 @@ var ProducerIngest = class {
869
1149
  }
870
1150
  };
871
1151
  //#endregion
1152
+ //#region src/builtins/device-history/query/point-shape.ts
1153
+ /** kWh in one W·ms: 1 kWh = 1 000 W × 3 600 000 ms. */
1154
+ var KWH_PER_WATT_MS = 1 / 36e8;
1155
+ /** Growth from `prev` to `cur`; a drop is a reset counted from zero. `fallback` when `prev` is unknown. */
1156
+ function counterDelta(prev, cur, fallback) {
1157
+ if (cur === null) return null;
1158
+ if (prev === null) return fallback;
1159
+ return cur >= prev ? cur - prev : cur;
1160
+ }
1161
+ /** The name of an ordinal, or `null` when the series is not an enum or the ordinal is unknown. */
1162
+ function stateName(meaning, ordinal) {
1163
+ if (meaning.kind !== "enum" || meaning.values === null || ordinal === null) return null;
1164
+ return meaning.values[ordinal] ?? null;
1165
+ }
1166
+ function namedStates(meaning, states) {
1167
+ if (meaning.kind !== "enum" || states === null) return null;
1168
+ const out = {};
1169
+ for (const [ordinal, ms] of Object.entries(states)) {
1170
+ const name = stateName(meaning, Number(ordinal));
1171
+ if (name !== null) out[name] = (out[name] ?? 0) + ms;
1172
+ }
1173
+ return out;
1174
+ }
1175
+ /** Raw points, oldest first; `prev` is the value of the point before the first one. */
1176
+ function rawPoints(rows, meaning, prev) {
1177
+ const isEnum = meaning.kind === "enum";
1178
+ let held = prev;
1179
+ return rows.map((r) => {
1180
+ const delta = meaning.kind === "counter" ? counterDelta(held, r.value, null) : null;
1181
+ held = r.value;
1182
+ return {
1183
+ t: r.ts,
1184
+ samples: 1,
1185
+ mean: isEnum ? null : r.value,
1186
+ twMean: null,
1187
+ min: r.value,
1188
+ max: r.value,
1189
+ last: r.value,
1190
+ delta,
1191
+ state: stateName(meaning, r.value),
1192
+ states: null
1193
+ };
1194
+ });
1195
+ }
1196
+ /** Rollup rows, oldest first; `prevLast` is the `last` of the bucket before the first one. */
1197
+ function rollupPoints(rows, meaning, prevLast) {
1198
+ const isEnum = meaning.kind === "enum";
1199
+ let held = prevLast;
1200
+ return rows.map((row) => {
1201
+ const delta = meaning.kind === "counter" ? counterDelta(held, row.last, row.last === null ? null : row.last - row.min) : null;
1202
+ if (row.last !== null) held = row.last;
1203
+ return {
1204
+ t: row.bucketStart,
1205
+ samples: row.samples,
1206
+ mean: isEnum || row.samples <= 0 ? null : row.sum / row.samples,
1207
+ twMean: !isEnum && row.twSum !== null && row.twMs !== null && row.twMs > 0 ? row.twSum / row.twMs : null,
1208
+ min: row.min,
1209
+ max: row.max,
1210
+ last: row.last,
1211
+ delta,
1212
+ state: stateName(meaning, row.last),
1213
+ states: namedStates(meaning, row.states)
1214
+ };
1215
+ });
1216
+ }
1217
+ /** Energy used in each bucket of a `watts` gauge: its time-weighted integral, in kWh. */
1218
+ function integratedEnergyPoints(rows) {
1219
+ return rows.map((row) => ({
1220
+ t: row.bucketStart,
1221
+ samples: row.samples,
1222
+ mean: null,
1223
+ twMean: null,
1224
+ min: row.min,
1225
+ max: row.max,
1226
+ last: null,
1227
+ delta: row.twSum === null ? null : row.twSum * KWH_PER_WATT_MS,
1228
+ state: null,
1229
+ states: null
1230
+ }));
1231
+ }
1232
+ //#endregion
872
1233
  //#region src/builtins/device-history/query/history-query.ts
1234
+ /**
1235
+ * Reads: `listSeries`, `query` and `queryMany` (plan §5.1).
1236
+ *
1237
+ * `auto` resolution picks the finest tier whose retention covers `from` and
1238
+ * whose point count fits `maxPoints`: a rollup tier fits when
1239
+ * `ceil(range / bucket) <= maxPoints`, raw fits when a read of `maxPoints + 1`
1240
+ * rows comes back short. The coarsest covering tier is the last resort and is
1241
+ * truncated to its newest `maxPoints` points (`truncated: true`) rather than
1242
+ * answering nothing. A series fed by closed buckets has NO raw rows, so raw is
1243
+ * never a candidate for it: an empty raw read "fits" every window.
1244
+ *
1245
+ * `before` is the last point strictly before the window in the chosen tier, so a
1246
+ * step chart can start at the value in force when the window opens — a quiet
1247
+ * bucket has no row, and the held value is otherwise invisible.
1248
+ *
1249
+ * {@link DEVICE_HISTORY_ENERGY_KEY} is computed here at read time, never stored:
1250
+ * the device's `kwhTotal` meter when it has one (per-bucket growth), else the
1251
+ * time-weighted integral of its `watts` (`tw_sum`, already in every rollup).
1252
+ * One place makes that choice, so two charts of one device cannot disagree, and
1253
+ * it covers every hour of history recorded before the key existed.
1254
+ */
873
1255
  var TIER_ORDER = [
874
1256
  "raw",
875
1257
  "5m",
@@ -883,46 +1265,101 @@ var BUCKET_MS = {
883
1265
  };
884
1266
  /** A key with more scopes than this is cut (a zone x class matrix, not a chart). */
885
1267
  var MAX_SCOPES = 50;
1268
+ /** The power-meter series the energy key is derived from, meter first. */
1269
+ var ENERGY_METER_KEY = capSeriesKey("power-meter", "kwhTotal");
1270
+ var ENERGY_POWER_KEY = capSeriesKey("power-meter", "watts");
886
1271
  function retentionMsOf(tier, policy) {
887
1272
  if (tier === "raw") return policy.rawDays * DAY_MS;
888
1273
  if (tier === "5m") return policy.fiveMinuteDays * DAY_MS;
889
1274
  if (tier === "1h") return policy.hourDays * DAY_MS;
890
1275
  return policy.dayDays === null || policy.dayDays <= 0 ? null : policy.dayDays * DAY_MS;
891
1276
  }
892
- function rawPoint(ts, value) {
1277
+ /** Rows arrive newest first and one over the bound; keep the newest `maxPoints`, oldest first. */
1278
+ function bounded(newestFirst, maxPoints) {
1279
+ const overflow = newestFirst.length > maxPoints;
893
1280
  return {
894
- t: ts,
895
- samples: 1,
896
- mean: value,
897
- twMean: null,
898
- min: value,
899
- max: value,
900
- last: value
1281
+ rows: (overflow ? newestFirst.slice(0, maxPoints) : newestFirst).toReversed(),
1282
+ overflow
901
1283
  };
902
1284
  }
903
- function rollupPoint(row) {
904
- return {
905
- t: row.bucketStart,
906
- samples: row.samples,
907
- mean: row.samples > 0 ? row.sum / row.samples : null,
908
- twMean: row.twSum !== null && row.twMs !== null && row.twMs > 0 ? row.twSum / row.twMs : null,
909
- min: row.min,
910
- max: row.max,
911
- last: row.last
912
- };
1285
+ function errorText(err) {
1286
+ return err instanceof Error ? err.message : String(err);
913
1287
  }
914
1288
  var HistoryQuery = class {
915
1289
  deps;
916
1290
  constructor(deps) {
917
1291
  this.deps = deps;
918
1292
  }
919
- async query(input) {
920
- const { registry } = this.deps;
921
- const subjectId = subjectIdOf(input.subject);
922
- const wanted = registry.forKeyAndSubject(input.key, subjectId).filter((s) => input.scope === void 0 || s.scope === input.scope).toSorted((a, b) => a.scope.localeCompare(b.scope)).slice(0, MAX_SCOPES);
923
- const candidates = this.coveringTiers(input.from);
1293
+ async query(input) {
1294
+ if (input.key === "derived:energy.kwh") return this.queryEnergy(input);
1295
+ const { registry } = this.deps;
1296
+ const wanted = this.seriesOf(input);
1297
+ const metric = registry.metric(input.key);
1298
+ const meaning = {
1299
+ kind: metric?.kind ?? "gauge",
1300
+ values: metric?.values ?? null
1301
+ };
1302
+ const { chosen, reads } = await this.choose(input, wanted, metric?.ingest === "buckets");
1303
+ const series = [];
1304
+ for (const [s, read] of reads) {
1305
+ const before = await this.before(chosen, s, input.from, meaning);
1306
+ const points = read.tier === "raw" ? rawPoints(read.rows, meaning, before?.value ?? null) : rollupPoints(read.rows, meaning, before?.value ?? null);
1307
+ series.push({
1308
+ scope: s.scope,
1309
+ before,
1310
+ points,
1311
+ derivedFrom: null
1312
+ });
1313
+ }
1314
+ return {
1315
+ resolution: chosen,
1316
+ series,
1317
+ truncated: reads.some(([, r]) => r.overflow)
1318
+ };
1319
+ }
1320
+ /** Several reads over one window; one failing item answers its error, not the batch's. */
1321
+ async queryMany(input) {
1322
+ const out = [];
1323
+ for (const item of input.items) {
1324
+ const subject = "deviceId" in item ? {
1325
+ kind: "device",
1326
+ deviceId: item.deviceId
1327
+ } : {
1328
+ kind: "node",
1329
+ nodeId: item.forNodeId
1330
+ };
1331
+ try {
1332
+ const result = await this.query({
1333
+ key: item.key,
1334
+ subject,
1335
+ ...item.scope !== void 0 ? { scope: item.scope } : {},
1336
+ from: input.from,
1337
+ to: input.to,
1338
+ resolution: input.resolution,
1339
+ maxPoints: input.maxPoints
1340
+ });
1341
+ out.push({
1342
+ ok: true,
1343
+ result
1344
+ });
1345
+ } catch (err) {
1346
+ out.push({
1347
+ ok: false,
1348
+ error: errorText(err)
1349
+ });
1350
+ }
1351
+ }
1352
+ return out;
1353
+ }
1354
+ seriesOf(input) {
1355
+ 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);
1356
+ }
1357
+ /** The tier to answer from and its reads. `noRaw`: the series has no raw rows to read. */
1358
+ async choose(input, wanted, noRaw) {
1359
+ const covering = this.coveringTiers(input.from);
1360
+ const candidates = noRaw ? withoutRaw(covering) : covering;
924
1361
  const forced = input.resolution === "auto" ? null : input.resolution;
925
- const order = forced === null ? candidates : [forced];
1362
+ const order = forced === null ? candidates : [noRaw && forced === "raw" ? "5m" : forced];
926
1363
  let chosen = order[order.length - 1] ?? "1d";
927
1364
  let reads = [];
928
1365
  for (const [index, tier] of order.entries()) {
@@ -933,16 +1370,9 @@ var HistoryQuery = class {
933
1370
  if (reads.some(([, r]) => r.overflow) && !last) continue;
934
1371
  break;
935
1372
  }
936
- const series = [];
937
- for (const [s, read] of reads) series.push({
938
- scope: s.scope,
939
- before: await this.before(chosen, s, input.from),
940
- points: read.points
941
- });
942
1373
  return {
943
- resolution: chosen,
944
- series,
945
- truncated: reads.some(([, r]) => r.overflow)
1374
+ chosen,
1375
+ reads
946
1376
  };
947
1377
  }
948
1378
  /** Tiers whose retention covers `from`, finest first; the coarsest is always present. */
@@ -964,35 +1394,79 @@ var HistoryQuery = class {
964
1394
  async readTier(tier, series, input) {
965
1395
  const { store } = this.deps;
966
1396
  const limit = input.maxPoints + 1;
967
- if (tier === "raw") {
968
- const rows = await store.readRaw(series.id, input.from, input.to, limit, "desc");
969
- return this.shape(rows.map((r) => rawPoint(r.ts, r.value)), input.maxPoints);
970
- }
1397
+ if (tier === "raw") return {
1398
+ tier,
1399
+ ...bounded(await store.readRaw(series.id, input.from, input.to, limit, "desc"), input.maxPoints)
1400
+ };
971
1401
  const lower = bucketStartOf(tier, input.from);
972
- const rows = await store.readRollups(tier, series.id, lower, input.to, limit, "desc");
973
- return this.shape(rows.map(rollupPoint), input.maxPoints);
974
- }
975
- /** Rows arrive newest first and one over the bound; keep the newest `maxPoints`, oldest first. */
976
- shape(newestFirst, maxPoints) {
977
- const overflow = newestFirst.length > maxPoints;
978
1402
  return {
979
- points: (overflow ? newestFirst.slice(0, maxPoints) : newestFirst).toReversed(),
980
- overflow
1403
+ tier,
1404
+ ...bounded(await store.readRollups(tier, series.id, lower, input.to, limit, "desc"), input.maxPoints)
981
1405
  };
982
1406
  }
983
- async before(tier, series, from) {
1407
+ async before(tier, series, from, meaning) {
984
1408
  const { store } = this.deps;
985
1409
  if (tier === "raw") {
986
1410
  const p = await store.rawBefore(series.id, from);
987
1411
  return p === null ? null : {
988
1412
  t: p.ts,
989
- value: p.value
1413
+ value: p.value,
1414
+ state: stateName(meaning, p.value)
990
1415
  };
991
1416
  }
992
1417
  const row = await store.rollupBefore(tier, series.id, bucketStartOf(tier, from));
993
1418
  return row === null ? null : {
994
1419
  t: row.bucketStart,
995
- value: row.last
1420
+ value: row.last,
1421
+ state: stateName(meaning, row.last)
1422
+ };
1423
+ }
1424
+ /** The series energy is derived from for a device: the meter when it has one, else its power. */
1425
+ energySource(subject) {
1426
+ if (subject.kind !== "device") return null;
1427
+ const subjectId = subjectIdOf(subject);
1428
+ const meter = this.deps.registry.find(ENERGY_METER_KEY, subjectId, "");
1429
+ if (meter !== void 0) return {
1430
+ series: meter,
1431
+ from: "meter"
1432
+ };
1433
+ const power = this.deps.registry.find(ENERGY_POWER_KEY, subjectId, "");
1434
+ return power === void 0 ? null : {
1435
+ series: power,
1436
+ from: "power"
1437
+ };
1438
+ }
1439
+ async queryEnergy(input) {
1440
+ const source = this.energySource(input.subject);
1441
+ if (source === null || input.scope !== void 0 && input.scope !== "") return {
1442
+ resolution: "5m",
1443
+ series: [],
1444
+ truncated: false
1445
+ };
1446
+ const { chosen, reads } = await this.choose(input, [source.series], true);
1447
+ const read = reads[0]?.[1];
1448
+ if (read === void 0 || read.tier === "raw") return {
1449
+ resolution: chosen,
1450
+ series: [],
1451
+ truncated: false
1452
+ };
1453
+ let points;
1454
+ if (source.from === "meter") {
1455
+ const prev = await this.deps.store.rollupBefore(read.tier, source.series.id, bucketStartOf(read.tier, input.from));
1456
+ points = rollupPoints(read.rows, {
1457
+ kind: "counter",
1458
+ values: null
1459
+ }, prev?.last ?? null);
1460
+ } else points = integratedEnergyPoints(read.rows);
1461
+ return {
1462
+ resolution: chosen,
1463
+ series: [{
1464
+ scope: "",
1465
+ before: null,
1466
+ points,
1467
+ derivedFrom: source.series.key
1468
+ }],
1469
+ truncated: read.overflow
996
1470
  };
997
1471
  }
998
1472
  async listSeries(subject) {
@@ -1009,10 +1483,23 @@ var HistoryQuery = class {
1009
1483
  unit: await this.unitOf(subject, s, metric.unit, slices),
1010
1484
  displayName: metric.displayName,
1011
1485
  source: metric.source,
1486
+ values: metric.kind === "enum" ? metric.values ?? [] : null,
1012
1487
  firstTs: s.firstTs,
1013
1488
  lastTs: s.lastTs
1014
1489
  });
1015
1490
  }
1491
+ const energy = this.energySource(subject);
1492
+ if (energy !== null) infos.push({
1493
+ key: DEVICE_HISTORY_ENERGY_KEY,
1494
+ scope: "",
1495
+ kind: "counter",
1496
+ unit: "kWh",
1497
+ displayName: "Energy",
1498
+ source: "derived",
1499
+ values: null,
1500
+ firstTs: energy.series.firstTs,
1501
+ lastTs: energy.series.lastTs
1502
+ });
1016
1503
  return infos.toSorted((a, b) => a.key.localeCompare(b.key) || a.scope.localeCompare(b.scope));
1017
1504
  }
1018
1505
  /** The metric's fixed unit, else the device's own (a `unitFrom` field), else unknown. */
@@ -1033,191 +1520,11 @@ var HistoryQuery = class {
1033
1520
  return this.deps.catalog[body.slice(0, dot)]?.[body.slice(dot + 1)];
1034
1521
  }
1035
1522
  };
1036
- //#endregion
1037
- //#region src/builtins/device-history/retention/departure-tracker.ts
1038
- var DEPARTED_ABSENCE_MS = 7 * DAY_MS;
1039
- var DepartureTracker = class {
1040
- store;
1041
- registry;
1042
- logger;
1043
- missing = /* @__PURE__ */ new Map();
1044
- constructor(store, registry, logger) {
1045
- this.store = store;
1046
- this.registry = registry;
1047
- this.logger = logger;
1048
- }
1049
- async load() {
1050
- this.missing = await this.store.loadDepartures();
1051
- }
1052
- missingSince(deviceId) {
1053
- return this.missing.get(deviceId);
1054
- }
1055
- /** Forget a device's clock — its series were removed because it was DELETED (D865). */
1056
- async forget(deviceId) {
1057
- await this.store.clearMissing([deviceId]);
1058
- const next = new Map(this.missing);
1059
- next.delete(deviceId);
1060
- this.missing = next;
1061
- }
1062
- /** Feed one SUCCESSFUL listing. A failed read never reaches here. */
1063
- async observe(listed, now) {
1064
- const withSeries = new Set(this.registry.all().flatMap((s) => s.deviceId === null ? [] : [s.deviceId]));
1065
- const back = [...this.missing.keys()].filter((id) => listed.has(id));
1066
- const gone = [...withSeries].filter((id) => !listed.has(id) && !this.missing.has(id));
1067
- try {
1068
- await this.store.clearMissing(back);
1069
- await this.store.markMissing(gone, now);
1070
- } catch (err) {
1071
- this.logger.warn("device-history: departure clock not written", { meta: { error: err instanceof Error ? err.message : String(err) } });
1072
- return;
1073
- }
1074
- const next = new Map(this.missing);
1075
- for (const id of back) next.delete(id);
1076
- for (const id of gone) next.set(id, now);
1077
- this.missing = next;
1078
- }
1079
- };
1080
- //#endregion
1081
- //#region src/builtins/device-history/retention/retention.ts
1082
- var DEFAULT_RETENTION = {
1083
- rawDays: 7,
1084
- fiveMinuteDays: 14,
1085
- hourDays: 365,
1086
- dayDays: null
1087
- };
1088
- /** Cutoff per tier: rows strictly older are deleted. `null` = keep forever. */
1089
- function retentionCutoffs(policy, now) {
1090
- return {
1091
- raw: now - policy.rawDays * DAY_MS,
1092
- "5m": now - policy.fiveMinuteDays * DAY_MS,
1093
- "1h": now - policy.hourDays * DAY_MS,
1094
- "1d": policy.dayDays === null || policy.dayDays <= 0 ? null : now - policy.dayDays * DAY_MS
1095
- };
1523
+ /** Every covering tier but raw; the coarsest stays when it is the only one. */
1524
+ function withoutRaw(tiers) {
1525
+ const rollups = tiers.filter((t) => t !== "raw");
1526
+ return rollups.length > 0 ? rollups : ["5m"];
1096
1527
  }
1097
- var TIERS = [
1098
- "raw",
1099
- "5m",
1100
- "1h",
1101
- "1d"
1102
- ];
1103
- var RETENTION_STEP_MS = {
1104
- raw: HOUR_MS,
1105
- "5m": HOUR_MS,
1106
- "1h": DAY_MS,
1107
- "1d": DAY_MS
1108
- };
1109
- var MAX_PURGES_PER_RUN = 20;
1110
- /** Width of one delete statement's time range, per tier. */
1111
- var SLICE_MS = {
1112
- raw: DAY_MS,
1113
- "5m": DAY_MS,
1114
- "1h": 30 * DAY_MS,
1115
- "1d": 30 * DAY_MS
1116
- };
1117
- /** A bucket's start may precede the series' first point by up to a day. */
1118
- var BUCKET_LEAD_MS = 2 * DAY_MS;
1119
- /** The short tiers hold nothing older than this unless the operator configured more. */
1120
- var SHORT_TIER_LOOKBACK_MS = 400 * DAY_MS;
1121
- var yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve));
1122
- var RetentionJob = class {
1123
- deps;
1124
- appliedUpTo = /* @__PURE__ */ new Map();
1125
- running = null;
1126
- constructor(deps) {
1127
- this.deps = deps;
1128
- }
1129
- run(policy, now) {
1130
- if (this.running !== null) return this.running;
1131
- const run = this.doRun(policy, now).finally(() => {
1132
- this.running = null;
1133
- });
1134
- this.running = run;
1135
- return run;
1136
- }
1137
- async doRun(policy, now) {
1138
- const { registry, counters, logger } = this.deps;
1139
- const cutoffs = retentionCutoffs(policy, now);
1140
- try {
1141
- for (const tier of TIERS) {
1142
- const cutoff = cutoffs[tier];
1143
- if (cutoff === null) continue;
1144
- for (const series of registry.all()) {
1145
- if (series.firstTs - 864e5 >= cutoff) continue;
1146
- if (tier === "raw" && registry.metric(series.key)?.ingest === "buckets") continue;
1147
- const marker = `${tier}:${series.id}`;
1148
- const applied = this.appliedUpTo.get(marker);
1149
- if (applied !== void 0 && cutoff - applied < RETENTION_STEP_MS[tier]) continue;
1150
- const lo = Math.max(0, applied === void 0 ? series.firstTs - BUCKET_LEAD_MS : applied - RETENTION_STEP_MS[tier]);
1151
- counters.addDeleted(tier, await this.deleteSliced(tier, series.id, lo, cutoff - 1));
1152
- this.appliedUpTo.set(marker, cutoff);
1153
- }
1154
- }
1155
- await this.purgeDeparted(now);
1156
- counters.retentionLastRunAt = now;
1157
- } catch (err) {
1158
- logger.warn("device-history: retention pass failed — retried at the next run", { meta: { error: err instanceof Error ? err.message : String(err) } });
1159
- }
1160
- }
1161
- /** Delete `[lo, hiInclusive]` of one tier in time slices, yielding between them. */
1162
- /** Drop the visit markers of a purged series. */
1163
- forgetSeries(seriesId) {
1164
- for (const tier of TIERS) this.appliedUpTo.delete(`${tier}:${seriesId}`);
1165
- }
1166
- async deleteSliced(tier, seriesId, lo, hiInclusive) {
1167
- const yieldNow = this.deps.yieldNow ?? yieldToEventLoop;
1168
- let deleted = 0;
1169
- for (let from = lo; from <= hiInclusive; from += SLICE_MS[tier]) {
1170
- const to = Math.min(from + SLICE_MS[tier] - 1, hiInclusive);
1171
- deleted += await this.deps.store.deleteRange(tier, seriesId, from, to);
1172
- await yieldNow();
1173
- }
1174
- return deleted;
1175
- }
1176
- /** Tier by tier in slices, then the series row (and whatever the slices missed). */
1177
- async purgeSeries(series, now) {
1178
- for (const tier of TIERS) {
1179
- const lo = tier === "raw" || tier === "5m" ? Math.max(series.firstTs - BUCKET_LEAD_MS, now - SHORT_TIER_LOOKBACK_MS) : series.firstTs - BUCKET_LEAD_MS;
1180
- await this.deleteSliced(tier, series.id, Math.max(0, lo), now + DAY_MS);
1181
- }
1182
- await this.deps.store.deleteSeries(series.id);
1183
- }
1184
- /**
1185
- * Delete every series of a DELETED device now (D865). Driven by a tombstone
1186
- * through the `device-removal-hook`, never by a listing: the week-long
1187
- * absence clock above remains the backstop for a device removed before
1188
- * tombstones existed.
1189
- */
1190
- async purgeDevice(deviceId, now) {
1191
- const { registry, counters } = this.deps;
1192
- let purged = 0;
1193
- for (const series of registry.all()) {
1194
- if (series.deviceId !== deviceId) continue;
1195
- await this.purgeSeries(series, now);
1196
- registry.remove(series.id);
1197
- counters.purgedSeries += 1;
1198
- purged += 1;
1199
- }
1200
- return purged;
1201
- }
1202
- async purgeDeparted(now) {
1203
- const { registry, departures, counters, logger } = this.deps;
1204
- let purged = 0;
1205
- for (const series of registry.all()) {
1206
- if (purged >= MAX_PURGES_PER_RUN) break;
1207
- if (series.deviceId === null) continue;
1208
- const since = departures.missingSince(series.deviceId);
1209
- if (since === void 0 || now - since < DEPARTED_ABSENCE_MS) continue;
1210
- await this.purgeSeries(series, now);
1211
- registry.remove(series.id);
1212
- counters.purgedSeries += 1;
1213
- purged += 1;
1214
- logger.info("device-history: purged the series of an unregistered device", {
1215
- tags: { deviceId: series.deviceId },
1216
- meta: { key: series.key }
1217
- });
1218
- }
1219
- }
1220
- };
1221
1528
  //#endregion
1222
1529
  //#region src/builtins/device-history/store/history-schema.ts
1223
1530
  var HISTORY_NAMESPACE = "device-history";
@@ -1287,6 +1594,10 @@ function rollupDeclaration(collection) {
1287
1594
  {
1288
1595
  name: "tw_ms",
1289
1596
  type: "INTEGER"
1597
+ },
1598
+ {
1599
+ name: "state_ms",
1600
+ type: "TEXT"
1290
1601
  }
1291
1602
  ]
1292
1603
  };
@@ -1341,6 +1652,10 @@ var HISTORY_DECLARATIONS = [
1341
1652
  name: "declared_at",
1342
1653
  type: "INTEGER",
1343
1654
  notNull: true
1655
+ },
1656
+ {
1657
+ name: "enum_values",
1658
+ type: "TEXT"
1344
1659
  }
1345
1660
  ]
1346
1661
  },
@@ -1514,9 +1829,34 @@ function chunks(items, size) {
1514
1829
  return out;
1515
1830
  }
1516
1831
  function parseKind(value) {
1517
- if (value === "gauge" || value === "counter" || value === "boolean") return value;
1832
+ if (value === "gauge" || value === "counter" || value === "boolean" || value === "enum") return value;
1518
1833
  throw new Error(`history row: unknown kind "${value}"`);
1519
1834
  }
1835
+ /** A JSON TEXT column: the engine hands back the decoded value, or the text itself. */
1836
+ function jsonColumn(data, column) {
1837
+ const raw = data[column];
1838
+ if (raw === null || raw === void 0) return null;
1839
+ return typeof raw === "string" ? JSON.parse(raw) : raw;
1840
+ }
1841
+ /** A JSON column that must hold a string array, or nothing. */
1842
+ function parseValues(data) {
1843
+ const parsed = jsonColumn(data, "enum_values");
1844
+ if (parsed === null) return null;
1845
+ if (Array.isArray(parsed) && parsed.every((v) => typeof v === "string")) return parsed;
1846
+ throw new Error("history row: column \"enum_values\" is not a JSON string array");
1847
+ }
1848
+ /** A JSON column that must hold `{ "<ordinal>": ms }`, or nothing. */
1849
+ function parseStates(data) {
1850
+ const parsed = jsonColumn(data, "state_ms");
1851
+ if (parsed === null) return null;
1852
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("history row: column \"state_ms\" is not a JSON object");
1853
+ const out = {};
1854
+ for (const [k, v] of Object.entries(parsed)) {
1855
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new Error("history row: column \"state_ms\" holds a non-number");
1856
+ out[k] = v;
1857
+ }
1858
+ return out;
1859
+ }
1520
1860
  function parseMetric(data) {
1521
1861
  const source = readText(data, "source");
1522
1862
  const subjectKind = readText(data, "subject_kind");
@@ -1533,7 +1873,8 @@ function parseMetric(data) {
1533
1873
  unit: readTextOrNull(data, "unit"),
1534
1874
  displayName: readText(data, "display_name"),
1535
1875
  ingest,
1536
- declaredAt: readNumber(data, "declared_at")
1876
+ declaredAt: readNumber(data, "declared_at"),
1877
+ values: parseValues(data)
1537
1878
  };
1538
1879
  }
1539
1880
  function parseSeries(data) {
@@ -1558,7 +1899,8 @@ function parseRollup(data) {
1558
1899
  last: readNumberOrNull(data, "last"),
1559
1900
  lastTs: readNumberOrNull(data, "last_ts"),
1560
1901
  twSum: readNumberOrNull(data, "tw_sum"),
1561
- twMs: readNumberOrNull(data, "tw_ms")
1902
+ twMs: readNumberOrNull(data, "tw_ms"),
1903
+ states: parseStates(data)
1562
1904
  };
1563
1905
  }
1564
1906
  function rollupData(row) {
@@ -1572,7 +1914,8 @@ function rollupData(row) {
1572
1914
  last: row.last,
1573
1915
  last_ts: row.lastTs,
1574
1916
  tw_sum: row.twSum,
1575
- tw_ms: row.twMs
1917
+ tw_ms: row.twMs,
1918
+ state_ms: row.states === null ? null : JSON.stringify(row.states)
1576
1919
  };
1577
1920
  }
1578
1921
  function collectionOf(tier) {
@@ -1596,22 +1939,37 @@ var HistoryStore = class {
1596
1939
  filter: { limit: 2e4 }
1597
1940
  })).map((r) => parseMetric(r.data));
1598
1941
  }
1942
+ /**
1943
+ * Delete metric rows no lookup can reach: rows an earlier build wrote under an
1944
+ * engine-minted random key (every metric key is `cap:` / `addon:` prefixed).
1945
+ */
1946
+ async deleteMetrics(keys) {
1947
+ for (const batch of chunks(keys, WRITE_CHUNK)) await this.door.deleteWhere.mutate({
1948
+ namespace: HISTORY_NAMESPACE,
1949
+ collection: COLLECTIONS.metrics,
1950
+ filter: { whereIn: { key: [...batch] } }
1951
+ });
1952
+ }
1599
1953
  async upsertMetric(metric) {
1600
1954
  await this.door.upsertMany.mutate({
1601
1955
  namespace: HISTORY_NAMESPACE,
1602
1956
  collection: COLLECTIONS.metrics,
1603
1957
  conflict: ["key"],
1604
- records: [{ data: {
1605
- key: metric.key,
1606
- source: metric.source,
1607
- owner_addon: metric.ownerAddon,
1608
- subject_kind: metric.subjectKind,
1609
- kind: metric.kind,
1610
- unit: metric.unit,
1611
- display_name: metric.displayName,
1612
- ingest: metric.ingest,
1613
- declared_at: metric.declaredAt
1614
- } }]
1958
+ records: [{
1959
+ id: metric.key,
1960
+ data: {
1961
+ key: metric.key,
1962
+ source: metric.source,
1963
+ owner_addon: metric.ownerAddon,
1964
+ subject_kind: metric.subjectKind,
1965
+ kind: metric.kind,
1966
+ unit: metric.unit,
1967
+ display_name: metric.displayName,
1968
+ ingest: metric.ingest,
1969
+ declared_at: metric.declaredAt,
1970
+ enum_values: metric.values === null ? null : JSON.stringify(metric.values)
1971
+ }
1972
+ }]
1615
1973
  });
1616
1974
  }
1617
1975
  /** Series with `id > afterId`, ascending — the boot load pages with this. */
@@ -1858,6 +2216,11 @@ var HistoryStore = class {
1858
2216
  * The held value also counts toward `min`/`max` when it was in force at the
1859
2217
  * start of a bucket, because it really was the series' value then.
1860
2218
  */
2219
+ function credit(states, value, ms) {
2220
+ if (states === null || ms <= 0) return;
2221
+ const key = String(value);
2222
+ states[key] = (states[key] ?? 0) + ms;
2223
+ }
1861
2224
  /** One row per 5-minute bucket that has at least one point. */
1862
2225
  function rawToFiveMinuteRows(input) {
1863
2226
  const rows = [];
@@ -1876,6 +2239,7 @@ function rawToFiveMinuteRows(input) {
1876
2239
  let max = Number.NEGATIVE_INFINITY;
1877
2240
  let twSum = 0;
1878
2241
  let twMs = 0;
2242
+ const states = input.trackStates === true ? {} : null;
1879
2243
  let cursor = bucketStart;
1880
2244
  let value = held;
1881
2245
  if (value !== null) {
@@ -1890,6 +2254,7 @@ function rawToFiveMinuteRows(input) {
1890
2254
  const dt = p.ts - cursor;
1891
2255
  twSum += value * dt;
1892
2256
  twMs += dt;
2257
+ credit(states, value, dt);
1893
2258
  }
1894
2259
  cursor = p.ts;
1895
2260
  value = p.value;
@@ -1903,6 +2268,7 @@ function rawToFiveMinuteRows(input) {
1903
2268
  if (value !== null && coverageEnd > cursor) {
1904
2269
  twSum += value * (coverageEnd - cursor);
1905
2270
  twMs += coverageEnd - cursor;
2271
+ credit(states, value, coverageEnd - cursor);
1906
2272
  }
1907
2273
  held = last.value;
1908
2274
  rows.push({
@@ -1915,7 +2281,8 @@ function rawToFiveMinuteRows(input) {
1915
2281
  last: last.value,
1916
2282
  lastTs: last.ts,
1917
2283
  twSum,
1918
- twMs
2284
+ twMs,
2285
+ states
1919
2286
  });
1920
2287
  }
1921
2288
  return rows;
@@ -1932,6 +2299,7 @@ function combineRows(input) {
1932
2299
  let max = Number.NEGATIVE_INFINITY;
1933
2300
  let twSum = 0;
1934
2301
  let twMs = 0;
2302
+ const states = input.trackStates === true ? {} : null;
1935
2303
  let held = input.carryIn;
1936
2304
  let cursor = input.windowStart;
1937
2305
  for (const row of rows) {
@@ -1943,6 +2311,7 @@ function combineRows(input) {
1943
2311
  if (hasTw && gap > 0 && held !== null) {
1944
2312
  twSum += held * gap;
1945
2313
  twMs += gap;
2314
+ credit(states, held, gap);
1946
2315
  if (held < min) min = held;
1947
2316
  if (held > max) max = held;
1948
2317
  }
@@ -1950,6 +2319,7 @@ function combineRows(input) {
1950
2319
  twSum += row.twSum;
1951
2320
  twMs += row.twMs;
1952
2321
  }
2322
+ if (states !== null && row.states !== null) for (const [state, ms] of Object.entries(row.states)) states[state] = (states[state] ?? 0) + ms;
1953
2323
  cursor = row.bucketStart + input.childWidthMs;
1954
2324
  held = row.last;
1955
2325
  }
@@ -1957,6 +2327,7 @@ function combineRows(input) {
1957
2327
  if (hasTw && held !== null && coverageEnd > cursor) {
1958
2328
  twSum += held * (coverageEnd - cursor);
1959
2329
  twMs += coverageEnd - cursor;
2330
+ credit(states, held, coverageEnd - cursor);
1960
2331
  }
1961
2332
  return {
1962
2333
  seriesId: input.seriesId,
@@ -1968,7 +2339,8 @@ function combineRows(input) {
1968
2339
  last: lastRow.last,
1969
2340
  lastTs: lastRow.lastTs,
1970
2341
  twSum: hasTw ? twSum : null,
1971
- twMs: hasTw ? twMs : null
2342
+ twMs: hasTw ? twMs : null,
2343
+ states
1972
2344
  };
1973
2345
  }
1974
2346
  /** How far back a boot rebuilds from raw and re-marks the upper tiers. */
@@ -2109,6 +2481,11 @@ var RollupEngine = class {
2109
2481
  }
2110
2482
  if (handled > 0) this.deps.counters.last1dAt = now;
2111
2483
  }
2484
+ /** An `enum` series keeps the time spent in each state in its rollups. */
2485
+ isEnum(seriesId) {
2486
+ const series = this.deps.registry.byIdOf(seriesId);
2487
+ return series !== void 0 && this.deps.registry.metric(series.key)?.kind === "enum";
2488
+ }
2112
2489
  /** Raw → 5m rows for buckets in `[from, toExclusive)`; writes them and marks their hours. */
2113
2490
  async recomputeFiveMinute(seriesId, from, toExclusive, horizon) {
2114
2491
  const { store } = this.deps;
@@ -2125,7 +2502,8 @@ var RollupEngine = class {
2125
2502
  seriesId,
2126
2503
  points,
2127
2504
  carryIn: await store.rawBefore(seriesId, from),
2128
- horizon
2505
+ horizon,
2506
+ trackStates: this.isEnum(seriesId)
2129
2507
  });
2130
2508
  if (rows.length === 0) return 0;
2131
2509
  await store.upsertRollups("5m", rows);
@@ -2149,7 +2527,8 @@ var RollupEngine = class {
2149
2527
  childWidthMs,
2150
2528
  rows: await store.readRollups(child, seriesId, windowStart, windowEnd - 1, READ_PAGE),
2151
2529
  carryIn: (await store.rollupBefore(child, seriesId, windowStart))?.last ?? null,
2152
- horizon: now
2530
+ horizon: now,
2531
+ trackStates: this.isEnum(seriesId)
2153
2532
  });
2154
2533
  if (combined === null) return;
2155
2534
  await store.upsertRollups(parent, [combined]);
@@ -2609,6 +2988,13 @@ var HistoryRuntime = class {
2609
2988
  * are hub capabilities, and a forked runner cannot call them from
2610
2989
  * `onInitialize`. Until then the provider answers "starting" rather than an
2611
2990
  * empty history (an unanswered query is not an empty one, D315).
2991
+ *
2992
+ * Readiness follows the STORE, not the registration: the provider registers at
2993
+ * `onInitialize`, but `ready` is emitted only once the runtime is open in
2994
+ * `onHubReachable` (`autoEmitReadiness` is off). Producers declare their
2995
+ * series on `ready`; the automatic emit at registration sent every one of
2996
+ * them into "device-history is starting" on each boot (`.50`, 1.2.607:
2997
+ * metrics-native and pipeline-analytics).
2612
2998
  */
2613
2999
  var DEFAULT_CONFIG = {
2614
3000
  rawDays: DEFAULT_RETENTION.rawDays,
@@ -2632,9 +3018,40 @@ var DeviceHistoryAddon = class extends BaseAddon {
2632
3018
  runtime = null;
2633
3019
  unsubscribe = null;
2634
3020
  removalHook = null;
3021
+ /** This instance's readiness generation (the base class keeps its own private). */
3022
+ readinessGeneration = `device-history-${Date.now().toString(36)}`;
2635
3023
  constructor() {
2636
3024
  super({ ...DEFAULT_CONFIG });
2637
3025
  }
3026
+ /** `ready` is the store being open, emitted by {@link emitStoreReadiness}. */
3027
+ get autoEmitReadiness() {
3028
+ return false;
3029
+ }
3030
+ /** Readiness of both provided caps, on the bare node this runner belongs to. */
3031
+ emitStoreReadiness(state) {
3032
+ const ctx = this.ctxIfReady;
3033
+ if (ctx === null) return;
3034
+ const raw = ctx.kernel?.localNodeId ?? "hub";
3035
+ const nodeId = raw.includes("/") ? raw.split("/")[0] ?? raw : raw;
3036
+ for (const capName of [deviceHistoryCapability.name, deviceRemovalHookCapability.name]) try {
3037
+ emitReadiness(ctx.eventBus, {
3038
+ capName,
3039
+ scope: {
3040
+ type: "node",
3041
+ nodeId
3042
+ },
3043
+ state,
3044
+ generation: this.readinessGeneration,
3045
+ sourceNodeId: nodeId
3046
+ });
3047
+ } catch (err) {
3048
+ ctx.logger.warn("device-history: readiness not emitted", { meta: {
3049
+ capName,
3050
+ state,
3051
+ error: err instanceof Error ? err.message : String(err)
3052
+ } });
3053
+ }
3054
+ }
2638
3055
  retention() {
2639
3056
  const c = this.config;
2640
3057
  return {
@@ -2656,7 +3073,7 @@ var DeviceHistoryAddon = class extends BaseAddon {
2656
3073
  }),
2657
3074
  listNodeSeries: async (input) => this.requireRuntime().query.listSeries({
2658
3075
  kind: "node",
2659
- nodeId: input.nodeId
3076
+ nodeId: input.forNodeId
2660
3077
  }),
2661
3078
  query: async ({ deviceId, ...window }) => this.requireRuntime().query.query({
2662
3079
  ...window,
@@ -2665,13 +3082,14 @@ var DeviceHistoryAddon = class extends BaseAddon {
2665
3082
  deviceId
2666
3083
  }
2667
3084
  }),
2668
- queryNode: async ({ nodeId, ...window }) => this.requireRuntime().query.query({
3085
+ queryNode: async ({ forNodeId, ...window }) => this.requireRuntime().query.query({
2669
3086
  ...window,
2670
3087
  subject: {
2671
3088
  kind: "node",
2672
- nodeId
3089
+ nodeId: forNodeId
2673
3090
  }
2674
3091
  }),
3092
+ queryMany: async (input) => ({ results: await this.requireRuntime().query.queryMany(input) }),
2675
3093
  declareSeries: async (input) => this.requireRuntime().ingest.declareSeries(callerOf(input), input),
2676
3094
  record: async (input) => this.requireRuntime().ingest.record(callerOf(input), input.points),
2677
3095
  recordBuckets: async (input) => this.requireRuntime().ingest.recordBuckets(callerOf(input), input.buckets),
@@ -2679,6 +3097,7 @@ var DeviceHistoryAddon = class extends BaseAddon {
2679
3097
  setDeviceEnabled: async (input) => ({ enabled: await this.requireRuntime().setEnabled(input.deviceId, input.enabled) }),
2680
3098
  getStatus: async () => this.requireRuntime().status()
2681
3099
  };
3100
+ this.emitStoreReadiness("starting");
2682
3101
  this.ctx.logger.info("Initialized — device-history provider registered (store opens on hub link)");
2683
3102
  const removalHook = {
2684
3103
  onDeviceRemoved: async (input) => {
@@ -2755,6 +3174,7 @@ var DeviceHistoryAddon = class extends BaseAddon {
2755
3174
  logger: this.ctx.logger,
2756
3175
  remove: (input) => removalHook.onDeviceRemoved(input)
2757
3176
  }));
3177
+ this.emitStoreReadiness("ready");
2758
3178
  this.ctx.logger.info("device-history: capturing", { meta: {
2759
3179
  series: runtime.status().series,
2760
3180
  metrics: runtime.status().metrics
@@ -2766,6 +3186,7 @@ var DeviceHistoryAddon = class extends BaseAddon {
2766
3186
  await this.runtime?.stop();
2767
3187
  }
2768
3188
  async onShutdown() {
3189
+ this.emitStoreReadiness("down");
2769
3190
  await this.stopRuntime();
2770
3191
  this.runtime = null;
2771
3192
  }