@camstack/system 1.2.439 → 1.2.440

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.
Files changed (103) hide show
  1. package/dist/addon-runner.js +743 -79
  2. package/dist/addon-runner.mjs +743 -79
  3. package/dist/addon-utils.js +1 -1
  4. package/dist/addon-utils.mjs +1 -1
  5. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  9. package/dist/builtins/alerts/alerts.addon.js +1 -1
  10. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  11. package/dist/builtins/autotrack/index.js +1 -1
  12. package/dist/builtins/autotrack/index.mjs +1 -1
  13. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2 -2
  14. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
  15. package/dist/builtins/calendars/calendars.addon.js +1 -1
  16. package/dist/builtins/calendars/calendars.addon.mjs +1 -1
  17. package/dist/builtins/camera-grid/index.js +1 -1
  18. package/dist/builtins/camera-grid/index.mjs +1 -1
  19. package/dist/builtins/composer/composer.addon.js +2 -2
  20. package/dist/builtins/composer/composer.addon.mjs +2 -2
  21. package/dist/builtins/console-logging/index.js +1 -1
  22. package/dist/builtins/console-logging/index.mjs +1 -1
  23. package/dist/builtins/core-blocks/block-settings-store.d.ts +1 -1
  24. package/dist/builtins/core-blocks/core-blocks.addon.js +77 -11
  25. package/dist/builtins/core-blocks/core-blocks.addon.mjs +77 -12
  26. package/dist/builtins/core-blocks/index.mjs +1 -1
  27. package/dist/builtins/device-manager/device-manager.addon.js +181 -59
  28. package/dist/builtins/device-manager/device-manager.addon.mjs +180 -60
  29. package/dist/builtins/device-manager/device-removal-hooks.d.ts +0 -11
  30. package/dist/builtins/hub-forwarder/index.js +1 -1
  31. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  32. package/dist/builtins/keychain/index.js +29 -11
  33. package/dist/builtins/keychain/index.mjs +29 -11
  34. package/dist/builtins/keychain/keychain-service.d.ts +8 -0
  35. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  36. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  37. package/dist/builtins/local-auth/local-auth.addon.js +2 -2
  38. package/dist/builtins/local-auth/local-auth.addon.mjs +2 -2
  39. package/dist/builtins/local-network/local-network.addon.js +2 -2
  40. package/dist/builtins/local-network/local-network.addon.mjs +2 -2
  41. package/dist/builtins/loki-logging/index.js +1 -1
  42. package/dist/builtins/loki-logging/index.mjs +1 -1
  43. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  44. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  45. package/dist/builtins/platform-probe/index.js +1 -1
  46. package/dist/builtins/platform-probe/index.mjs +1 -1
  47. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  48. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  49. package/dist/builtins/snapshot/index.js +1 -1
  50. package/dist/builtins/snapshot/index.mjs +1 -1
  51. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  52. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  53. package/dist/builtins/sqlite-storage/index.js +1 -1
  54. package/dist/builtins/sqlite-storage/index.mjs +1 -1
  55. package/dist/builtins/sqlite-storage/sqlite-settings-backend.d.ts +38 -7
  56. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +69 -3
  57. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +69 -3
  58. package/dist/builtins/sqlite-storage/storage-admin-provider.d.ts +31 -0
  59. package/dist/builtins/storage-orchestrator/hub-settings-forward.d.ts +3 -3
  60. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +4 -4
  61. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +4 -4
  62. package/dist/builtins/system-config/system-config.addon.js +2 -2
  63. package/dist/builtins/system-config/system-config.addon.mjs +283 -283
  64. package/dist/builtins/winston-logging/index.js +1 -1
  65. package/dist/builtins/winston-logging/index.mjs +1 -1
  66. package/dist/{composition-sources-BGOSCGWa.mjs → composition-sources-CS0D7imL.mjs} +1 -1
  67. package/dist/{composition-sources-B4jC2FC7.js → composition-sources-cBG8fBTu.js} +1 -1
  68. package/dist/{constants-B92BjObl.mjs → constants-CIzPjw7L.mjs} +1 -1
  69. package/dist/{constants-BRsjQwi-.js → constants-CRI3QzT4.js} +1 -1
  70. package/dist/{device-ca-46BUsWD1.mjs → device-ca-DLKXYseS.mjs} +1 -1
  71. package/dist/{device-ca-DHG9OW2m.js → device-ca-DNHjts4_.js} +1 -1
  72. package/dist/{dist-yI6kF6r6.mjs → dist-9fcsrFU3.mjs} +237 -25
  73. package/dist/{dist-BOWdLjoK.js → dist-BKhlcYz6.js} +254 -24
  74. package/dist/file-host-worker.js +1 -1
  75. package/dist/file-host-worker.mjs +1 -1
  76. package/dist/{hub-mdns-ciao-D7S5AJRU.js → hub-mdns-ciao-3Zc9G9MS.js} +1 -1
  77. package/dist/{hub-mdns-ciao-DGwVc2uh.mjs → hub-mdns-ciao-Dy2YjRqC.mjs} +1 -1
  78. package/dist/index.js +336 -336
  79. package/dist/index.mjs +337 -337
  80. package/dist/kernel/addon-settings-store.d.ts +43 -0
  81. package/dist/kernel/config-manager.d.ts +0 -19
  82. package/dist/kernel/device-settings-store.d.ts +74 -0
  83. package/dist/kernel/legacy-blob-guard.d.ts +58 -0
  84. package/dist/kernel/moleculer/runner-addon-settings.d.ts +34 -0
  85. package/dist/kernel/moleculer/runner-device-settings.d.ts +39 -0
  86. package/dist/kernel/prefix-walk.d.ts +39 -0
  87. package/dist/kernel/sections-store.d.ts +20 -0
  88. package/dist/kernel/settings-door-view.d.ts +2 -2
  89. package/dist/kernel/settings-view-types.d.ts +5 -30
  90. package/dist/kernel/store-settings-view.d.ts +0 -3
  91. package/dist/prefix-range-D_KueXbQ.js +27 -0
  92. package/dist/prefix-range-Dl0znowN.mjs +22 -0
  93. package/dist/prefix-walk-DAOBSyH1.mjs +88 -0
  94. package/dist/prefix-walk-Dv6MLZlM.js +99 -0
  95. package/dist/{restore-archive-DiGiI6Jp.js → restore-archive-49aZwkp8.js} +1 -1
  96. package/dist/{restore-archive-CpdUO-uS.mjs → restore-archive-DgvDecXM.mjs} +82 -82
  97. package/dist/{retired-settings-keys-UsVtLOT0.mjs → retired-settings-keys-Bhwe4ymb.mjs} +2 -2
  98. package/dist/{retired-settings-keys-CyQ7uvta.js → retired-settings-keys-D_VwZiDt.js} +10 -4
  99. package/dist/{wal-maintenance-xsvdT5Go.mjs → wal-maintenance-BZGmF-3U.mjs} +0 -0
  100. package/dist/{wal-maintenance-DEL_jeG0.js → wal-maintenance-C041zn6V.js} +0 -0
  101. package/package.json +5 -2
  102. package/dist/{custom-action-registry-Bf_YnagS.js → custom-action-registry-BKmoaqG0.js} +1 -1
  103. package/dist/{custom-action-registry-BQnbN0DP.mjs → custom-action-registry-CyUuhATc.mjs} +1 -1
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The ONE addon-level settings store (D870): collection `addon-settings`, one
3
+ * row per field, id `<addonId>.<field>`, row data `{ addonId, key, value }` -
4
+ * hub-main's layout, now also the forked runner's.
5
+ *
6
+ * Before D870 a runner kept a blob of its own (`<addonId>:addon-settings`,
7
+ * key `root`) next to the hub-main rows, so the two processes read different
8
+ * tables for the same addon. The rules are the per-device store's
9
+ * ({@link ./device-settings-store.ts}): reads are three-way, a write is ONE
10
+ * `transact`, a merge never reads, errors propagate.
11
+ */
12
+ import type { ISettingsStoreProvider } from "@camstack/types";
13
+ /** The door slice the module needs. */
14
+ export type AddonSettingsDoor = Pick<ISettingsStoreProvider, "query" | "get" | "transact">;
15
+ /** Three-way answer for an addon's whole settings record. */
16
+ export type AddonSettingsRead = {
17
+ readonly kind: "present";
18
+ readonly value: Readonly<Record<string, unknown>>;
19
+ } | {
20
+ readonly kind: "absent";
21
+ } | {
22
+ readonly kind: "unreadable";
23
+ readonly error: string;
24
+ };
25
+ /** Three-way answer for one field. */
26
+ export type AddonSettingKeyRead = {
27
+ readonly kind: "present";
28
+ readonly value: unknown;
29
+ } | {
30
+ readonly kind: "absent";
31
+ } | {
32
+ readonly kind: "unreadable";
33
+ readonly error: string;
34
+ };
35
+ export interface AddonSettingsStore {
36
+ /** Every field of the addon; `absent` when the store answered with no row. */
37
+ read(): Promise<AddonSettingsRead>;
38
+ /** ONE row, never the addon's whole range. */
39
+ readKey(key: string): Promise<AddonSettingKeyRead>;
40
+ /** Set the fields of `patch`; a field whose value is `undefined` is removed. ONE transaction, no read. */
41
+ merge(patch: Readonly<Record<string, unknown>>): Promise<void>;
42
+ }
43
+ export declare function createAddonSettingsStore(door: AddonSettingsDoor, addonId: string): AddonSettingsStore;
@@ -117,25 +117,6 @@ export declare class ConfigManager {
117
117
  /** Write (bulk-replace) config for an addon to addon_settings. */
118
118
  setAddonConfig(addonId: string, config: Record<string, unknown>): void;
119
119
  /**
120
- * Read per-device overrides for a specific addon. Returns a flat record
121
- * of field → value. Missing keys fall back to the addon-global value via
122
- * `getAddonConfig(addonId)` — that composition is the resolver service's
123
- * responsibility, not ConfigManager's.
124
- * SYNC store only — THROWS without one (D675); async: {@link readAddonDeviceStrict}.
125
- */
126
- getAddonDevice(addonId: string, deviceId: string): Record<string, unknown>;
127
- /**
128
- * Write (bulk-replace) per-device overrides for a specific addon.
129
- * Throws if the settings store is not yet wired.
130
- */
131
- setAddonDevice(addonId: string, deviceId: string, values: Record<string, unknown>): void;
132
- /**
133
- * Clear all per-device overrides for a specific addon/device pair.
134
- * Resolution falls back to the addon-global value after this call.
135
- * No-op if the settings store is not yet wired.
136
- */
137
- clearAddonDevice(addonId: string, deviceId: string): void;
138
- /**
139
120
  * The device's persisted runtime-state blob. THROWS before the store is
140
121
  * wired: a store not asked is not an empty row (D677, D675).
141
122
  */
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The ONE per-device settings store (D870): collection `addon-device-settings`,
3
+ * one row per field, id `<addonId>:<deviceId>.<field>`, row data
4
+ * `{ addonId, deviceId, key, value }`.
5
+ *
6
+ * Every process reaches it through this module — hub-main with the settings
7
+ * door, a forked runner with an adapter over its `settingsStore` client — so the
8
+ * two views cannot disagree about where a device's settings live. This is the
9
+ * only module that names the collection (guard: `check-device-settings-standard`).
10
+ *
11
+ * Rules the module holds, each one an incident:
12
+ * - A read is THREE-way. A failed read is `unreadable`, never `{}` (D49): every
13
+ * caller that merges would otherwise persist its patch alone and destroy the
14
+ * rest (the recorder configuration, 2026-07-31).
15
+ * - A write is ONE `transact` (upserts + deletes together). The old
16
+ * `deleteWhere` + N `set` window is where a reader, or a crash, saw a device
17
+ * with half its settings; on 2026-10-17 that window stopped all recordings.
18
+ * - A merge never reads: one row per field means two writers touching
19
+ * different fields cannot overwrite each other, and nothing needs a
20
+ * read-modify-write.
21
+ * - Errors propagate. Nothing here swallows one.
22
+ */
23
+ import type { ISettingsStoreProvider } from "@camstack/types";
24
+ /** The slice of the settings store the module needs; the door or a client adapter. */
25
+ export type DeviceSettingsDoor = Pick<ISettingsStoreProvider, "query" | "transact" | "deleteWhere">;
26
+ /** Three-way answer for one device's settings. */
27
+ export type DeviceSettingsRead = {
28
+ readonly kind: "present";
29
+ readonly value: Readonly<Record<string, unknown>>;
30
+ } | {
31
+ readonly kind: "absent";
32
+ } | {
33
+ readonly kind: "unreadable";
34
+ readonly error: string;
35
+ };
36
+ /** One addon's view of the per-device settings store. */
37
+ export interface DeviceSettingsStore {
38
+ /** The device's fields; `absent` when the store answered with no row. */
39
+ read(deviceId: number): Promise<DeviceSettingsRead>;
40
+ /**
41
+ * An entry for EVERY id asked for (`{}` for a device with no rows — the store
42
+ * answered). THROWS when the store could not be asked or answered partially.
43
+ */
44
+ readBatch(deviceIds: readonly number[]): Promise<ReadonlyMap<number, Record<string, unknown>>>;
45
+ /** Ids this addon holds at least one row for, ascending. Throws on a failed read. */
46
+ listIds(): Promise<readonly number[]>;
47
+ /**
48
+ * Set the fields of `patch`; a field whose value is `undefined` is removed.
49
+ * ONE transaction, no read.
50
+ */
51
+ merge(deviceId: number, patch: Readonly<Record<string, unknown>>): Promise<void>;
52
+ /** Remove every field of this addon for the device (one statement). */
53
+ clear(deviceId: number): Promise<void>;
54
+ }
55
+ export declare function createDeviceSettingsStore(door: DeviceSettingsDoor, addonId: string): DeviceSettingsStore;
56
+ /** The `Record` form of a read: `{}` for a store that answered with no row, a THROW for one that did not. */
57
+ export declare function readDeviceOrThrow(store: Pick<DeviceSettingsStore, "read">, deviceId: number): Promise<Record<string, unknown>>;
58
+ /**
59
+ * Remove a device's rows for EVERY addon, in one statement (D865's central
60
+ * delete). Matched on the device id each row carries, so no addon needs a
61
+ * handler. Returns how many rows went.
62
+ */
63
+ export declare function clearDeviceSettings(door: Pick<ISettingsStoreProvider, "deleteWhere">, deviceId: number): Promise<number>;
64
+ /** One (addon, device) pair that holds at least one settings row. */
65
+ export interface DeviceSettingsScope {
66
+ readonly deviceId: number;
67
+ readonly addonId: string;
68
+ }
69
+ /**
70
+ * Every (addon, device) pair holding rows, across all addons - the admin
71
+ * "deleted devices" listing. A keyset walk by primary key (never one query cut
72
+ * at a row cap): a read that cannot be completed THROWS.
73
+ */
74
+ export declare function listDeviceSettingsScopes(door: Pick<ISettingsStoreProvider, "query">): Promise<readonly DeviceSettingsScope[]>;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The boot guard of the D870 cut-over: an addon never starts on `absent`.
3
+ *
4
+ * Until `scripts/hub/migrate-device-store.ts --copy` has run, a forked addon's
5
+ * settings still sit in its per-addon blobs (`<addonId>:addon-devices`,
6
+ * `<addonId>:addon-settings` / `root`) while the standard rows are empty. New
7
+ * code reads only the standard rows, so it would run on "no settings" - every
8
+ * camera unconfigured, recording off, with nothing in a log. Instead the addon
9
+ * REFUSES to start, and says why.
10
+ *
11
+ * Reads of an unwritten scoped table answer absent and create nothing, so the
12
+ * guard costs two counts and a `get` on a hub that never had blobs, and it goes
13
+ * quiet by itself once `--drop` removed the old tables.
14
+ */
15
+ import type { IScopedLogger } from "@camstack/types";
16
+ /** Is `key` of `addonId` a root key that was retired (and must not be migrated)? */
17
+ export declare function isRetiredRootKey(addonId: string, key: string): boolean;
18
+ /** The client calls the guard uses. */
19
+ export interface LegacyGuardClient {
20
+ readonly query: {
21
+ query(input: {
22
+ namespace?: string;
23
+ collection: string;
24
+ filter?: {
25
+ whereBetween: Record<string, [string, string]>;
26
+ orderBy: {
27
+ field: string;
28
+ direction: "asc" | "desc";
29
+ };
30
+ limit: number;
31
+ };
32
+ }): Promise<unknown>;
33
+ };
34
+ readonly get: {
35
+ query(input: {
36
+ namespace: string;
37
+ collection: string;
38
+ key: string;
39
+ }): Promise<unknown>;
40
+ };
41
+ readonly count: {
42
+ query(input: {
43
+ namespace?: string;
44
+ collection: string;
45
+ filter?: {
46
+ whereBetween: Record<string, [string, string]>;
47
+ };
48
+ }): Promise<unknown>;
49
+ };
50
+ }
51
+ export declare class UnmigratedSettingsError extends Error {
52
+ constructor(addonId: string, what: string);
53
+ }
54
+ /**
55
+ * THROWS when the addon has blob settings and the standard store has none of
56
+ * its own. A store that cannot be asked also throws: it is not "no blobs".
57
+ */
58
+ export declare function assertLegacyBlobsMigrated(client: LegacyGuardClient, addonId: string, logger: IScopedLogger): Promise<void>;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The forked runner's addon-level `ctx.settings` methods (D870): the SAME
3
+ * modules hub-main's view uses ({@link ../addon-settings-store.ts},
4
+ * {@link ../sections-store.ts}) over this runner's settings-store client, so an
5
+ * addon's settings are one set of rows whichever process asks.
6
+ */
7
+ import type { AddonStoreRead, StoredKeyRead } from "@camstack/types";
8
+ import { type AddonSettingsDoor } from "../addon-settings-store.js";
9
+ import type { LegacyGuardClient } from "../legacy-blob-guard.js";
10
+ import { type SectionsDoor } from "../sections-store.js";
11
+ import type { RunnerSettingsClient } from "./runner-device-settings.js";
12
+ /** The client calls the addon-level adapter uses, on top of the device ones. */
13
+ export interface RunnerAddonSettingsClient extends RunnerSettingsClient {
14
+ readonly get: {
15
+ query: AddonSettingsDoor["get"];
16
+ };
17
+ readonly set: {
18
+ mutate: SectionsDoor["set"];
19
+ };
20
+ /** For the boot guard against unmigrated blobs (D870). */
21
+ readonly count: {
22
+ query: LegacyGuardClient["count"]["query"];
23
+ };
24
+ }
25
+ export interface RunnerAddonSettingsView {
26
+ readAddonStore(): Promise<Record<string, unknown>>;
27
+ readAddonStoreResult(): Promise<AddonStoreRead>;
28
+ readAddonStoreKeyResult(key: string): Promise<StoredKeyRead>;
29
+ writeAddonStore(patch: Record<string, unknown>): Promise<void>;
30
+ getSection(section: string): Promise<Record<string, unknown>>;
31
+ setSection(section: string, patch: Record<string, unknown>): Promise<void>;
32
+ }
33
+ /** @param client the hub's `settingsStore` client; `undefined` makes every call THROW. */
34
+ export declare function createRunnerAddonSettings(client: RunnerAddonSettingsClient | undefined, addonId: string, before?: () => Promise<void>): RunnerAddonSettingsView;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The forked runner's `ctx.settings` device methods (D870).
3
+ *
4
+ * A runner does not hold the settings door; it holds a tRPC client of the hub's
5
+ * `settings-store` cap. This file adapts that client to the door slice the
6
+ * per-device store needs and hands back the five view methods — the SAME module
7
+ * hub-main's view uses, so a device's settings are one set of rows whichever
8
+ * process asks. Before D870 the runner kept its own `<addonId>:addon-devices`
9
+ * blob per device; a hub-side write to the rows was invisible to it.
10
+ */
11
+ import { type DeviceSettingsDoor, type DeviceSettingsStore } from "../device-settings-store.js";
12
+ /** The client calls the adapter uses. */
13
+ export interface RunnerSettingsClient {
14
+ readonly query: {
15
+ query: DeviceSettingsDoor["query"];
16
+ };
17
+ readonly transact: {
18
+ mutate: DeviceSettingsDoor["transact"];
19
+ };
20
+ readonly deleteWhere: {
21
+ mutate: DeviceSettingsDoor["deleteWhere"];
22
+ };
23
+ }
24
+ /** The door slice the per-device store needs, over a tRPC client. */
25
+ export declare function doorOverClient(client: RunnerSettingsClient, before?: () => Promise<void>): DeviceSettingsDoor;
26
+ /** The per-device methods of a runner's `ctx.settings`. */
27
+ export interface RunnerDeviceSettingsView {
28
+ readDeviceStore(deviceId: number): Promise<Record<string, unknown>>;
29
+ readDeviceStoreBatch: DeviceSettingsStore["readBatch"];
30
+ listDeviceStoreIds: DeviceSettingsStore["listIds"];
31
+ writeDeviceStore(deviceId: number, patch: Record<string, unknown>): Promise<void>;
32
+ clearDeviceStore(deviceId: number): Promise<void>;
33
+ }
34
+ /**
35
+ * @param client the hub's `settingsStore` client, or `undefined` when the
36
+ * runner has none — every call then THROWS: a store that could not be asked
37
+ * is not an empty one.
38
+ */
39
+ export declare function createRunnerDeviceSettings(client: RunnerSettingsClient | undefined, addonId: string, before?: () => Promise<void>): RunnerDeviceSettingsView;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The keyset walk over one id prefix of a key/value collection, shared by the
3
+ * settings door view and the per-device settings store. Split out of
4
+ * `settings-door-view.ts` so the device store module does not import the view
5
+ * that imports it.
6
+ */
7
+ import type { ISettingsStoreProvider } from "@camstack/types";
8
+ export declare function unwrapStoredValue(parsed: unknown): unknown;
9
+ /**
10
+ * Every row under `prefix`, or a throw — never the first 2 000 of them.
11
+ *
12
+ * **The defect.** This issued ONE `query` with no `limit`, and
13
+ * `queryDeclared` resolves an absent limit to `SETTINGS_QUERY_DEFAULT_ROW_CAP`
14
+ * and cuts the result there. On the live hub that fired 138 times in two hours
15
+ * (`settings-store query truncated by a defensive row cap`, collection
16
+ * `addon-device-settings`, `rowCap: 2000`, `requestedLimit: null`): a
17
+ * ~1 019-device fleet with more than two stored keys per device puts one
18
+ * addon's `<addonId>:` range past the cap. The engine warned; the door did not
19
+ * notice, and handed its caller a short map that is **shape-identical to a
20
+ * complete one**. Every device whose rows sort past the cap reads as "has no
21
+ * stored settings", which is exactly what a device with none looks like.
22
+ *
23
+ * **The fix is a keyset walk, not a bigger limit.** The cursor is the last id
24
+ * of the previous page, INCLUSIVE — so the page boundary re-reads exactly one
25
+ * row (ids are unique, so a full page always advances) and no row can fall
26
+ * between two pages the way an `OFFSET` walk drops rows under a concurrent
27
+ * write (D295 rule 1). Re-reading is free here: the accumulator is keyed by
28
+ * suffix, so the duplicate overwrites itself with its own value.
29
+ *
30
+ * `ORDER BY id` is not decoration — the cursor is only meaningful if the
31
+ * engine returns the range in the order the cursor advances through. It is the
32
+ * primary key, so the ordering is the range scan's own.
33
+ *
34
+ * @throws when the prefix cannot be expressed as a range and the collection is
35
+ * larger than one page, or when the walk exceeds {@link PREFIX_PAGE_BUDGET}.
36
+ * A throw is the point: a caller that cannot be answered completely must not
37
+ * be handed something that looks like a complete answer.
38
+ */
39
+ export declare function loadPrefixed(door: Pick<ISettingsStoreProvider, "query">, collection: string, prefix: string, decode?: (data: unknown) => unknown): Promise<Record<string, unknown>>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Settings SECTIONS (D870): a section is the `system-settings` rows under
3
+ * `<section>.` - one place for every process. A forked runner used to keep a
4
+ * `sections` blob table of its own that nobody else wrote, so `getSection('server')`
5
+ * answered `{}` there and the real values in hub-main.
6
+ *
7
+ * A section a bootstrap file owns (`auth`, `server`, ...) is answered only for
8
+ * the keys the store may answer ({@link storeMayAnswer}); the rest of such a
9
+ * section is env + config.yaml and lives in the hub process alone.
10
+ */
11
+ import type { ISettingsStoreProvider } from "@camstack/types";
12
+ /** The door slice sections need. */
13
+ export type SectionsDoor = Pick<ISettingsStoreProvider, "query" | "get" | "set">;
14
+ export interface SectionsStore {
15
+ /** Stored keys of a section, or a throw when the store cannot be asked. */
16
+ read(section: string): Promise<Record<string, unknown>>;
17
+ /** Set each key of `patch`. Errors propagate; no read, so no lost update. */
18
+ write(section: string, patch: Readonly<Record<string, unknown>>): Promise<void>;
19
+ }
20
+ export declare function createSectionsStore(door: SectionsDoor): SectionsStore;
@@ -13,7 +13,7 @@
13
13
  * forked-runner namespaced tables. Device-manager still lives here.
14
14
  */
15
15
  import type { ISettingsStoreProvider } from "@camstack/types";
16
- import type { SettingsViewBackend } from "./config-manager.js";
16
+ import type { SettingsViewBackend } from "./settings-view-types.js";
17
17
  /**
18
18
  * Every `system-settings` row under `prefix`, keyed by the suffix — or a throw.
19
19
  *
@@ -28,7 +28,7 @@ import type { SettingsViewBackend } from "./config-manager.js";
28
28
  * (D675). These reads are small and rare: a section's handful of keys when a
29
29
  * form opens, and the capability preferences once at boot.
30
30
  */
31
- export declare function loadSystemSettingsPrefix(door: ISettingsStoreProvider, prefix: string, mayRead?: (suffix: string) => boolean): Promise<Record<string, unknown>>;
31
+ export declare function loadSystemSettingsPrefix(door: Pick<ISettingsStoreProvider, "query" | "get">, prefix: string, mayRead?: (suffix: string) => boolean): Promise<Record<string, unknown>>;
32
32
  /**
33
33
  * The `system-settings` section surface a door-backed view delegates to.
34
34
  * `getSection` is ASYNC: it reads the section through the door (D675) — a
@@ -12,12 +12,10 @@ export interface AddonSettingsView {
12
12
  readAddonStoreKeyResult?(key: string): Promise<StoredKeyRead>;
13
13
  writeAddonStore(patch: Record<string, unknown>): Promise<void>;
14
14
  readDeviceStore(deviceId: number): Promise<Record<string, unknown>>;
15
- /** See `IAddonContext['settings'].readDeviceStoreBatch` — optional there and
16
- * here for the same reason: only the door-backed view can answer it in one
17
- * range scan, and a caller must fall back rather than refuse. */
18
- readDeviceStoreBatch?(deviceIds: readonly number[]): Promise<ReadonlyMap<number, Record<string, unknown>>>;
19
- /** See `IAddonContext['settings'].listDeviceStoreIds` — door-backed views only. */
20
- listDeviceStoreIds?(): Promise<readonly number[]>;
15
+ /** See `IAddonContext['settings'].readDeviceStoreBatch`. */
16
+ readDeviceStoreBatch(deviceIds: readonly number[]): Promise<ReadonlyMap<number, Record<string, unknown>>>;
17
+ /** See `IAddonContext['settings'].listDeviceStoreIds`. */
18
+ listDeviceStoreIds(): Promise<readonly number[]>;
21
19
  writeDeviceStore(deviceId: number, patch: Record<string, unknown>): Promise<void>;
22
20
  clearDeviceStore(deviceId: number): Promise<void>;
23
21
  getSection(section: string): Promise<Record<string, unknown>>;
@@ -44,15 +42,8 @@ export interface AddonSettingsView {
44
42
  /**
45
43
  * A CONCRETE backend behind {@link ConfigManager.createSettingsView} — the
46
44
  * sync-`ISettingsStore` one or the async-door one.
47
- *
48
- * `readDeviceStoreBatch` is required here and optional on
49
- * {@link AddonSettingsView} because both of these can answer it, so the view
50
- * the kernel hands an addon never has to decide whether the method exists.
51
- * The optionality on the public interface stays for the implementation that
52
- * genuinely cannot batch: a forked runner's UDS-backed view.
53
45
  */
54
46
  export interface SettingsViewBackend extends AddonSettingsView {
55
- readDeviceStoreBatch(deviceIds: readonly number[]): Promise<ReadonlyMap<number, Record<string, unknown>>>;
56
47
  readAddonStoreKeyResult(key: string): Promise<StoredKeyRead>;
57
48
  }
58
49
  export interface ISettingsStore {
@@ -70,26 +61,10 @@ export interface ISettingsStore {
70
61
  getAllDevice(deviceId: string): Record<string, unknown>;
71
62
  setDevice(deviceId: string, key: string, value: unknown): void;
72
63
  /**
73
- * Multi-level addon settings: per-device overrides for a specific addon.
74
- *
75
- * Resolution chain when the frontend asks for the effective value of a
76
- * field on a given device:
77
- * 1. schema default (from `ConfigUISchema.field.default`)
78
- * 2. `getAllAddon(addonId)` — addon global value
79
- * 3. `getAddonDevice(addonId, devId)` — per-device override (wins)
80
- *
81
- * Only fields declared with `scope: 'device'` in the addon's
82
- * `ConfigUISchema` should ever be persisted here.
83
- */
84
- getAddonDevice(addonId: string, deviceId: string): Record<string, unknown>;
85
- setAddonDevice(addonId: string, deviceId: string, values: Record<string, unknown>): void;
86
- clearAddonDevice(addonId: string, deviceId: string): void;
87
- /**
88
64
  * Per-device runtime state — the whole cap-slice map as ONE row in the
89
65
  * `device-runtime-state` collection.
90
66
  *
91
- * These are separate from `getAddonDevice`/`setAddonDevice` because runtime
92
- * state is not an addon's config: it belongs to the device, it is replaced
67
+ * Runtime state is not an addon's config: it belongs to the device, it is replaced
93
68
  * wholesale on every flush, and it is written orders of magnitude more often.
94
69
  * It USED to share that keyspace under a synthetic `__device-state` addonId,
95
70
  * which cost one row (and one INSERT) per cap slice per flush and left the
@@ -5,9 +5,6 @@ export interface StoreViewHost {
5
5
  getBootstrap(configPath: string): unknown;
6
6
  getAddonConfig(addonId: string): Record<string, unknown>;
7
7
  setAddonConfig(addonId: string, config: Record<string, unknown>): void;
8
- getAddonDevice(addonId: string, deviceId: string): Record<string, unknown>;
9
- setAddonDevice(addonId: string, deviceId: string, values: Record<string, unknown>): void;
10
- clearAddonDevice(addonId: string, deviceId: string): void;
11
8
  getDeviceRuntimeState(deviceId: string): Record<string, unknown>;
12
9
  setDeviceRuntimeState(deviceId: string, blob: Record<string, unknown>): void;
13
10
  clearDeviceRuntimeState(deviceId: string): void;
@@ -0,0 +1,27 @@
1
+ //#region src/builtins/sqlite-storage/prefix-range.ts
2
+ var MAX_ASCII = 127;
3
+ /**
4
+ * @returns the range for `prefix`, or `null` when the caller must fall back to
5
+ * a `LIKE` scan (empty prefix — which is the whole table and has no upper
6
+ * bound — or any byte outside ASCII).
7
+ */
8
+ function prefixRange(prefix) {
9
+ if (prefix.length === 0) return null;
10
+ for (let i = 0; i < prefix.length; i++) {
11
+ const code = prefix.charCodeAt(i);
12
+ if (code === 0 || code > MAX_ASCII) return null;
13
+ }
14
+ const last = prefix.charCodeAt(prefix.length - 1);
15
+ if (last >= MAX_ASCII) return null;
16
+ return {
17
+ lo: prefix,
18
+ hi: prefix.slice(0, -1) + String.fromCharCode(last + 1)
19
+ };
20
+ }
21
+ //#endregion
22
+ Object.defineProperty(exports, "prefixRange", {
23
+ enumerable: true,
24
+ get: function() {
25
+ return prefixRange;
26
+ }
27
+ });
@@ -0,0 +1,22 @@
1
+ //#region src/builtins/sqlite-storage/prefix-range.ts
2
+ var MAX_ASCII = 127;
3
+ /**
4
+ * @returns the range for `prefix`, or `null` when the caller must fall back to
5
+ * a `LIKE` scan (empty prefix — which is the whole table and has no upper
6
+ * bound — or any byte outside ASCII).
7
+ */
8
+ function prefixRange(prefix) {
9
+ if (prefix.length === 0) return null;
10
+ for (let i = 0; i < prefix.length; i++) {
11
+ const code = prefix.charCodeAt(i);
12
+ if (code === 0 || code > MAX_ASCII) return null;
13
+ }
14
+ const last = prefix.charCodeAt(prefix.length - 1);
15
+ if (last >= MAX_ASCII) return null;
16
+ return {
17
+ lo: prefix,
18
+ hi: prefix.slice(0, -1) + String.fromCharCode(last + 1)
19
+ };
20
+ }
21
+ //#endregion
22
+ export { prefixRange as t };
@@ -0,0 +1,88 @@
1
+ import { t as prefixRange } from "./prefix-range-Dl0znowN.mjs";
2
+ //#region src/kernel/prefix-walk.ts
3
+ function isRecord(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+ function unwrapStoredValue(parsed) {
7
+ if (!isRecord(parsed) || !("value" in parsed)) return parsed;
8
+ return parsed.value;
9
+ }
10
+ /**
11
+ * Rows one page of a prefix read asks for.
12
+ *
13
+ * Below `SETTINGS_QUERY_DEFAULT_ROW_CAP` (2 000, `query-bounds.ts`) on purpose:
14
+ * this read now names its own bound, so the engine's defensive cap is never the
15
+ * thing that decides how much of the answer the caller gets.
16
+ */
17
+ var PREFIX_PAGE_ROWS = 1e3;
18
+ /**
19
+ * Pages one prefix read may take before it refuses.
20
+ *
21
+ * 512 pages is 512 000 rows — three orders of magnitude past the largest live
22
+ * scope (`addon-device-settings` for one addon over a ~1 019-device fleet) and
23
+ * still a bound, because a walk with no bound is the defect this file was
24
+ * fixing, not one to reintroduce.
25
+ */
26
+ var PREFIX_PAGE_BUDGET = 512;
27
+ /**
28
+ * Every row under `prefix`, or a throw — never the first 2 000 of them.
29
+ *
30
+ * **The defect.** This issued ONE `query` with no `limit`, and
31
+ * `queryDeclared` resolves an absent limit to `SETTINGS_QUERY_DEFAULT_ROW_CAP`
32
+ * and cuts the result there. On the live hub that fired 138 times in two hours
33
+ * (`settings-store query truncated by a defensive row cap`, collection
34
+ * `addon-device-settings`, `rowCap: 2000`, `requestedLimit: null`): a
35
+ * ~1 019-device fleet with more than two stored keys per device puts one
36
+ * addon's `<addonId>:` range past the cap. The engine warned; the door did not
37
+ * notice, and handed its caller a short map that is **shape-identical to a
38
+ * complete one**. Every device whose rows sort past the cap reads as "has no
39
+ * stored settings", which is exactly what a device with none looks like.
40
+ *
41
+ * **The fix is a keyset walk, not a bigger limit.** The cursor is the last id
42
+ * of the previous page, INCLUSIVE — so the page boundary re-reads exactly one
43
+ * row (ids are unique, so a full page always advances) and no row can fall
44
+ * between two pages the way an `OFFSET` walk drops rows under a concurrent
45
+ * write (D295 rule 1). Re-reading is free here: the accumulator is keyed by
46
+ * suffix, so the duplicate overwrites itself with its own value.
47
+ *
48
+ * `ORDER BY id` is not decoration — the cursor is only meaningful if the
49
+ * engine returns the range in the order the cursor advances through. It is the
50
+ * primary key, so the ordering is the range scan's own.
51
+ *
52
+ * @throws when the prefix cannot be expressed as a range and the collection is
53
+ * larger than one page, or when the walk exceeds {@link PREFIX_PAGE_BUDGET}.
54
+ * A throw is the point: a caller that cannot be answered completely must not
55
+ * be handed something that looks like a complete answer.
56
+ */
57
+ async function loadPrefixed(door, collection, prefix, decode = unwrapStoredValue) {
58
+ const range = prefixRange(prefix);
59
+ const result = {};
60
+ let cursor = range?.lo ?? null;
61
+ for (let page = 0;; page++) {
62
+ if (page >= PREFIX_PAGE_BUDGET) throw new Error(`settings door: reading "${prefix}" from "${collection}" exceeded ${PREFIX_PAGE_BUDGET} pages of ${PREFIX_PAGE_ROWS} rows. Refusing to return the part that was read — a partial scope read is indistinguishable from an empty one.`);
63
+ const rows = await door.query({
64
+ collection,
65
+ filter: {
66
+ ...range !== null && cursor !== null ? { whereBetween: { id: [cursor, range.hi] } } : {},
67
+ orderBy: {
68
+ field: "id",
69
+ direction: "asc"
70
+ },
71
+ limit: PREFIX_PAGE_ROWS
72
+ }
73
+ });
74
+ for (const row of rows) {
75
+ if (range !== null && (row.id < range.lo || row.id >= range.hi)) continue;
76
+ if (!row.id.startsWith(prefix)) continue;
77
+ result[row.id.slice(prefix.length)] = decode(row.data);
78
+ }
79
+ if (rows.length < PREFIX_PAGE_ROWS) return result;
80
+ const last = rows[rows.length - 1];
81
+ if (last === void 0) return result;
82
+ if (range === null) throw new Error(`settings door: prefix "${prefix}" on "${collection}" cannot be expressed as an id range, and the unfiltered read filled a page — the answer would be partial. Use an ASCII, non-empty prefix.`);
83
+ if (last.id === cursor) throw new Error(`settings door: reading "${prefix}" from "${collection}" made no progress at id "${last.id}" — ids under a prefix must be unique.`);
84
+ cursor = last.id;
85
+ }
86
+ }
87
+ //#endregion
88
+ export { unwrapStoredValue as n, loadPrefixed as t };