@camstack/system 1.2.106 → 1.2.108
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/dist/builtins/device-manager/device-aggregation.d.ts +2 -2
- package/dist/builtins/device-manager/device-bindings-store.d.ts +12 -6
- package/dist/builtins/device-manager/device-manager.addon.d.ts +6 -0
- package/dist/builtins/device-manager/device-manager.addon.js +1201 -801
- package/dist/builtins/device-manager/device-manager.addon.mjs +1201 -801
- package/dist/builtins/device-manager/device-meta-actions.d.ts +4 -4
- package/dist/builtins/device-manager/device-meta-store.d.ts +31 -16
- package/dist/builtins/device-manager/device-meta-types.d.ts +19 -17
- package/dist/builtins/device-manager/device-row-store.d.ts +229 -0
- package/dist/builtins/sqlite-storage/retired-settings-keys.d.ts +99 -0
- package/dist/builtins/sqlite-storage/sqlite-settings-backend.d.ts +24 -2
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +48 -199
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +47 -198
- package/dist/retired-settings-keys-Bsjf7HQ-.mjs +278 -0
- package/dist/retired-settings-keys-pNmoHPTg.js +295 -0
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ Object.defineProperties(exports, {
|
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
6
6
|
const require_dist = require("../../dist-Ctfhlrtl.js");
|
|
7
|
+
const require_retired_settings_keys = require("../../retired-settings-keys-pNmoHPTg.js");
|
|
7
8
|
let node_crypto = require("node:crypto");
|
|
8
9
|
let node_fs = require("node:fs");
|
|
9
10
|
let node_module = require("node:module");
|
|
@@ -84,181 +85,6 @@ var SQLITE_MMAP_SIZE_BYTES = 268435456;
|
|
|
84
85
|
*/
|
|
85
86
|
var SQLITE_JOURNAL_SIZE_LIMIT_BYTES = 67108864;
|
|
86
87
|
//#endregion
|
|
87
|
-
//#region src/builtins/sqlite-storage/retired-settings-keys.ts
|
|
88
|
-
/**
|
|
89
|
-
* Is THIS node the one whose settings store is the cluster's authority?
|
|
90
|
-
*
|
|
91
|
-
* Mirrors `resolveNodeRole` (server/backend `node-role.ts`) — unset or `'hub'`
|
|
92
|
-
* means hub, which is the launcher's own default. It is duplicated rather than
|
|
93
|
-
* imported because `@camstack/system` is a dependency OF the backend, not the
|
|
94
|
-
* other way round; the parse is four lines and the env var is the contract.
|
|
95
|
-
*
|
|
96
|
-
* Anything else answers `false`. Skipping where the purge should have run is a
|
|
97
|
-
* missed cleanup the next hub boot fixes; running where it should not have is a
|
|
98
|
-
* write to a store this node does not own. Only one of those is recoverable.
|
|
99
|
-
*/
|
|
100
|
-
function settingsStoreIsAuthoritativeHere(env) {
|
|
101
|
-
const raw = (env["CAMSTACK_ROLE"] ?? "").trim().toLowerCase();
|
|
102
|
-
return raw === "" || raw === "hub";
|
|
103
|
-
}
|
|
104
|
-
/**
|
|
105
|
-
* The dead keys, per row.
|
|
106
|
-
*
|
|
107
|
-
* `detection-pipeline` / `addon-settings` / `root` — every key in this row is
|
|
108
|
-
* dead; the row itself is kept because the addon writes live keys into it
|
|
109
|
-
* (`pipelineTemplates`, `videoPipelineSteps`).
|
|
110
|
-
*
|
|
111
|
-
* - `pipelineSteps` / `pipelineEngine`: the persisted GLOBAL step + engine
|
|
112
|
-
* seed. Removed with `getGlobalSteps` becoming pure — dispatch builds steps
|
|
113
|
-
* from the orchestrator's `agentSettings` plus per-camera overrides, and
|
|
114
|
-
* never consulted the seed. See `addon-pipeline/src/detection-pipeline/provider.ts`.
|
|
115
|
-
* - `engineRuntime` / `engineBackend` / `engineDevice` / `probedBestEngine`:
|
|
116
|
-
* the per-node engine CASCADE, removed with the operator-facing engine
|
|
117
|
-
* election. Each node now runs a device-array of inference pools balanced
|
|
118
|
-
* per session, and the node-default pool derives live from the node's own
|
|
119
|
-
* hardware (`resolveAutoEngine`). See
|
|
120
|
-
* `addon-pipeline/src/detection-pipeline/engine-store-keys.ts`.
|
|
121
|
-
*/
|
|
122
|
-
var RETIRED_SETTINGS_KEYS = [{
|
|
123
|
-
namespace: "detection-pipeline",
|
|
124
|
-
collection: "addon-settings",
|
|
125
|
-
row: "root",
|
|
126
|
-
keys: ["pipelineSteps", "pipelineEngine"],
|
|
127
|
-
perNodeKeys: [
|
|
128
|
-
"engineRuntime",
|
|
129
|
-
"engineBackend",
|
|
130
|
-
"engineDevice",
|
|
131
|
-
"probedBestEngine"
|
|
132
|
-
],
|
|
133
|
-
reason: "global step/engine seed and the per-node engine cascade — both removed; the live per-camera dispatch path never read either"
|
|
134
|
-
}, {
|
|
135
|
-
namespace: "device-manager",
|
|
136
|
-
collection: "addon-settings",
|
|
137
|
-
row: "deviceMeta",
|
|
138
|
-
keys: [],
|
|
139
|
-
perNodeKeys: [],
|
|
140
|
-
mapValueKeys: ["deviceLinks"],
|
|
141
|
-
reason: "cross-device field wiring, deleted 2026-08-08 — 0 links across 302 devices a month after the UI shipped. There is no reader left: the resolver, the overlay, the cycle guard, the fifth binding kind and the tab are gone"
|
|
142
|
-
}];
|
|
143
|
-
/**
|
|
144
|
-
* Adapt the settings backend to {@link RetiredKeyStore}.
|
|
145
|
-
*
|
|
146
|
-
* `get` answering a non-object (missing row) is a MISS, not a fault: it maps to
|
|
147
|
-
* `null` and the purge skips the row. A backend throw propagates, which is what
|
|
148
|
-
* routes it to the "read failed, row left untouched" branch.
|
|
149
|
-
*/
|
|
150
|
-
function retiredKeyStoreOf(backend) {
|
|
151
|
-
return {
|
|
152
|
-
async read(spec) {
|
|
153
|
-
return require_dist.asJsonObject(await backend.get({
|
|
154
|
-
namespace: spec.namespace,
|
|
155
|
-
collection: spec.collection,
|
|
156
|
-
key: spec.row
|
|
157
|
-
}));
|
|
158
|
-
},
|
|
159
|
-
async write(spec, value) {
|
|
160
|
-
await backend.set({
|
|
161
|
-
namespace: spec.namespace,
|
|
162
|
-
collection: spec.collection,
|
|
163
|
-
key: spec.row,
|
|
164
|
-
value
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* Nested `<mapKey>.<retiredKey>` paths that `spec` retires inside a map-shaped
|
|
171
|
-
* row. Pure. Values that are not plain objects are skipped, never rewritten.
|
|
172
|
-
*/
|
|
173
|
-
function planNestedRetiredKeyPurge(blob, spec) {
|
|
174
|
-
const nestedKeys = spec.mapValueKeys ?? [];
|
|
175
|
-
if (nestedKeys.length === 0) return [];
|
|
176
|
-
const found = [];
|
|
177
|
-
for (const [mapKey, value] of Object.entries(blob)) {
|
|
178
|
-
const inner = require_dist.asJsonObject(value);
|
|
179
|
-
if (inner === null) continue;
|
|
180
|
-
for (const retired of nestedKeys) if (retired in inner) found.push(`${mapKey}.${retired}`);
|
|
181
|
-
}
|
|
182
|
-
return found;
|
|
183
|
-
}
|
|
184
|
-
/** Keys of `blob` that `spec` retires. Pure. */
|
|
185
|
-
function planRetiredKeyPurge(blob, spec) {
|
|
186
|
-
const exact = new Set(spec.keys);
|
|
187
|
-
const found = [];
|
|
188
|
-
for (const key of Object.keys(blob)) {
|
|
189
|
-
if (exact.has(key)) {
|
|
190
|
-
found.push(key);
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
193
|
-
for (const perNode of spec.perNodeKeys) if (key === perNode || key.startsWith(`${perNode}@`)) {
|
|
194
|
-
found.push(key);
|
|
195
|
-
break;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
return found;
|
|
199
|
-
}
|
|
200
|
-
/**
|
|
201
|
-
* Delete every retired key present in the declared rows — on the hub.
|
|
202
|
-
*
|
|
203
|
-
* Off the authoritative node this is a no-op that touches nothing and says
|
|
204
|
-
* nothing (see the module header). Returns one entry per row that actually
|
|
205
|
-
* changed — empty when the store is already clean, which is the steady state
|
|
206
|
-
* after the first boot that ran it.
|
|
207
|
-
*/
|
|
208
|
-
async function purgeRetiredSettingsKeys(store, logger, specs = RETIRED_SETTINGS_KEYS, env = process.env) {
|
|
209
|
-
if (!settingsStoreIsAuthoritativeHere(env)) return [];
|
|
210
|
-
const results = [];
|
|
211
|
-
for (const spec of specs) {
|
|
212
|
-
let blob;
|
|
213
|
-
try {
|
|
214
|
-
blob = await store.read(spec);
|
|
215
|
-
} catch (err) {
|
|
216
|
-
logger.warn("retired settings keys — read failed, row left untouched", { meta: {
|
|
217
|
-
addon: spec.namespace,
|
|
218
|
-
collection: spec.collection,
|
|
219
|
-
row: spec.row,
|
|
220
|
-
error: err instanceof Error ? err.message : String(err)
|
|
221
|
-
} });
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
if (blob === null) continue;
|
|
225
|
-
const doomed = planRetiredKeyPurge(blob, spec);
|
|
226
|
-
const doomedNested = planNestedRetiredKeyPurge(blob, spec);
|
|
227
|
-
if (doomed.length === 0 && doomedNested.length === 0) continue;
|
|
228
|
-
const doomedSet = new Set(doomed);
|
|
229
|
-
const nestedByMapKey = /* @__PURE__ */ new Map();
|
|
230
|
-
for (const path of doomedNested) {
|
|
231
|
-
const cut = path.indexOf(".");
|
|
232
|
-
const mapKey = path.slice(0, cut);
|
|
233
|
-
const inner = path.slice(cut + 1);
|
|
234
|
-
const bucket = nestedByMapKey.get(mapKey) ?? /* @__PURE__ */ new Set();
|
|
235
|
-
bucket.add(inner);
|
|
236
|
-
nestedByMapKey.set(mapKey, bucket);
|
|
237
|
-
}
|
|
238
|
-
const next = Object.fromEntries(Object.entries(blob).filter(([key]) => !doomedSet.has(key)).map(([key, value]) => {
|
|
239
|
-
const retired = nestedByMapKey.get(key);
|
|
240
|
-
if (retired === void 0) return [key, value];
|
|
241
|
-
const inner = require_dist.asJsonObject(value);
|
|
242
|
-
if (inner === null) return [key, value];
|
|
243
|
-
return [key, Object.fromEntries(Object.entries(inner).filter(([k]) => !retired.has(k)))];
|
|
244
|
-
}));
|
|
245
|
-
await store.write(spec, next);
|
|
246
|
-
const result = {
|
|
247
|
-
addon: spec.namespace,
|
|
248
|
-
collection: spec.collection,
|
|
249
|
-
row: spec.row,
|
|
250
|
-
keys: doomed,
|
|
251
|
-
nested: doomedNested
|
|
252
|
-
};
|
|
253
|
-
results.push(result);
|
|
254
|
-
logger.info(`purged ${doomed.length + doomedNested.length} retired settings keys`, { meta: {
|
|
255
|
-
...result,
|
|
256
|
-
reason: spec.reason
|
|
257
|
-
} });
|
|
258
|
-
}
|
|
259
|
-
return results;
|
|
260
|
-
}
|
|
261
|
-
//#endregion
|
|
262
88
|
//#region src/builtins/sqlite-storage/filter-compiler.ts
|
|
263
89
|
/** Thrown by `mutate` mode. Distinct type so a caller can map it to a 400. */
|
|
264
90
|
var UnsafeFilterError = class extends Error {
|
|
@@ -321,6 +147,27 @@ function compileFilter$1(filter, shape, mode, serialize) {
|
|
|
321
147
|
};
|
|
322
148
|
}
|
|
323
149
|
//#endregion
|
|
150
|
+
//#region src/builtins/sqlite-storage/prefix-range.ts
|
|
151
|
+
var MAX_ASCII = 127;
|
|
152
|
+
/**
|
|
153
|
+
* @returns the range for `prefix`, or `null` when the caller must fall back to
|
|
154
|
+
* a `LIKE` scan (empty prefix — which is the whole table and has no upper
|
|
155
|
+
* bound — or any byte outside ASCII).
|
|
156
|
+
*/
|
|
157
|
+
function prefixRange(prefix) {
|
|
158
|
+
if (prefix.length === 0) return null;
|
|
159
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
160
|
+
const code = prefix.charCodeAt(i);
|
|
161
|
+
if (code === 0 || code > MAX_ASCII) return null;
|
|
162
|
+
}
|
|
163
|
+
const last = prefix.charCodeAt(prefix.length - 1);
|
|
164
|
+
if (last >= MAX_ASCII) return null;
|
|
165
|
+
return {
|
|
166
|
+
lo: prefix,
|
|
167
|
+
hi: prefix.slice(0, -1) + String.fromCharCode(last + 1)
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
//#endregion
|
|
324
171
|
//#region src/builtins/sqlite-storage/query-bounds.ts
|
|
325
172
|
/**
|
|
326
173
|
* Defensive row bounds for `data-store-provider.query`.
|
|
@@ -442,27 +289,6 @@ function isImposedBound(source) {
|
|
|
442
289
|
return source !== "caller";
|
|
443
290
|
}
|
|
444
291
|
//#endregion
|
|
445
|
-
//#region src/builtins/sqlite-storage/prefix-range.ts
|
|
446
|
-
var MAX_ASCII = 127;
|
|
447
|
-
/**
|
|
448
|
-
* @returns the range for `prefix`, or `null` when the caller must fall back to
|
|
449
|
-
* a `LIKE` scan (empty prefix — which is the whole table and has no upper
|
|
450
|
-
* bound — or any byte outside ASCII).
|
|
451
|
-
*/
|
|
452
|
-
function prefixRange(prefix) {
|
|
453
|
-
if (prefix.length === 0) return null;
|
|
454
|
-
for (let i = 0; i < prefix.length; i++) {
|
|
455
|
-
const code = prefix.charCodeAt(i);
|
|
456
|
-
if (code === 0 || code > MAX_ASCII) return null;
|
|
457
|
-
}
|
|
458
|
-
const last = prefix.charCodeAt(prefix.length - 1);
|
|
459
|
-
if (last >= MAX_ASCII) return null;
|
|
460
|
-
return {
|
|
461
|
-
lo: prefix,
|
|
462
|
-
hi: prefix.slice(0, -1) + String.fromCharCode(last + 1)
|
|
463
|
-
};
|
|
464
|
-
}
|
|
465
|
-
//#endregion
|
|
466
292
|
//#region src/builtins/sqlite-storage/sqlite-settings-backend.ts
|
|
467
293
|
function parseRowData(raw) {
|
|
468
294
|
return require_dist.asJsonObject(require_dist.parseJsonUnknown(raw)) ?? {};
|
|
@@ -890,10 +716,33 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
|
|
|
890
716
|
const rows = this.getDb().prepare("SELECT id, data FROM \"system-settings\"").all();
|
|
891
717
|
return Object.fromEntries(rows.map((r) => [r.id, JSON.parse(r.data)]));
|
|
892
718
|
}
|
|
893
|
-
/**
|
|
719
|
+
/**
|
|
720
|
+
* Get all settings for an addon.
|
|
721
|
+
*
|
|
722
|
+
* Selected by the SAME key range {@link setAllAddon} deletes and re-inserts
|
|
723
|
+
* — `prefixWhere("<addonId>.")` — and for the same reason `getAllScoped`
|
|
724
|
+
* uses it: `"<addonId>.<key>"` is the PRIMARY KEY, so the range is a
|
|
725
|
+
* `SEARCH … USING INDEX` while anything else is a full `SCAN`.
|
|
726
|
+
*
|
|
727
|
+
* It used to select on `json_extract(data, '$.addonId')`, which is a second
|
|
728
|
+
* authority for "this addon's rows" and disagreed with the writer in both
|
|
729
|
+
* directions: a row inside the JSON's idea of the addon but outside the key
|
|
730
|
+
* range was READ and never DELETED (a config key that could not be cleared),
|
|
731
|
+
* and `json_extract` was evaluated on EVERY row — so a four-key addon paid
|
|
732
|
+
* for the whole table and one unparseable neighbour aborted the statement,
|
|
733
|
+
* taking out every addon's config read at once.
|
|
734
|
+
*
|
|
735
|
+
* The cost was not theoretical. On the live hub `addon-settings` is 627 KB
|
|
736
|
+
* in 24 rows, 625 KB of it device-manager's three fleet blobs; a V8 profile
|
|
737
|
+
* of a boot (2026-08-19) had hub-main's JS thread 99.9% busy with **64% of
|
|
738
|
+
* it inside this method**, which is what put a ~45 s queue in front of every
|
|
739
|
+
* runner's first store read. Measured on that table: 0.947 ms → ~0.02 ms for
|
|
740
|
+
* a four-key addon, 2.90 ms → 2.13 ms for device-manager's own.
|
|
741
|
+
*/
|
|
894
742
|
getAllAddon(addonId) {
|
|
895
743
|
this.requireDeclared("addon-settings");
|
|
896
|
-
const
|
|
744
|
+
const where = this.prefixWhere(`${addonId}.`);
|
|
745
|
+
const rows = this.getDb().prepare(`SELECT id, data FROM "addon-settings" WHERE ${where.sql}`).all(...where.params);
|
|
897
746
|
if (rows.length === 0) return {};
|
|
898
747
|
const result = {};
|
|
899
748
|
for (const row of rows) {
|
|
@@ -1800,7 +1649,7 @@ var SqliteSettingsAddon = class extends require_dist.BaseAddon {
|
|
|
1800
1649
|
this.backend = new SqliteSettingsBackend(dbPath, { ...require_dist.RUNTIME_DEFAULTS }, this.ctx.logger.child("Query"));
|
|
1801
1650
|
await this.backend.initialize();
|
|
1802
1651
|
try {
|
|
1803
|
-
await purgeRetiredSettingsKeys(retiredKeyStoreOf(this.backend), this.ctx.logger.child("RetiredKeys"));
|
|
1652
|
+
await require_retired_settings_keys.purgeRetiredSettingsKeys(require_retired_settings_keys.retiredKeyStoreOf(this.backend), this.ctx.logger.child("RetiredKeys"));
|
|
1804
1653
|
} catch (err) {
|
|
1805
1654
|
this.ctx.logger.warn("Retired-key purge failed", { meta: { error: require_dist.errMsg(err) } });
|
|
1806
1655
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { A as dataStoreProviderCapability, Bt as parseJsonUnknown, _t as vectorStoreCapability, f as RUNTIME_DEFAULTS, gt as vectorDimFromBase64, j as decodeVectorBase64, kt as asJsonObject, vt as errMsg, w as bareAddonId, yt as BaseAddon } from "../../dist-_oC_QkQA.mjs";
|
|
2
|
+
import { r as retiredKeyStoreOf, t as purgeRetiredSettingsKeys } from "../../retired-settings-keys-Bsjf7HQ-.mjs";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
5
|
import { statSync } from "node:fs";
|
|
@@ -78,181 +79,6 @@ var SQLITE_MMAP_SIZE_BYTES = 268435456;
|
|
|
78
79
|
*/
|
|
79
80
|
var SQLITE_JOURNAL_SIZE_LIMIT_BYTES = 67108864;
|
|
80
81
|
//#endregion
|
|
81
|
-
//#region src/builtins/sqlite-storage/retired-settings-keys.ts
|
|
82
|
-
/**
|
|
83
|
-
* Is THIS node the one whose settings store is the cluster's authority?
|
|
84
|
-
*
|
|
85
|
-
* Mirrors `resolveNodeRole` (server/backend `node-role.ts`) — unset or `'hub'`
|
|
86
|
-
* means hub, which is the launcher's own default. It is duplicated rather than
|
|
87
|
-
* imported because `@camstack/system` is a dependency OF the backend, not the
|
|
88
|
-
* other way round; the parse is four lines and the env var is the contract.
|
|
89
|
-
*
|
|
90
|
-
* Anything else answers `false`. Skipping where the purge should have run is a
|
|
91
|
-
* missed cleanup the next hub boot fixes; running where it should not have is a
|
|
92
|
-
* write to a store this node does not own. Only one of those is recoverable.
|
|
93
|
-
*/
|
|
94
|
-
function settingsStoreIsAuthoritativeHere(env) {
|
|
95
|
-
const raw = (env["CAMSTACK_ROLE"] ?? "").trim().toLowerCase();
|
|
96
|
-
return raw === "" || raw === "hub";
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* The dead keys, per row.
|
|
100
|
-
*
|
|
101
|
-
* `detection-pipeline` / `addon-settings` / `root` — every key in this row is
|
|
102
|
-
* dead; the row itself is kept because the addon writes live keys into it
|
|
103
|
-
* (`pipelineTemplates`, `videoPipelineSteps`).
|
|
104
|
-
*
|
|
105
|
-
* - `pipelineSteps` / `pipelineEngine`: the persisted GLOBAL step + engine
|
|
106
|
-
* seed. Removed with `getGlobalSteps` becoming pure — dispatch builds steps
|
|
107
|
-
* from the orchestrator's `agentSettings` plus per-camera overrides, and
|
|
108
|
-
* never consulted the seed. See `addon-pipeline/src/detection-pipeline/provider.ts`.
|
|
109
|
-
* - `engineRuntime` / `engineBackend` / `engineDevice` / `probedBestEngine`:
|
|
110
|
-
* the per-node engine CASCADE, removed with the operator-facing engine
|
|
111
|
-
* election. Each node now runs a device-array of inference pools balanced
|
|
112
|
-
* per session, and the node-default pool derives live from the node's own
|
|
113
|
-
* hardware (`resolveAutoEngine`). See
|
|
114
|
-
* `addon-pipeline/src/detection-pipeline/engine-store-keys.ts`.
|
|
115
|
-
*/
|
|
116
|
-
var RETIRED_SETTINGS_KEYS = [{
|
|
117
|
-
namespace: "detection-pipeline",
|
|
118
|
-
collection: "addon-settings",
|
|
119
|
-
row: "root",
|
|
120
|
-
keys: ["pipelineSteps", "pipelineEngine"],
|
|
121
|
-
perNodeKeys: [
|
|
122
|
-
"engineRuntime",
|
|
123
|
-
"engineBackend",
|
|
124
|
-
"engineDevice",
|
|
125
|
-
"probedBestEngine"
|
|
126
|
-
],
|
|
127
|
-
reason: "global step/engine seed and the per-node engine cascade — both removed; the live per-camera dispatch path never read either"
|
|
128
|
-
}, {
|
|
129
|
-
namespace: "device-manager",
|
|
130
|
-
collection: "addon-settings",
|
|
131
|
-
row: "deviceMeta",
|
|
132
|
-
keys: [],
|
|
133
|
-
perNodeKeys: [],
|
|
134
|
-
mapValueKeys: ["deviceLinks"],
|
|
135
|
-
reason: "cross-device field wiring, deleted 2026-08-08 — 0 links across 302 devices a month after the UI shipped. There is no reader left: the resolver, the overlay, the cycle guard, the fifth binding kind and the tab are gone"
|
|
136
|
-
}];
|
|
137
|
-
/**
|
|
138
|
-
* Adapt the settings backend to {@link RetiredKeyStore}.
|
|
139
|
-
*
|
|
140
|
-
* `get` answering a non-object (missing row) is a MISS, not a fault: it maps to
|
|
141
|
-
* `null` and the purge skips the row. A backend throw propagates, which is what
|
|
142
|
-
* routes it to the "read failed, row left untouched" branch.
|
|
143
|
-
*/
|
|
144
|
-
function retiredKeyStoreOf(backend) {
|
|
145
|
-
return {
|
|
146
|
-
async read(spec) {
|
|
147
|
-
return asJsonObject(await backend.get({
|
|
148
|
-
namespace: spec.namespace,
|
|
149
|
-
collection: spec.collection,
|
|
150
|
-
key: spec.row
|
|
151
|
-
}));
|
|
152
|
-
},
|
|
153
|
-
async write(spec, value) {
|
|
154
|
-
await backend.set({
|
|
155
|
-
namespace: spec.namespace,
|
|
156
|
-
collection: spec.collection,
|
|
157
|
-
key: spec.row,
|
|
158
|
-
value
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
/**
|
|
164
|
-
* Nested `<mapKey>.<retiredKey>` paths that `spec` retires inside a map-shaped
|
|
165
|
-
* row. Pure. Values that are not plain objects are skipped, never rewritten.
|
|
166
|
-
*/
|
|
167
|
-
function planNestedRetiredKeyPurge(blob, spec) {
|
|
168
|
-
const nestedKeys = spec.mapValueKeys ?? [];
|
|
169
|
-
if (nestedKeys.length === 0) return [];
|
|
170
|
-
const found = [];
|
|
171
|
-
for (const [mapKey, value] of Object.entries(blob)) {
|
|
172
|
-
const inner = asJsonObject(value);
|
|
173
|
-
if (inner === null) continue;
|
|
174
|
-
for (const retired of nestedKeys) if (retired in inner) found.push(`${mapKey}.${retired}`);
|
|
175
|
-
}
|
|
176
|
-
return found;
|
|
177
|
-
}
|
|
178
|
-
/** Keys of `blob` that `spec` retires. Pure. */
|
|
179
|
-
function planRetiredKeyPurge(blob, spec) {
|
|
180
|
-
const exact = new Set(spec.keys);
|
|
181
|
-
const found = [];
|
|
182
|
-
for (const key of Object.keys(blob)) {
|
|
183
|
-
if (exact.has(key)) {
|
|
184
|
-
found.push(key);
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
for (const perNode of spec.perNodeKeys) if (key === perNode || key.startsWith(`${perNode}@`)) {
|
|
188
|
-
found.push(key);
|
|
189
|
-
break;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
return found;
|
|
193
|
-
}
|
|
194
|
-
/**
|
|
195
|
-
* Delete every retired key present in the declared rows — on the hub.
|
|
196
|
-
*
|
|
197
|
-
* Off the authoritative node this is a no-op that touches nothing and says
|
|
198
|
-
* nothing (see the module header). Returns one entry per row that actually
|
|
199
|
-
* changed — empty when the store is already clean, which is the steady state
|
|
200
|
-
* after the first boot that ran it.
|
|
201
|
-
*/
|
|
202
|
-
async function purgeRetiredSettingsKeys(store, logger, specs = RETIRED_SETTINGS_KEYS, env = process.env) {
|
|
203
|
-
if (!settingsStoreIsAuthoritativeHere(env)) return [];
|
|
204
|
-
const results = [];
|
|
205
|
-
for (const spec of specs) {
|
|
206
|
-
let blob;
|
|
207
|
-
try {
|
|
208
|
-
blob = await store.read(spec);
|
|
209
|
-
} catch (err) {
|
|
210
|
-
logger.warn("retired settings keys — read failed, row left untouched", { meta: {
|
|
211
|
-
addon: spec.namespace,
|
|
212
|
-
collection: spec.collection,
|
|
213
|
-
row: spec.row,
|
|
214
|
-
error: err instanceof Error ? err.message : String(err)
|
|
215
|
-
} });
|
|
216
|
-
continue;
|
|
217
|
-
}
|
|
218
|
-
if (blob === null) continue;
|
|
219
|
-
const doomed = planRetiredKeyPurge(blob, spec);
|
|
220
|
-
const doomedNested = planNestedRetiredKeyPurge(blob, spec);
|
|
221
|
-
if (doomed.length === 0 && doomedNested.length === 0) continue;
|
|
222
|
-
const doomedSet = new Set(doomed);
|
|
223
|
-
const nestedByMapKey = /* @__PURE__ */ new Map();
|
|
224
|
-
for (const path of doomedNested) {
|
|
225
|
-
const cut = path.indexOf(".");
|
|
226
|
-
const mapKey = path.slice(0, cut);
|
|
227
|
-
const inner = path.slice(cut + 1);
|
|
228
|
-
const bucket = nestedByMapKey.get(mapKey) ?? /* @__PURE__ */ new Set();
|
|
229
|
-
bucket.add(inner);
|
|
230
|
-
nestedByMapKey.set(mapKey, bucket);
|
|
231
|
-
}
|
|
232
|
-
const next = Object.fromEntries(Object.entries(blob).filter(([key]) => !doomedSet.has(key)).map(([key, value]) => {
|
|
233
|
-
const retired = nestedByMapKey.get(key);
|
|
234
|
-
if (retired === void 0) return [key, value];
|
|
235
|
-
const inner = asJsonObject(value);
|
|
236
|
-
if (inner === null) return [key, value];
|
|
237
|
-
return [key, Object.fromEntries(Object.entries(inner).filter(([k]) => !retired.has(k)))];
|
|
238
|
-
}));
|
|
239
|
-
await store.write(spec, next);
|
|
240
|
-
const result = {
|
|
241
|
-
addon: spec.namespace,
|
|
242
|
-
collection: spec.collection,
|
|
243
|
-
row: spec.row,
|
|
244
|
-
keys: doomed,
|
|
245
|
-
nested: doomedNested
|
|
246
|
-
};
|
|
247
|
-
results.push(result);
|
|
248
|
-
logger.info(`purged ${doomed.length + doomedNested.length} retired settings keys`, { meta: {
|
|
249
|
-
...result,
|
|
250
|
-
reason: spec.reason
|
|
251
|
-
} });
|
|
252
|
-
}
|
|
253
|
-
return results;
|
|
254
|
-
}
|
|
255
|
-
//#endregion
|
|
256
82
|
//#region src/builtins/sqlite-storage/filter-compiler.ts
|
|
257
83
|
/** Thrown by `mutate` mode. Distinct type so a caller can map it to a 400. */
|
|
258
84
|
var UnsafeFilterError = class extends Error {
|
|
@@ -315,6 +141,27 @@ function compileFilter$1(filter, shape, mode, serialize) {
|
|
|
315
141
|
};
|
|
316
142
|
}
|
|
317
143
|
//#endregion
|
|
144
|
+
//#region src/builtins/sqlite-storage/prefix-range.ts
|
|
145
|
+
var MAX_ASCII = 127;
|
|
146
|
+
/**
|
|
147
|
+
* @returns the range for `prefix`, or `null` when the caller must fall back to
|
|
148
|
+
* a `LIKE` scan (empty prefix — which is the whole table and has no upper
|
|
149
|
+
* bound — or any byte outside ASCII).
|
|
150
|
+
*/
|
|
151
|
+
function prefixRange(prefix) {
|
|
152
|
+
if (prefix.length === 0) return null;
|
|
153
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
154
|
+
const code = prefix.charCodeAt(i);
|
|
155
|
+
if (code === 0 || code > MAX_ASCII) return null;
|
|
156
|
+
}
|
|
157
|
+
const last = prefix.charCodeAt(prefix.length - 1);
|
|
158
|
+
if (last >= MAX_ASCII) return null;
|
|
159
|
+
return {
|
|
160
|
+
lo: prefix,
|
|
161
|
+
hi: prefix.slice(0, -1) + String.fromCharCode(last + 1)
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
//#endregion
|
|
318
165
|
//#region src/builtins/sqlite-storage/query-bounds.ts
|
|
319
166
|
/**
|
|
320
167
|
* Defensive row bounds for `data-store-provider.query`.
|
|
@@ -436,27 +283,6 @@ function isImposedBound(source) {
|
|
|
436
283
|
return source !== "caller";
|
|
437
284
|
}
|
|
438
285
|
//#endregion
|
|
439
|
-
//#region src/builtins/sqlite-storage/prefix-range.ts
|
|
440
|
-
var MAX_ASCII = 127;
|
|
441
|
-
/**
|
|
442
|
-
* @returns the range for `prefix`, or `null` when the caller must fall back to
|
|
443
|
-
* a `LIKE` scan (empty prefix — which is the whole table and has no upper
|
|
444
|
-
* bound — or any byte outside ASCII).
|
|
445
|
-
*/
|
|
446
|
-
function prefixRange(prefix) {
|
|
447
|
-
if (prefix.length === 0) return null;
|
|
448
|
-
for (let i = 0; i < prefix.length; i++) {
|
|
449
|
-
const code = prefix.charCodeAt(i);
|
|
450
|
-
if (code === 0 || code > MAX_ASCII) return null;
|
|
451
|
-
}
|
|
452
|
-
const last = prefix.charCodeAt(prefix.length - 1);
|
|
453
|
-
if (last >= MAX_ASCII) return null;
|
|
454
|
-
return {
|
|
455
|
-
lo: prefix,
|
|
456
|
-
hi: prefix.slice(0, -1) + String.fromCharCode(last + 1)
|
|
457
|
-
};
|
|
458
|
-
}
|
|
459
|
-
//#endregion
|
|
460
286
|
//#region src/builtins/sqlite-storage/sqlite-settings-backend.ts
|
|
461
287
|
function parseRowData(raw) {
|
|
462
288
|
return asJsonObject(parseJsonUnknown(raw)) ?? {};
|
|
@@ -884,10 +710,33 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
|
|
|
884
710
|
const rows = this.getDb().prepare("SELECT id, data FROM \"system-settings\"").all();
|
|
885
711
|
return Object.fromEntries(rows.map((r) => [r.id, JSON.parse(r.data)]));
|
|
886
712
|
}
|
|
887
|
-
/**
|
|
713
|
+
/**
|
|
714
|
+
* Get all settings for an addon.
|
|
715
|
+
*
|
|
716
|
+
* Selected by the SAME key range {@link setAllAddon} deletes and re-inserts
|
|
717
|
+
* — `prefixWhere("<addonId>.")` — and for the same reason `getAllScoped`
|
|
718
|
+
* uses it: `"<addonId>.<key>"` is the PRIMARY KEY, so the range is a
|
|
719
|
+
* `SEARCH … USING INDEX` while anything else is a full `SCAN`.
|
|
720
|
+
*
|
|
721
|
+
* It used to select on `json_extract(data, '$.addonId')`, which is a second
|
|
722
|
+
* authority for "this addon's rows" and disagreed with the writer in both
|
|
723
|
+
* directions: a row inside the JSON's idea of the addon but outside the key
|
|
724
|
+
* range was READ and never DELETED (a config key that could not be cleared),
|
|
725
|
+
* and `json_extract` was evaluated on EVERY row — so a four-key addon paid
|
|
726
|
+
* for the whole table and one unparseable neighbour aborted the statement,
|
|
727
|
+
* taking out every addon's config read at once.
|
|
728
|
+
*
|
|
729
|
+
* The cost was not theoretical. On the live hub `addon-settings` is 627 KB
|
|
730
|
+
* in 24 rows, 625 KB of it device-manager's three fleet blobs; a V8 profile
|
|
731
|
+
* of a boot (2026-08-19) had hub-main's JS thread 99.9% busy with **64% of
|
|
732
|
+
* it inside this method**, which is what put a ~45 s queue in front of every
|
|
733
|
+
* runner's first store read. Measured on that table: 0.947 ms → ~0.02 ms for
|
|
734
|
+
* a four-key addon, 2.90 ms → 2.13 ms for device-manager's own.
|
|
735
|
+
*/
|
|
888
736
|
getAllAddon(addonId) {
|
|
889
737
|
this.requireDeclared("addon-settings");
|
|
890
|
-
const
|
|
738
|
+
const where = this.prefixWhere(`${addonId}.`);
|
|
739
|
+
const rows = this.getDb().prepare(`SELECT id, data FROM "addon-settings" WHERE ${where.sql}`).all(...where.params);
|
|
891
740
|
if (rows.length === 0) return {};
|
|
892
741
|
const result = {};
|
|
893
742
|
for (const row of rows) {
|