@camstack/system 1.2.476 → 1.2.478
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.
- package/assets/display-palette/icon.svg +7 -0
- package/dist/{agent-https-vpf_D97z.mjs → agent-https-CXMsY05a.mjs} +1 -1
- package/dist/{agent-https-DkXBXEvz.js → agent-https-D6a1IRPK.js} +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/autotrack/index.js +1 -1
- package/dist/builtins/autotrack/index.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2 -2
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
- package/dist/builtins/calendars/calendars.addon.js +1 -1
- package/dist/builtins/calendars/calendars.addon.mjs +1 -1
- package/dist/builtins/camera-grid/index.js +1 -1
- package/dist/builtins/camera-grid/index.mjs +1 -1
- package/dist/builtins/composer/composer-runtime-factory.d.ts +2 -1
- package/dist/builtins/composer/composer.addon.js +453 -6
- package/dist/builtins/composer/composer.addon.mjs +453 -6
- package/dist/builtins/composer/composer.d.ts +6 -0
- package/dist/builtins/composer/composition-runtime.d.ts +3 -0
- package/dist/builtins/composer/field-record.d.ts +7 -0
- package/dist/builtins/composer/pulse-seed-resolve.d.ts +59 -0
- package/dist/builtins/composer/pulse-seed.d.ts +75 -0
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
- package/dist/builtins/device-history/device-history.addon.js +63 -953
- package/dist/builtins/device-history/device-history.addon.mjs +2 -892
- package/dist/builtins/device-manager/device-manager.addon.js +1 -1
- package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
- package/dist/builtins/display-palette/display-palette.addon.d.ts +12 -0
- package/dist/builtins/display-palette/display-palette.addon.js +85 -0
- package/dist/builtins/display-palette/display-palette.addon.mjs +79 -0
- package/dist/builtins/display-palette/index.d.ts +1 -0
- package/dist/builtins/display-palette/index.js +8 -0
- package/dist/builtins/display-palette/index.mjs +2 -0
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/keychain/index.js +3 -3
- package/dist/builtins/keychain/index.mjs +3 -3
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +2 -2
- package/dist/builtins/local-auth/local-auth.addon.mjs +2 -2
- package/dist/builtins/local-network/local-network.addon.js +2 -2
- package/dist/builtins/local-network/local-network.addon.mjs +2 -2
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/record-entries/index.js +1 -1
- package/dist/builtins/record-entries/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/index.js +1 -1
- package/dist/builtins/sqlite-storage/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +2 -2
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +2 -2
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +3 -3
- package/dist/builtins/system-config/system-config.addon.mjs +3 -3
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{composition-sources-B5nK7pHt.mjs → composition-sources-CQcYYZxL.mjs} +1 -1
- package/dist/{composition-sources-BNOP9mFp.js → composition-sources-yfxjp3qV.js} +1 -1
- package/dist/{constants-D4VigrMM.js → constants-D--JWd55.js} +1 -1
- package/dist/{constants-jbhoFV7w.mjs → constants-lWkcQxVE.mjs} +1 -1
- package/dist/{device-ca-DhyQeXsa.js → device-ca-D0KnLHMw.js} +1 -1
- package/dist/{device-ca-CvBZeyVX.mjs → device-ca-J9ZsyoFw.mjs} +1 -1
- package/dist/{dist-B_V9r61Z.mjs → dist-OC-L50JY.mjs} +751 -70
- package/dist/{dist-CNU7cdFf.js → dist-iVUkxljP.js} +792 -69
- package/dist/file-host-worker.js +1 -1
- package/dist/file-host-worker.mjs +1 -1
- package/dist/{hub-mdns-ciao-DFidf52M.js → hub-mdns-ciao-BDKiKV75.js} +2 -2
- package/dist/{hub-mdns-ciao-BuUg0gpI.mjs → hub-mdns-ciao-CvXWbJub.mjs} +2 -2
- package/dist/index.js +7 -7
- package/dist/index.mjs +7 -7
- package/dist/{restore-archive-CDo4Zlmx.mjs → restore-archive-D1Uzojhc.mjs} +1 -1
- package/dist/{restore-archive-BtCheeWu.js → restore-archive-DxkdJpWd.js} +1 -1
- package/dist/retention-B3z_lBP1.js +1024 -0
- package/dist/retention-DdVnmXXS.mjs +893 -0
- package/dist/{wal-maintenance-DItlr0Yu.mjs → wal-maintenance-DuO0D2H3.mjs} +0 -0
- package/dist/{wal-maintenance-B7xBeIFi.js → wal-maintenance-o9TyZeYp.js} +0 -0
- package/package.json +19 -1
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
import { _i as HISTORY_DEFAULT_MIN_INTERVAL_MS, aa as historyItemFieldPath, oi as DeviceType } from "./dist-OC-L50JY.mjs";
|
|
2
|
+
//#region src/builtins/device-history/store/series-registry.ts
|
|
3
|
+
function subjectIdOf(subject) {
|
|
4
|
+
return subject.kind === "device" ? `d:${subject.deviceId}` : `n:${subject.nodeId}`;
|
|
5
|
+
}
|
|
6
|
+
function deviceIdOf(subject) {
|
|
7
|
+
return subject.kind === "device" ? subject.deviceId : null;
|
|
8
|
+
}
|
|
9
|
+
/** Every metric key is a cap field (`cap:`), an addon's series (`addon:`) or derived. */
|
|
10
|
+
function isMetricKey(key) {
|
|
11
|
+
return key.startsWith("cap:") || key.startsWith("addon:");
|
|
12
|
+
}
|
|
13
|
+
function identityOf(key, subjectId, scope) {
|
|
14
|
+
return `${key}\u0000${subjectId}\u0000${scope}`;
|
|
15
|
+
}
|
|
16
|
+
var LOAD_PAGE = 5e3;
|
|
17
|
+
/** Append what `next` names that `stored` lacks, in `next`'s order: an ordinal never moves. */
|
|
18
|
+
function mergeEnumValues(stored, next) {
|
|
19
|
+
if (stored === null) return next;
|
|
20
|
+
if (next === null) return stored;
|
|
21
|
+
const missing = next.filter((v) => !stored.includes(v));
|
|
22
|
+
return missing.length === 0 ? stored : [...stored, ...missing];
|
|
23
|
+
}
|
|
24
|
+
var SeriesRegistry = class {
|
|
25
|
+
store;
|
|
26
|
+
metrics = /* @__PURE__ */ new Map();
|
|
27
|
+
byIdentity = /* @__PURE__ */ new Map();
|
|
28
|
+
byId = /* @__PURE__ */ new Map();
|
|
29
|
+
inflight = /* @__PURE__ */ new Map();
|
|
30
|
+
/** Enum metrics whose value list grew in memory and is not stored yet. */
|
|
31
|
+
dirtyMetrics = /* @__PURE__ */ new Set();
|
|
32
|
+
removeListeners = [];
|
|
33
|
+
constructor(store) {
|
|
34
|
+
this.store = store;
|
|
35
|
+
}
|
|
36
|
+
async load() {
|
|
37
|
+
this.metrics.clear();
|
|
38
|
+
this.byIdentity.clear();
|
|
39
|
+
this.byId.clear();
|
|
40
|
+
const stray = [];
|
|
41
|
+
for (const m of await this.store.loadMetrics()) if (isMetricKey(m.key)) this.metrics.set(m.key, m);
|
|
42
|
+
else stray.push(m.key);
|
|
43
|
+
if (stray.length > 0) await this.store.deleteMetrics(stray);
|
|
44
|
+
let afterId = 0;
|
|
45
|
+
for (;;) {
|
|
46
|
+
const page = await this.store.loadSeriesPage(afterId, LOAD_PAGE);
|
|
47
|
+
for (const s of page) this.index(s);
|
|
48
|
+
const last = page[page.length - 1];
|
|
49
|
+
if (last === void 0 || page.length < LOAD_PAGE) break;
|
|
50
|
+
afterId = last.id;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
index(series) {
|
|
54
|
+
this.byId.set(series.id, series);
|
|
55
|
+
this.byIdentity.set(identityOf(series.key, series.subject, series.scope), series);
|
|
56
|
+
}
|
|
57
|
+
metric(key) {
|
|
58
|
+
return this.metrics.get(key);
|
|
59
|
+
}
|
|
60
|
+
allMetrics() {
|
|
61
|
+
return [...this.metrics.values()];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Persist and remember a metric; idempotent. An `enum` value list is MERGED
|
|
65
|
+
* append-only with the stored one, so reordering a `z.enum` never changes
|
|
66
|
+
* what a stored ordinal means.
|
|
67
|
+
*/
|
|
68
|
+
async declareMetric(metric) {
|
|
69
|
+
const existing = this.metrics.get(metric.key);
|
|
70
|
+
const merged = {
|
|
71
|
+
...metric,
|
|
72
|
+
values: mergeEnumValues(existing?.values ?? null, metric.values)
|
|
73
|
+
};
|
|
74
|
+
if (existing !== void 0 && sameMetric(existing, merged)) return;
|
|
75
|
+
await this.store.upsertMetric(merged);
|
|
76
|
+
this.metrics.set(metric.key, merged);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The ordinal of `value` in an `enum` metric's list, learning (appending) a
|
|
80
|
+
* value seen for the first time. `null` when the key is not an enum metric or
|
|
81
|
+
* its list is full ({@link MAX_ENUM_VALUES}). A learned value is stored by
|
|
82
|
+
* {@link persistDirtyMetrics} BEFORE any point that uses it.
|
|
83
|
+
*/
|
|
84
|
+
ordinalOf(key, value) {
|
|
85
|
+
const metric = this.metrics.get(key);
|
|
86
|
+
if (metric === void 0 || metric.kind !== "enum") return null;
|
|
87
|
+
const values = metric.values ?? [];
|
|
88
|
+
const known = values.indexOf(value);
|
|
89
|
+
if (known >= 0) return known;
|
|
90
|
+
if (values.length >= 1024) return null;
|
|
91
|
+
this.metrics.set(key, {
|
|
92
|
+
...metric,
|
|
93
|
+
values: [...values, value]
|
|
94
|
+
});
|
|
95
|
+
this.dirtyMetrics.add(key);
|
|
96
|
+
return values.length;
|
|
97
|
+
}
|
|
98
|
+
/** Store every value list that grew in memory. Throws (and keeps them dirty) on a failed write. */
|
|
99
|
+
async persistDirtyMetrics() {
|
|
100
|
+
for (const key of this.dirtyMetrics) {
|
|
101
|
+
const metric = this.metrics.get(key);
|
|
102
|
+
if (metric !== void 0) await this.store.upsertMetric(metric);
|
|
103
|
+
this.dirtyMetrics.delete(key);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
find(key, subjectId, scope) {
|
|
107
|
+
return this.byIdentity.get(identityOf(key, subjectId, scope));
|
|
108
|
+
}
|
|
109
|
+
byIdOf(id) {
|
|
110
|
+
return this.byId.get(id);
|
|
111
|
+
}
|
|
112
|
+
all() {
|
|
113
|
+
return [...this.byId.values()];
|
|
114
|
+
}
|
|
115
|
+
forSubject(subjectId) {
|
|
116
|
+
return this.all().filter((s) => s.subject === subjectId);
|
|
117
|
+
}
|
|
118
|
+
forKeyAndSubject(key, subjectId) {
|
|
119
|
+
return this.all().filter((s) => s.key === key && s.subject === subjectId);
|
|
120
|
+
}
|
|
121
|
+
/** The series, created on first sight. `ts` stamps `first_ts`/`last_ts` of a new one. */
|
|
122
|
+
async ensure(key, subject, scope, ts) {
|
|
123
|
+
const subjectId = subjectIdOf(subject);
|
|
124
|
+
const identity = identityOf(key, subjectId, scope);
|
|
125
|
+
const known = this.byIdentity.get(identity);
|
|
126
|
+
if (known !== void 0) return known;
|
|
127
|
+
const pending = this.inflight.get(identity);
|
|
128
|
+
if (pending !== void 0) return pending;
|
|
129
|
+
const created = this.create(key, subjectId, deviceIdOf(subject), scope, ts).finally(() => {
|
|
130
|
+
this.inflight.delete(identity);
|
|
131
|
+
});
|
|
132
|
+
this.inflight.set(identity, created);
|
|
133
|
+
return created;
|
|
134
|
+
}
|
|
135
|
+
async create(key, subjectId, deviceId, scope, ts) {
|
|
136
|
+
const stored = await this.store.findSeries(key, subjectId, scope) ?? await this.store.insertSeries({
|
|
137
|
+
key,
|
|
138
|
+
subject: subjectId,
|
|
139
|
+
deviceId,
|
|
140
|
+
scope,
|
|
141
|
+
ts
|
|
142
|
+
});
|
|
143
|
+
this.index(stored);
|
|
144
|
+
return stored;
|
|
145
|
+
}
|
|
146
|
+
/** Remember a widened span (the store write is the caller's). */
|
|
147
|
+
noteSpan(id, firstTs, lastTs) {
|
|
148
|
+
const current = this.byId.get(id);
|
|
149
|
+
if (current === void 0) return;
|
|
150
|
+
this.index({
|
|
151
|
+
...current,
|
|
152
|
+
firstTs: Math.min(current.firstTs, firstTs),
|
|
153
|
+
lastTs: Math.max(current.lastTs, lastTs)
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
remove(id) {
|
|
157
|
+
const current = this.byId.get(id);
|
|
158
|
+
if (current === void 0) return;
|
|
159
|
+
this.byId.delete(id);
|
|
160
|
+
this.byIdentity.delete(identityOf(current.key, current.subject, current.scope));
|
|
161
|
+
for (const listener of this.removeListeners) listener(current);
|
|
162
|
+
}
|
|
163
|
+
/** Per-series state elsewhere (last recorded value, dirty marks, rate limits) is dropped through this. */
|
|
164
|
+
onRemove(listener) {
|
|
165
|
+
this.removeListeners.push(listener);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
function sameMetric(a, b) {
|
|
169
|
+
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);
|
|
170
|
+
}
|
|
171
|
+
//#endregion
|
|
172
|
+
//#region src/builtins/device-history/capture/capture-filter.ts
|
|
173
|
+
/**
|
|
174
|
+
* The per-point capture decision for a cap field (D850, plan §5.3). PURE.
|
|
175
|
+
*
|
|
176
|
+
* A point is written only when the value CHANGED from the last one recorded,
|
|
177
|
+
* by more than the field's deadband, and no sooner than its capture floor:
|
|
178
|
+
*
|
|
179
|
+
* - `unchanged` — the same value again; a change-only series stores nothing;
|
|
180
|
+
* - `deadband` — a change smaller than `deadband` (absolute) or
|
|
181
|
+
* `deadbandPct` (relative to the last recorded value); a slow
|
|
182
|
+
* drift is still caught, because the comparison is against
|
|
183
|
+
* the last RECORDED value, not the last seen one;
|
|
184
|
+
* - `defer` — changed, but inside `minIntervalMs` of the last recorded
|
|
185
|
+
* point. NOT dropped: the caller keeps it pending and emits it
|
|
186
|
+
* once the floor has passed, so a burst never loses its end.
|
|
187
|
+
*/
|
|
188
|
+
/** Tolerance for floating-point steps such as 21.3 - 21.2 against a 0.1 deadband. */
|
|
189
|
+
var DEADBAND_EPSILON = 1e-9;
|
|
190
|
+
/** The capture floor of a field: its own `minIntervalMs`, else the kind's default. */
|
|
191
|
+
function minIntervalOf(spec) {
|
|
192
|
+
if (spec.minIntervalMs !== void 0) return spec.minIntervalMs;
|
|
193
|
+
return spec.kind === "boolean" || spec.kind === "enum" ? 0 : HISTORY_DEFAULT_MIN_INTERVAL_MS;
|
|
194
|
+
}
|
|
195
|
+
function clearsDeadband(spec, last, value) {
|
|
196
|
+
if (spec.kind === "boolean" || spec.kind === "enum") return true;
|
|
197
|
+
const delta = Math.abs(value - last);
|
|
198
|
+
if (spec.deadband !== void 0 && delta < spec.deadband - DEADBAND_EPSILON) return false;
|
|
199
|
+
if (spec.deadbandPct !== void 0) {
|
|
200
|
+
if (delta < Math.abs(last) * spec.deadbandPct / 100 - DEADBAND_EPSILON) return false;
|
|
201
|
+
}
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
function decideCapture(spec, recorded, value, now, options = {}) {
|
|
205
|
+
if (!Number.isFinite(value)) return {
|
|
206
|
+
action: "skip",
|
|
207
|
+
reason: "non-finite"
|
|
208
|
+
};
|
|
209
|
+
if (recorded === null) return { action: "emit" };
|
|
210
|
+
if (value === recorded.value) return {
|
|
211
|
+
action: "skip",
|
|
212
|
+
reason: "unchanged"
|
|
213
|
+
};
|
|
214
|
+
if (!clearsDeadband(spec, recorded.value, value)) return {
|
|
215
|
+
action: "skip",
|
|
216
|
+
reason: "deadband"
|
|
217
|
+
};
|
|
218
|
+
if (options.ignoreFloor !== true) {
|
|
219
|
+
const notBefore = recorded.ts + minIntervalOf(spec);
|
|
220
|
+
if (now < notBefore) return {
|
|
221
|
+
action: "defer",
|
|
222
|
+
notBefore
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
return { action: "emit" };
|
|
226
|
+
}
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/builtins/device-history/capture/device-directory.ts
|
|
229
|
+
/**
|
|
230
|
+
* Which devices exist and what type they are — the "real devices only" gate
|
|
231
|
+
* (`hub` and `container` are not devices anyone charts) and the
|
|
232
|
+
* `excludeDeviceTypes` gate of a history field.
|
|
233
|
+
*
|
|
234
|
+
* Built from `deviceManager.listAll({ projection: 'slim' })` at boot and every
|
|
235
|
+
* reconcile, and per device (`getDevice`) the first time a state event names one
|
|
236
|
+
* the map does not know. A FAILED read changes nothing (D49): the previous map
|
|
237
|
+
* stays, and the failure is counted and logged, never read as "no devices".
|
|
238
|
+
*/
|
|
239
|
+
function isRealDeviceType(type) {
|
|
240
|
+
return type !== DeviceType.Hub && type !== DeviceType.Container;
|
|
241
|
+
}
|
|
242
|
+
var DeviceDirectory = class {
|
|
243
|
+
source;
|
|
244
|
+
counters;
|
|
245
|
+
logger;
|
|
246
|
+
clock;
|
|
247
|
+
types = /* @__PURE__ */ new Map();
|
|
248
|
+
inflight = /* @__PURE__ */ new Map();
|
|
249
|
+
refreshInflight = null;
|
|
250
|
+
lastFullRefreshAt = null;
|
|
251
|
+
constructor(source, counters, logger, clock) {
|
|
252
|
+
this.source = source;
|
|
253
|
+
this.counters = counters;
|
|
254
|
+
this.logger = logger;
|
|
255
|
+
this.clock = clock;
|
|
256
|
+
}
|
|
257
|
+
typeOf(deviceId) {
|
|
258
|
+
return this.types.get(deviceId);
|
|
259
|
+
}
|
|
260
|
+
size() {
|
|
261
|
+
return this.types.size;
|
|
262
|
+
}
|
|
263
|
+
/** When the full listing last ANSWERED; `null` before the first answer. */
|
|
264
|
+
lastRefreshAt() {
|
|
265
|
+
return this.lastFullRefreshAt;
|
|
266
|
+
}
|
|
267
|
+
/** Ids in the directory now (the last full listing plus per-device lookups). */
|
|
268
|
+
listedIds() {
|
|
269
|
+
return new Set(this.types.keys());
|
|
270
|
+
}
|
|
271
|
+
knows(deviceId) {
|
|
272
|
+
return this.types.has(deviceId);
|
|
273
|
+
}
|
|
274
|
+
/** Replace the map from the full listing. Returns false (map untouched) when the read failed. */
|
|
275
|
+
refresh() {
|
|
276
|
+
if (this.refreshInflight !== null) return this.refreshInflight;
|
|
277
|
+
const run = this.doRefresh().finally(() => {
|
|
278
|
+
this.refreshInflight = null;
|
|
279
|
+
});
|
|
280
|
+
this.refreshInflight = run;
|
|
281
|
+
return run;
|
|
282
|
+
}
|
|
283
|
+
async doRefresh() {
|
|
284
|
+
try {
|
|
285
|
+
const all = await this.source.listAll();
|
|
286
|
+
this.types = new Map(all.map((d) => [d.id, d.type]));
|
|
287
|
+
this.lastFullRefreshAt = this.clock();
|
|
288
|
+
this.counters.directoryRefreshAt = this.lastFullRefreshAt;
|
|
289
|
+
return true;
|
|
290
|
+
} catch (err) {
|
|
291
|
+
this.logger.warn("device directory: listing failed — keeping the previous map", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/** The device's type, asking the hub once when the map does not know it. */
|
|
296
|
+
ensure(deviceId) {
|
|
297
|
+
const known = this.types.get(deviceId);
|
|
298
|
+
if (known !== void 0) return Promise.resolve(known);
|
|
299
|
+
const pending = this.inflight.get(deviceId);
|
|
300
|
+
if (pending !== void 0) return pending;
|
|
301
|
+
const run = this.lookup(deviceId).finally(() => {
|
|
302
|
+
this.inflight.delete(deviceId);
|
|
303
|
+
});
|
|
304
|
+
this.inflight.set(deviceId, run);
|
|
305
|
+
return run;
|
|
306
|
+
}
|
|
307
|
+
async lookup(deviceId) {
|
|
308
|
+
try {
|
|
309
|
+
const device = await this.source.getDevice(deviceId);
|
|
310
|
+
if (device === null) return void 0;
|
|
311
|
+
this.types.set(device.id, device.type);
|
|
312
|
+
return device.type;
|
|
313
|
+
} catch (err) {
|
|
314
|
+
this.logger.warn("device directory: lookup failed", {
|
|
315
|
+
tags: { deviceId },
|
|
316
|
+
meta: { error: err instanceof Error ? err.message : String(err) }
|
|
317
|
+
});
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
//#endregion
|
|
323
|
+
//#region src/builtins/device-history/capture/field-path.ts
|
|
324
|
+
/**
|
|
325
|
+
* Reading a history field out of a cap slice. A catalog path is a top-level
|
|
326
|
+
* `runtimeState` key or a path into a FIXED object (`dustBin.level`); the
|
|
327
|
+
* catalog guard keeps it out of arrays and records, so a plain walk is enough.
|
|
328
|
+
*/
|
|
329
|
+
function isRecord$1(value) {
|
|
330
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
331
|
+
}
|
|
332
|
+
/** The value at `path`, or `undefined` when any step is missing or not an object. */
|
|
333
|
+
function readPath(slice, path) {
|
|
334
|
+
let current = slice;
|
|
335
|
+
for (const step of path.split(".")) {
|
|
336
|
+
if (!isRecord$1(current)) return void 0;
|
|
337
|
+
current = current[step];
|
|
338
|
+
}
|
|
339
|
+
return current;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* The number a field contributes: a finite number for a gauge/counter, 0/1 for
|
|
343
|
+
* a boolean field. Anything else (null, undefined, a string, NaN) is NOT a
|
|
344
|
+
* reading — `null` means unknown and must never become 0.
|
|
345
|
+
*/
|
|
346
|
+
function readNumeric(slice, path, kind) {
|
|
347
|
+
const raw = readPath(slice, path);
|
|
348
|
+
if (kind === "boolean") {
|
|
349
|
+
if (typeof raw === "boolean") return raw ? 1 : 0;
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
/** A unit string a `unitFrom` sibling field states, or `null` when it states none. */
|
|
356
|
+
function readUnit(slice, unitFrom) {
|
|
357
|
+
const raw = readPath(slice, unitFrom);
|
|
358
|
+
return typeof raw === "string" && raw.length > 0 ? raw : null;
|
|
359
|
+
}
|
|
360
|
+
//#endregion
|
|
361
|
+
//#region src/builtins/device-history/capture/capture-engine.ts
|
|
362
|
+
/**
|
|
363
|
+
* Capture of capability fields (D850, plan §5.3), driven by the GENERATED
|
|
364
|
+
* history catalog — a field is recorded because its `*.cap.ts` says so, never
|
|
365
|
+
* because someone listed it here.
|
|
366
|
+
*
|
|
367
|
+
* For a `DeviceStateChanged { deviceId, capName, slice }` the engine:
|
|
368
|
+
* 1. ignores a cap with no catalog entry (the common case — cheap, uncounted);
|
|
369
|
+
* 2. skips an opted-out device (counted), a device whose type is not yet
|
|
370
|
+
* known (parked, then replayed once the hub answers), and a `hub` /
|
|
371
|
+
* `container` (counted: not a real device);
|
|
372
|
+
* 3. per field: skips an excluded device type, an absent or non-numeric value
|
|
373
|
+
* (null is UNKNOWN, never 0), then runs {@link decideCapture} against the
|
|
374
|
+
* last RECORDED value — change-only, deadband, capture floor;
|
|
375
|
+
* 4. buffers the point for the batched write.
|
|
376
|
+
*
|
|
377
|
+
* A change inside the capture floor is kept PENDING and emitted when the floor
|
|
378
|
+
* has passed ({@link CaptureEngine.tickPending}), so the end of a burst is never
|
|
379
|
+
* lost. Reconcile ({@link CaptureEngine.reconcile}) restores what a dropped
|
|
380
|
+
* event missed from the state mirror's snapshot.
|
|
381
|
+
*/
|
|
382
|
+
var CAP_KEY_PREFIX = "cap:";
|
|
383
|
+
function capSeriesKey(capName, fieldPath) {
|
|
384
|
+
return `${CAP_KEY_PREFIX}${capName}.${fieldPath}`;
|
|
385
|
+
}
|
|
386
|
+
/** The series key of an item field: `cap:<capName>.<path>[].<field>`. */
|
|
387
|
+
function capItemSeriesKey(capName, items, field) {
|
|
388
|
+
return capSeriesKey(capName, historyItemFieldPath(items.path, field));
|
|
389
|
+
}
|
|
390
|
+
function humanize(capName, fieldPath) {
|
|
391
|
+
const field = fieldPath.replace(/([A-Z])/g, " $1").replace(/\./g, " ").toLowerCase();
|
|
392
|
+
return `${capName.replace(/-/g, " ")} ${field}`;
|
|
393
|
+
}
|
|
394
|
+
function metricOf(key, capName, path, spec, now) {
|
|
395
|
+
return {
|
|
396
|
+
key,
|
|
397
|
+
source: "cap",
|
|
398
|
+
ownerAddon: "device-history",
|
|
399
|
+
subjectKind: "device",
|
|
400
|
+
kind: spec.kind,
|
|
401
|
+
unit: spec.unit ?? null,
|
|
402
|
+
displayName: spec.displayName ?? humanize(capName, path),
|
|
403
|
+
ingest: "points",
|
|
404
|
+
declaredAt: now,
|
|
405
|
+
values: spec.kind === "enum" ? spec.values ?? [] : null
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
/** The metric rows of every catalog field and keyed-item field (owner `device-history`). */
|
|
409
|
+
function catalogMetrics(catalog, itemCatalog, now) {
|
|
410
|
+
const fields = Object.entries(catalog).flatMap(([capName, specs]) => Object.entries(specs).map(([path, spec]) => metricOf(capSeriesKey(capName, path), capName, path, spec, now)));
|
|
411
|
+
const items = Object.entries(itemCatalog).flatMap(([capName, block]) => Object.entries(block.fields).map(([field, spec]) => metricOf(capItemSeriesKey(capName, block, field), capName, field, spec, now)));
|
|
412
|
+
return [...fields, ...items];
|
|
413
|
+
}
|
|
414
|
+
function isRecord(value) {
|
|
415
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
416
|
+
}
|
|
417
|
+
/** Devices waiting on a type lookup; beyond this the oldest is dropped (and counted). */
|
|
418
|
+
var MAX_PARKED_DEVICES = 5e3;
|
|
419
|
+
/** Seeding reads the newest raw row of each cap series, this many at once. */
|
|
420
|
+
var SEED_CONCURRENCY = 8;
|
|
421
|
+
var CaptureEngine = class {
|
|
422
|
+
deps;
|
|
423
|
+
recorded = /* @__PURE__ */ new Map();
|
|
424
|
+
pending = /* @__PURE__ */ new Map();
|
|
425
|
+
parked = /* @__PURE__ */ new Map();
|
|
426
|
+
constructor(deps) {
|
|
427
|
+
this.deps = deps;
|
|
428
|
+
}
|
|
429
|
+
/** Learn the last recorded value of every cap series from the store, so a restart repeats nothing. */
|
|
430
|
+
async seed() {
|
|
431
|
+
const { registry, store } = this.deps;
|
|
432
|
+
const capSeries = registry.all().filter((s) => s.key.startsWith(CAP_KEY_PREFIX));
|
|
433
|
+
for (let i = 0; i < capSeries.length; i += SEED_CONCURRENCY) await Promise.all(capSeries.slice(i, i + SEED_CONCURRENCY).map(async (series) => {
|
|
434
|
+
const newest = await store.rawBefore(series.id, Number.MAX_SAFE_INTEGER);
|
|
435
|
+
if (newest === null) return;
|
|
436
|
+
this.recorded.set(identityOf(series.key, series.subject, series.scope), newest);
|
|
437
|
+
}));
|
|
438
|
+
}
|
|
439
|
+
/** One `DeviceStateChanged`. Synchronous: the bus must never wait on us. */
|
|
440
|
+
handleState(deviceId, capName, slice, at) {
|
|
441
|
+
const fields = this.deps.catalog[capName];
|
|
442
|
+
const items = this.deps.itemCatalog[capName];
|
|
443
|
+
if (fields === void 0 && items === void 0) return;
|
|
444
|
+
const { counters, directory } = this.deps;
|
|
445
|
+
counters.eventsSeen += 1;
|
|
446
|
+
if (this.deps.optedOut().has(deviceId)) {
|
|
447
|
+
counters.skip("opted-out");
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const type = directory.typeOf(deviceId);
|
|
451
|
+
if (type === void 0) {
|
|
452
|
+
this.park(deviceId, capName, slice, at);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (!isRealDeviceType(type)) {
|
|
456
|
+
counters.skip("not-a-real-device");
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
this.captureSlice(deviceId, type, capName, slice, at, false);
|
|
460
|
+
}
|
|
461
|
+
/** Every catalogued field and keyed item of one cap slice, for a real device. */
|
|
462
|
+
captureSlice(deviceId, type, capName, slice, at, reconciling) {
|
|
463
|
+
for (const [path, spec] of Object.entries(this.deps.catalog[capName] ?? {})) {
|
|
464
|
+
if (spec.excludeDeviceTypes?.includes(type) === true) {
|
|
465
|
+
if (!reconciling) this.deps.counters.skip("excluded-device-type");
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const key = capSeriesKey(capName, path);
|
|
469
|
+
const value = this.readValue(slice, key, path, spec);
|
|
470
|
+
if (value === null) continue;
|
|
471
|
+
this.consider(deviceId, key, "", spec, value, at, reconciling);
|
|
472
|
+
}
|
|
473
|
+
const items = this.deps.itemCatalog[capName];
|
|
474
|
+
if (items !== void 0) this.captureItems(deviceId, type, capName, items, slice, at, reconciling);
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* A keyed array (D917): each item with a usable key contributes its fields
|
|
478
|
+
* under that key as the series scope. A value that is not an array is no
|
|
479
|
+
* reading; an item without a string key cannot be followed and is counted.
|
|
480
|
+
*/
|
|
481
|
+
captureItems(deviceId, type, capName, items, slice, at, reconciling) {
|
|
482
|
+
const list = readPath(slice, items.path);
|
|
483
|
+
if (!Array.isArray(list)) return;
|
|
484
|
+
const { counters } = this.deps;
|
|
485
|
+
if (list.length > 64) counters.skip("item-overflow", list.length - 64);
|
|
486
|
+
for (const entry of list.slice(0, 64)) {
|
|
487
|
+
const scope = isRecord(entry) ? entry[items.key] : void 0;
|
|
488
|
+
if (!isRecord(entry) || typeof scope !== "string" || scope.length === 0 || scope.length > 128) {
|
|
489
|
+
counters.skip("item-key-invalid");
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
for (const [field, spec] of Object.entries(items.fields)) {
|
|
493
|
+
if (spec.excludeDeviceTypes?.includes(type) === true) continue;
|
|
494
|
+
const key = capItemSeriesKey(capName, items, field);
|
|
495
|
+
const value = this.readValue(entry, key, field, spec);
|
|
496
|
+
if (value === null) continue;
|
|
497
|
+
this.consider(deviceId, key, scope, spec, value, at, reconciling);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* The number a field contributes, or `null` (not a reading — never 0). An
|
|
503
|
+
* `enum` state becomes the ordinal of its value in the metric's list; a value
|
|
504
|
+
* the full list cannot take is refused and counted (`enum-overflow`).
|
|
505
|
+
*/
|
|
506
|
+
readValue(slice, key, path, spec) {
|
|
507
|
+
if (spec.skipWhenSet !== void 0) {
|
|
508
|
+
const gate = readPath(slice, spec.skipWhenSet);
|
|
509
|
+
if (gate !== void 0 && gate !== null) return null;
|
|
510
|
+
}
|
|
511
|
+
if (spec.kind !== "enum") return readNumeric(slice, path, spec.kind);
|
|
512
|
+
const raw = readPath(slice, path);
|
|
513
|
+
if (typeof raw !== "string" || raw.length === 0) return null;
|
|
514
|
+
const ordinal = this.deps.registry.ordinalOf(key, raw);
|
|
515
|
+
if (ordinal === null) this.deps.counters.skip("enum-overflow");
|
|
516
|
+
return ordinal;
|
|
517
|
+
}
|
|
518
|
+
consider(deviceId, key, scope, spec, value, at, reconciling) {
|
|
519
|
+
const subject = {
|
|
520
|
+
kind: "device",
|
|
521
|
+
deviceId
|
|
522
|
+
};
|
|
523
|
+
const identity = identityOf(key, `d:${deviceId}`, scope);
|
|
524
|
+
const decision = decideCapture(spec, this.recorded.get(identity) ?? null, value, at, { ignoreFloor: reconciling });
|
|
525
|
+
if (decision.action === "emit") {
|
|
526
|
+
this.emit(identity, key, subject, scope, value, at);
|
|
527
|
+
if (reconciling) this.deps.counters.reconciledPoints += 1;
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (decision.action === "defer") {
|
|
531
|
+
this.pending.set(identity, {
|
|
532
|
+
key,
|
|
533
|
+
subject,
|
|
534
|
+
scope,
|
|
535
|
+
spec,
|
|
536
|
+
value
|
|
537
|
+
});
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
this.pending.delete(identity);
|
|
541
|
+
this.deps.counters.skip(decision.reason);
|
|
542
|
+
}
|
|
543
|
+
emit(identity, key, subject, scope, value, ts) {
|
|
544
|
+
this.recorded.set(identity, {
|
|
545
|
+
value,
|
|
546
|
+
ts
|
|
547
|
+
});
|
|
548
|
+
this.pending.delete(identity);
|
|
549
|
+
this.deps.counters.pointsEmitted += 1;
|
|
550
|
+
this.deps.writer.enqueue({
|
|
551
|
+
key,
|
|
552
|
+
subject,
|
|
553
|
+
scope,
|
|
554
|
+
ts,
|
|
555
|
+
value
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
/** Emit every pending change whose capture floor has passed, stamped where the floor ended. */
|
|
559
|
+
tickPending(now) {
|
|
560
|
+
for (const [identity, p] of this.pending) {
|
|
561
|
+
const recorded = this.recorded.get(identity);
|
|
562
|
+
const deviceId = p.subject.kind === "device" ? p.subject.deviceId : -1;
|
|
563
|
+
if (this.deps.optedOut().has(deviceId)) {
|
|
564
|
+
this.pending.delete(identity);
|
|
565
|
+
this.deps.counters.skip("opted-out");
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
const notBefore = (recorded?.ts ?? now) + minIntervalOf(p.spec);
|
|
569
|
+
if (now < notBefore) continue;
|
|
570
|
+
this.emit(identity, p.key, p.subject, p.scope, p.value, notBefore);
|
|
571
|
+
this.deps.counters.trailingEmitted += 1;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
park(deviceId, capName, slice, at) {
|
|
575
|
+
const existing = this.parked.get(deviceId);
|
|
576
|
+
if (existing !== void 0) {
|
|
577
|
+
existing.set(capName, {
|
|
578
|
+
capName,
|
|
579
|
+
slice,
|
|
580
|
+
at
|
|
581
|
+
});
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (this.parked.size >= MAX_PARKED_DEVICES) {
|
|
585
|
+
const oldest = this.parked.keys().next();
|
|
586
|
+
if (oldest.done !== true) this.parked.delete(oldest.value);
|
|
587
|
+
this.deps.counters.skip("parked-overflow");
|
|
588
|
+
}
|
|
589
|
+
this.parked.set(deviceId, new Map([[capName, {
|
|
590
|
+
capName,
|
|
591
|
+
slice,
|
|
592
|
+
at
|
|
593
|
+
}]]));
|
|
594
|
+
this.deps.directory.ensure(deviceId).then((type) => {
|
|
595
|
+
const waiting = this.parked.get(deviceId);
|
|
596
|
+
this.parked.delete(deviceId);
|
|
597
|
+
if (waiting === void 0) return;
|
|
598
|
+
if (type === void 0) {
|
|
599
|
+
this.deps.counters.skip("unknown-device", waiting.size);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
for (const p of waiting.values()) this.handleState(deviceId, p.capName, p.slice, p.at);
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Restore a missed change from the state mirror's whole snapshot: a field whose
|
|
607
|
+
* value differs from the last recorded one gets a point stamped `now`.
|
|
608
|
+
* Additive only — it never removes anything, and a snapshot that could not be
|
|
609
|
+
* read is the CALLER's failure to count (D49), not an empty snapshot.
|
|
610
|
+
*/
|
|
611
|
+
reconcile(snapshots, now) {
|
|
612
|
+
const optedOut = this.deps.optedOut();
|
|
613
|
+
for (const [deviceKey, caps] of Object.entries(snapshots)) {
|
|
614
|
+
const deviceId = Number(deviceKey);
|
|
615
|
+
if (!Number.isInteger(deviceId) || optedOut.has(deviceId)) continue;
|
|
616
|
+
const type = this.deps.directory.typeOf(deviceId);
|
|
617
|
+
if (type === void 0 || !isRealDeviceType(type)) continue;
|
|
618
|
+
for (const [capName, slice] of Object.entries(caps)) this.captureSlice(deviceId, type, capName, slice, now, true);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Forget what was "recorded" for points that never reached the store (dropped),
|
|
623
|
+
* so the next change-only comparison — and the next reconcile — starts from what
|
|
624
|
+
* is really there instead of leaving a hole until the value moves.
|
|
625
|
+
*/
|
|
626
|
+
forget(points) {
|
|
627
|
+
for (const p of points) {
|
|
628
|
+
const identity = identityOf(p.key, subjectIdOf(p.subject), p.scope);
|
|
629
|
+
this.recorded.delete(identity);
|
|
630
|
+
this.pending.delete(identity);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/** A purged series leaves no state behind. */
|
|
634
|
+
forgetSeries(series) {
|
|
635
|
+
const identity = identityOf(series.key, series.subject, series.scope);
|
|
636
|
+
this.recorded.delete(identity);
|
|
637
|
+
this.pending.delete(identity);
|
|
638
|
+
}
|
|
639
|
+
/** For status and specs. */
|
|
640
|
+
pendingCount() {
|
|
641
|
+
return this.pending.size;
|
|
642
|
+
}
|
|
643
|
+
/** The last recorded state of a series, for specs. */
|
|
644
|
+
lastRecorded(key, subjectId, scope = "") {
|
|
645
|
+
return this.recorded.get(identityOf(key, subjectId, scope));
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
//#endregion
|
|
649
|
+
//#region src/builtins/device-history/time/bucket-time.ts
|
|
650
|
+
/**
|
|
651
|
+
* Bucket arithmetic for the three rollup tiers (D850, plan §4/§5.5).
|
|
652
|
+
*
|
|
653
|
+
* 5m and 1h buckets are aligned to UTC; a 1d bucket starts at HUB-LOCAL
|
|
654
|
+
* midnight, so a day is 23, 24 or 25 hours long across a DST change (operator
|
|
655
|
+
* answer Q4). Pure: the zone is the process zone (`TZ`), nothing is read from
|
|
656
|
+
* the environment here.
|
|
657
|
+
*/
|
|
658
|
+
var FIVE_MINUTES_MS = 5 * 6e4;
|
|
659
|
+
var HOUR_MS = 60 * 6e4;
|
|
660
|
+
var DAY_MS = 24 * HOUR_MS;
|
|
661
|
+
function floorTo(ts, widthMs) {
|
|
662
|
+
return Math.floor(ts / widthMs) * widthMs;
|
|
663
|
+
}
|
|
664
|
+
/** Hub-local midnight at or before `ts`. */
|
|
665
|
+
function startOfLocalDay(ts) {
|
|
666
|
+
const d = new Date(ts);
|
|
667
|
+
d.setHours(0, 0, 0, 0);
|
|
668
|
+
return d.getTime();
|
|
669
|
+
}
|
|
670
|
+
/** The local midnight following the local midnight `dayStart`. */
|
|
671
|
+
function nextLocalDayStart(dayStart) {
|
|
672
|
+
const d = new Date(dayStart);
|
|
673
|
+
d.setDate(d.getDate() + 1);
|
|
674
|
+
d.setHours(0, 0, 0, 0);
|
|
675
|
+
return d.getTime();
|
|
676
|
+
}
|
|
677
|
+
/** Start of the `tier` bucket containing `ts`. */
|
|
678
|
+
function bucketStartOf(tier, ts) {
|
|
679
|
+
if (tier === "5m") return floorTo(ts, FIVE_MINUTES_MS);
|
|
680
|
+
if (tier === "1h") return floorTo(ts, HOUR_MS);
|
|
681
|
+
return startOfLocalDay(ts);
|
|
682
|
+
}
|
|
683
|
+
/** Exclusive end of the `tier` bucket that starts at `start`. */
|
|
684
|
+
function bucketEndOf(tier, start) {
|
|
685
|
+
if (tier === "5m") return start + FIVE_MINUTES_MS;
|
|
686
|
+
if (tier === "1h") return start + HOUR_MS;
|
|
687
|
+
return nextLocalDayStart(start);
|
|
688
|
+
}
|
|
689
|
+
/** Quiet time after a bucket closes before it is rolled up (late writers). */
|
|
690
|
+
var ROLLUP_GRACE_MS = {
|
|
691
|
+
"5m": 3e4,
|
|
692
|
+
"1h": 5 * 6e4,
|
|
693
|
+
"1d": 15 * 6e4
|
|
694
|
+
};
|
|
695
|
+
//#endregion
|
|
696
|
+
//#region src/builtins/device-history/retention/departure-tracker.ts
|
|
697
|
+
var DEPARTED_ABSENCE_MS = 7 * DAY_MS;
|
|
698
|
+
var DepartureTracker = class {
|
|
699
|
+
store;
|
|
700
|
+
registry;
|
|
701
|
+
logger;
|
|
702
|
+
missing = /* @__PURE__ */ new Map();
|
|
703
|
+
constructor(store, registry, logger) {
|
|
704
|
+
this.store = store;
|
|
705
|
+
this.registry = registry;
|
|
706
|
+
this.logger = logger;
|
|
707
|
+
}
|
|
708
|
+
async load() {
|
|
709
|
+
this.missing = await this.store.loadDepartures();
|
|
710
|
+
}
|
|
711
|
+
missingSince(deviceId) {
|
|
712
|
+
return this.missing.get(deviceId);
|
|
713
|
+
}
|
|
714
|
+
/** Forget a device's clock — its series were removed because it was DELETED (D865). */
|
|
715
|
+
async forget(deviceId) {
|
|
716
|
+
await this.store.clearMissing([deviceId]);
|
|
717
|
+
const next = new Map(this.missing);
|
|
718
|
+
next.delete(deviceId);
|
|
719
|
+
this.missing = next;
|
|
720
|
+
}
|
|
721
|
+
/** Feed one SUCCESSFUL listing. A failed read never reaches here. */
|
|
722
|
+
async observe(listed, now) {
|
|
723
|
+
const withSeries = new Set(this.registry.all().flatMap((s) => s.deviceId === null ? [] : [s.deviceId]));
|
|
724
|
+
const back = [...this.missing.keys()].filter((id) => listed.has(id));
|
|
725
|
+
const gone = [...withSeries].filter((id) => !listed.has(id) && !this.missing.has(id));
|
|
726
|
+
try {
|
|
727
|
+
await this.store.clearMissing(back);
|
|
728
|
+
await this.store.markMissing(gone, now);
|
|
729
|
+
} catch (err) {
|
|
730
|
+
this.logger.warn("device-history: departure clock not written", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const next = new Map(this.missing);
|
|
734
|
+
for (const id of back) next.delete(id);
|
|
735
|
+
for (const id of gone) next.set(id, now);
|
|
736
|
+
this.missing = next;
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
//#endregion
|
|
740
|
+
//#region src/builtins/device-history/retention/retention.ts
|
|
741
|
+
var DEFAULT_RETENTION = {
|
|
742
|
+
rawDays: 7,
|
|
743
|
+
fiveMinuteDays: 30,
|
|
744
|
+
hourDays: 365,
|
|
745
|
+
dayDays: null
|
|
746
|
+
};
|
|
747
|
+
/** Cutoff per tier: rows strictly older are deleted. `null` = keep forever. */
|
|
748
|
+
function retentionCutoffs(policy, now) {
|
|
749
|
+
return {
|
|
750
|
+
raw: now - policy.rawDays * DAY_MS,
|
|
751
|
+
"5m": now - policy.fiveMinuteDays * DAY_MS,
|
|
752
|
+
"1h": now - policy.hourDays * DAY_MS,
|
|
753
|
+
"1d": policy.dayDays === null || policy.dayDays <= 0 ? null : now - policy.dayDays * DAY_MS
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
var TIERS = [
|
|
757
|
+
"raw",
|
|
758
|
+
"5m",
|
|
759
|
+
"1h",
|
|
760
|
+
"1d"
|
|
761
|
+
];
|
|
762
|
+
var RETENTION_STEP_MS = {
|
|
763
|
+
raw: HOUR_MS,
|
|
764
|
+
"5m": HOUR_MS,
|
|
765
|
+
"1h": DAY_MS,
|
|
766
|
+
"1d": DAY_MS
|
|
767
|
+
};
|
|
768
|
+
var MAX_PURGES_PER_RUN = 20;
|
|
769
|
+
/** Width of one delete statement's time range, per tier. */
|
|
770
|
+
var SLICE_MS = {
|
|
771
|
+
raw: DAY_MS,
|
|
772
|
+
"5m": DAY_MS,
|
|
773
|
+
"1h": 30 * DAY_MS,
|
|
774
|
+
"1d": 30 * DAY_MS
|
|
775
|
+
};
|
|
776
|
+
/** A bucket's start may precede the series' first point by up to a day. */
|
|
777
|
+
var BUCKET_LEAD_MS = 2 * DAY_MS;
|
|
778
|
+
/** The short tiers hold nothing older than this unless the operator configured more. */
|
|
779
|
+
var SHORT_TIER_LOOKBACK_MS = 400 * DAY_MS;
|
|
780
|
+
var yieldToEventLoop = () => new Promise((resolve) => setImmediate(resolve));
|
|
781
|
+
var RetentionJob = class {
|
|
782
|
+
deps;
|
|
783
|
+
appliedUpTo = /* @__PURE__ */ new Map();
|
|
784
|
+
running = null;
|
|
785
|
+
constructor(deps) {
|
|
786
|
+
this.deps = deps;
|
|
787
|
+
}
|
|
788
|
+
run(policy, now) {
|
|
789
|
+
if (this.running !== null) return this.running;
|
|
790
|
+
const run = this.doRun(policy, now).finally(() => {
|
|
791
|
+
this.running = null;
|
|
792
|
+
});
|
|
793
|
+
this.running = run;
|
|
794
|
+
return run;
|
|
795
|
+
}
|
|
796
|
+
async doRun(policy, now) {
|
|
797
|
+
const { registry, counters, logger } = this.deps;
|
|
798
|
+
const cutoffs = retentionCutoffs(policy, now);
|
|
799
|
+
try {
|
|
800
|
+
for (const tier of TIERS) {
|
|
801
|
+
const cutoff = cutoffs[tier];
|
|
802
|
+
if (cutoff === null) continue;
|
|
803
|
+
for (const series of registry.all()) {
|
|
804
|
+
if (series.firstTs - 864e5 >= cutoff) continue;
|
|
805
|
+
if (tier === "raw" && registry.metric(series.key)?.ingest === "buckets") continue;
|
|
806
|
+
const marker = `${tier}:${series.id}`;
|
|
807
|
+
const applied = this.appliedUpTo.get(marker);
|
|
808
|
+
if (applied !== void 0 && cutoff - applied < RETENTION_STEP_MS[tier]) continue;
|
|
809
|
+
const lo = Math.max(0, applied === void 0 ? series.firstTs - BUCKET_LEAD_MS : applied - RETENTION_STEP_MS[tier]);
|
|
810
|
+
counters.addDeleted(tier, await this.deleteSliced(tier, series.id, lo, cutoff - 1));
|
|
811
|
+
this.appliedUpTo.set(marker, cutoff);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
await this.purgeDeparted(now);
|
|
815
|
+
counters.retentionLastRunAt = now;
|
|
816
|
+
} catch (err) {
|
|
817
|
+
logger.warn("device-history: retention pass failed — retried at the next run", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/** Delete `[lo, hiInclusive]` of one tier in time slices, yielding between them. */
|
|
821
|
+
/** Drop the visit markers of a purged series. */
|
|
822
|
+
forgetSeries(seriesId) {
|
|
823
|
+
for (const tier of TIERS) this.appliedUpTo.delete(`${tier}:${seriesId}`);
|
|
824
|
+
}
|
|
825
|
+
async deleteSliced(tier, seriesId, lo, hiInclusive) {
|
|
826
|
+
const yieldNow = this.deps.yieldNow ?? yieldToEventLoop;
|
|
827
|
+
let deleted = 0;
|
|
828
|
+
for (let from = lo; from <= hiInclusive; from += SLICE_MS[tier]) {
|
|
829
|
+
const to = Math.min(from + SLICE_MS[tier] - 1, hiInclusive);
|
|
830
|
+
deleted += await this.deps.store.deleteRange(tier, seriesId, from, to);
|
|
831
|
+
await yieldNow();
|
|
832
|
+
}
|
|
833
|
+
return deleted;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Tier by tier in slices, then the series row (and whatever the slices missed).
|
|
837
|
+
* Answers the rows the slices deleted per tier. The caller removes the series
|
|
838
|
+
* from the registry, which releases its per-series state.
|
|
839
|
+
*/
|
|
840
|
+
async purgeSeries(series, now) {
|
|
841
|
+
const deleted = {
|
|
842
|
+
raw: 0,
|
|
843
|
+
"5m": 0,
|
|
844
|
+
"1h": 0,
|
|
845
|
+
"1d": 0
|
|
846
|
+
};
|
|
847
|
+
for (const tier of TIERS) {
|
|
848
|
+
const lo = tier === "raw" || tier === "5m" ? Math.max(series.firstTs - BUCKET_LEAD_MS, now - SHORT_TIER_LOOKBACK_MS) : series.firstTs - BUCKET_LEAD_MS;
|
|
849
|
+
deleted[tier] = await this.deleteSliced(tier, series.id, Math.max(0, lo), now + DAY_MS);
|
|
850
|
+
this.deps.counters.addDeleted(tier, deleted[tier]);
|
|
851
|
+
}
|
|
852
|
+
await this.deps.store.deleteSeries(series.id);
|
|
853
|
+
return deleted;
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Delete every series of a DELETED device now (D865). Driven by a tombstone
|
|
857
|
+
* through the `device-removal-hook`, never by a listing: the week-long
|
|
858
|
+
* absence clock above remains the backstop for a device removed before
|
|
859
|
+
* tombstones existed.
|
|
860
|
+
*/
|
|
861
|
+
async purgeDevice(deviceId, now) {
|
|
862
|
+
const { registry, counters } = this.deps;
|
|
863
|
+
let purged = 0;
|
|
864
|
+
for (const series of registry.all()) {
|
|
865
|
+
if (series.deviceId !== deviceId) continue;
|
|
866
|
+
await this.purgeSeries(series, now);
|
|
867
|
+
registry.remove(series.id);
|
|
868
|
+
counters.purgedSeries += 1;
|
|
869
|
+
purged += 1;
|
|
870
|
+
}
|
|
871
|
+
return purged;
|
|
872
|
+
}
|
|
873
|
+
async purgeDeparted(now) {
|
|
874
|
+
const { registry, departures, counters, logger } = this.deps;
|
|
875
|
+
let purged = 0;
|
|
876
|
+
for (const series of registry.all()) {
|
|
877
|
+
if (purged >= MAX_PURGES_PER_RUN) break;
|
|
878
|
+
if (series.deviceId === null) continue;
|
|
879
|
+
const since = departures.missingSince(series.deviceId);
|
|
880
|
+
if (since === void 0 || now - since < DEPARTED_ABSENCE_MS) continue;
|
|
881
|
+
await this.purgeSeries(series, now);
|
|
882
|
+
registry.remove(series.id);
|
|
883
|
+
counters.purgedSeries += 1;
|
|
884
|
+
purged += 1;
|
|
885
|
+
logger.info("device-history: purged the series of an unregistered device", {
|
|
886
|
+
tags: { deviceId: series.deviceId },
|
|
887
|
+
meta: { key: series.key }
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
//#endregion
|
|
893
|
+
export { subjectIdOf as S, readUnit as _, FIVE_MINUTES_MS as a, SeriesRegistry as b, bucketEndOf as c, CAP_KEY_PREFIX as d, CaptureEngine as f, readPath as g, catalogMetrics as h, DAY_MS as i, bucketStartOf as l, capSeriesKey as m, RetentionJob as n, HOUR_MS as o, capItemSeriesKey as p, DepartureTracker as r, ROLLUP_GRACE_MS as s, DEFAULT_RETENTION as t, floorTo as u, DeviceDirectory as v, identityOf as x, isRealDeviceType as y };
|