@camstack/system 1.2.57 → 1.2.59

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 (69) hide show
  1. package/dist/addon-runner.js +1 -1
  2. package/dist/addon-runner.mjs +1 -1
  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 +2 -2
  6. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +2 -2
  7. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +2 -2
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +2 -2
  9. package/dist/builtins/alerts/alerts.addon.js +1 -1
  10. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2 -2
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
  13. package/dist/builtins/console-logging/index.js +1 -1
  14. package/dist/builtins/console-logging/index.mjs +1 -1
  15. package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
  16. package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
  17. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  18. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  19. package/dist/builtins/doorbell/binding-mirror.d.ts +65 -0
  20. package/dist/builtins/doorbell/virtual-doorbell.addon.d.ts +56 -11
  21. package/dist/builtins/doorbell/virtual-doorbell.addon.js +248 -26
  22. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +248 -26
  23. package/dist/builtins/hub-forwarder/index.js +1 -1
  24. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  25. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  26. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  27. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  28. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  29. package/dist/builtins/local-network/local-network.addon.js +1 -1
  30. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  31. package/dist/builtins/loki-logging/index.js +1 -1
  32. package/dist/builtins/loki-logging/index.mjs +1 -1
  33. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  34. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  35. package/dist/builtins/platform-probe/index.js +1 -1
  36. package/dist/builtins/platform-probe/index.mjs +1 -1
  37. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  38. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  39. package/dist/builtins/snapshot/index.js +2 -2
  40. package/dist/builtins/snapshot/index.mjs +2 -2
  41. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +2 -2
  42. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +2 -2
  43. package/dist/builtins/sqlite-storage/index.d.ts +3 -0
  44. package/dist/builtins/sqlite-storage/index.js +8 -0
  45. package/dist/builtins/sqlite-storage/index.mjs +2 -2
  46. package/dist/builtins/sqlite-storage/retired-settings-keys.d.ts +94 -0
  47. package/dist/builtins/sqlite-storage/sqlite-pragmas.d.ts +85 -0
  48. package/dist/builtins/sqlite-storage/sqlite-settings.addon.d.ts +1 -0
  49. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +445 -2
  50. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +438 -3
  51. package/dist/builtins/sqlite-storage/wal-maintenance.d.ts +113 -0
  52. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  53. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  54. package/dist/builtins/system-config/system-config.addon.js +1 -1
  55. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  56. package/dist/builtins/winston-logging/index.js +1 -1
  57. package/dist/builtins/winston-logging/index.mjs +1 -1
  58. package/dist/{dist-DhL_zGRT.mjs → dist-BqJJWCS8.mjs} +994 -34
  59. package/dist/{dist-BupxnZOi.js → dist-CV6XTApk.js} +1005 -33
  60. package/dist/index.js +190 -42
  61. package/dist/index.mjs +191 -43
  62. package/dist/kernel/addon-installer.d.ts +55 -7
  63. package/dist/kernel/addon-manifest.d.ts +36 -16
  64. package/dist/kernel/transport/frame-codec.d.ts +43 -2
  65. package/dist/{manifest-python-deps-Ck4-9K9m.mjs → manifest-python-deps-CX5tGRqf.mjs} +95 -10
  66. package/dist/{manifest-python-deps-BqE5j0-O.js → manifest-python-deps-d4acYXVV.js} +95 -10
  67. package/package.json +1 -1
  68. package/dist/{model-download-service-hf0ookyy.js → model-download-service-Cpc6SWzV.js} +1 -1
  69. package/dist/{model-download-service-Cp9f4dk6.mjs → model-download-service-D8B-4ktF.mjs} +1 -1
@@ -3,11 +3,222 @@ 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-BupxnZOi.js");
6
+ const require_dist = require("../../dist-CV6XTApk.js");
7
7
  let node_crypto = require("node:crypto");
8
+ let node_fs = require("node:fs");
8
9
  let node_module = require("node:module");
9
10
  let better_sqlite3 = require("better-sqlite3");
10
11
  better_sqlite3 = require_chunk.__toESM(better_sqlite3);
12
+ //#region src/builtins/sqlite-storage/sqlite-pragmas.ts
13
+ /**
14
+ * Connection tuning for the hub's SQLite database.
15
+ *
16
+ * Every number here is sized against a MEASURED database, not a blog post.
17
+ * On the live hub (2026-08-06), 20 Hz syscall sampling of hub-main put **210
18
+ * of 2,561 samples inside `pread64`** — 161 on `camstack.db` and 49 on
19
+ * `camstack.db-wal`, all 4 KiB page reads, i.e. page-cache misses. That is
20
+ * 8.2% of wall-clock and ~20% during bursts, on the single thread that also
21
+ * serves every tRPC request. fds 79/81 of hub-main were the two files, so the
22
+ * cost is proven in-process rather than inferred.
23
+ *
24
+ * The database was **130 MB** with a **249 MB** WAL high-water mark. Its
25
+ * tenants are not settings: the analytics event/track/media stores and the
26
+ * sqlite-vec index all live in this one file.
27
+ */
28
+ /**
29
+ * Page cache, in KiB (SQLite reads a NEGATIVE `cache_size` as KiB rather than
30
+ * as a page count, which is what makes it independent of `page_size`).
31
+ *
32
+ * -65536 = **64 MiB**. Sized from the 130 MB file: the hot working set is the
33
+ * B-tree interior pages plus the indexes the analytics queries walk, which is
34
+ * a fraction of the total — 64 MiB covers roughly half the file outright, so a
35
+ * repeated query stops re-reading the same 4 KiB pages from disk. Going to the
36
+ * full 130 MB would buy the tail of a cold table scan for twice the resident
37
+ * cost; going smaller leaves the measured miss rate roughly where it is.
38
+ *
39
+ * Why the build default is not enough: better-sqlite3 compiles with
40
+ * `SQLITE_DEFAULT_CACHE_SIZE=-16000`, so the starting point is ~16 MiB — 8x
41
+ * short of the file, not the ~2 MiB stock SQLite default. This is a 4x
42
+ * increase, not a 32x one.
43
+ *
44
+ * Cost: 64 MiB of malloc'd C memory, ONE connection's worth (better-sqlite3
45
+ * holds a single handle and the vector index shares it). It is off-heap, so it
46
+ * does not move V8's heap ceiling — the thing that produced the hub's previous
47
+ * OOM.
48
+ */
49
+ var SQLITE_PAGE_CACHE_KIB = -65536;
50
+ /**
51
+ * Memory-mapped I/O window, in bytes. 268435456 = **256 MiB**.
52
+ *
53
+ * Chosen to exceed the 130 MB database with room for it to roughly double, so
54
+ * the whole main file can be mapped. Pages then arrive as minor faults against
55
+ * the OS page cache instead of `pread64` syscalls — which is precisely the
56
+ * 161-sample half of the measurement. It also means a page evicted from
57
+ * SQLite's own cache is usually still resident in the OS cache, so the two
58
+ * knobs compound rather than duplicate.
59
+ *
60
+ * Two honest caveats:
61
+ * - SQLite **never mmaps the WAL**. The 49 WAL samples are not addressed
62
+ * here; they are addressed by keeping the WAL small (see
63
+ * {@link SQLITE_JOURNAL_SIZE_LIMIT_BYTES} and `wal-maintenance.ts`).
64
+ * - With mmap, an I/O error on a mapped page surfaces as SIGBUS rather than a
65
+ * catchable SQLite error. The pragma is asserted in
66
+ * `__tests__/sqlite-pragmas.spec.ts` by reading it back, so a build that
67
+ * ignores it (`SQLITE_MAX_MMAP_SIZE=0`) fails loudly instead of quietly
68
+ * doing nothing.
69
+ */
70
+ var SQLITE_MMAP_SIZE_BYTES = 268435456;
71
+ /**
72
+ * Upper bound on the WAL file left on disk after a checkpoint resets it, in
73
+ * bytes. 67108864 = **64 MiB**.
74
+ *
75
+ * This is the passive half of the WAL fix and it carries no stall risk at all:
76
+ * it takes no lock and blocks nothing. It only says "when a checkpoint has
77
+ * already succeeded, truncate the file rather than leaving the space
78
+ * allocated". Without it, a WAL that spiked once stays spiked — which is how
79
+ * a 130 MB database ends up with a 249 MB sidecar.
80
+ *
81
+ * 64 MiB is deliberately well above the ~4 MiB `wal_autocheckpoint` threshold
82
+ * (1000 pages), so ordinary operation never touches this ceiling and the file
83
+ * is not being repeatedly truncated and re-grown.
84
+ */
85
+ var SQLITE_JOURNAL_SIZE_LIMIT_BYTES = 67108864;
86
+ //#endregion
87
+ //#region src/builtins/sqlite-storage/retired-settings-keys.ts
88
+ /**
89
+ * Is THIS node the one whose settings store is the cluster's authority?
90
+ *
91
+ * Mirrors `resolveNodeRole` (server/backend `node-role.ts`) — unset or `'hub'`
92
+ * means hub, which is the launcher's own default. It is duplicated rather than
93
+ * imported because `@camstack/system` is a dependency OF the backend, not the
94
+ * other way round; the parse is four lines and the env var is the contract.
95
+ *
96
+ * Anything else answers `false`. Skipping where the purge should have run is a
97
+ * missed cleanup the next hub boot fixes; running where it should not have is a
98
+ * write to a store this node does not own. Only one of those is recoverable.
99
+ */
100
+ function settingsStoreIsAuthoritativeHere(env) {
101
+ const raw = (env["CAMSTACK_ROLE"] ?? "").trim().toLowerCase();
102
+ return raw === "" || raw === "hub";
103
+ }
104
+ /**
105
+ * The dead keys, per row.
106
+ *
107
+ * `detection-pipeline` / `addon-settings` / `root` — every key in this row is
108
+ * dead; the row itself is kept because the addon writes live keys into it
109
+ * (`pipelineTemplates`, `videoPipelineSteps`).
110
+ *
111
+ * - `pipelineSteps` / `pipelineEngine`: the persisted GLOBAL step + engine
112
+ * seed. Removed with `getGlobalSteps` becoming pure — dispatch builds steps
113
+ * from the orchestrator's `agentSettings` plus per-camera overrides, and
114
+ * never consulted the seed. See `addon-pipeline/src/detection-pipeline/provider.ts`.
115
+ * - `engineRuntime` / `engineBackend` / `engineDevice` / `probedBestEngine`:
116
+ * the per-node engine CASCADE, removed with the operator-facing engine
117
+ * election. Each node now runs a device-array of inference pools balanced
118
+ * per session, and the node-default pool derives live from the node's own
119
+ * hardware (`resolveAutoEngine`). See
120
+ * `addon-pipeline/src/detection-pipeline/engine-store-keys.ts`.
121
+ */
122
+ var RETIRED_SETTINGS_KEYS = [{
123
+ namespace: "detection-pipeline",
124
+ collection: "addon-settings",
125
+ row: "root",
126
+ keys: ["pipelineSteps", "pipelineEngine"],
127
+ perNodeKeys: [
128
+ "engineRuntime",
129
+ "engineBackend",
130
+ "engineDevice",
131
+ "probedBestEngine"
132
+ ],
133
+ reason: "global step/engine seed and the per-node engine cascade — both removed; the live per-camera dispatch path never read either"
134
+ }];
135
+ /**
136
+ * Adapt the settings backend to {@link RetiredKeyStore}.
137
+ *
138
+ * `get` answering a non-object (missing row) is a MISS, not a fault: it maps to
139
+ * `null` and the purge skips the row. A backend throw propagates, which is what
140
+ * routes it to the "read failed, row left untouched" branch.
141
+ */
142
+ function retiredKeyStoreOf(backend) {
143
+ return {
144
+ async read(spec) {
145
+ return require_dist.asJsonObject(await backend.get({
146
+ namespace: spec.namespace,
147
+ collection: spec.collection,
148
+ key: spec.row
149
+ }));
150
+ },
151
+ async write(spec, value) {
152
+ await backend.set({
153
+ namespace: spec.namespace,
154
+ collection: spec.collection,
155
+ key: spec.row,
156
+ value
157
+ });
158
+ }
159
+ };
160
+ }
161
+ /** Keys of `blob` that `spec` retires. Pure. */
162
+ function planRetiredKeyPurge(blob, spec) {
163
+ const exact = new Set(spec.keys);
164
+ const found = [];
165
+ for (const key of Object.keys(blob)) {
166
+ if (exact.has(key)) {
167
+ found.push(key);
168
+ continue;
169
+ }
170
+ for (const perNode of spec.perNodeKeys) if (key === perNode || key.startsWith(`${perNode}@`)) {
171
+ found.push(key);
172
+ break;
173
+ }
174
+ }
175
+ return found;
176
+ }
177
+ /**
178
+ * Delete every retired key present in the declared rows — on the hub.
179
+ *
180
+ * Off the authoritative node this is a no-op that touches nothing and says
181
+ * nothing (see the module header). Returns one entry per row that actually
182
+ * changed — empty when the store is already clean, which is the steady state
183
+ * after the first boot that ran it.
184
+ */
185
+ async function purgeRetiredSettingsKeys(store, logger, specs = RETIRED_SETTINGS_KEYS, env = process.env) {
186
+ if (!settingsStoreIsAuthoritativeHere(env)) return [];
187
+ const results = [];
188
+ for (const spec of specs) {
189
+ let blob;
190
+ try {
191
+ blob = await store.read(spec);
192
+ } catch (err) {
193
+ logger.warn("retired settings keys — read failed, row left untouched", { meta: {
194
+ addon: spec.namespace,
195
+ collection: spec.collection,
196
+ row: spec.row,
197
+ error: err instanceof Error ? err.message : String(err)
198
+ } });
199
+ continue;
200
+ }
201
+ if (blob === null) continue;
202
+ const doomed = planRetiredKeyPurge(blob, spec);
203
+ if (doomed.length === 0) continue;
204
+ const doomedSet = new Set(doomed);
205
+ const next = Object.fromEntries(Object.entries(blob).filter(([key]) => !doomedSet.has(key)));
206
+ await store.write(spec, next);
207
+ const result = {
208
+ addon: spec.namespace,
209
+ collection: spec.collection,
210
+ row: spec.row,
211
+ keys: doomed
212
+ };
213
+ results.push(result);
214
+ logger.info(`purged ${doomed.length} retired settings keys`, { meta: {
215
+ ...result,
216
+ reason: spec.reason
217
+ } });
218
+ }
219
+ return results;
220
+ }
221
+ //#endregion
11
222
  //#region src/builtins/sqlite-storage/filter-compiler.ts
12
223
  /** Thrown by `mutate` mode. Distinct type so a caller can map it to a 400. */
13
224
  var UnsafeFilterError = class extends Error {
@@ -115,6 +326,9 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
115
326
  this.db = new better_sqlite3.default(this.dbPath);
116
327
  this.db.pragma("journal_mode = WAL");
117
328
  this.db.pragma("foreign_keys = ON");
329
+ this.db.pragma(`cache_size = ${SQLITE_PAGE_CACHE_KIB}`);
330
+ this.db.pragma(`mmap_size = ${SQLITE_MMAP_SIZE_BYTES}`);
331
+ this.db.pragma(`journal_size_limit = ${SQLITE_JOURNAL_SIZE_LIMIT_BYTES}`);
118
332
  for (const collection of SqliteSettingsBackend.CANONICAL_KV_COLLECTIONS) {
119
333
  await this.ensureTable(collection, { columns: [{
120
334
  name: "id",
@@ -957,6 +1171,209 @@ function rebuild(row) {
957
1171
  return out;
958
1172
  }
959
1173
  //#endregion
1174
+ //#region src/builtins/sqlite-storage/wal-maintenance.ts
1175
+ /**
1176
+ * Size at or above which the WAL is a candidate for a truncating checkpoint,
1177
+ * in bytes. 67108864 = **64 MiB**.
1178
+ *
1179
+ * Matched to `SQLITE_JOURNAL_SIZE_LIMIT_BYTES`: below this, the passive
1180
+ * mechanism (autocheckpoint + `journal_size_limit`) is doing its job and there
1181
+ * is nothing to reclaim. The live hub's WAL had reached 248,955,152 bytes —
1182
+ * roughly 2x the 130 MB database — which is what says the passive path alone
1183
+ * had stopped keeping up.
1184
+ */
1185
+ var WAL_HIGH_WATER_BYTES = 67108864;
1186
+ /**
1187
+ * How long the WAL must have gone UNWRITTEN before a truncating checkpoint is
1188
+ * allowed, in ms.
1189
+ *
1190
+ * This is the stall guard and the reason this file exists. `wal_checkpoint
1191
+ * (TRUNCATE)` takes the writer lock, waits for readers, and copies the whole
1192
+ * WAL back into the database — synchronously, on the thread that serves every
1193
+ * request. An unconditional periodic one on a busy NVR would be the disease
1194
+ * (hub-main already spends 42 s per 2 minutes inside uninterrupted blocks),
1195
+ * not the cure.
1196
+ *
1197
+ * The idle signal is the WAL file's own mtime. It is one `stat`, and unlike a
1198
+ * write counter maintained in this class it covers EVERY writer on the file —
1199
+ * the settings backend, the sqlite-vec index sharing the handle, and the
1200
+ * short-lived second connection system-backup opens. On a genuinely busy NVR
1201
+ * this gate simply never opens, and `journal_size_limit` remains the only
1202
+ * thing bounding the file. That is the intended failure mode: doing nothing is
1203
+ * strictly better than a 249 MB copy in the middle of a live request.
1204
+ */
1205
+ var WAL_IDLE_QUIET_MS = 3e4;
1206
+ /**
1207
+ * Ceiling on how long the checkpoint may wait for a lock, in ms.
1208
+ *
1209
+ * better-sqlite3 defaults `busy_timeout` to 5000. A checkpoint invokes the
1210
+ * busy handler while waiting for readers, so without this a "quiet" moment
1211
+ * that turns out not to be quiet could park the main thread for 5 s. 250 ms
1212
+ * means the checkpoint gives up and we try again on the next tick — the WAL
1213
+ * staying big for another minute costs nothing.
1214
+ */
1215
+ var WAL_CHECKPOINT_BUSY_TIMEOUT_MS = 250;
1216
+ /** How often the gate is evaluated, in ms. Cheap: a `stat` unless it fires. */
1217
+ var WAL_MAINTENANCE_INTERVAL_MS = 6e4;
1218
+ /**
1219
+ * Multiple of the high-water mark past which a persistently un-checkpointable
1220
+ * WAL stops being a debug line and becomes a warning. At 4x (256 MiB) the WAL
1221
+ * is bigger than the database and something is holding a read transaction
1222
+ * open; that is worth seeing in Loki.
1223
+ */
1224
+ var WAL_ALARM_MULTIPLE = 4;
1225
+ function isCheckpointRow(value) {
1226
+ if (typeof value !== "object" || value === null) return false;
1227
+ if (!("busy" in value) || !("log" in value) || !("checkpointed" in value)) return false;
1228
+ return typeof value.busy === "number" && typeof value.log === "number" && typeof value.checkpointed === "number";
1229
+ }
1230
+ /** Narrow `PRAGMA wal_checkpoint` output without a cast; null when unreadable. */
1231
+ function readCheckpointResult(value) {
1232
+ if (isCheckpointRow(value)) return value;
1233
+ if (Array.isArray(value)) {
1234
+ const [first] = value;
1235
+ if (isCheckpointRow(first)) return first;
1236
+ }
1237
+ return null;
1238
+ }
1239
+ /**
1240
+ * Periodic, heavily gated `wal_checkpoint(TRUNCATE)`.
1241
+ *
1242
+ * The interesting behaviour is all refusal. A tick costs one `stat` in the
1243
+ * common case; it escalates to a lock-taking checkpoint only when the WAL is
1244
+ * over the high-water mark AND nothing has written to it for
1245
+ * {@link WAL_IDLE_QUIET_MS}, and even then the wait is bounded to
1246
+ * {@link WAL_CHECKPOINT_BUSY_TIMEOUT_MS}.
1247
+ */
1248
+ var WalMaintenance = class {
1249
+ db;
1250
+ walPath;
1251
+ logger;
1252
+ highWaterBytes;
1253
+ idleQuietMs;
1254
+ intervalMs;
1255
+ busyTimeoutMs;
1256
+ timer = null;
1257
+ constructor(options) {
1258
+ this.db = options.db;
1259
+ this.walPath = `${options.dbPath}-wal`;
1260
+ this.logger = options.logger;
1261
+ this.highWaterBytes = options.highWaterBytes ?? 67108864;
1262
+ this.idleQuietMs = options.idleQuietMs ?? 3e4;
1263
+ this.intervalMs = options.intervalMs ?? 6e4;
1264
+ this.busyTimeoutMs = options.busyTimeoutMs ?? 250;
1265
+ }
1266
+ start() {
1267
+ if (this.timer !== null) return;
1268
+ const timer = setInterval(() => {
1269
+ this.runOnce();
1270
+ }, this.intervalMs);
1271
+ timer.unref?.();
1272
+ this.timer = timer;
1273
+ this.logger.info("WAL maintenance armed", { meta: {
1274
+ intervalMs: this.intervalMs,
1275
+ highWaterBytes: this.highWaterBytes,
1276
+ idleQuietMs: this.idleQuietMs
1277
+ } });
1278
+ }
1279
+ stop() {
1280
+ if (this.timer === null) return;
1281
+ clearInterval(this.timer);
1282
+ this.timer = null;
1283
+ }
1284
+ isRunning() {
1285
+ return this.timer !== null;
1286
+ }
1287
+ /** Evaluate the gate once and act. Never throws. */
1288
+ runOnce() {
1289
+ try {
1290
+ return this.evaluate();
1291
+ } catch (err) {
1292
+ const error = require_dist.errMsg(err);
1293
+ this.logger.warn("WAL maintenance tick failed", { meta: {
1294
+ error,
1295
+ walPath: this.walPath
1296
+ } });
1297
+ return {
1298
+ kind: "failed",
1299
+ error
1300
+ };
1301
+ }
1302
+ }
1303
+ evaluate() {
1304
+ const stat = this.statWal();
1305
+ if (stat === null) return { kind: "no-wal" };
1306
+ if (stat.size < this.highWaterBytes) return {
1307
+ kind: "below-high-water",
1308
+ walBytes: stat.size
1309
+ };
1310
+ const quietForMs = Date.now() - stat.mtimeMs;
1311
+ if (quietForMs < this.idleQuietMs) {
1312
+ const meta = {
1313
+ walBytes: stat.size,
1314
+ quietForMs,
1315
+ idleQuietMs: this.idleQuietMs
1316
+ };
1317
+ if (stat.size >= this.highWaterBytes * WAL_ALARM_MULTIPLE) this.logger.warn("WAL checkpoint deferred: file far over high-water and still being written", { meta });
1318
+ else this.logger.debug("WAL checkpoint deferred: database is not idle", { meta });
1319
+ return {
1320
+ kind: "deferred-not-idle",
1321
+ walBytes: stat.size,
1322
+ quietForMs
1323
+ };
1324
+ }
1325
+ return this.checkpoint(stat.size);
1326
+ }
1327
+ /**
1328
+ * Take the writer lock and fold the WAL back, with the wait bounded.
1329
+ *
1330
+ * The original `busy_timeout` is restored in a `finally`: this connection is
1331
+ * shared with the settings backend and the vector index, and leaving them on
1332
+ * a 250 ms timeout would turn an ordinary contended write into a spurious
1333
+ * SQLITE_BUSY.
1334
+ */
1335
+ checkpoint(beforeBytes) {
1336
+ const previousTimeout = this.db.pragma("busy_timeout", { simple: true });
1337
+ this.db.pragma(`busy_timeout = ${this.busyTimeoutMs}`);
1338
+ let raw;
1339
+ try {
1340
+ raw = this.db.pragma("wal_checkpoint(TRUNCATE)");
1341
+ } finally {
1342
+ if (typeof previousTimeout === "number") this.db.pragma(`busy_timeout = ${previousTimeout}`);
1343
+ }
1344
+ const result = readCheckpointResult(raw);
1345
+ const afterBytes = this.statWal()?.size ?? 0;
1346
+ const outcome = {
1347
+ kind: "checkpointed",
1348
+ beforeBytes,
1349
+ afterBytes,
1350
+ busy: result !== null && result.busy !== 0,
1351
+ walFrames: result?.log ?? -1,
1352
+ checkpointedFrames: result?.checkpointed ?? -1
1353
+ };
1354
+ this.logger.info("WAL truncating checkpoint completed", { meta: {
1355
+ beforeBytes,
1356
+ afterBytes,
1357
+ reclaimedBytes: beforeBytes - afterBytes,
1358
+ busy: outcome.busy,
1359
+ walFrames: outcome.walFrames,
1360
+ checkpointedFrames: outcome.checkpointedFrames
1361
+ } });
1362
+ return outcome;
1363
+ }
1364
+ statWal() {
1365
+ try {
1366
+ const s = (0, node_fs.statSync)(this.walPath);
1367
+ return {
1368
+ size: s.size,
1369
+ mtimeMs: s.mtimeMs
1370
+ };
1371
+ } catch {
1372
+ return null;
1373
+ }
1374
+ }
1375
+ };
1376
+ //#endregion
960
1377
  //#region src/builtins/sqlite-storage/sqlite-settings.addon.ts
961
1378
  /**
962
1379
  * SQLite Settings addon — the relational ENGINE behind the data door.
@@ -977,11 +1394,12 @@ function rebuild(row) {
977
1394
  var SqliteSettingsAddon = class extends require_dist.BaseAddon {
978
1395
  backend = null;
979
1396
  vectorIndex = null;
1397
+ walMaintenance = null;
980
1398
  constructor() {
981
1399
  super({});
982
1400
  }
983
1401
  async onInitialize() {
984
- const addonId = this.ctx.id.replace("addon:", "");
1402
+ const addonId = require_dist.bareAddonId(this.ctx.id);
985
1403
  const path = await import("node:path");
986
1404
  const dbPath = this.ctx.dataDir ? path.join(this.ctx.dataDir, "camstack.db") : path.join("camstack-data", "addons-data", addonId, "camstack.db");
987
1405
  const pathSource = this.ctx.dataDir ? "dataDir" : "fallback (hardcoded)";
@@ -1000,6 +1418,11 @@ var SqliteSettingsAddon = class extends require_dist.BaseAddon {
1000
1418
  this.ctx.logger.info("DB file status", { meta: { dbExists } });
1001
1419
  this.backend = new SqliteSettingsBackend(dbPath, { ...require_dist.RUNTIME_DEFAULTS });
1002
1420
  await this.backend.initialize();
1421
+ try {
1422
+ await purgeRetiredSettingsKeys(retiredKeyStoreOf(this.backend), this.ctx.logger.child("RetiredKeys"));
1423
+ } catch (err) {
1424
+ this.ctx.logger.warn("Retired-key purge failed", { meta: { error: require_dist.errMsg(err) } });
1425
+ }
1003
1426
  const vectorIndex = this.createVectorIndex(this.backend);
1004
1427
  await vectorIndex.loadIndexRegistry();
1005
1428
  this.vectorIndex = vectorIndex;
@@ -1020,6 +1443,16 @@ var SqliteSettingsAddon = class extends require_dist.BaseAddon {
1020
1443
  deleteByFilter: async (input) => ({ deleted: await vectorIndex.deleteByFilter(input.index, input.filter) }),
1021
1444
  stats: async (input) => vectorIndex.stats(input.index)
1022
1445
  };
1446
+ const db = this.backend.getDatabase();
1447
+ if (db !== null) {
1448
+ const maintenance = new WalMaintenance({
1449
+ db,
1450
+ dbPath,
1451
+ logger: this.ctx.logger.child("WalMaintenance")
1452
+ });
1453
+ maintenance.start();
1454
+ this.walMaintenance = maintenance;
1455
+ }
1023
1456
  this.ctx.logger.info("Initialized successfully");
1024
1457
  return [{
1025
1458
  capability: require_dist.dataStoreProviderCapability,
@@ -1030,6 +1463,8 @@ var SqliteSettingsAddon = class extends require_dist.BaseAddon {
1030
1463
  }];
1031
1464
  }
1032
1465
  async onShutdown() {
1466
+ this.walMaintenance?.stop();
1467
+ this.walMaintenance = null;
1033
1468
  await this.backend?.shutdown();
1034
1469
  }
1035
1470
  getBackend() {
@@ -1067,6 +1502,14 @@ var SqliteSettingsAddon = class extends require_dist.BaseAddon {
1067
1502
  }
1068
1503
  };
1069
1504
  //#endregion
1505
+ exports.SQLITE_JOURNAL_SIZE_LIMIT_BYTES = SQLITE_JOURNAL_SIZE_LIMIT_BYTES;
1506
+ exports.SQLITE_MMAP_SIZE_BYTES = SQLITE_MMAP_SIZE_BYTES;
1507
+ exports.SQLITE_PAGE_CACHE_KIB = SQLITE_PAGE_CACHE_KIB;
1070
1508
  exports.SqliteSettingsAddon = SqliteSettingsAddon;
1071
1509
  exports.default = SqliteSettingsAddon;
1072
1510
  exports.SqliteSettingsBackend = SqliteSettingsBackend;
1511
+ exports.WAL_CHECKPOINT_BUSY_TIMEOUT_MS = WAL_CHECKPOINT_BUSY_TIMEOUT_MS;
1512
+ exports.WAL_HIGH_WATER_BYTES = WAL_HIGH_WATER_BYTES;
1513
+ exports.WAL_IDLE_QUIET_MS = WAL_IDLE_QUIET_MS;
1514
+ exports.WAL_MAINTENANCE_INTERVAL_MS = WAL_MAINTENANCE_INTERVAL_MS;
1515
+ exports.WalMaintenance = WalMaintenance;