@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.
@@ -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
- /** Get all settings for an addon */
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 rows = this.getDb().prepare("SELECT id, data FROM \"addon-settings\" WHERE json_extract(data, '$.addonId') = ?").all(addonId);
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
- /** Get all settings for an addon */
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 rows = this.getDb().prepare("SELECT id, data FROM \"addon-settings\" WHERE json_extract(data, '$.addonId') = ?").all(addonId);
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) {