@camstack/system 1.2.60 → 1.2.62

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 (58) hide show
  1. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  2. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  3. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/alerts/alerts.addon.js +1 -1
  6. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  7. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  8. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  9. package/dist/builtins/console-logging/index.js +1 -1
  10. package/dist/builtins/console-logging/index.mjs +1 -1
  11. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  12. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  13. package/dist/builtins/device-manager/device-aggregation.d.ts +49 -2
  14. package/dist/builtins/device-manager/device-manager.addon.js +60 -5
  15. package/dist/builtins/device-manager/device-manager.addon.mjs +60 -5
  16. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  17. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  18. package/dist/builtins/hub-forwarder/index.js +1 -1
  19. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  20. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  21. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  22. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  23. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  24. package/dist/builtins/local-network/local-network.addon.js +1 -1
  25. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  26. package/dist/builtins/loki-logging/index.js +1 -1
  27. package/dist/builtins/loki-logging/index.mjs +1 -1
  28. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  29. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  30. package/dist/builtins/platform-probe/index.js +1 -1
  31. package/dist/builtins/platform-probe/index.mjs +1 -1
  32. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  33. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  34. package/dist/builtins/snapshot/index.js +1 -1
  35. package/dist/builtins/snapshot/index.mjs +1 -1
  36. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  37. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  38. package/dist/builtins/sqlite-storage/prefix-range.d.ts +50 -0
  39. package/dist/builtins/sqlite-storage/query-bounds.d.ts +116 -0
  40. package/dist/builtins/sqlite-storage/sqlite-pragmas.d.ts +12 -0
  41. package/dist/builtins/sqlite-storage/sqlite-settings-backend.d.ts +38 -2
  42. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +281 -19
  43. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +281 -19
  44. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  45. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  46. package/dist/builtins/system-config/system-config.addon.js +1 -1
  47. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  48. package/dist/builtins/winston-logging/index.js +1 -1
  49. package/dist/builtins/winston-logging/index.mjs +1 -1
  50. package/dist/{dist-Cis_iV-u.js → dist-DgPUarcs.js} +636 -3
  51. package/dist/{dist-C5XpOjbI.mjs → dist-DnKpFaP1.mjs} +636 -3
  52. package/dist/index.d.ts +2 -2
  53. package/dist/index.js +337 -12
  54. package/dist/index.mjs +328 -12
  55. package/dist/kernel/addon-residue.d.ts +49 -0
  56. package/dist/kernel/heap-watch.d.ts +48 -1
  57. package/dist/kernel/index.d.ts +2 -0
  58. package/package.json +1 -1
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Turn a key PREFIX into a half-open range SQLite can answer from the index.
3
+ *
4
+ * **The measurement.** The two canonical KV tables (`addon-settings`,
5
+ * `addon-device-settings`) are keyed `"<scope>.<key>"` and every scoped read
6
+ * and write selects by prefix. That was written as `WHERE id LIKE ? || '%'`,
7
+ * and SQLite cannot use an index for it: `LIKE` is case-insensitive for ASCII
8
+ * by default, so the LIKE optimisation requires an index with `COLLATE
9
+ * NOCASE`, which the implicit PRIMARY KEY index is not. `EXPLAIN QUERY PLAN`
10
+ * says so in one word:
11
+ *
12
+ * LIKE => SCAN addon-device-settings
13
+ * RANGE => SEARCH addon-device-settings USING INDEX … (id>? AND id<?)
14
+ *
15
+ * Every `writeDeviceRuntimeState` therefore scanned the whole table before
16
+ * inserting, on the main thread, synchronously — 15.6% of hub-main's CPU in
17
+ * the 2026-08-07 V8 profile sat in this transaction. Measured on a 2 240-row
18
+ * table (far smaller than the live one, and the gap widens linearly with rows):
19
+ *
20
+ * write (1 DELETE + 14 UPSERT, one txn) 0.253 ms → 0.143 ms 1.8x
21
+ * read (getAllScoped) 0.207 ms → 0.011 ms 19.4x
22
+ *
23
+ * **It is also more CORRECT, which was not the goal.** `LIKE` treats `_` and
24
+ * `%` in the PATTERN as wildcards, and the pattern here is built from an addon
25
+ * id and a device id. `clearAddonDevice('a_b', …)` deleted `axb`'s rows too,
26
+ * and `LIKE`'s ASCII case-folding meant `Recorder:615.` matched
27
+ * `recorder:615.`. A byte range matches exactly the keys that start with the
28
+ * prefix and nothing else.
29
+ *
30
+ * **Why it can refuse.** SQLite's BINARY collation compares UTF-8 BYTES; JS
31
+ * compares UTF-16 code units. The two orders agree for ASCII and can disagree
32
+ * above it. Rather than encode a subtle equivalence nobody will re-derive, a
33
+ * non-ASCII prefix returns `null` and the caller keeps the existing `LIKE` —
34
+ * slower, but identical to what shipped. Every prefix in this codebase is an
35
+ * addon id, a device id and separators, so the fast path is the only one that
36
+ * runs in practice; the fallback exists so a future key shape cannot silently
37
+ * lose rows.
38
+ */
39
+ /** Half-open key range `[lo, hi)` covering exactly the keys starting with a prefix. */
40
+ export interface PrefixRange {
41
+ readonly lo: string;
42
+ /** EXCLUSIVE upper bound: the prefix with its last byte incremented. */
43
+ readonly hi: string;
44
+ }
45
+ /**
46
+ * @returns the range for `prefix`, or `null` when the caller must fall back to
47
+ * a `LIKE` scan (empty prefix — which is the whole table and has no upper
48
+ * bound — or any byte outside ASCII).
49
+ */
50
+ export declare function prefixRange(prefix: string): PrefixRange | null;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Defensive row bounds for `data-store-provider.query`.
3
+ *
4
+ * **Why this file exists.** On 2026-08-07 hub-main died with
5
+ * `FATAL ERROR: Reached heap limit` under `Data::GetValueJS` /
6
+ * `Statement::JS_all`, four seconds after reporting `heapUsed=644MB
7
+ * heapLimit=4288MB`. The heap sampler cannot see an allocation that begins and
8
+ * ends between two samples, so the `[mem]` line said everything was fine right
9
+ * up to the kill. The same stack had already killed the hub twice on 01/08 and
10
+ * twice on 02/08.
11
+ *
12
+ * **It was NOT one 3.4 GB statement, and that correction matters.** The whole
13
+ * database measures 153 MiB (134 MB in a backup taken 14 h earlier, with
14
+ * `auto_vacuum=NONE` and `freelist_count=6` proving it had never been larger)
15
+ * — no single `.all()` on it can produce 3.4 GB. What happened was MANY reads
16
+ * live at once: a viewer resumed from background and requested a 12-hour
17
+ * window over 16 cameras, twice, 33 ms apart. The final Mark-Compact reclaimed
18
+ * 4 MB of 4 090 MB, i.e. every byte was still reachable. `Statement::JS_all` is
19
+ * where the wall was hit, not the size of that one call. The caller-side bound
20
+ * for that specific fan-out lives in `track-store.ts`
21
+ * (`RECENT_FANOUT_ROW_BUDGET`); this file is the backstop under everything
22
+ * else.
23
+ *
24
+ * So: a bound on the query, NOT a bigger `--max-old-space-size`. hub-main's
25
+ * routine p95 heap is already 2 108 MB against a 4 288 MB ceiling; a larger
26
+ * ceiling only moves the number the next unbounded read has to beat.
27
+ *
28
+ * **Why a cap and not a required `limit`.** 64 call sites across the repo
29
+ * query a collection with no `limit`, and most of them are correct — a filter
30
+ * narrow enough that the result set is a handful of rows (`where: { trackId }`,
31
+ * `where: { ownerId }`). Making `limit` mandatory would be 64 edits that each
32
+ * have to guess a number, and the 65th call site added next month would be
33
+ * unbounded again. A cap in the ENGINE holds for every caller, including the
34
+ * ones not written yet.
35
+ *
36
+ * **Why the cap is never silent.** A truncated read that looks like a complete
37
+ * one is worse than the OOM: it turns "the hub died" into "the retention sweep
38
+ * quietly stopped deleting". Every cap hit is logged at WARN naming the
39
+ * collection and the bound, and the caller is expected to page.
40
+ */
41
+ /**
42
+ * Rows returned when the caller supplied no `limit` at all.
43
+ *
44
+ * Sized against the MEASURED hub database (2026-08-08), because a row cap is
45
+ * meaningless without knowing what a row weighs:
46
+ *
47
+ * | table | rows | bytes | avg row |
48
+ * | ---------------------------------- | ------- | ------ | ------- |
49
+ * | `pipeline-analytics:tracks` | 4 668 | 54 MB | 11.5 KB |
50
+ * | `pipeline-analytics:media` | 101 953 | 23 MB | 230 B |
51
+ * | `pipeline-analytics:motion-events` | 148 738 | 12 MB | 78 B |
52
+ * | `pipeline-analytics:audio-events` | 99 608 | 8 MB | 80 B |
53
+ * | `pipeline-analytics:faces` | 372 | 4 MB | 10.8 KB |
54
+ *
55
+ * `tracks` is the pacing table: its `positions` JSON alone is 47 MB of the 54,
56
+ * averaging **10 KB per row**, and it is parsed into JS objects on read. 2 000
57
+ * such rows is ~23 MB of text before parsing — an order of magnitude under
58
+ * hub-main's routine p95 heap (2 108 MB) rather than a multiple of it. The
59
+ * same 2 000 rows of `motion-events` is 160 KB, so narrow tables (auth,
60
+ * alerts, integrations, schedules) never notice the cap at all.
61
+ *
62
+ * An earlier draft sized this at 5 000 on the belief that `faces` was the
63
+ * widest table. It is 372 rows and 4 MB — `pruneCapOverflow` keeps it that
64
+ * way. The number was defensible; the stated evidence for it was wrong, which
65
+ * is worse, because the next person reasons from the evidence.
66
+ *
67
+ * It is deliberately NOT tuned to "the largest legitimate result set" — a
68
+ * caller that genuinely wants more says so with an explicit `limit`, which is
69
+ * honoured up to {@link SETTINGS_QUERY_HARD_ROW_CAP}.
70
+ */
71
+ export declare const SETTINGS_QUERY_DEFAULT_ROW_CAP = 2000;
72
+ /**
73
+ * Absolute ceiling, applied even to an explicit `limit`.
74
+ *
75
+ * The default cap can be opted out of; this one cannot, because the failure it
76
+ * prevents is an OOM and there is no result set worth taking the process down
77
+ * for. A caller needing more than 20 000 rows is describing a stream, not a
78
+ * query, and must page (`limit` + `offset`) — every bulk consumer in the repo
79
+ * already does. At the measured 11.5 KB/row of `tracks`, 20 000 rows is
80
+ * ~230 MB: ruinous to serve, but survivable, which is the right place for a
81
+ * ceiling whose only job is to stop the process dying.
82
+ */
83
+ export declare const SETTINGS_QUERY_HARD_ROW_CAP = 20000;
84
+ /** Where the effective row bound came from. */
85
+ export type RowBoundSource =
86
+ /** The caller's own `limit`, honoured verbatim. Not a truncation. */
87
+ 'caller'
88
+ /** No usable `limit` was given — {@link SETTINGS_QUERY_DEFAULT_ROW_CAP}. */
89
+ | 'default-cap'
90
+ /** The caller asked for more than {@link SETTINGS_QUERY_HARD_ROW_CAP}. */
91
+ | 'hard-cap';
92
+ export interface ResolvedRowBound {
93
+ /** Rows the statement is allowed to return. */
94
+ readonly limit: number;
95
+ readonly source: RowBoundSource;
96
+ }
97
+ /**
98
+ * Resolve the row bound a `query` will run under.
99
+ *
100
+ * Treats a non-finite, negative or non-integer `limit` as ABSENT rather than
101
+ * as an error. That is not leniency for its own sake: SQLite reads a negative
102
+ * `LIMIT` as "no limit at all", so `limit: -1` arriving over the wire — the
103
+ * cap schema is `z.number().optional()`, which accepts it — would have been
104
+ * the one input able to reproduce the OOM through a bound that looked applied.
105
+ *
106
+ * `limit: 0` is a real request for zero rows and is honoured.
107
+ */
108
+ export declare function resolveRowBound(requested: number | undefined): ResolvedRowBound;
109
+ /**
110
+ * Whether a bound being hit is something the CALLER did not ask for, and must
111
+ * therefore be told about.
112
+ *
113
+ * A caller that asked for 500 rows and got 500 has not been truncated — it has
114
+ * been served. Only a cap the engine imposed is news.
115
+ */
116
+ export declare function isImposedBound(source: RowBoundSource): boolean;
@@ -81,5 +81,17 @@ export declare const SQLITE_JOURNAL_SIZE_LIMIT_BYTES = 67108864;
81
81
  * looks like verification. `__tests__/sqlite-pragmas.spec.ts` asserts the
82
82
  * effective value instead, so a dependency bump that changes the default is
83
83
  * caught rather than assumed.
84
+ *
85
+ * **When it takes effect, measured (2026-08-08).** The WAL default is applied
86
+ * when the WAL is actually opened — the first read/write transaction — NOT when
87
+ * `PRAGMA journal_mode = WAL` runs. On a brand-new file the sequence reads:
88
+ *
89
+ * after `journal_mode = WAL`, before any write → 2 (FULL)
90
+ * after the first write → 1 (NORMAL)
91
+ *
92
+ * `initialize()` writes (`ensureTable`, `seedDefaults`) before anything can
93
+ * observe the value, so production is at NORMAL on first boot and every boot
94
+ * after. Recorded because reading the pragma immediately after setting the
95
+ * journal mode reports FULL and looks exactly like a regression in this file.
84
96
  */
85
97
  export declare const SQLITE_SYNCHRONOUS_IS_BUILD_DEFAULT = true;
@@ -1,4 +1,4 @@
1
- import { CollectionColumn, CollectionIndex, DataStoreEngineInfo, HistogramBucket, ISettingsBackend, SettingsCountInput, SettingsDeleteInput, SettingsGetInput, SettingsHistogramInput, SettingsInsertInput, SettingsIsEmptyInput, SettingsQueryInput, SettingsRecord, SettingsSetInput, SettingsUpdateInput } from '@camstack/types';
1
+ import { CollectionColumn, CollectionIndex, DataStoreEngineInfo, HistogramBucket, ISettingsBackend, SettingsCountInput, SettingsDeleteInput, SettingsGetInput, SettingsHistogramInput, SettingsInsertInput, SettingsIsEmptyInput, SettingsQueryInput, SettingsRecord, SettingsSetInput, SettingsUpdateInput, IScopedLogger } 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}. */
@@ -34,6 +34,23 @@ interface SettingsUpdateWhereResult {
34
34
  export declare class SqliteSettingsBackend implements ISettingsBackend {
35
35
  private readonly dbPath;
36
36
  private db;
37
+ /**
38
+ * Table name → fingerprint of the schema this process last materialised.
39
+ *
40
+ * Keyed by FINGERPRINT, not by name, and that is the whole point. An addon
41
+ * declares its schema at every boot, but the backend lives in hub-main,
42
+ * which outlives an addon respawn: `camstack deploy` restarts the addon
43
+ * runner and NOT hub-main. A name-only memo therefore made the second
44
+ * declaration of a table a no-op FOREVER — a redeployed addon whose schema
45
+ * had gained a column never got the `ALTER TABLE … ADD COLUMN`, and, because
46
+ * `compileFilter` silently drops a predicate naming a column the collection
47
+ * cannot express (select mode), the missing column read as "matches
48
+ * everything" instead of failing. That is how `pipeline-analytics:tracks`
49
+ * ran for a whole release with no `retrainStatus`: the retention sweep's
50
+ * `whereIn: { retrainStatus: ['none','trained'] }` pin evaporated, so every
51
+ * operator-staged track was eligible for deletion, and `countStaging`
52
+ * reported the entire history as staged.
53
+ */
37
54
  private readonly structuredTables;
38
55
  /** Map from scoped collection name → set of column names (non-id) that
39
56
  * the structured schema owns. Routes set/get/insert/update/query to
@@ -47,7 +64,15 @@ export declare class SqliteSettingsBackend implements ISettingsBackend {
47
64
  * to the previous legacy path; only the routing is unified.
48
65
  */
49
66
  private static readonly CANONICAL_KV_COLLECTIONS;
50
- constructor(dbPath: string, runtimeDefaults?: Record<string, unknown>);
67
+ /**
68
+ * Optional, because the engine is constructed in places that have no addon
69
+ * context (migrations, one-shot tools, most specs). It is NOT optional in
70
+ * production: `sqlite-settings.addon.ts` passes one, and without it a row cap
71
+ * being hit would be silent — which is the failure mode the cap exists to
72
+ * avoid replacing.
73
+ */
74
+ private readonly logger;
75
+ constructor(dbPath: string, runtimeDefaults?: Record<string, unknown>, logger?: IScopedLogger);
51
76
  initialize(): Promise<void>;
52
77
  private requireDeclared;
53
78
  /**
@@ -122,6 +147,17 @@ export declare class SqliteSettingsBackend implements ISettingsBackend {
122
147
  */
123
148
  getDatabase(): Database.Database | null;
124
149
  private getDb;
150
+ /**
151
+ * `WHERE` fragment selecting every key that starts with `prefix`, plus its
152
+ * bound parameters.
153
+ *
154
+ * The range form is what makes the implicit PRIMARY KEY index usable — see
155
+ * `prefix-range.ts` for the `EXPLAIN QUERY PLAN` output and the measured
156
+ * 1.8x/19.4x. The `LIKE` fallback is the SQL that shipped before, kept
157
+ * verbatim for the prefixes a byte range cannot express, so a key shape this
158
+ * function refuses degrades in speed and never in correctness.
159
+ */
160
+ private prefixWhere;
125
161
  private getAllScoped;
126
162
  private setScopedKey;
127
163
  private scopedName;
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  const require_chunk = require("../../chunk-Cek0wNdY.js");
6
- const require_dist = require("../../dist-Cis_iV-u.js");
6
+ const require_dist = require("../../dist-DgPUarcs.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  let node_fs = require("node:fs");
9
9
  let node_module = require("node:module");
@@ -275,11 +275,191 @@ function compileFilter$1(filter, shape, mode, serialize) {
275
275
  };
276
276
  }
277
277
  //#endregion
278
+ //#region src/builtins/sqlite-storage/query-bounds.ts
279
+ /**
280
+ * Defensive row bounds for `data-store-provider.query`.
281
+ *
282
+ * **Why this file exists.** On 2026-08-07 hub-main died with
283
+ * `FATAL ERROR: Reached heap limit` under `Data::GetValueJS` /
284
+ * `Statement::JS_all`, four seconds after reporting `heapUsed=644MB
285
+ * heapLimit=4288MB`. The heap sampler cannot see an allocation that begins and
286
+ * ends between two samples, so the `[mem]` line said everything was fine right
287
+ * up to the kill. The same stack had already killed the hub twice on 01/08 and
288
+ * twice on 02/08.
289
+ *
290
+ * **It was NOT one 3.4 GB statement, and that correction matters.** The whole
291
+ * database measures 153 MiB (134 MB in a backup taken 14 h earlier, with
292
+ * `auto_vacuum=NONE` and `freelist_count=6` proving it had never been larger)
293
+ * — no single `.all()` on it can produce 3.4 GB. What happened was MANY reads
294
+ * live at once: a viewer resumed from background and requested a 12-hour
295
+ * window over 16 cameras, twice, 33 ms apart. The final Mark-Compact reclaimed
296
+ * 4 MB of 4 090 MB, i.e. every byte was still reachable. `Statement::JS_all` is
297
+ * where the wall was hit, not the size of that one call. The caller-side bound
298
+ * for that specific fan-out lives in `track-store.ts`
299
+ * (`RECENT_FANOUT_ROW_BUDGET`); this file is the backstop under everything
300
+ * else.
301
+ *
302
+ * So: a bound on the query, NOT a bigger `--max-old-space-size`. hub-main's
303
+ * routine p95 heap is already 2 108 MB against a 4 288 MB ceiling; a larger
304
+ * ceiling only moves the number the next unbounded read has to beat.
305
+ *
306
+ * **Why a cap and not a required `limit`.** 64 call sites across the repo
307
+ * query a collection with no `limit`, and most of them are correct — a filter
308
+ * narrow enough that the result set is a handful of rows (`where: { trackId }`,
309
+ * `where: { ownerId }`). Making `limit` mandatory would be 64 edits that each
310
+ * have to guess a number, and the 65th call site added next month would be
311
+ * unbounded again. A cap in the ENGINE holds for every caller, including the
312
+ * ones not written yet.
313
+ *
314
+ * **Why the cap is never silent.** A truncated read that looks like a complete
315
+ * one is worse than the OOM: it turns "the hub died" into "the retention sweep
316
+ * quietly stopped deleting". Every cap hit is logged at WARN naming the
317
+ * collection and the bound, and the caller is expected to page.
318
+ */
319
+ /**
320
+ * Rows returned when the caller supplied no `limit` at all.
321
+ *
322
+ * Sized against the MEASURED hub database (2026-08-08), because a row cap is
323
+ * meaningless without knowing what a row weighs:
324
+ *
325
+ * | table | rows | bytes | avg row |
326
+ * | ---------------------------------- | ------- | ------ | ------- |
327
+ * | `pipeline-analytics:tracks` | 4 668 | 54 MB | 11.5 KB |
328
+ * | `pipeline-analytics:media` | 101 953 | 23 MB | 230 B |
329
+ * | `pipeline-analytics:motion-events` | 148 738 | 12 MB | 78 B |
330
+ * | `pipeline-analytics:audio-events` | 99 608 | 8 MB | 80 B |
331
+ * | `pipeline-analytics:faces` | 372 | 4 MB | 10.8 KB |
332
+ *
333
+ * `tracks` is the pacing table: its `positions` JSON alone is 47 MB of the 54,
334
+ * averaging **10 KB per row**, and it is parsed into JS objects on read. 2 000
335
+ * such rows is ~23 MB of text before parsing — an order of magnitude under
336
+ * hub-main's routine p95 heap (2 108 MB) rather than a multiple of it. The
337
+ * same 2 000 rows of `motion-events` is 160 KB, so narrow tables (auth,
338
+ * alerts, integrations, schedules) never notice the cap at all.
339
+ *
340
+ * An earlier draft sized this at 5 000 on the belief that `faces` was the
341
+ * widest table. It is 372 rows and 4 MB — `pruneCapOverflow` keeps it that
342
+ * way. The number was defensible; the stated evidence for it was wrong, which
343
+ * is worse, because the next person reasons from the evidence.
344
+ *
345
+ * It is deliberately NOT tuned to "the largest legitimate result set" — a
346
+ * caller that genuinely wants more says so with an explicit `limit`, which is
347
+ * honoured up to {@link SETTINGS_QUERY_HARD_ROW_CAP}.
348
+ */
349
+ var SETTINGS_QUERY_DEFAULT_ROW_CAP = 2e3;
350
+ /**
351
+ * Absolute ceiling, applied even to an explicit `limit`.
352
+ *
353
+ * The default cap can be opted out of; this one cannot, because the failure it
354
+ * prevents is an OOM and there is no result set worth taking the process down
355
+ * for. A caller needing more than 20 000 rows is describing a stream, not a
356
+ * query, and must page (`limit` + `offset`) — every bulk consumer in the repo
357
+ * already does. At the measured 11.5 KB/row of `tracks`, 20 000 rows is
358
+ * ~230 MB: ruinous to serve, but survivable, which is the right place for a
359
+ * ceiling whose only job is to stop the process dying.
360
+ */
361
+ var SETTINGS_QUERY_HARD_ROW_CAP = 2e4;
362
+ /**
363
+ * Resolve the row bound a `query` will run under.
364
+ *
365
+ * Treats a non-finite, negative or non-integer `limit` as ABSENT rather than
366
+ * as an error. That is not leniency for its own sake: SQLite reads a negative
367
+ * `LIMIT` as "no limit at all", so `limit: -1` arriving over the wire — the
368
+ * cap schema is `z.number().optional()`, which accepts it — would have been
369
+ * the one input able to reproduce the OOM through a bound that looked applied.
370
+ *
371
+ * `limit: 0` is a real request for zero rows and is honoured.
372
+ */
373
+ function resolveRowBound(requested) {
374
+ if (requested === void 0 || !Number.isFinite(requested) || requested < 0) return {
375
+ limit: SETTINGS_QUERY_DEFAULT_ROW_CAP,
376
+ source: "default-cap"
377
+ };
378
+ const asked = Math.floor(requested);
379
+ if (asked > 2e4) return {
380
+ limit: SETTINGS_QUERY_HARD_ROW_CAP,
381
+ source: "hard-cap"
382
+ };
383
+ return {
384
+ limit: asked,
385
+ source: "caller"
386
+ };
387
+ }
388
+ /**
389
+ * Whether a bound being hit is something the CALLER did not ask for, and must
390
+ * therefore be told about.
391
+ *
392
+ * A caller that asked for 500 rows and got 500 has not been truncated — it has
393
+ * been served. Only a cap the engine imposed is news.
394
+ */
395
+ function isImposedBound(source) {
396
+ return source !== "caller";
397
+ }
398
+ //#endregion
399
+ //#region src/builtins/sqlite-storage/prefix-range.ts
400
+ var MAX_ASCII = 127;
401
+ /**
402
+ * @returns the range for `prefix`, or `null` when the caller must fall back to
403
+ * a `LIKE` scan (empty prefix — which is the whole table and has no upper
404
+ * bound — or any byte outside ASCII).
405
+ */
406
+ function prefixRange(prefix) {
407
+ if (prefix.length === 0) return null;
408
+ for (let i = 0; i < prefix.length; i++) {
409
+ const code = prefix.charCodeAt(i);
410
+ if (code === 0 || code > MAX_ASCII) return null;
411
+ }
412
+ const last = prefix.charCodeAt(prefix.length - 1);
413
+ if (last >= MAX_ASCII) return null;
414
+ return {
415
+ lo: prefix,
416
+ hi: prefix.slice(0, -1) + String.fromCharCode(last + 1)
417
+ };
418
+ }
419
+ //#endregion
278
420
  //#region src/builtins/sqlite-storage/sqlite-settings-backend.ts
279
421
  function parseRowData(raw) {
280
422
  return require_dist.asJsonObject(require_dist.parseJsonUnknown(raw)) ?? {};
281
423
  }
282
424
  /**
425
+ * `tags: { deviceId }` for a log line about a query, when the filter names one.
426
+ *
427
+ * Repo rule: every log line about a device carries the numeric id under that
428
+ * exact key, because every question asked of these logs is per-camera ("why is
429
+ * 617 truncating and 615 not?") and a line without the tag cannot be grouped.
430
+ * Returns an empty object — not a `deviceId: undefined` — so a collection-wide
431
+ * query does not log a tag that reads as "device unknown".
432
+ */
433
+ function deviceTagOf(filter) {
434
+ const raw = filter?.where?.["deviceId"];
435
+ return typeof raw === "number" && Number.isFinite(raw) ? { tags: { deviceId: raw } } : {};
436
+ }
437
+ /**
438
+ * Stable identity of a declared schema — every property that changes the DDL
439
+ * or the set of columns a filter can name.
440
+ *
441
+ * Deliberately covers `indexes` too: an added index is as invisible to a
442
+ * name-only memo as an added column, and `CREATE INDEX IF NOT EXISTS` is
443
+ * cheap enough that re-running it costs nothing.
444
+ */
445
+ function schemaFingerprint(schema) {
446
+ return JSON.stringify({
447
+ columns: schema.columns.map((c) => [
448
+ c.name,
449
+ c.type,
450
+ c.primaryKey ?? false,
451
+ c.notNull ?? false,
452
+ c.unique ?? false,
453
+ c.defaultValue ?? null
454
+ ]),
455
+ indexes: (schema.indexes ?? []).map((i) => [
456
+ i.name,
457
+ [...i.columns],
458
+ i.unique ?? false
459
+ ])
460
+ });
461
+ }
462
+ /**
283
463
  * SQLite implementation of ISettingsBackend.
284
464
  *
285
465
  * Every collection is structured: declared at boot (canonical KV
@@ -293,7 +473,24 @@ function parseRowData(raw) {
293
473
  var SqliteSettingsBackend = class SqliteSettingsBackend {
294
474
  dbPath;
295
475
  db = null;
296
- structuredTables = /* @__PURE__ */ new Set();
476
+ /**
477
+ * Table name → fingerprint of the schema this process last materialised.
478
+ *
479
+ * Keyed by FINGERPRINT, not by name, and that is the whole point. An addon
480
+ * declares its schema at every boot, but the backend lives in hub-main,
481
+ * which outlives an addon respawn: `camstack deploy` restarts the addon
482
+ * runner and NOT hub-main. A name-only memo therefore made the second
483
+ * declaration of a table a no-op FOREVER — a redeployed addon whose schema
484
+ * had gained a column never got the `ALTER TABLE … ADD COLUMN`, and, because
485
+ * `compileFilter` silently drops a predicate naming a column the collection
486
+ * cannot express (select mode), the missing column read as "matches
487
+ * everything" instead of failing. That is how `pipeline-analytics:tracks`
488
+ * ran for a whole release with no `retrainStatus`: the retention sweep's
489
+ * `whereIn: { retrainStatus: ['none','trained'] }` pin evaporated, so every
490
+ * operator-staged track was eligible for deletion, and `countStaging`
491
+ * reported the entire history as staged.
492
+ */
493
+ structuredTables = /* @__PURE__ */ new Map();
297
494
  /** Map from scoped collection name → set of column names (non-id) that
298
495
  * the structured schema owns. Routes set/get/insert/update/query to
299
496
  * typed columns. Every collection MUST be declared here before use. */
@@ -316,9 +513,18 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
316
513
  "device-settings",
317
514
  "alerts"
318
515
  ];
319
- constructor(dbPath, runtimeDefaults) {
516
+ /**
517
+ * Optional, because the engine is constructed in places that have no addon
518
+ * context (migrations, one-shot tools, most specs). It is NOT optional in
519
+ * production: `sqlite-settings.addon.ts` passes one, and without it a row cap
520
+ * being hit would be silent — which is the failure mode the cap exists to
521
+ * avoid replacing.
522
+ */
523
+ logger;
524
+ constructor(dbPath, runtimeDefaults, logger) {
320
525
  this.dbPath = dbPath;
321
526
  this.runtimeDefaults = runtimeDefaults ?? {};
527
+ this.logger = logger;
322
528
  }
323
529
  async initialize() {
324
530
  const dir = this.dbPath.substring(0, this.dbPath.lastIndexOf("/"));
@@ -552,15 +758,47 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
552
758
  sql += ` ORDER BY ${expr} ${dir}`;
553
759
  }
554
760
  }
555
- if (filter?.limit !== void 0) {
556
- sql += ` LIMIT ?`;
557
- params.push(filter.limit);
558
- }
761
+ /**
762
+ * A `LIMIT` is ALWAYS appended — this is the fix for the 2026-08-07 OOM,
763
+ * where the absent one let `Statement.all()` materialise a whole table into
764
+ * V8 in a single call (`query-bounds.ts` has the evidence).
765
+ *
766
+ * One row MORE than the bound is fetched, deliberately. Without the
767
+ * sentinel, "the result set was exactly the cap" and "the result set was
768
+ * truncated at the cap" are the same observation, and the warning below
769
+ * would either cry wolf on every full page or stay silent on real
770
+ * truncation. The extra row is dropped before it reaches the caller.
771
+ */
772
+ const bound = resolveRowBound(filter?.limit);
773
+ sql += ` LIMIT ?`;
774
+ params.push(bound.limit + 1);
559
775
  if (filter?.offset !== void 0) {
560
776
  sql += ` OFFSET ?`;
561
777
  params.push(filter.offset);
562
778
  }
563
- return this.getDb().prepare(sql).all(...params).map((r) => {
779
+ const fetched = this.getDb().prepare(sql).all(...params);
780
+ const truncated = fetched.length > bound.limit;
781
+ const rows = truncated ? fetched.slice(0, bound.limit) : fetched;
782
+ if (truncated && isImposedBound(bound.source))
783
+ /**
784
+ * WARN, not debug: the caller believes it read the whole collection and
785
+ * is about to act on that belief. A retention sweep that sees 5 000 of
786
+ * 40 000 rows deletes 5 000 and reports success.
787
+ */
788
+ this.logger?.warn("settings-store query truncated by a defensive row cap", {
789
+ ...deviceTagOf(filter),
790
+ meta: {
791
+ collection: table,
792
+ rowCap: bound.limit,
793
+ boundSource: bound.source,
794
+ requestedLimit: filter?.limit ?? null,
795
+ hasWhere: filter?.where !== void 0,
796
+ hasWhereIn: filter?.whereIn !== void 0,
797
+ hasWhereBetween: filter?.whereBetween !== void 0,
798
+ remedy: "page with limit + offset, or narrow the filter"
799
+ }
800
+ });
801
+ return rows.map((r) => {
564
802
  const id = String(r[decl.primaryKey] ?? "");
565
803
  if (isKvShape) {
566
804
  const v = r["data"];
@@ -626,10 +864,11 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
626
864
  setAllAddon(addonId, config) {
627
865
  this.requireDeclared("addon-settings");
628
866
  const db = this.getDb();
629
- const deleteStmt = db.prepare("DELETE FROM \"addon-settings\" WHERE id LIKE ? || '%'");
867
+ const where = this.prefixWhere(`${addonId}.`);
868
+ const deleteStmt = db.prepare(`DELETE FROM "addon-settings" WHERE ${where.sql}`);
630
869
  const insertStmt = db.prepare("INSERT INTO \"addon-settings\" (id, data) VALUES (?, ?)");
631
870
  db.transaction(() => {
632
- deleteStmt.run(`${addonId}.`);
871
+ deleteStmt.run(...where.params);
633
872
  for (const [key, value] of Object.entries(config)) {
634
873
  if (value === void 0) continue;
635
874
  insertStmt.run(`${addonId}.${key}`, JSON.stringify({
@@ -663,11 +902,12 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
663
902
  this.requireDeclared("addon-device-settings");
664
903
  const db = this.getDb();
665
904
  const prefix = `${addonId}:${deviceId}.`;
666
- const deleteStmt = db.prepare(`DELETE FROM "addon-device-settings" WHERE id LIKE ? || '%'`);
905
+ const where = this.prefixWhere(prefix);
906
+ const deleteStmt = db.prepare(`DELETE FROM "addon-device-settings" WHERE ${where.sql}`);
667
907
  const insertStmt = db.prepare(`INSERT INTO "addon-device-settings" (id, data) VALUES (?, ?)
668
908
  ON CONFLICT(id) DO UPDATE SET data = excluded.data`);
669
909
  db.transaction(() => {
670
- deleteStmt.run(prefix);
910
+ deleteStmt.run(...where.params);
671
911
  for (const [key, value] of Object.entries(values)) insertStmt.run(`${prefix}${key}`, JSON.stringify({
672
912
  addonId,
673
913
  deviceId,
@@ -678,8 +918,8 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
678
918
  }
679
919
  clearAddonDevice(addonId, deviceId) {
680
920
  this.requireDeclared("addon-device-settings");
681
- const prefix = `${addonId}:${deviceId}.`;
682
- this.getDb().prepare(`DELETE FROM "addon-device-settings" WHERE id LIKE ? || '%'`).run(prefix);
921
+ const where = this.prefixWhere(`${addonId}:${deviceId}.`);
922
+ this.getDb().prepare(`DELETE FROM "addon-device-settings" WHERE ${where.sql}`).run(...where.params);
683
923
  }
684
924
  /** Seed system-settings with runtime defaults (first boot) */
685
925
  async seedDefaults() {
@@ -701,9 +941,31 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
701
941
  if (!this.db) throw new Error("SqliteSettingsBackend not initialized — call initialize() first");
702
942
  return this.db;
703
943
  }
944
+ /**
945
+ * `WHERE` fragment selecting every key that starts with `prefix`, plus its
946
+ * bound parameters.
947
+ *
948
+ * The range form is what makes the implicit PRIMARY KEY index usable — see
949
+ * `prefix-range.ts` for the `EXPLAIN QUERY PLAN` output and the measured
950
+ * 1.8x/19.4x. The `LIKE` fallback is the SQL that shipped before, kept
951
+ * verbatim for the prefixes a byte range cannot express, so a key shape this
952
+ * function refuses degrades in speed and never in correctness.
953
+ */
954
+ prefixWhere(prefix) {
955
+ const range = prefixRange(prefix);
956
+ if (range === null) return {
957
+ sql: `id LIKE ? || '%'`,
958
+ params: [prefix]
959
+ };
960
+ return {
961
+ sql: "id >= ? AND id < ?",
962
+ params: [range.lo, range.hi]
963
+ };
964
+ }
704
965
  getAllScoped(collection, scopeId) {
705
966
  this.requireDeclared(collection);
706
- const rows = this.getDb().prepare(`SELECT id, data FROM "${collection}" WHERE id LIKE ? || '.%'`).all(scopeId);
967
+ const where = this.prefixWhere(`${scopeId}.`);
968
+ const rows = this.getDb().prepare(`SELECT id, data FROM "${collection}" WHERE ${where.sql}`).all(...where.params);
707
969
  const result = {};
708
970
  for (const row of rows) {
709
971
  const key = row.id.slice(scopeId.length + 1);
@@ -738,7 +1000,6 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
738
1000
  }
739
1001
  async declareCollection(input) {
740
1002
  const table = this.scopedName(input.namespace, input.collection);
741
- if (this.declaredCollections.has(table)) return;
742
1003
  const columns = input.columns.some((c) => c.primaryKey === true) ? input.columns : [{
743
1004
  name: "id",
744
1005
  type: "TEXT",
@@ -779,7 +1040,8 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
779
1040
  return v;
780
1041
  }
781
1042
  async ensureTable(table, schema) {
782
- if (this.structuredTables.has(table)) return;
1043
+ const fingerprint = schemaFingerprint(schema);
1044
+ if (this.structuredTables.get(table) === fingerprint) return;
783
1045
  const colDefs = schema.columns.map((col) => {
784
1046
  const parts = [`"${col.name}" ${col.type}`];
785
1047
  if (col.primaryKey) parts.push("PRIMARY KEY");
@@ -811,7 +1073,7 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
811
1073
  const cols = idx.columns.map((c) => `"${c}"`).join(", ");
812
1074
  this.getDb().exec(`CREATE ${unique}INDEX IF NOT EXISTS "${idx.name}" ON "${table}" (${cols})`);
813
1075
  }
814
- this.structuredTables.add(table);
1076
+ this.structuredTables.set(table, fingerprint);
815
1077
  }
816
1078
  async tableInsert(table, row) {
817
1079
  const keys = Object.keys(row);
@@ -1416,7 +1678,7 @@ var SqliteSettingsAddon = class extends require_dist.BaseAddon {
1416
1678
  dbPath
1417
1679
  } });
1418
1680
  this.ctx.logger.info("DB file status", { meta: { dbExists } });
1419
- this.backend = new SqliteSettingsBackend(dbPath, { ...require_dist.RUNTIME_DEFAULTS });
1681
+ this.backend = new SqliteSettingsBackend(dbPath, { ...require_dist.RUNTIME_DEFAULTS }, this.ctx.logger.child("Query"));
1420
1682
  await this.backend.initialize();
1421
1683
  try {
1422
1684
  await purgeRetiredSettingsKeys(retiredKeyStoreOf(this.backend), this.ctx.logger.child("RetiredKeys"));