@camstack/system 1.2.106 → 1.2.107

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.
@@ -953,7 +953,7 @@ function resolveDeviceById(registry, deviceId) {
953
953
  //#region src/builtins/device-manager/device-queries.ts
954
954
  async function listPersistedByAddon(pctx, input) {
955
955
  const { addonId } = input;
956
- const [index, meta] = await Promise.all([pctx.metaStore.readIndex(), pctx.metaStore.readMeta()]);
956
+ const { index, meta } = await pctx.metaStore.readAll();
957
957
  const stableIds = index[addonId] ?? [];
958
958
  const byStableId = /* @__PURE__ */ new Map();
959
959
  for (const m of Object.values(meta)) if (m.addonId === addonId) byStableId.set(m.stableId, m);
@@ -978,8 +978,7 @@ async function listAll(pctx, input) {
978
978
  const camerasOnly = input.isCamera === true;
979
979
  const results = [];
980
980
  const seen = /* @__PURE__ */ new Set();
981
- const meta = await pctx.metaStore.readMeta();
982
- const metadataMap = await pctx.metaStore.readMetadataMap();
981
+ const { meta, metadata: metadataMap, index } = await pctx.metaStore.readAll();
983
982
  if (pctx.registry) {
984
983
  const liveEntries = addonId ? pctx.registry.getAllForAddon(addonId).map((device) => ({
985
984
  addonId,
@@ -997,7 +996,6 @@ async function listAll(pctx, input) {
997
996
  } : info);
998
997
  }
999
998
  }
1000
- const index = await pctx.metaStore.readIndex();
1001
999
  const metaByAddonStable = /* @__PURE__ */ new Map();
1002
1000
  for (const m of Object.values(meta)) metaByAddonStable.set(`${m.addonId}${m.stableId}`, m);
1003
1001
  const targetAddons = addonId ? [addonId] : Object.keys(index);
@@ -1095,11 +1093,7 @@ async function getChildren(pctx, input) {
1095
1093
  }
1096
1094
  const results = [];
1097
1095
  const seen = /* @__PURE__ */ new Set();
1098
- const [index, meta, metadataMap] = await Promise.all([
1099
- pctx.metaStore.readIndex(),
1100
- pctx.metaStore.readMeta(),
1101
- pctx.metaStore.readMetadataMap()
1102
- ]);
1096
+ const { index, meta, metadata: metadataMap } = await pctx.metaStore.readAll();
1103
1097
  if (pctx.registry) {
1104
1098
  const liveChildren = pctx.registry.getChildren(parentDeviceId);
1105
1099
  for (const device of liveChildren) {
@@ -3875,8 +3869,61 @@ var DeviceMetaStore = class {
3875
3869
  this.settings = settings;
3876
3870
  this.registry = registry;
3877
3871
  }
3872
+ /** The read currently in flight, or null. Never a settled value — see
3873
+ * {@link readStore}. */
3874
+ inFlightRead = null;
3875
+ /**
3876
+ * The whole persisted addon store.
3877
+ *
3878
+ * **Concurrent callers join the read already in flight.** This is not a
3879
+ * cache and nothing survives settlement: a caller that awaited the running
3880
+ * promise could not have observed anything older than its result, so the
3881
+ * only thing that changes is cost. What that cost was, measured on the
3882
+ * 2026-08-19 boot: `readAddonStore` lands in `SqliteSettingsBackend
3883
+ * .getAllAddon`, which reads and `JSON.parse`s this addon's rows —
3884
+ * 625 KB on the live hub (`deviceMeta` 467 KB + `deviceMetadata` 87 KB +
3885
+ * `deviceIndex` 70 KB) — synchronously, on the hub's event loop. A V8
3886
+ * profile of that boot had hub-main's JS thread 99.9% busy with 64% of it
3887
+ * inside `getAllAddon`, ~89% of that entered here, and every runner's first
3888
+ * store read queued behind it (the notification centre's six parallel reads
3889
+ * all resolved together at t+44.7 s).
3890
+ *
3891
+ * A rejection is NOT latched: the slot is cleared before the promise
3892
+ * settles either way, so a failed read costs the joiners that one failure
3893
+ * and the next caller reaches the store again.
3894
+ */
3878
3895
  readStore = async () => {
3879
- return await this.settings.readAddonStore();
3896
+ const existing = this.inFlightRead;
3897
+ if (existing !== null) return existing;
3898
+ const read = (async () => {
3899
+ try {
3900
+ return await this.settings.readAddonStore();
3901
+ } finally {
3902
+ this.inFlightRead = null;
3903
+ }
3904
+ })();
3905
+ this.inFlightRead = read;
3906
+ return read;
3907
+ };
3908
+ /**
3909
+ * The three fleet projections from ONE read.
3910
+ *
3911
+ * `listAll` asked for `deviceMeta`, then `deviceMetadata`, then
3912
+ * `deviceIndex` — three SEQUENTIAL awaits, which {@link readStore}'s
3913
+ * in-flight join cannot collapse because each starts after the previous one
3914
+ * settled. Three full 625 KB parses per call, on a call made once per device
3915
+ * lifecycle event during a 974-device boot: 23% of hub-main's CPU.
3916
+ *
3917
+ * It is also ONE snapshot. Three separate reads could straddle a write and
3918
+ * hand back an index that names a device the meta map no longer has.
3919
+ */
3920
+ readAll = async () => {
3921
+ const store = await this.readStore();
3922
+ return {
3923
+ index: store.deviceIndex ?? {},
3924
+ meta: store.deviceMeta ?? {},
3925
+ metadata: store.deviceMetadata ?? {}
3926
+ };
3880
3927
  };
3881
3928
  readIndex = async () => {
3882
3929
  return (await this.readStore()).deviceIndex ?? {};
@@ -948,7 +948,7 @@ function resolveDeviceById(registry, deviceId) {
948
948
  //#region src/builtins/device-manager/device-queries.ts
949
949
  async function listPersistedByAddon(pctx, input) {
950
950
  const { addonId } = input;
951
- const [index, meta] = await Promise.all([pctx.metaStore.readIndex(), pctx.metaStore.readMeta()]);
951
+ const { index, meta } = await pctx.metaStore.readAll();
952
952
  const stableIds = index[addonId] ?? [];
953
953
  const byStableId = /* @__PURE__ */ new Map();
954
954
  for (const m of Object.values(meta)) if (m.addonId === addonId) byStableId.set(m.stableId, m);
@@ -973,8 +973,7 @@ async function listAll(pctx, input) {
973
973
  const camerasOnly = input.isCamera === true;
974
974
  const results = [];
975
975
  const seen = /* @__PURE__ */ new Set();
976
- const meta = await pctx.metaStore.readMeta();
977
- const metadataMap = await pctx.metaStore.readMetadataMap();
976
+ const { meta, metadata: metadataMap, index } = await pctx.metaStore.readAll();
978
977
  if (pctx.registry) {
979
978
  const liveEntries = addonId ? pctx.registry.getAllForAddon(addonId).map((device) => ({
980
979
  addonId,
@@ -992,7 +991,6 @@ async function listAll(pctx, input) {
992
991
  } : info);
993
992
  }
994
993
  }
995
- const index = await pctx.metaStore.readIndex();
996
994
  const metaByAddonStable = /* @__PURE__ */ new Map();
997
995
  for (const m of Object.values(meta)) metaByAddonStable.set(`${m.addonId}${m.stableId}`, m);
998
996
  const targetAddons = addonId ? [addonId] : Object.keys(index);
@@ -1090,11 +1088,7 @@ async function getChildren(pctx, input) {
1090
1088
  }
1091
1089
  const results = [];
1092
1090
  const seen = /* @__PURE__ */ new Set();
1093
- const [index, meta, metadataMap] = await Promise.all([
1094
- pctx.metaStore.readIndex(),
1095
- pctx.metaStore.readMeta(),
1096
- pctx.metaStore.readMetadataMap()
1097
- ]);
1091
+ const { index, meta, metadata: metadataMap } = await pctx.metaStore.readAll();
1098
1092
  if (pctx.registry) {
1099
1093
  const liveChildren = pctx.registry.getChildren(parentDeviceId);
1100
1094
  for (const device of liveChildren) {
@@ -3870,8 +3864,61 @@ var DeviceMetaStore = class {
3870
3864
  this.settings = settings;
3871
3865
  this.registry = registry;
3872
3866
  }
3867
+ /** The read currently in flight, or null. Never a settled value — see
3868
+ * {@link readStore}. */
3869
+ inFlightRead = null;
3870
+ /**
3871
+ * The whole persisted addon store.
3872
+ *
3873
+ * **Concurrent callers join the read already in flight.** This is not a
3874
+ * cache and nothing survives settlement: a caller that awaited the running
3875
+ * promise could not have observed anything older than its result, so the
3876
+ * only thing that changes is cost. What that cost was, measured on the
3877
+ * 2026-08-19 boot: `readAddonStore` lands in `SqliteSettingsBackend
3878
+ * .getAllAddon`, which reads and `JSON.parse`s this addon's rows —
3879
+ * 625 KB on the live hub (`deviceMeta` 467 KB + `deviceMetadata` 87 KB +
3880
+ * `deviceIndex` 70 KB) — synchronously, on the hub's event loop. A V8
3881
+ * profile of that boot had hub-main's JS thread 99.9% busy with 64% of it
3882
+ * inside `getAllAddon`, ~89% of that entered here, and every runner's first
3883
+ * store read queued behind it (the notification centre's six parallel reads
3884
+ * all resolved together at t+44.7 s).
3885
+ *
3886
+ * A rejection is NOT latched: the slot is cleared before the promise
3887
+ * settles either way, so a failed read costs the joiners that one failure
3888
+ * and the next caller reaches the store again.
3889
+ */
3873
3890
  readStore = async () => {
3874
- return await this.settings.readAddonStore();
3891
+ const existing = this.inFlightRead;
3892
+ if (existing !== null) return existing;
3893
+ const read = (async () => {
3894
+ try {
3895
+ return await this.settings.readAddonStore();
3896
+ } finally {
3897
+ this.inFlightRead = null;
3898
+ }
3899
+ })();
3900
+ this.inFlightRead = read;
3901
+ return read;
3902
+ };
3903
+ /**
3904
+ * The three fleet projections from ONE read.
3905
+ *
3906
+ * `listAll` asked for `deviceMeta`, then `deviceMetadata`, then
3907
+ * `deviceIndex` — three SEQUENTIAL awaits, which {@link readStore}'s
3908
+ * in-flight join cannot collapse because each starts after the previous one
3909
+ * settled. Three full 625 KB parses per call, on a call made once per device
3910
+ * lifecycle event during a 974-device boot: 23% of hub-main's CPU.
3911
+ *
3912
+ * It is also ONE snapshot. Three separate reads could straddle a write and
3913
+ * hand back an index that names a device the meta map no longer has.
3914
+ */
3915
+ readAll = async () => {
3916
+ const store = await this.readStore();
3917
+ return {
3918
+ index: store.deviceIndex ?? {},
3919
+ meta: store.deviceMeta ?? {},
3920
+ metadata: store.deviceMetadata ?? {}
3921
+ };
3875
3922
  };
3876
3923
  readIndex = async () => {
3877
3924
  return (await this.readStore()).deviceIndex ?? {};
@@ -19,7 +19,47 @@ export declare class DeviceMetaStore {
19
19
  constructor(settings: DeviceManagerSettings,
20
20
  /** Hub-process device registry (null when this node owns no registry). */
21
21
  registry: IDeviceRegistry | null);
22
+ /** The read currently in flight, or null. Never a settled value — see
23
+ * {@link readStore}. */
24
+ private inFlightRead;
25
+ /**
26
+ * The whole persisted addon store.
27
+ *
28
+ * **Concurrent callers join the read already in flight.** This is not a
29
+ * cache and nothing survives settlement: a caller that awaited the running
30
+ * promise could not have observed anything older than its result, so the
31
+ * only thing that changes is cost. What that cost was, measured on the
32
+ * 2026-08-19 boot: `readAddonStore` lands in `SqliteSettingsBackend
33
+ * .getAllAddon`, which reads and `JSON.parse`s this addon's rows —
34
+ * 625 KB on the live hub (`deviceMeta` 467 KB + `deviceMetadata` 87 KB +
35
+ * `deviceIndex` 70 KB) — synchronously, on the hub's event loop. A V8
36
+ * profile of that boot had hub-main's JS thread 99.9% busy with 64% of it
37
+ * inside `getAllAddon`, ~89% of that entered here, and every runner's first
38
+ * store read queued behind it (the notification centre's six parallel reads
39
+ * all resolved together at t+44.7 s).
40
+ *
41
+ * A rejection is NOT latched: the slot is cleared before the promise
42
+ * settles either way, so a failed read costs the joiners that one failure
43
+ * and the next caller reaches the store again.
44
+ */
22
45
  readStore: () => Promise<AddonStore>;
46
+ /**
47
+ * The three fleet projections from ONE read.
48
+ *
49
+ * `listAll` asked for `deviceMeta`, then `deviceMetadata`, then
50
+ * `deviceIndex` — three SEQUENTIAL awaits, which {@link readStore}'s
51
+ * in-flight join cannot collapse because each starts after the previous one
52
+ * settled. Three full 625 KB parses per call, on a call made once per device
53
+ * lifecycle event during a 974-device boot: 23% of hub-main's CPU.
54
+ *
55
+ * It is also ONE snapshot. Three separate reads could straddle a write and
56
+ * hand back an index that names a device the meta map no longer has.
57
+ */
58
+ readAll: () => Promise<{
59
+ readonly index: Record<string, string[]>;
60
+ readonly meta: Record<string, PersistedDeviceMeta>;
61
+ readonly metadata: Record<string, Record<string, unknown>>;
62
+ }>;
23
63
  readIndex: () => Promise<Record<string, string[]>>;
24
64
  readMeta: () => Promise<Record<string, PersistedDeviceMeta>>;
25
65
  /** Hardware-identity metadata map. Lives in a sibling key on the
@@ -1,4 +1,4 @@
1
- import { CollectionColumn, CollectionIndex, DataStoreEngineInfo, HistogramBucket, ISettingsBackend, SettingsCountInput, SettingsDeleteInput, SettingsGetInput, SettingsHistogramInput, SettingsInsertInput, SettingsIsEmptyInput, SettingsQueryInput, SettingsRecord, SettingsSetInput, SettingsUpdateInput, IScopedLogger } from '@camstack/types';
1
+ import { CollectionColumn, CollectionIndex, DataStoreEngineInfo, HistogramBucket, IScopedLogger, ISettingsBackend, SettingsCountInput, SettingsDeleteInput, SettingsGetInput, SettingsHistogramInput, SettingsInsertInput, SettingsIsEmptyInput, SettingsQueryInput, SettingsRecord, SettingsSetInput, SettingsUpdateInput } from '@camstack/types';
2
2
  import { default as Database } from 'better-sqlite3';
3
3
  import { MutationFilterInput } from './filter-compiler.js';
4
4
  /** Input for {@link SqliteSettingsBackend.deleteWhere}. */
@@ -123,7 +123,29 @@ export declare class SqliteSettingsBackend implements ISettingsBackend {
123
123
  setSystem(key: string, value: unknown): void;
124
124
  /** Get all system settings as flat key-value */
125
125
  getAllSystem(): Record<string, unknown>;
126
- /** Get all settings for an addon */
126
+ /**
127
+ * Get all settings for an addon.
128
+ *
129
+ * Selected by the SAME key range {@link setAllAddon} deletes and re-inserts
130
+ * — `prefixWhere("<addonId>.")` — and for the same reason `getAllScoped`
131
+ * uses it: `"<addonId>.<key>"` is the PRIMARY KEY, so the range is a
132
+ * `SEARCH … USING INDEX` while anything else is a full `SCAN`.
133
+ *
134
+ * It used to select on `json_extract(data, '$.addonId')`, which is a second
135
+ * authority for "this addon's rows" and disagreed with the writer in both
136
+ * directions: a row inside the JSON's idea of the addon but outside the key
137
+ * range was READ and never DELETED (a config key that could not be cleared),
138
+ * and `json_extract` was evaluated on EVERY row — so a four-key addon paid
139
+ * for the whole table and one unparseable neighbour aborted the statement,
140
+ * taking out every addon's config read at once.
141
+ *
142
+ * The cost was not theoretical. On the live hub `addon-settings` is 627 KB
143
+ * in 24 rows, 625 KB of it device-manager's three fleet blobs; a V8 profile
144
+ * of a boot (2026-08-19) had hub-main's JS thread 99.9% busy with **64% of
145
+ * it inside this method**, which is what put a ~45 s queue in front of every
146
+ * runner's first store read. Measured on that table: 0.947 ms → ~0.02 ms for
147
+ * a four-key addon, 2.90 ms → 2.13 ms for device-manager's own.
148
+ */
127
149
  getAllAddon(addonId: string): Record<string, unknown>;
128
150
  /** Bulk-set all settings for an addon */
129
151
  setAllAddon(addonId: string, config: Record<string, unknown>): void;
@@ -321,6 +321,27 @@ function compileFilter$1(filter, shape, mode, serialize) {
321
321
  };
322
322
  }
323
323
  //#endregion
324
+ //#region src/builtins/sqlite-storage/prefix-range.ts
325
+ var MAX_ASCII = 127;
326
+ /**
327
+ * @returns the range for `prefix`, or `null` when the caller must fall back to
328
+ * a `LIKE` scan (empty prefix — which is the whole table and has no upper
329
+ * bound — or any byte outside ASCII).
330
+ */
331
+ function prefixRange(prefix) {
332
+ if (prefix.length === 0) return null;
333
+ for (let i = 0; i < prefix.length; i++) {
334
+ const code = prefix.charCodeAt(i);
335
+ if (code === 0 || code > MAX_ASCII) return null;
336
+ }
337
+ const last = prefix.charCodeAt(prefix.length - 1);
338
+ if (last >= MAX_ASCII) return null;
339
+ return {
340
+ lo: prefix,
341
+ hi: prefix.slice(0, -1) + String.fromCharCode(last + 1)
342
+ };
343
+ }
344
+ //#endregion
324
345
  //#region src/builtins/sqlite-storage/query-bounds.ts
325
346
  /**
326
347
  * Defensive row bounds for `data-store-provider.query`.
@@ -442,27 +463,6 @@ function isImposedBound(source) {
442
463
  return source !== "caller";
443
464
  }
444
465
  //#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
466
  //#region src/builtins/sqlite-storage/sqlite-settings-backend.ts
467
467
  function parseRowData(raw) {
468
468
  return require_dist.asJsonObject(require_dist.parseJsonUnknown(raw)) ?? {};
@@ -890,10 +890,33 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
890
890
  const rows = this.getDb().prepare("SELECT id, data FROM \"system-settings\"").all();
891
891
  return Object.fromEntries(rows.map((r) => [r.id, JSON.parse(r.data)]));
892
892
  }
893
- /** Get all settings for an addon */
893
+ /**
894
+ * Get all settings for an addon.
895
+ *
896
+ * Selected by the SAME key range {@link setAllAddon} deletes and re-inserts
897
+ * — `prefixWhere("<addonId>.")` — and for the same reason `getAllScoped`
898
+ * uses it: `"<addonId>.<key>"` is the PRIMARY KEY, so the range is a
899
+ * `SEARCH … USING INDEX` while anything else is a full `SCAN`.
900
+ *
901
+ * It used to select on `json_extract(data, '$.addonId')`, which is a second
902
+ * authority for "this addon's rows" and disagreed with the writer in both
903
+ * directions: a row inside the JSON's idea of the addon but outside the key
904
+ * range was READ and never DELETED (a config key that could not be cleared),
905
+ * and `json_extract` was evaluated on EVERY row — so a four-key addon paid
906
+ * for the whole table and one unparseable neighbour aborted the statement,
907
+ * taking out every addon's config read at once.
908
+ *
909
+ * The cost was not theoretical. On the live hub `addon-settings` is 627 KB
910
+ * in 24 rows, 625 KB of it device-manager's three fleet blobs; a V8 profile
911
+ * of a boot (2026-08-19) had hub-main's JS thread 99.9% busy with **64% of
912
+ * it inside this method**, which is what put a ~45 s queue in front of every
913
+ * runner's first store read. Measured on that table: 0.947 ms → ~0.02 ms for
914
+ * a four-key addon, 2.90 ms → 2.13 ms for device-manager's own.
915
+ */
894
916
  getAllAddon(addonId) {
895
917
  this.requireDeclared("addon-settings");
896
- const rows = this.getDb().prepare("SELECT id, data FROM \"addon-settings\" WHERE json_extract(data, '$.addonId') = ?").all(addonId);
918
+ const where = this.prefixWhere(`${addonId}.`);
919
+ const rows = this.getDb().prepare(`SELECT id, data FROM "addon-settings" WHERE ${where.sql}`).all(...where.params);
897
920
  if (rows.length === 0) return {};
898
921
  const result = {};
899
922
  for (const row of rows) {
@@ -315,6 +315,27 @@ function compileFilter$1(filter, shape, mode, serialize) {
315
315
  };
316
316
  }
317
317
  //#endregion
318
+ //#region src/builtins/sqlite-storage/prefix-range.ts
319
+ var MAX_ASCII = 127;
320
+ /**
321
+ * @returns the range for `prefix`, or `null` when the caller must fall back to
322
+ * a `LIKE` scan (empty prefix — which is the whole table and has no upper
323
+ * bound — or any byte outside ASCII).
324
+ */
325
+ function prefixRange(prefix) {
326
+ if (prefix.length === 0) return null;
327
+ for (let i = 0; i < prefix.length; i++) {
328
+ const code = prefix.charCodeAt(i);
329
+ if (code === 0 || code > MAX_ASCII) return null;
330
+ }
331
+ const last = prefix.charCodeAt(prefix.length - 1);
332
+ if (last >= MAX_ASCII) return null;
333
+ return {
334
+ lo: prefix,
335
+ hi: prefix.slice(0, -1) + String.fromCharCode(last + 1)
336
+ };
337
+ }
338
+ //#endregion
318
339
  //#region src/builtins/sqlite-storage/query-bounds.ts
319
340
  /**
320
341
  * Defensive row bounds for `data-store-provider.query`.
@@ -436,27 +457,6 @@ function isImposedBound(source) {
436
457
  return source !== "caller";
437
458
  }
438
459
  //#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
460
  //#region src/builtins/sqlite-storage/sqlite-settings-backend.ts
461
461
  function parseRowData(raw) {
462
462
  return asJsonObject(parseJsonUnknown(raw)) ?? {};
@@ -884,10 +884,33 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
884
884
  const rows = this.getDb().prepare("SELECT id, data FROM \"system-settings\"").all();
885
885
  return Object.fromEntries(rows.map((r) => [r.id, JSON.parse(r.data)]));
886
886
  }
887
- /** Get all settings for an addon */
887
+ /**
888
+ * Get all settings for an addon.
889
+ *
890
+ * Selected by the SAME key range {@link setAllAddon} deletes and re-inserts
891
+ * — `prefixWhere("<addonId>.")` — and for the same reason `getAllScoped`
892
+ * uses it: `"<addonId>.<key>"` is the PRIMARY KEY, so the range is a
893
+ * `SEARCH … USING INDEX` while anything else is a full `SCAN`.
894
+ *
895
+ * It used to select on `json_extract(data, '$.addonId')`, which is a second
896
+ * authority for "this addon's rows" and disagreed with the writer in both
897
+ * directions: a row inside the JSON's idea of the addon but outside the key
898
+ * range was READ and never DELETED (a config key that could not be cleared),
899
+ * and `json_extract` was evaluated on EVERY row — so a four-key addon paid
900
+ * for the whole table and one unparseable neighbour aborted the statement,
901
+ * taking out every addon's config read at once.
902
+ *
903
+ * The cost was not theoretical. On the live hub `addon-settings` is 627 KB
904
+ * in 24 rows, 625 KB of it device-manager's three fleet blobs; a V8 profile
905
+ * of a boot (2026-08-19) had hub-main's JS thread 99.9% busy with **64% of
906
+ * it inside this method**, which is what put a ~45 s queue in front of every
907
+ * runner's first store read. Measured on that table: 0.947 ms → ~0.02 ms for
908
+ * a four-key addon, 2.90 ms → 2.13 ms for device-manager's own.
909
+ */
888
910
  getAllAddon(addonId) {
889
911
  this.requireDeclared("addon-settings");
890
- const rows = this.getDb().prepare("SELECT id, data FROM \"addon-settings\" WHERE json_extract(data, '$.addonId') = ?").all(addonId);
912
+ const where = this.prefixWhere(`${addonId}.`);
913
+ const rows = this.getDb().prepare(`SELECT id, data FROM "addon-settings" WHERE ${where.sql}`).all(...where.params);
891
914
  if (rows.length === 0) return {};
892
915
  const result = {};
893
916
  for (const row of rows) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/system",
3
- "version": "1.2.106",
3
+ "version": "1.2.107",
4
4
  "description": "Core addon for CamStack — builtins, pipeline, process management, auth, logging, events",
5
5
  "keywords": [
6
6
  "camstack",