@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.
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-aggregation.d.ts +49 -2
- package/dist/builtins/device-manager/device-manager.addon.js +60 -5
- package/dist/builtins/device-manager/device-manager.addon.mjs +60 -5
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/prefix-range.d.ts +50 -0
- package/dist/builtins/sqlite-storage/query-bounds.d.ts +116 -0
- package/dist/builtins/sqlite-storage/sqlite-pragmas.d.ts +12 -0
- package/dist/builtins/sqlite-storage/sqlite-settings-backend.d.ts +38 -2
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +281 -19
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +281 -19
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-Cis_iV-u.js → dist-DgPUarcs.js} +636 -3
- package/dist/{dist-C5XpOjbI.mjs → dist-DnKpFaP1.mjs} +636 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +337 -12
- package/dist/index.mjs +328 -12
- package/dist/kernel/addon-residue.d.ts +49 -0
- package/dist/kernel/heap-watch.d.ts +48 -1
- package/dist/kernel/index.d.ts +2 -0
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Ct as asJsonObject, Pt as parseJsonUnknown, T as decodeVectorBase64, dt as vectorStoreCapability, ft as errMsg, l as RUNTIME_DEFAULTS, pt as BaseAddon, ut as vectorDimFromBase64, w as dataStoreProviderCapability, x as bareAddonId } from "../../dist-
|
|
1
|
+
import { Ct as asJsonObject, Pt as parseJsonUnknown, T as decodeVectorBase64, dt as vectorStoreCapability, ft as errMsg, l as RUNTIME_DEFAULTS, pt as BaseAddon, ut as vectorDimFromBase64, w as dataStoreProviderCapability, x as bareAddonId } from "../../dist-DnKpFaP1.mjs";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { statSync } from "node:fs";
|
|
@@ -269,11 +269,191 @@ function compileFilter$1(filter, shape, mode, serialize) {
|
|
|
269
269
|
};
|
|
270
270
|
}
|
|
271
271
|
//#endregion
|
|
272
|
+
//#region src/builtins/sqlite-storage/query-bounds.ts
|
|
273
|
+
/**
|
|
274
|
+
* Defensive row bounds for `data-store-provider.query`.
|
|
275
|
+
*
|
|
276
|
+
* **Why this file exists.** On 2026-08-07 hub-main died with
|
|
277
|
+
* `FATAL ERROR: Reached heap limit` under `Data::GetValueJS` /
|
|
278
|
+
* `Statement::JS_all`, four seconds after reporting `heapUsed=644MB
|
|
279
|
+
* heapLimit=4288MB`. The heap sampler cannot see an allocation that begins and
|
|
280
|
+
* ends between two samples, so the `[mem]` line said everything was fine right
|
|
281
|
+
* up to the kill. The same stack had already killed the hub twice on 01/08 and
|
|
282
|
+
* twice on 02/08.
|
|
283
|
+
*
|
|
284
|
+
* **It was NOT one 3.4 GB statement, and that correction matters.** The whole
|
|
285
|
+
* database measures 153 MiB (134 MB in a backup taken 14 h earlier, with
|
|
286
|
+
* `auto_vacuum=NONE` and `freelist_count=6` proving it had never been larger)
|
|
287
|
+
* — no single `.all()` on it can produce 3.4 GB. What happened was MANY reads
|
|
288
|
+
* live at once: a viewer resumed from background and requested a 12-hour
|
|
289
|
+
* window over 16 cameras, twice, 33 ms apart. The final Mark-Compact reclaimed
|
|
290
|
+
* 4 MB of 4 090 MB, i.e. every byte was still reachable. `Statement::JS_all` is
|
|
291
|
+
* where the wall was hit, not the size of that one call. The caller-side bound
|
|
292
|
+
* for that specific fan-out lives in `track-store.ts`
|
|
293
|
+
* (`RECENT_FANOUT_ROW_BUDGET`); this file is the backstop under everything
|
|
294
|
+
* else.
|
|
295
|
+
*
|
|
296
|
+
* So: a bound on the query, NOT a bigger `--max-old-space-size`. hub-main's
|
|
297
|
+
* routine p95 heap is already 2 108 MB against a 4 288 MB ceiling; a larger
|
|
298
|
+
* ceiling only moves the number the next unbounded read has to beat.
|
|
299
|
+
*
|
|
300
|
+
* **Why a cap and not a required `limit`.** 64 call sites across the repo
|
|
301
|
+
* query a collection with no `limit`, and most of them are correct — a filter
|
|
302
|
+
* narrow enough that the result set is a handful of rows (`where: { trackId }`,
|
|
303
|
+
* `where: { ownerId }`). Making `limit` mandatory would be 64 edits that each
|
|
304
|
+
* have to guess a number, and the 65th call site added next month would be
|
|
305
|
+
* unbounded again. A cap in the ENGINE holds for every caller, including the
|
|
306
|
+
* ones not written yet.
|
|
307
|
+
*
|
|
308
|
+
* **Why the cap is never silent.** A truncated read that looks like a complete
|
|
309
|
+
* one is worse than the OOM: it turns "the hub died" into "the retention sweep
|
|
310
|
+
* quietly stopped deleting". Every cap hit is logged at WARN naming the
|
|
311
|
+
* collection and the bound, and the caller is expected to page.
|
|
312
|
+
*/
|
|
313
|
+
/**
|
|
314
|
+
* Rows returned when the caller supplied no `limit` at all.
|
|
315
|
+
*
|
|
316
|
+
* Sized against the MEASURED hub database (2026-08-08), because a row cap is
|
|
317
|
+
* meaningless without knowing what a row weighs:
|
|
318
|
+
*
|
|
319
|
+
* | table | rows | bytes | avg row |
|
|
320
|
+
* | ---------------------------------- | ------- | ------ | ------- |
|
|
321
|
+
* | `pipeline-analytics:tracks` | 4 668 | 54 MB | 11.5 KB |
|
|
322
|
+
* | `pipeline-analytics:media` | 101 953 | 23 MB | 230 B |
|
|
323
|
+
* | `pipeline-analytics:motion-events` | 148 738 | 12 MB | 78 B |
|
|
324
|
+
* | `pipeline-analytics:audio-events` | 99 608 | 8 MB | 80 B |
|
|
325
|
+
* | `pipeline-analytics:faces` | 372 | 4 MB | 10.8 KB |
|
|
326
|
+
*
|
|
327
|
+
* `tracks` is the pacing table: its `positions` JSON alone is 47 MB of the 54,
|
|
328
|
+
* averaging **10 KB per row**, and it is parsed into JS objects on read. 2 000
|
|
329
|
+
* such rows is ~23 MB of text before parsing — an order of magnitude under
|
|
330
|
+
* hub-main's routine p95 heap (2 108 MB) rather than a multiple of it. The
|
|
331
|
+
* same 2 000 rows of `motion-events` is 160 KB, so narrow tables (auth,
|
|
332
|
+
* alerts, integrations, schedules) never notice the cap at all.
|
|
333
|
+
*
|
|
334
|
+
* An earlier draft sized this at 5 000 on the belief that `faces` was the
|
|
335
|
+
* widest table. It is 372 rows and 4 MB — `pruneCapOverflow` keeps it that
|
|
336
|
+
* way. The number was defensible; the stated evidence for it was wrong, which
|
|
337
|
+
* is worse, because the next person reasons from the evidence.
|
|
338
|
+
*
|
|
339
|
+
* It is deliberately NOT tuned to "the largest legitimate result set" — a
|
|
340
|
+
* caller that genuinely wants more says so with an explicit `limit`, which is
|
|
341
|
+
* honoured up to {@link SETTINGS_QUERY_HARD_ROW_CAP}.
|
|
342
|
+
*/
|
|
343
|
+
var SETTINGS_QUERY_DEFAULT_ROW_CAP = 2e3;
|
|
344
|
+
/**
|
|
345
|
+
* Absolute ceiling, applied even to an explicit `limit`.
|
|
346
|
+
*
|
|
347
|
+
* The default cap can be opted out of; this one cannot, because the failure it
|
|
348
|
+
* prevents is an OOM and there is no result set worth taking the process down
|
|
349
|
+
* for. A caller needing more than 20 000 rows is describing a stream, not a
|
|
350
|
+
* query, and must page (`limit` + `offset`) — every bulk consumer in the repo
|
|
351
|
+
* already does. At the measured 11.5 KB/row of `tracks`, 20 000 rows is
|
|
352
|
+
* ~230 MB: ruinous to serve, but survivable, which is the right place for a
|
|
353
|
+
* ceiling whose only job is to stop the process dying.
|
|
354
|
+
*/
|
|
355
|
+
var SETTINGS_QUERY_HARD_ROW_CAP = 2e4;
|
|
356
|
+
/**
|
|
357
|
+
* Resolve the row bound a `query` will run under.
|
|
358
|
+
*
|
|
359
|
+
* Treats a non-finite, negative or non-integer `limit` as ABSENT rather than
|
|
360
|
+
* as an error. That is not leniency for its own sake: SQLite reads a negative
|
|
361
|
+
* `LIMIT` as "no limit at all", so `limit: -1` arriving over the wire — the
|
|
362
|
+
* cap schema is `z.number().optional()`, which accepts it — would have been
|
|
363
|
+
* the one input able to reproduce the OOM through a bound that looked applied.
|
|
364
|
+
*
|
|
365
|
+
* `limit: 0` is a real request for zero rows and is honoured.
|
|
366
|
+
*/
|
|
367
|
+
function resolveRowBound(requested) {
|
|
368
|
+
if (requested === void 0 || !Number.isFinite(requested) || requested < 0) return {
|
|
369
|
+
limit: SETTINGS_QUERY_DEFAULT_ROW_CAP,
|
|
370
|
+
source: "default-cap"
|
|
371
|
+
};
|
|
372
|
+
const asked = Math.floor(requested);
|
|
373
|
+
if (asked > 2e4) return {
|
|
374
|
+
limit: SETTINGS_QUERY_HARD_ROW_CAP,
|
|
375
|
+
source: "hard-cap"
|
|
376
|
+
};
|
|
377
|
+
return {
|
|
378
|
+
limit: asked,
|
|
379
|
+
source: "caller"
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Whether a bound being hit is something the CALLER did not ask for, and must
|
|
384
|
+
* therefore be told about.
|
|
385
|
+
*
|
|
386
|
+
* A caller that asked for 500 rows and got 500 has not been truncated — it has
|
|
387
|
+
* been served. Only a cap the engine imposed is news.
|
|
388
|
+
*/
|
|
389
|
+
function isImposedBound(source) {
|
|
390
|
+
return source !== "caller";
|
|
391
|
+
}
|
|
392
|
+
//#endregion
|
|
393
|
+
//#region src/builtins/sqlite-storage/prefix-range.ts
|
|
394
|
+
var MAX_ASCII = 127;
|
|
395
|
+
/**
|
|
396
|
+
* @returns the range for `prefix`, or `null` when the caller must fall back to
|
|
397
|
+
* a `LIKE` scan (empty prefix — which is the whole table and has no upper
|
|
398
|
+
* bound — or any byte outside ASCII).
|
|
399
|
+
*/
|
|
400
|
+
function prefixRange(prefix) {
|
|
401
|
+
if (prefix.length === 0) return null;
|
|
402
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
403
|
+
const code = prefix.charCodeAt(i);
|
|
404
|
+
if (code === 0 || code > MAX_ASCII) return null;
|
|
405
|
+
}
|
|
406
|
+
const last = prefix.charCodeAt(prefix.length - 1);
|
|
407
|
+
if (last >= MAX_ASCII) return null;
|
|
408
|
+
return {
|
|
409
|
+
lo: prefix,
|
|
410
|
+
hi: prefix.slice(0, -1) + String.fromCharCode(last + 1)
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
//#endregion
|
|
272
414
|
//#region src/builtins/sqlite-storage/sqlite-settings-backend.ts
|
|
273
415
|
function parseRowData(raw) {
|
|
274
416
|
return asJsonObject(parseJsonUnknown(raw)) ?? {};
|
|
275
417
|
}
|
|
276
418
|
/**
|
|
419
|
+
* `tags: { deviceId }` for a log line about a query, when the filter names one.
|
|
420
|
+
*
|
|
421
|
+
* Repo rule: every log line about a device carries the numeric id under that
|
|
422
|
+
* exact key, because every question asked of these logs is per-camera ("why is
|
|
423
|
+
* 617 truncating and 615 not?") and a line without the tag cannot be grouped.
|
|
424
|
+
* Returns an empty object — not a `deviceId: undefined` — so a collection-wide
|
|
425
|
+
* query does not log a tag that reads as "device unknown".
|
|
426
|
+
*/
|
|
427
|
+
function deviceTagOf(filter) {
|
|
428
|
+
const raw = filter?.where?.["deviceId"];
|
|
429
|
+
return typeof raw === "number" && Number.isFinite(raw) ? { tags: { deviceId: raw } } : {};
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Stable identity of a declared schema — every property that changes the DDL
|
|
433
|
+
* or the set of columns a filter can name.
|
|
434
|
+
*
|
|
435
|
+
* Deliberately covers `indexes` too: an added index is as invisible to a
|
|
436
|
+
* name-only memo as an added column, and `CREATE INDEX IF NOT EXISTS` is
|
|
437
|
+
* cheap enough that re-running it costs nothing.
|
|
438
|
+
*/
|
|
439
|
+
function schemaFingerprint(schema) {
|
|
440
|
+
return JSON.stringify({
|
|
441
|
+
columns: schema.columns.map((c) => [
|
|
442
|
+
c.name,
|
|
443
|
+
c.type,
|
|
444
|
+
c.primaryKey ?? false,
|
|
445
|
+
c.notNull ?? false,
|
|
446
|
+
c.unique ?? false,
|
|
447
|
+
c.defaultValue ?? null
|
|
448
|
+
]),
|
|
449
|
+
indexes: (schema.indexes ?? []).map((i) => [
|
|
450
|
+
i.name,
|
|
451
|
+
[...i.columns],
|
|
452
|
+
i.unique ?? false
|
|
453
|
+
])
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
277
457
|
* SQLite implementation of ISettingsBackend.
|
|
278
458
|
*
|
|
279
459
|
* Every collection is structured: declared at boot (canonical KV
|
|
@@ -287,7 +467,24 @@ function parseRowData(raw) {
|
|
|
287
467
|
var SqliteSettingsBackend = class SqliteSettingsBackend {
|
|
288
468
|
dbPath;
|
|
289
469
|
db = null;
|
|
290
|
-
|
|
470
|
+
/**
|
|
471
|
+
* Table name → fingerprint of the schema this process last materialised.
|
|
472
|
+
*
|
|
473
|
+
* Keyed by FINGERPRINT, not by name, and that is the whole point. An addon
|
|
474
|
+
* declares its schema at every boot, but the backend lives in hub-main,
|
|
475
|
+
* which outlives an addon respawn: `camstack deploy` restarts the addon
|
|
476
|
+
* runner and NOT hub-main. A name-only memo therefore made the second
|
|
477
|
+
* declaration of a table a no-op FOREVER — a redeployed addon whose schema
|
|
478
|
+
* had gained a column never got the `ALTER TABLE … ADD COLUMN`, and, because
|
|
479
|
+
* `compileFilter` silently drops a predicate naming a column the collection
|
|
480
|
+
* cannot express (select mode), the missing column read as "matches
|
|
481
|
+
* everything" instead of failing. That is how `pipeline-analytics:tracks`
|
|
482
|
+
* ran for a whole release with no `retrainStatus`: the retention sweep's
|
|
483
|
+
* `whereIn: { retrainStatus: ['none','trained'] }` pin evaporated, so every
|
|
484
|
+
* operator-staged track was eligible for deletion, and `countStaging`
|
|
485
|
+
* reported the entire history as staged.
|
|
486
|
+
*/
|
|
487
|
+
structuredTables = /* @__PURE__ */ new Map();
|
|
291
488
|
/** Map from scoped collection name → set of column names (non-id) that
|
|
292
489
|
* the structured schema owns. Routes set/get/insert/update/query to
|
|
293
490
|
* typed columns. Every collection MUST be declared here before use. */
|
|
@@ -310,9 +507,18 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
|
|
|
310
507
|
"device-settings",
|
|
311
508
|
"alerts"
|
|
312
509
|
];
|
|
313
|
-
|
|
510
|
+
/**
|
|
511
|
+
* Optional, because the engine is constructed in places that have no addon
|
|
512
|
+
* context (migrations, one-shot tools, most specs). It is NOT optional in
|
|
513
|
+
* production: `sqlite-settings.addon.ts` passes one, and without it a row cap
|
|
514
|
+
* being hit would be silent — which is the failure mode the cap exists to
|
|
515
|
+
* avoid replacing.
|
|
516
|
+
*/
|
|
517
|
+
logger;
|
|
518
|
+
constructor(dbPath, runtimeDefaults, logger) {
|
|
314
519
|
this.dbPath = dbPath;
|
|
315
520
|
this.runtimeDefaults = runtimeDefaults ?? {};
|
|
521
|
+
this.logger = logger;
|
|
316
522
|
}
|
|
317
523
|
async initialize() {
|
|
318
524
|
const dir = this.dbPath.substring(0, this.dbPath.lastIndexOf("/"));
|
|
@@ -546,15 +752,47 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
|
|
|
546
752
|
sql += ` ORDER BY ${expr} ${dir}`;
|
|
547
753
|
}
|
|
548
754
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
755
|
+
/**
|
|
756
|
+
* A `LIMIT` is ALWAYS appended — this is the fix for the 2026-08-07 OOM,
|
|
757
|
+
* where the absent one let `Statement.all()` materialise a whole table into
|
|
758
|
+
* V8 in a single call (`query-bounds.ts` has the evidence).
|
|
759
|
+
*
|
|
760
|
+
* One row MORE than the bound is fetched, deliberately. Without the
|
|
761
|
+
* sentinel, "the result set was exactly the cap" and "the result set was
|
|
762
|
+
* truncated at the cap" are the same observation, and the warning below
|
|
763
|
+
* would either cry wolf on every full page or stay silent on real
|
|
764
|
+
* truncation. The extra row is dropped before it reaches the caller.
|
|
765
|
+
*/
|
|
766
|
+
const bound = resolveRowBound(filter?.limit);
|
|
767
|
+
sql += ` LIMIT ?`;
|
|
768
|
+
params.push(bound.limit + 1);
|
|
553
769
|
if (filter?.offset !== void 0) {
|
|
554
770
|
sql += ` OFFSET ?`;
|
|
555
771
|
params.push(filter.offset);
|
|
556
772
|
}
|
|
557
|
-
|
|
773
|
+
const fetched = this.getDb().prepare(sql).all(...params);
|
|
774
|
+
const truncated = fetched.length > bound.limit;
|
|
775
|
+
const rows = truncated ? fetched.slice(0, bound.limit) : fetched;
|
|
776
|
+
if (truncated && isImposedBound(bound.source))
|
|
777
|
+
/**
|
|
778
|
+
* WARN, not debug: the caller believes it read the whole collection and
|
|
779
|
+
* is about to act on that belief. A retention sweep that sees 5 000 of
|
|
780
|
+
* 40 000 rows deletes 5 000 and reports success.
|
|
781
|
+
*/
|
|
782
|
+
this.logger?.warn("settings-store query truncated by a defensive row cap", {
|
|
783
|
+
...deviceTagOf(filter),
|
|
784
|
+
meta: {
|
|
785
|
+
collection: table,
|
|
786
|
+
rowCap: bound.limit,
|
|
787
|
+
boundSource: bound.source,
|
|
788
|
+
requestedLimit: filter?.limit ?? null,
|
|
789
|
+
hasWhere: filter?.where !== void 0,
|
|
790
|
+
hasWhereIn: filter?.whereIn !== void 0,
|
|
791
|
+
hasWhereBetween: filter?.whereBetween !== void 0,
|
|
792
|
+
remedy: "page with limit + offset, or narrow the filter"
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
return rows.map((r) => {
|
|
558
796
|
const id = String(r[decl.primaryKey] ?? "");
|
|
559
797
|
if (isKvShape) {
|
|
560
798
|
const v = r["data"];
|
|
@@ -620,10 +858,11 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
|
|
|
620
858
|
setAllAddon(addonId, config) {
|
|
621
859
|
this.requireDeclared("addon-settings");
|
|
622
860
|
const db = this.getDb();
|
|
623
|
-
const
|
|
861
|
+
const where = this.prefixWhere(`${addonId}.`);
|
|
862
|
+
const deleteStmt = db.prepare(`DELETE FROM "addon-settings" WHERE ${where.sql}`);
|
|
624
863
|
const insertStmt = db.prepare("INSERT INTO \"addon-settings\" (id, data) VALUES (?, ?)");
|
|
625
864
|
db.transaction(() => {
|
|
626
|
-
deleteStmt.run(
|
|
865
|
+
deleteStmt.run(...where.params);
|
|
627
866
|
for (const [key, value] of Object.entries(config)) {
|
|
628
867
|
if (value === void 0) continue;
|
|
629
868
|
insertStmt.run(`${addonId}.${key}`, JSON.stringify({
|
|
@@ -657,11 +896,12 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
|
|
|
657
896
|
this.requireDeclared("addon-device-settings");
|
|
658
897
|
const db = this.getDb();
|
|
659
898
|
const prefix = `${addonId}:${deviceId}.`;
|
|
660
|
-
const
|
|
899
|
+
const where = this.prefixWhere(prefix);
|
|
900
|
+
const deleteStmt = db.prepare(`DELETE FROM "addon-device-settings" WHERE ${where.sql}`);
|
|
661
901
|
const insertStmt = db.prepare(`INSERT INTO "addon-device-settings" (id, data) VALUES (?, ?)
|
|
662
902
|
ON CONFLICT(id) DO UPDATE SET data = excluded.data`);
|
|
663
903
|
db.transaction(() => {
|
|
664
|
-
deleteStmt.run(
|
|
904
|
+
deleteStmt.run(...where.params);
|
|
665
905
|
for (const [key, value] of Object.entries(values)) insertStmt.run(`${prefix}${key}`, JSON.stringify({
|
|
666
906
|
addonId,
|
|
667
907
|
deviceId,
|
|
@@ -672,8 +912,8 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
|
|
|
672
912
|
}
|
|
673
913
|
clearAddonDevice(addonId, deviceId) {
|
|
674
914
|
this.requireDeclared("addon-device-settings");
|
|
675
|
-
const
|
|
676
|
-
this.getDb().prepare(`DELETE FROM "addon-device-settings" WHERE
|
|
915
|
+
const where = this.prefixWhere(`${addonId}:${deviceId}.`);
|
|
916
|
+
this.getDb().prepare(`DELETE FROM "addon-device-settings" WHERE ${where.sql}`).run(...where.params);
|
|
677
917
|
}
|
|
678
918
|
/** Seed system-settings with runtime defaults (first boot) */
|
|
679
919
|
async seedDefaults() {
|
|
@@ -695,9 +935,31 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
|
|
|
695
935
|
if (!this.db) throw new Error("SqliteSettingsBackend not initialized — call initialize() first");
|
|
696
936
|
return this.db;
|
|
697
937
|
}
|
|
938
|
+
/**
|
|
939
|
+
* `WHERE` fragment selecting every key that starts with `prefix`, plus its
|
|
940
|
+
* bound parameters.
|
|
941
|
+
*
|
|
942
|
+
* The range form is what makes the implicit PRIMARY KEY index usable — see
|
|
943
|
+
* `prefix-range.ts` for the `EXPLAIN QUERY PLAN` output and the measured
|
|
944
|
+
* 1.8x/19.4x. The `LIKE` fallback is the SQL that shipped before, kept
|
|
945
|
+
* verbatim for the prefixes a byte range cannot express, so a key shape this
|
|
946
|
+
* function refuses degrades in speed and never in correctness.
|
|
947
|
+
*/
|
|
948
|
+
prefixWhere(prefix) {
|
|
949
|
+
const range = prefixRange(prefix);
|
|
950
|
+
if (range === null) return {
|
|
951
|
+
sql: `id LIKE ? || '%'`,
|
|
952
|
+
params: [prefix]
|
|
953
|
+
};
|
|
954
|
+
return {
|
|
955
|
+
sql: "id >= ? AND id < ?",
|
|
956
|
+
params: [range.lo, range.hi]
|
|
957
|
+
};
|
|
958
|
+
}
|
|
698
959
|
getAllScoped(collection, scopeId) {
|
|
699
960
|
this.requireDeclared(collection);
|
|
700
|
-
const
|
|
961
|
+
const where = this.prefixWhere(`${scopeId}.`);
|
|
962
|
+
const rows = this.getDb().prepare(`SELECT id, data FROM "${collection}" WHERE ${where.sql}`).all(...where.params);
|
|
701
963
|
const result = {};
|
|
702
964
|
for (const row of rows) {
|
|
703
965
|
const key = row.id.slice(scopeId.length + 1);
|
|
@@ -732,7 +994,6 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
|
|
|
732
994
|
}
|
|
733
995
|
async declareCollection(input) {
|
|
734
996
|
const table = this.scopedName(input.namespace, input.collection);
|
|
735
|
-
if (this.declaredCollections.has(table)) return;
|
|
736
997
|
const columns = input.columns.some((c) => c.primaryKey === true) ? input.columns : [{
|
|
737
998
|
name: "id",
|
|
738
999
|
type: "TEXT",
|
|
@@ -773,7 +1034,8 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
|
|
|
773
1034
|
return v;
|
|
774
1035
|
}
|
|
775
1036
|
async ensureTable(table, schema) {
|
|
776
|
-
|
|
1037
|
+
const fingerprint = schemaFingerprint(schema);
|
|
1038
|
+
if (this.structuredTables.get(table) === fingerprint) return;
|
|
777
1039
|
const colDefs = schema.columns.map((col) => {
|
|
778
1040
|
const parts = [`"${col.name}" ${col.type}`];
|
|
779
1041
|
if (col.primaryKey) parts.push("PRIMARY KEY");
|
|
@@ -805,7 +1067,7 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
|
|
|
805
1067
|
const cols = idx.columns.map((c) => `"${c}"`).join(", ");
|
|
806
1068
|
this.getDb().exec(`CREATE ${unique}INDEX IF NOT EXISTS "${idx.name}" ON "${table}" (${cols})`);
|
|
807
1069
|
}
|
|
808
|
-
this.structuredTables.
|
|
1070
|
+
this.structuredTables.set(table, fingerprint);
|
|
809
1071
|
}
|
|
810
1072
|
async tableInsert(table, row) {
|
|
811
1073
|
const keys = Object.keys(row);
|
|
@@ -1410,7 +1672,7 @@ var SqliteSettingsAddon = class extends BaseAddon {
|
|
|
1410
1672
|
dbPath
|
|
1411
1673
|
} });
|
|
1412
1674
|
this.ctx.logger.info("DB file status", { meta: { dbExists } });
|
|
1413
|
-
this.backend = new SqliteSettingsBackend(dbPath, { ...RUNTIME_DEFAULTS });
|
|
1675
|
+
this.backend = new SqliteSettingsBackend(dbPath, { ...RUNTIME_DEFAULTS }, this.ctx.logger.child("Query"));
|
|
1414
1676
|
await this.backend.initialize();
|
|
1415
1677
|
try {
|
|
1416
1678
|
await purgeRetiredSettingsKeys(retiredKeyStoreOf(this.backend), this.ctx.logger.child("RetiredKeys"));
|
|
@@ -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-
|
|
6
|
+
const require_dist = require("../../dist-DgPUarcs.js");
|
|
7
7
|
let node_path = require("node:path");
|
|
8
8
|
node_path = require_chunk.__toESM(node_path);
|
|
9
9
|
let node_fs_promises = require("node:fs/promises");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Nt as parseJsonObject, f as StorageLocationTypeSchema, it as storageCapability, nt as settingsStoreCapability, pt as BaseAddon } from "../../dist-
|
|
1
|
+
import { Nt as parseJsonObject, f as StorageLocationTypeSchema, it as storageCapability, nt as settingsStoreCapability, pt as BaseAddon } from "../../dist-DnKpFaP1.mjs";
|
|
2
2
|
import * as path$1 from "node:path";
|
|
3
3
|
import * as fs from "node:fs/promises";
|
|
4
4
|
import { buildStorageLocationRegistry } from "@camstack/system";
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-DgPUarcs.js");
|
|
7
7
|
//#region src/builtins/system-config/system-config.addon.ts
|
|
8
8
|
/**
|
|
9
9
|
* Built-in `system-config` addon — Phase 4 of the settings redesign.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { At as hydrateSchema, ft as errMsg, pt as BaseAddon } from "../../dist-
|
|
1
|
+
import { At as hydrateSchema, ft as errMsg, pt as BaseAddon } from "../../dist-DnKpFaP1.mjs";
|
|
2
2
|
//#region src/builtins/system-config/system-config.addon.ts
|
|
3
3
|
/**
|
|
4
4
|
* Built-in `system-config` addon — Phase 4 of the settings redesign.
|
|
@@ -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-
|
|
6
|
+
const require_dist = require("../../dist-DgPUarcs.js");
|
|
7
7
|
const require_formatter = require("../../formatter-DqAKDlvN.js");
|
|
8
8
|
let node_path = require("node:path");
|
|
9
9
|
node_path = require_chunk.__toESM(node_path);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { G as logDestinationCapability, pt as BaseAddon } from "../../dist-
|
|
1
|
+
import { G as logDestinationCapability, pt as BaseAddon } from "../../dist-DnKpFaP1.mjs";
|
|
2
2
|
import { t as formatLogLine } from "../../formatter-B7qW8bPJ.mjs";
|
|
3
3
|
import * as path$1 from "node:path";
|
|
4
4
|
import path from "node:path";
|