@camstack/system 1.2.28 → 1.2.30

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 (60) hide show
  1. package/dist/addon-runner.js +3 -3
  2. package/dist/addon-runner.mjs +2 -2
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/alerts/alerts.addon.d.ts +0 -7
  8. package/dist/builtins/alerts/alerts.addon.js +47 -25
  9. package/dist/builtins/alerts/alerts.addon.mjs +47 -25
  10. package/dist/builtins/alerts/prune-policy.d.ts +44 -0
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  13. package/dist/builtins/console-logging/index.js +1 -1
  14. package/dist/builtins/console-logging/index.mjs +1 -1
  15. package/dist/builtins/device-manager/device-manager.addon.js +1 -1
  16. package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
  17. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  18. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  19. package/dist/builtins/hub-forwarder/index.js +1 -1
  20. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  21. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  22. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  23. package/dist/builtins/local-network/local-network.addon.js +1 -1
  24. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  25. package/dist/builtins/loki-logging/index.js +1 -1
  26. package/dist/builtins/loki-logging/index.mjs +1 -1
  27. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  28. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  29. package/dist/builtins/platform-probe/index.js +1 -1
  30. package/dist/builtins/platform-probe/index.mjs +1 -1
  31. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  32. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  33. package/dist/builtins/snapshot/index.js +1 -1
  34. package/dist/builtins/snapshot/index.mjs +1 -1
  35. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  36. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  37. package/dist/builtins/sqlite-storage/filter-compiler.d.ts +61 -0
  38. package/dist/builtins/sqlite-storage/sqlite-settings-backend.d.ts +50 -1
  39. package/dist/builtins/sqlite-storage/sqlite-settings.addon.d.ts +8 -2
  40. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +136 -64
  41. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +136 -64
  42. package/dist/builtins/storage-orchestrator/data-store-dispatch.d.ts +12 -0
  43. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.d.ts +18 -7
  44. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +80 -11
  45. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +80 -11
  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-BXmFvn3h.mjs → dist-Dr_P0DOB.mjs} +492 -166
  51. package/dist/{dist-BxtIzexq.js → dist-VIwdvws5.js} +497 -165
  52. package/dist/index.d.ts +2 -0
  53. package/dist/index.js +150 -2
  54. package/dist/index.mjs +146 -3
  55. package/dist/kernel/addon-installer.d.ts +38 -0
  56. package/dist/kernel/heap-watch.d.ts +36 -0
  57. package/dist/kernel/transport/uds-event-bus.d.ts +4 -1
  58. package/dist/{manifest-python-deps-KZ00cbfP.js → manifest-python-deps-BqE5j0-O.js} +25 -4
  59. package/dist/{manifest-python-deps-BNOApgK0.mjs → manifest-python-deps-Ck4-9K9m.mjs} +25 -4
  60. package/package.json +7 -3
@@ -0,0 +1,61 @@
1
+ /**
2
+ * One filter compiler for both the read and the bulk-mutation paths.
3
+ *
4
+ * It exists because those two paths must NOT behave the same way, and until
5
+ * now the only compiler lived inline in `query` — where being forgiving is
6
+ * correct. Reusing it verbatim for `deleteWhere` would have shipped two
7
+ * data-loss shapes:
8
+ *
9
+ * - **A dropped predicate.** A field that is not a column of a structured
10
+ * table is skipped, so `{ deviceId: 615, kind: 'motion' }` can compile to
11
+ * `WHERE "deviceId" = ?` — on a SELECT that returns extra rows, on a DELETE
12
+ * it removes rows the caller meant to keep.
13
+ * - **No predicate at all.** An empty filter compiles to an empty WHERE,
14
+ * which on a DELETE is the entire collection.
15
+ *
16
+ * `select` mode keeps the historical behaviour, because every existing caller
17
+ * was written against it. `mutate` mode refuses both, before any SQL exists.
18
+ * Emptying a collection is a legitimate thing to want, and it should be a
19
+ * differently-named operation rather than a filter that happens to be empty.
20
+ */
21
+ /** The part of a declared collection this compiler needs. */
22
+ export interface CollectionShape {
23
+ readonly primaryKey: string;
24
+ readonly columns: ReadonlySet<string>;
25
+ /**
26
+ * The single JSON blob column for a KV-shaped collection (fields inside it
27
+ * are reachable via `json_extract`), or `null` for a structured table whose
28
+ * fields are real columns.
29
+ */
30
+ readonly kvBlobColumn: string | null;
31
+ }
32
+ export type FilterMode = 'select' | 'mutate';
33
+ /** Marshals a JS value to what the driver should bind. */
34
+ export type SerializeValue = (value: unknown) => unknown;
35
+ export interface QueryFilterInput {
36
+ readonly where?: Readonly<Record<string, unknown>>;
37
+ readonly whereIn?: Readonly<Record<string, readonly unknown[]>>;
38
+ readonly whereBetween?: Readonly<Record<string, readonly [unknown, unknown]>>;
39
+ }
40
+ /**
41
+ * The predicate-only filter a bulk mutation accepts — no ordering, no limit.
42
+ * Structurally the mutation half of {@link QueryFilterInput}.
43
+ */
44
+ export type MutationFilterInput = QueryFilterInput;
45
+ export interface CompiledFilter {
46
+ /** Either `''` or a leading-space ` WHERE …` clause, ready to concatenate. */
47
+ readonly whereSql: string;
48
+ readonly params: readonly unknown[];
49
+ }
50
+ /** Thrown by `mutate` mode. Distinct type so a caller can map it to a 400. */
51
+ export declare class UnsafeFilterError extends Error {
52
+ constructor(message: string);
53
+ }
54
+ /**
55
+ * SQL expression for `field` on this collection, or `null` when it cannot be
56
+ * expressed: a real column → `"field"`; a KV blob field → `json_extract`;
57
+ * anything else on a structured table → unresolvable. Exported because
58
+ * `histogram` needs the same resolution for its bucketed column.
59
+ */
60
+ export declare function fieldExprFor(field: string, shape: CollectionShape): string | null;
61
+ export declare function compileFilter(filter: QueryFilterInput | undefined, shape: CollectionShape, mode: FilterMode, serialize: SerializeValue): CompiledFilter;
@@ -1,5 +1,25 @@
1
1
  import { default as Database } from 'better-sqlite3';
2
- import { ISettingsBackend, SettingsRecord, TableSchema, TableQueryOptions, SettingsGetInput, SettingsSetInput, SettingsQueryInput, SettingsInsertInput, SettingsUpdateInput, SettingsDeleteInput, SettingsCountInput, SettingsIsEmptyInput, SettingsHistogramInput, HistogramBucket, CollectionColumn, CollectionIndex } from '@camstack/types';
2
+ import { DataStoreEngineInfo, ISettingsBackend, SettingsRecord, TableSchema, TableQueryOptions, SettingsGetInput, SettingsSetInput, SettingsQueryInput, SettingsInsertInput, SettingsUpdateInput, SettingsDeleteInput, SettingsCountInput, SettingsIsEmptyInput, SettingsHistogramInput, HistogramBucket, CollectionColumn, CollectionIndex } from '@camstack/types';
3
+ import { MutationFilterInput } from './filter-compiler.js';
4
+ /** Input for {@link SqliteSettingsBackend.deleteWhere}. */
5
+ interface SettingsDeleteWhereInput {
6
+ readonly namespace?: string;
7
+ readonly collection: string;
8
+ readonly filter: MutationFilterInput;
9
+ }
10
+ interface SettingsDeleteWhereResult {
11
+ readonly deleted: number;
12
+ }
13
+ /** Input for {@link SqliteSettingsBackend.updateWhere}. */
14
+ interface SettingsUpdateWhereInput {
15
+ readonly namespace?: string;
16
+ readonly collection: string;
17
+ readonly filter: MutationFilterInput;
18
+ readonly data: Readonly<Record<string, unknown>>;
19
+ }
20
+ interface SettingsUpdateWhereResult {
21
+ readonly updated: number;
22
+ }
3
23
  /**
4
24
  * SQLite implementation of ISettingsBackend.
5
25
  *
@@ -39,12 +59,35 @@ export declare class SqliteSettingsBackend implements ISettingsBackend {
39
59
  */
40
60
  private decodeColumnValue;
41
61
  shutdown(): Promise<void>;
62
+ /**
63
+ * `data-store-provider` self-description — how the orchestrator names
64
+ * this engine in a log line, and (once collections declare an engine)
65
+ * how it picks between registrants.
66
+ */
67
+ getEngineInfo(): Promise<DataStoreEngineInfo>;
42
68
  get({ namespace, collection, key }: SettingsGetInput): Promise<unknown>;
43
69
  set({ namespace, collection, key, value }: SettingsSetInput): Promise<void>;
44
70
  query<T extends object = Record<string, unknown>>({ namespace, collection, filter, }: SettingsQueryInput): Promise<readonly SettingsRecord<T>[]>;
45
71
  insert<T extends object = Record<string, unknown>>({ namespace, collection, record, }: SettingsInsertInput<T>): Promise<void>;
46
72
  update({ namespace, collection, id, data }: SettingsUpdateInput): Promise<void>;
47
73
  delete({ namespace, collection, key }: SettingsDeleteInput): Promise<void>;
74
+ /**
75
+ * Bulk delete. One statement, whatever the row count — this is what every
76
+ * retention path in the system was hand-rolling as a select-then-delete
77
+ * loop. The filter is compiled in `mutate` mode, so an unresolvable
78
+ * predicate or an empty one throws before any SQL exists.
79
+ */
80
+ deleteWhere({ namespace, collection, filter, }: SettingsDeleteWhereInput): Promise<SettingsDeleteWhereResult>;
81
+ /**
82
+ * Bulk update. Same filter contract as {@link deleteWhere}.
83
+ *
84
+ * Every key of `data` must be a REAL column. A KV-shaped collection keeps
85
+ * its fields inside a JSON blob, and setting one would need `json_set` with
86
+ * read-modify-write semantics this method does not have — so it refuses
87
+ * rather than writing a column that does not exist, which is what the
88
+ * uncapped `tableUpdate` would have done.
89
+ */
90
+ updateWhere({ namespace, collection, filter, data, }: SettingsUpdateWhereInput): Promise<SettingsUpdateWhereResult>;
48
91
  count({ namespace, collection, filter }: SettingsCountInput): Promise<number>;
49
92
  histogram({ namespace, collection, field, bucketSize, origin, filter, }: SettingsHistogramInput): Promise<readonly HistogramBucket[]>;
50
93
  isEmpty({ namespace, collection }: SettingsIsEmptyInput): Promise<boolean>;
@@ -82,6 +125,12 @@ export declare class SqliteSettingsBackend implements ISettingsBackend {
82
125
  private getAllScoped;
83
126
  private setScopedKey;
84
127
  private scopedName;
128
+ /**
129
+ * Project a declared collection onto what {@link compileFilter} needs. A
130
+ * collection whose only column is `data` is KV-shaped: its fields live
131
+ * inside that blob and are reached with `json_extract`.
132
+ */
133
+ private shapeOf;
85
134
  declareCollection(input: {
86
135
  namespace?: string;
87
136
  collection: string;
@@ -1,8 +1,14 @@
1
1
  import { ProviderRegistration, BaseAddon } from '@camstack/types';
2
2
  import { SqliteSettingsBackend } from './sqlite-settings-backend.js';
3
3
  /**
4
- * SQLite Settings addon — provides persistent settings storage.
5
- * Capability: 'settings-store' (singleton)
4
+ * SQLite Settings addon — the relational ENGINE behind the data door.
5
+ *
6
+ * Capability: `data-store-provider` (collection, internal). It does not
7
+ * serve `settings-store`: that is the public door, owned by
8
+ * storage-orchestrator, which dispatches here
9
+ * ([D44](../../../../../docs/decisions/adr-0044.md)). Callers reach this
10
+ * engine only through the door — which is the point, because it is what
11
+ * makes the engine replaceable.
6
12
  */
7
13
  export declare class SqliteSettingsAddon extends BaseAddon {
8
14
  private backend;
@@ -3,10 +3,66 @@ 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-BxtIzexq.js");
6
+ const require_dist = require("../../dist-VIwdvws5.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  let better_sqlite3 = require("better-sqlite3");
9
9
  better_sqlite3 = require_chunk.__toESM(better_sqlite3);
10
+ //#region src/builtins/sqlite-storage/filter-compiler.ts
11
+ /** Thrown by `mutate` mode. Distinct type so a caller can map it to a 400. */
12
+ var UnsafeFilterError = class extends Error {
13
+ constructor(message) {
14
+ super(message);
15
+ this.name = "UnsafeFilterError";
16
+ }
17
+ };
18
+ /**
19
+ * SQL expression for `field` on this collection, or `null` when it cannot be
20
+ * expressed: a real column → `"field"`; a KV blob field → `json_extract`;
21
+ * anything else on a structured table → unresolvable. Exported because
22
+ * `histogram` needs the same resolution for its bucketed column.
23
+ */
24
+ function fieldExprFor(field, shape) {
25
+ if (field === shape.primaryKey || shape.columns.has(field)) return `"${field}"`;
26
+ if (shape.kvBlobColumn !== null) return `json_extract("${shape.kvBlobColumn}", '$.${field}')`;
27
+ return null;
28
+ }
29
+ function compileFilter(filter, shape, mode, serialize) {
30
+ const clauses = [];
31
+ const params = [];
32
+ const resolve = (field) => {
33
+ const expr = fieldExprFor(field, shape);
34
+ if (expr === null && mode === "mutate") throw new UnsafeFilterError(`filter refers to "${field}", which this collection cannot express — refusing to run a bulk mutation with a dropped predicate`);
35
+ return expr;
36
+ };
37
+ for (const [field, value] of Object.entries(filter?.where ?? {})) {
38
+ const expr = resolve(field);
39
+ if (expr === null) continue;
40
+ clauses.push(`${expr} = ?`);
41
+ params.push(serialize(value));
42
+ }
43
+ for (const [field, values] of Object.entries(filter?.whereIn ?? {})) {
44
+ const expr = resolve(field);
45
+ if (expr === null) continue;
46
+ if (values.length === 0) {
47
+ if (mode === "mutate") throw new UnsafeFilterError(`filter has an empty whereIn list for "${field}" — it matches nothing, which is more likely a caller bug than an intent`);
48
+ continue;
49
+ }
50
+ clauses.push(`${expr} IN (${values.map(() => "?").join(", ")})`);
51
+ for (const v of values) params.push(serialize(v));
52
+ }
53
+ for (const [field, [low, high]] of Object.entries(filter?.whereBetween ?? {})) {
54
+ const expr = resolve(field);
55
+ if (expr === null) continue;
56
+ clauses.push(`${expr} BETWEEN ? AND ?`);
57
+ params.push(serialize(low), serialize(high));
58
+ }
59
+ if (clauses.length === 0 && mode === "mutate") throw new UnsafeFilterError("filter compiled to no predicate — that would affect every row in the collection; use the explicit clear operation if that is the intent");
60
+ return {
61
+ whereSql: clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "",
62
+ params
63
+ };
64
+ }
65
+ //#endregion
10
66
  //#region src/builtins/sqlite-storage/sqlite-settings-backend.ts
11
67
  function parseRowData(raw) {
12
68
  return require_dist.asJsonObject(require_dist.parseJsonUnknown(raw)) ?? {};
@@ -119,6 +175,18 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
119
175
  this.db?.close();
120
176
  this.db = null;
121
177
  }
178
+ /**
179
+ * `data-store-provider` self-description — how the orchestrator names
180
+ * this engine in a log line, and (once collections declare an engine)
181
+ * how it picks between registrants.
182
+ */
183
+ async getEngineInfo() {
184
+ return {
185
+ engineId: "sqlite",
186
+ kind: "relational",
187
+ displayName: "SQLite (WAL)"
188
+ };
189
+ }
122
190
  async get({ namespace, collection, key }) {
123
191
  const scoped = this.scopedName(namespace, collection);
124
192
  const decl = this.requireDeclared(scoped);
@@ -177,6 +245,41 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
177
245
  const decl = this.requireDeclared(scoped);
178
246
  await this.tableDelete(scoped, { [decl.primaryKey]: key });
179
247
  }
248
+ /**
249
+ * Bulk delete. One statement, whatever the row count — this is what every
250
+ * retention path in the system was hand-rolling as a select-then-delete
251
+ * loop. The filter is compiled in `mutate` mode, so an unresolvable
252
+ * predicate or an empty one throws before any SQL exists.
253
+ */
254
+ async deleteWhere({ namespace, collection, filter }) {
255
+ const scoped = this.scopedName(namespace, collection);
256
+ const decl = this.requireDeclared(scoped);
257
+ const { whereSql, params } = compileFilter(filter, this.shapeOf(decl), "mutate", (v) => this.serializeColumnValue(v));
258
+ return { deleted: this.getDb().prepare(`DELETE FROM "${scoped}"${whereSql}`).run(...params).changes };
259
+ }
260
+ /**
261
+ * Bulk update. Same filter contract as {@link deleteWhere}.
262
+ *
263
+ * Every key of `data` must be a REAL column. A KV-shaped collection keeps
264
+ * its fields inside a JSON blob, and setting one would need `json_set` with
265
+ * read-modify-write semantics this method does not have — so it refuses
266
+ * rather than writing a column that does not exist, which is what the
267
+ * uncapped `tableUpdate` would have done.
268
+ */
269
+ async updateWhere({ namespace, collection, filter, data }) {
270
+ const scoped = this.scopedName(namespace, collection);
271
+ const decl = this.requireDeclared(scoped);
272
+ const setClauses = [];
273
+ const setValues = [];
274
+ for (const [key, value] of Object.entries(data)) {
275
+ if (key !== decl.primaryKey && !decl.columns.has(key)) throw new UnsafeFilterError(`updateWhere cannot set "${key}" on "${scoped}" — it is not a column of this collection`);
276
+ setClauses.push(`"${key}" = ?`);
277
+ setValues.push(this.serializeColumnValue(value));
278
+ }
279
+ if (setClauses.length === 0) throw new UnsafeFilterError("updateWhere was given no fields to set");
280
+ const { whereSql, params } = compileFilter(filter, this.shapeOf(decl), "mutate", (v) => this.serializeColumnValue(v));
281
+ return { updated: this.getDb().prepare(`UPDATE "${scoped}" SET ${setClauses.join(", ")}${whereSql}`).run(...setValues, ...params).changes };
282
+ }
180
283
  async count({ namespace, collection, filter }) {
181
284
  const scoped = this.scopedName(namespace, collection);
182
285
  const decl = this.requireDeclared(scoped);
@@ -204,30 +307,10 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
204
307
  async histogram({ namespace, collection, field, bucketSize, origin, filter }) {
205
308
  const scoped = this.scopedName(namespace, collection);
206
309
  const decl = this.requireDeclared(scoped);
207
- const isKvShape = decl.columns.size === 1 && decl.columns.has("data");
208
- const isColumn = (f) => f === decl.primaryKey || decl.columns.has(f);
209
- const fieldExpr = (f) => {
210
- if (isColumn(f)) return `"${f}"`;
211
- if (isKvShape) return `json_extract("data", '$.${f}')`;
212
- return "";
213
- };
214
- const col = fieldExpr(field);
215
- if (!col) return [];
216
- const params = [];
217
- const clauses = [];
218
- if (filter?.where) for (const [f, value] of Object.entries(filter.where)) {
219
- const expr = fieldExpr(f);
220
- if (!expr) continue;
221
- clauses.push(`${expr} = ?`);
222
- params.push(this.serializeColumnValue(value));
223
- }
224
- if (filter?.whereBetween) for (const [f, [lo, hi]] of Object.entries(filter.whereBetween)) {
225
- const expr = fieldExpr(f);
226
- if (!expr) continue;
227
- clauses.push(`${expr} BETWEEN ? AND ?`);
228
- params.push(this.serializeColumnValue(lo), this.serializeColumnValue(hi));
229
- }
230
- const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
310
+ const shape = this.shapeOf(decl);
311
+ const col = fieldExprFor(field, shape);
312
+ if (col === null) return [];
313
+ const { whereSql: where, params } = compileFilter(filter, shape, "select", (v) => this.serializeColumnValue(v));
231
314
  const sql = `SELECT ${`CAST((${col} - ?) / ? AS INTEGER)`} AS bucket, COUNT(*) AS count FROM "${scoped}"${where} GROUP BY bucket ORDER BY bucket`;
232
315
  return this.getDb().prepare(sql).all(origin, bucketSize, ...params).map((r) => ({
233
316
  bucket: r.bucket,
@@ -243,43 +326,13 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
243
326
  const isKvShape = decl.columns.size === 1 && decl.columns.has("data");
244
327
  let sql = `SELECT ${[`"${decl.primaryKey}"`, ...[...decl.columns].map((c) => `"${c}"`)].join(", ")} FROM "${table}"`;
245
328
  const params = [];
246
- const whereClauses = [];
247
- const isColumn = (f) => f === decl.primaryKey || decl.columns.has(f);
248
- /**
249
- * SQL expression for `field` on this table.
250
- * - real column → `"field"`
251
- * - KV blob field (e.g. `username` inside `data`) → `json_extract`
252
- * - structured table + non-column → empty string (filter dropped,
253
- * matches legacy structured-table behaviour)
254
- */
255
- const fieldExpr = (f) => {
256
- if (isColumn(f)) return `"${f}"`;
257
- if (isKvShape) return `json_extract("data", '$.${f}')`;
258
- return "";
259
- };
260
- if (filter?.where) for (const [field, value] of Object.entries(filter.where)) {
261
- const expr = fieldExpr(field);
262
- if (!expr) continue;
263
- whereClauses.push(`${expr} = ?`);
264
- params.push(this.serializeColumnValue(value));
265
- }
266
- if (filter?.whereIn) for (const [field, values] of Object.entries(filter.whereIn)) {
267
- const expr = fieldExpr(field);
268
- if (!expr) continue;
269
- const placeholders = values.map(() => "?").join(", ");
270
- whereClauses.push(`${expr} IN (${placeholders})`);
271
- for (const v of values) params.push(this.serializeColumnValue(v));
272
- }
273
- if (filter?.whereBetween) for (const [field, [low, high]] of Object.entries(filter.whereBetween)) {
274
- const expr = fieldExpr(field);
275
- if (!expr) continue;
276
- whereClauses.push(`${expr} BETWEEN ? AND ?`);
277
- params.push(this.serializeColumnValue(low), this.serializeColumnValue(high));
278
- }
279
- if (whereClauses.length > 0) sql += ` WHERE ${whereClauses.join(" AND ")}`;
329
+ const shape = this.shapeOf(decl);
330
+ const compiled = compileFilter(filter, shape, "select", (v) => this.serializeColumnValue(v));
331
+ sql += compiled.whereSql;
332
+ params.push(...compiled.params);
280
333
  if (filter?.orderBy) {
281
- const expr = fieldExpr(filter.orderBy.field);
282
- if (expr) {
334
+ const expr = fieldExprFor(filter.orderBy.field, shape);
335
+ if (expr !== null) {
283
336
  const dir = filter.orderBy.direction === "desc" ? "DESC" : "ASC";
284
337
  sql += ` ORDER BY ${expr} ${dir}`;
285
338
  }
@@ -455,6 +508,19 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
455
508
  scopedName(namespace, collection) {
456
509
  return namespace ? `${namespace}:${collection}` : collection;
457
510
  }
511
+ /**
512
+ * Project a declared collection onto what {@link compileFilter} needs. A
513
+ * collection whose only column is `data` is KV-shaped: its fields live
514
+ * inside that blob and are reached with `json_extract`.
515
+ */
516
+ shapeOf(decl) {
517
+ const isKvShape = decl.columns.size === 1 && decl.columns.has("data");
518
+ return {
519
+ primaryKey: decl.primaryKey,
520
+ columns: decl.columns,
521
+ kvBlobColumn: isKvShape ? "data" : null
522
+ };
523
+ }
458
524
  async declareCollection(input) {
459
525
  const table = this.scopedName(input.namespace, input.collection);
460
526
  if (this.declaredCollections.has(table)) return;
@@ -605,8 +671,14 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
605
671
  //#endregion
606
672
  //#region src/builtins/sqlite-storage/sqlite-settings.addon.ts
607
673
  /**
608
- * SQLite Settings addon — provides persistent settings storage.
609
- * Capability: 'settings-store' (singleton)
674
+ * SQLite Settings addon — the relational ENGINE behind the data door.
675
+ *
676
+ * Capability: `data-store-provider` (collection, internal). It does not
677
+ * serve `settings-store`: that is the public door, owned by
678
+ * storage-orchestrator, which dispatches here
679
+ * ([D44](../../../../../docs/decisions/adr-0044.md)). Callers reach this
680
+ * engine only through the door — which is the point, because it is what
681
+ * makes the engine replaceable.
610
682
  */
611
683
  var SqliteSettingsAddon = class extends require_dist.BaseAddon {
612
684
  backend = null;
@@ -635,7 +707,7 @@ var SqliteSettingsAddon = class extends require_dist.BaseAddon {
635
707
  await this.backend.initialize();
636
708
  this.ctx.logger.info("Initialized successfully");
637
709
  return [{
638
- capability: require_dist.settingsStoreCapability,
710
+ capability: require_dist.dataStoreProviderCapability,
639
711
  provider: this.backend
640
712
  }];
641
713
  }
@@ -1,6 +1,62 @@
1
- import { Et as parseJsonUnknown, X as settingsStoreCapability, at as BaseAddon, c as RUNTIME_DEFAULTS, ht as asJsonObject, it as errMsg } from "../../dist-BXmFvn3h.mjs";
1
+ import { Dt as parseJsonUnknown, at as errMsg, c as RUNTIME_DEFAULTS, gt as asJsonObject, ot as BaseAddon, x as dataStoreProviderCapability } from "../../dist-Dr_P0DOB.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import Database from "better-sqlite3";
4
+ //#region src/builtins/sqlite-storage/filter-compiler.ts
5
+ /** Thrown by `mutate` mode. Distinct type so a caller can map it to a 400. */
6
+ var UnsafeFilterError = class extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "UnsafeFilterError";
10
+ }
11
+ };
12
+ /**
13
+ * SQL expression for `field` on this collection, or `null` when it cannot be
14
+ * expressed: a real column → `"field"`; a KV blob field → `json_extract`;
15
+ * anything else on a structured table → unresolvable. Exported because
16
+ * `histogram` needs the same resolution for its bucketed column.
17
+ */
18
+ function fieldExprFor(field, shape) {
19
+ if (field === shape.primaryKey || shape.columns.has(field)) return `"${field}"`;
20
+ if (shape.kvBlobColumn !== null) return `json_extract("${shape.kvBlobColumn}", '$.${field}')`;
21
+ return null;
22
+ }
23
+ function compileFilter(filter, shape, mode, serialize) {
24
+ const clauses = [];
25
+ const params = [];
26
+ const resolve = (field) => {
27
+ const expr = fieldExprFor(field, shape);
28
+ if (expr === null && mode === "mutate") throw new UnsafeFilterError(`filter refers to "${field}", which this collection cannot express — refusing to run a bulk mutation with a dropped predicate`);
29
+ return expr;
30
+ };
31
+ for (const [field, value] of Object.entries(filter?.where ?? {})) {
32
+ const expr = resolve(field);
33
+ if (expr === null) continue;
34
+ clauses.push(`${expr} = ?`);
35
+ params.push(serialize(value));
36
+ }
37
+ for (const [field, values] of Object.entries(filter?.whereIn ?? {})) {
38
+ const expr = resolve(field);
39
+ if (expr === null) continue;
40
+ if (values.length === 0) {
41
+ if (mode === "mutate") throw new UnsafeFilterError(`filter has an empty whereIn list for "${field}" — it matches nothing, which is more likely a caller bug than an intent`);
42
+ continue;
43
+ }
44
+ clauses.push(`${expr} IN (${values.map(() => "?").join(", ")})`);
45
+ for (const v of values) params.push(serialize(v));
46
+ }
47
+ for (const [field, [low, high]] of Object.entries(filter?.whereBetween ?? {})) {
48
+ const expr = resolve(field);
49
+ if (expr === null) continue;
50
+ clauses.push(`${expr} BETWEEN ? AND ?`);
51
+ params.push(serialize(low), serialize(high));
52
+ }
53
+ if (clauses.length === 0 && mode === "mutate") throw new UnsafeFilterError("filter compiled to no predicate — that would affect every row in the collection; use the explicit clear operation if that is the intent");
54
+ return {
55
+ whereSql: clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "",
56
+ params
57
+ };
58
+ }
59
+ //#endregion
4
60
  //#region src/builtins/sqlite-storage/sqlite-settings-backend.ts
5
61
  function parseRowData(raw) {
6
62
  return asJsonObject(parseJsonUnknown(raw)) ?? {};
@@ -113,6 +169,18 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
113
169
  this.db?.close();
114
170
  this.db = null;
115
171
  }
172
+ /**
173
+ * `data-store-provider` self-description — how the orchestrator names
174
+ * this engine in a log line, and (once collections declare an engine)
175
+ * how it picks between registrants.
176
+ */
177
+ async getEngineInfo() {
178
+ return {
179
+ engineId: "sqlite",
180
+ kind: "relational",
181
+ displayName: "SQLite (WAL)"
182
+ };
183
+ }
116
184
  async get({ namespace, collection, key }) {
117
185
  const scoped = this.scopedName(namespace, collection);
118
186
  const decl = this.requireDeclared(scoped);
@@ -171,6 +239,41 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
171
239
  const decl = this.requireDeclared(scoped);
172
240
  await this.tableDelete(scoped, { [decl.primaryKey]: key });
173
241
  }
242
+ /**
243
+ * Bulk delete. One statement, whatever the row count — this is what every
244
+ * retention path in the system was hand-rolling as a select-then-delete
245
+ * loop. The filter is compiled in `mutate` mode, so an unresolvable
246
+ * predicate or an empty one throws before any SQL exists.
247
+ */
248
+ async deleteWhere({ namespace, collection, filter }) {
249
+ const scoped = this.scopedName(namespace, collection);
250
+ const decl = this.requireDeclared(scoped);
251
+ const { whereSql, params } = compileFilter(filter, this.shapeOf(decl), "mutate", (v) => this.serializeColumnValue(v));
252
+ return { deleted: this.getDb().prepare(`DELETE FROM "${scoped}"${whereSql}`).run(...params).changes };
253
+ }
254
+ /**
255
+ * Bulk update. Same filter contract as {@link deleteWhere}.
256
+ *
257
+ * Every key of `data` must be a REAL column. A KV-shaped collection keeps
258
+ * its fields inside a JSON blob, and setting one would need `json_set` with
259
+ * read-modify-write semantics this method does not have — so it refuses
260
+ * rather than writing a column that does not exist, which is what the
261
+ * uncapped `tableUpdate` would have done.
262
+ */
263
+ async updateWhere({ namespace, collection, filter, data }) {
264
+ const scoped = this.scopedName(namespace, collection);
265
+ const decl = this.requireDeclared(scoped);
266
+ const setClauses = [];
267
+ const setValues = [];
268
+ for (const [key, value] of Object.entries(data)) {
269
+ if (key !== decl.primaryKey && !decl.columns.has(key)) throw new UnsafeFilterError(`updateWhere cannot set "${key}" on "${scoped}" — it is not a column of this collection`);
270
+ setClauses.push(`"${key}" = ?`);
271
+ setValues.push(this.serializeColumnValue(value));
272
+ }
273
+ if (setClauses.length === 0) throw new UnsafeFilterError("updateWhere was given no fields to set");
274
+ const { whereSql, params } = compileFilter(filter, this.shapeOf(decl), "mutate", (v) => this.serializeColumnValue(v));
275
+ return { updated: this.getDb().prepare(`UPDATE "${scoped}" SET ${setClauses.join(", ")}${whereSql}`).run(...setValues, ...params).changes };
276
+ }
174
277
  async count({ namespace, collection, filter }) {
175
278
  const scoped = this.scopedName(namespace, collection);
176
279
  const decl = this.requireDeclared(scoped);
@@ -198,30 +301,10 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
198
301
  async histogram({ namespace, collection, field, bucketSize, origin, filter }) {
199
302
  const scoped = this.scopedName(namespace, collection);
200
303
  const decl = this.requireDeclared(scoped);
201
- const isKvShape = decl.columns.size === 1 && decl.columns.has("data");
202
- const isColumn = (f) => f === decl.primaryKey || decl.columns.has(f);
203
- const fieldExpr = (f) => {
204
- if (isColumn(f)) return `"${f}"`;
205
- if (isKvShape) return `json_extract("data", '$.${f}')`;
206
- return "";
207
- };
208
- const col = fieldExpr(field);
209
- if (!col) return [];
210
- const params = [];
211
- const clauses = [];
212
- if (filter?.where) for (const [f, value] of Object.entries(filter.where)) {
213
- const expr = fieldExpr(f);
214
- if (!expr) continue;
215
- clauses.push(`${expr} = ?`);
216
- params.push(this.serializeColumnValue(value));
217
- }
218
- if (filter?.whereBetween) for (const [f, [lo, hi]] of Object.entries(filter.whereBetween)) {
219
- const expr = fieldExpr(f);
220
- if (!expr) continue;
221
- clauses.push(`${expr} BETWEEN ? AND ?`);
222
- params.push(this.serializeColumnValue(lo), this.serializeColumnValue(hi));
223
- }
224
- const where = clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
304
+ const shape = this.shapeOf(decl);
305
+ const col = fieldExprFor(field, shape);
306
+ if (col === null) return [];
307
+ const { whereSql: where, params } = compileFilter(filter, shape, "select", (v) => this.serializeColumnValue(v));
225
308
  const sql = `SELECT ${`CAST((${col} - ?) / ? AS INTEGER)`} AS bucket, COUNT(*) AS count FROM "${scoped}"${where} GROUP BY bucket ORDER BY bucket`;
226
309
  return this.getDb().prepare(sql).all(origin, bucketSize, ...params).map((r) => ({
227
310
  bucket: r.bucket,
@@ -237,43 +320,13 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
237
320
  const isKvShape = decl.columns.size === 1 && decl.columns.has("data");
238
321
  let sql = `SELECT ${[`"${decl.primaryKey}"`, ...[...decl.columns].map((c) => `"${c}"`)].join(", ")} FROM "${table}"`;
239
322
  const params = [];
240
- const whereClauses = [];
241
- const isColumn = (f) => f === decl.primaryKey || decl.columns.has(f);
242
- /**
243
- * SQL expression for `field` on this table.
244
- * - real column → `"field"`
245
- * - KV blob field (e.g. `username` inside `data`) → `json_extract`
246
- * - structured table + non-column → empty string (filter dropped,
247
- * matches legacy structured-table behaviour)
248
- */
249
- const fieldExpr = (f) => {
250
- if (isColumn(f)) return `"${f}"`;
251
- if (isKvShape) return `json_extract("data", '$.${f}')`;
252
- return "";
253
- };
254
- if (filter?.where) for (const [field, value] of Object.entries(filter.where)) {
255
- const expr = fieldExpr(field);
256
- if (!expr) continue;
257
- whereClauses.push(`${expr} = ?`);
258
- params.push(this.serializeColumnValue(value));
259
- }
260
- if (filter?.whereIn) for (const [field, values] of Object.entries(filter.whereIn)) {
261
- const expr = fieldExpr(field);
262
- if (!expr) continue;
263
- const placeholders = values.map(() => "?").join(", ");
264
- whereClauses.push(`${expr} IN (${placeholders})`);
265
- for (const v of values) params.push(this.serializeColumnValue(v));
266
- }
267
- if (filter?.whereBetween) for (const [field, [low, high]] of Object.entries(filter.whereBetween)) {
268
- const expr = fieldExpr(field);
269
- if (!expr) continue;
270
- whereClauses.push(`${expr} BETWEEN ? AND ?`);
271
- params.push(this.serializeColumnValue(low), this.serializeColumnValue(high));
272
- }
273
- if (whereClauses.length > 0) sql += ` WHERE ${whereClauses.join(" AND ")}`;
323
+ const shape = this.shapeOf(decl);
324
+ const compiled = compileFilter(filter, shape, "select", (v) => this.serializeColumnValue(v));
325
+ sql += compiled.whereSql;
326
+ params.push(...compiled.params);
274
327
  if (filter?.orderBy) {
275
- const expr = fieldExpr(filter.orderBy.field);
276
- if (expr) {
328
+ const expr = fieldExprFor(filter.orderBy.field, shape);
329
+ if (expr !== null) {
277
330
  const dir = filter.orderBy.direction === "desc" ? "DESC" : "ASC";
278
331
  sql += ` ORDER BY ${expr} ${dir}`;
279
332
  }
@@ -449,6 +502,19 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
449
502
  scopedName(namespace, collection) {
450
503
  return namespace ? `${namespace}:${collection}` : collection;
451
504
  }
505
+ /**
506
+ * Project a declared collection onto what {@link compileFilter} needs. A
507
+ * collection whose only column is `data` is KV-shaped: its fields live
508
+ * inside that blob and are reached with `json_extract`.
509
+ */
510
+ shapeOf(decl) {
511
+ const isKvShape = decl.columns.size === 1 && decl.columns.has("data");
512
+ return {
513
+ primaryKey: decl.primaryKey,
514
+ columns: decl.columns,
515
+ kvBlobColumn: isKvShape ? "data" : null
516
+ };
517
+ }
452
518
  async declareCollection(input) {
453
519
  const table = this.scopedName(input.namespace, input.collection);
454
520
  if (this.declaredCollections.has(table)) return;
@@ -599,8 +665,14 @@ var SqliteSettingsBackend = class SqliteSettingsBackend {
599
665
  //#endregion
600
666
  //#region src/builtins/sqlite-storage/sqlite-settings.addon.ts
601
667
  /**
602
- * SQLite Settings addon — provides persistent settings storage.
603
- * Capability: 'settings-store' (singleton)
668
+ * SQLite Settings addon — the relational ENGINE behind the data door.
669
+ *
670
+ * Capability: `data-store-provider` (collection, internal). It does not
671
+ * serve `settings-store`: that is the public door, owned by
672
+ * storage-orchestrator, which dispatches here
673
+ * ([D44](../../../../../docs/decisions/adr-0044.md)). Callers reach this
674
+ * engine only through the door — which is the point, because it is what
675
+ * makes the engine replaceable.
604
676
  */
605
677
  var SqliteSettingsAddon = class extends BaseAddon {
606
678
  backend = null;
@@ -629,7 +701,7 @@ var SqliteSettingsAddon = class extends BaseAddon {
629
701
  await this.backend.initialize();
630
702
  this.ctx.logger.info("Initialized successfully");
631
703
  return [{
632
- capability: settingsStoreCapability,
704
+ capability: dataStoreProviderCapability,
633
705
  provider: this.backend
634
706
  }];
635
707
  }