@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.
- package/dist/builtins/device-manager/device-manager.addon.js +57 -10
- package/dist/builtins/device-manager/device-manager.addon.mjs +57 -10
- package/dist/builtins/device-manager/device-meta-store.d.ts +40 -0
- package/dist/builtins/sqlite-storage/sqlite-settings-backend.d.ts +24 -2
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +46 -23
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +46 -23
- package/package.json +1 -1
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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) {
|