@blamejs/core 0.8.57 → 0.8.58

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/CHANGELOG.md CHANGED
@@ -8,6 +8,7 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.8.x
10
10
 
11
+ - v0.8.58 (2026-05-09) — `b.db` query-builder + facade extensions for HermitStash-shaped migrations + adjacent gaps. **Query atoms** on `b.db.from(table)`: `.increment(column, delta)` (atomic `UPDATE col = COALESCE(col, 0) + ?`, refuses unconditional, returns rows-changed), `.whereGroup(qb => ...)` (closure-form OR composition via new `WhereBuilder` class with `.eq` / `.neq` / `.gt` / `.gte` / `.lt` / `.lte` / `.in` / `.like` AND vs `.orEq` / `.orNeq` / ... OR + `.raw`), `.orWhere(...)` (top-level OR; accepts object map / `(field, value)` / `(field, op, value)` / closure), `.search(fields, term, opts?)` (chainable LIKE-OR with `match: "substring"|"prefix"|"exact"`, `~` ESCAPE char to safely handle user-supplied `%`/`_`), `.paginate(opts)` (returns `{ items, total, limit, offset, page, totalPages }`; default limit 25, cap 1000). **Mongo facade** at `b.db.collection(name)` returning `{ insert, insertMany, find, findOne, update, updateMany, remove, count, paginate }` — maps Mongo-shape calls onto `b.db.from(name)`. Update operators: `$set` / `$inc` (composes `Query.increment`) / `$unset`. Query operators: `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte` / `$in` / `$like`. Unknown operators throw at config-time. **db.init opt-outs**: `frameworkTables: false` skips provisioning `audit_log` / `consent_log` (operators with their own audit chain reuse the framework's `b.db` / `b.vault` / `b.cryptoField` primitives without the bundled chain tables — append-only triggers, chain verifies, WORM assertions, audit-signing bootstrap, checkpoint verifies, and the `audit_log` / `consent_log` reserved-name refusal all become no-ops). `auditSigning: false` (finer-grained — keep framework tables but skip the audit-signing-key bootstrap when the host manages its own signing key). **db.init path overrides**: `encryptedDbPath` (fully-qualified path to `db.enc`), `encryptedDbName` (basename under `dataDir`, default `"db.enc"`), `dbKeyPath` (encryption-key file outside `dataDir`, e.g. KMS-fronted volume). **`b.db.snapshot()`** — in-memory encrypted Buffer (same envelope `flushToDisk` writes, just held in memory; WAL checkpoint forced first so committed state captures cleanly). Plain mode returns the raw plaintext SQLite file. **`b.mtlsCa.create({ paths })` absolute-path fix** — `_resolvePaths` no longer joins absolute path entries under `dataDir`. The pre-v0.8.58 shape silently rewrote `MTLS_CA_KEY=/etc/ssl/ca.key` → `<dataDir>/etc/ssl/ca.key`, breaking operators with externally-mounted CA files. Standard `path.join` semantics already preserve absolute arguments — the always-join was an oversight. Relative entries still join under `dataDir` (back-compat). **CLAUDE.md release workflow §5** rewritten — host smoke + host wiki e2e (Phase A) run BEFORE container smoke + container wiki e2e (Phase B), sequentially, never in parallel. Both runners write to `.test-output/smoke.log` and `.test-output/wiki-e2e.log`; parallel runs clobber each other so the log of the actually-blocking failure may be overwritten by whichever leg finishes second. Phase B uses `smoke-container.log` / `wiki-e2e-container.log` so diagnose-after-failure is one file lookup. New per-primitive Layer 0 tests: `db-query-extensions.test.js` (27 checks), `db-collection.test.js` (23 checks), `db-init-extensions.test.js` (13 checks), `mtls-ca-paths.test.js` (7 checks). G7 (configurable framework-schema column names) deferred — original message cut off at the heading; re-open with the full proposal. G3 (configurable framework-table names — the `audit_log` / `consent_log` rename) deferred — `frameworkTables: false` covers the audit-conflict use case; full rename touches ~47 hardcoded SQL literals across `lib/audit*.js` / `lib/consent.js` and is its own patch with a refactor proper.
11
12
  - v0.8.57 (2026-05-09) — CI green-up for v0.8.56. The v0.8.56 npm-publish workflow's smoke gate passed but the `prepack-guard` script (`scripts/check-pack-against-gitignore.js`) rejected the tarball: it ran `git check-ignore -v` against every packed path and treated EVERY matching gitignore line as "ignored", including `!`-prefixed negation rules. The newly-tracked `lib/vendor/bimi-trust-anchors.pem` matches the `!lib/vendor/*.pem` negation rule that v0.8.54 added, but the script flagged it as "gitignored in tarball" and exited 1. Fix: filter out lines whose matching pattern starts with `!` — those are negation rules indicating the file is NOT actually ignored. No primitive surface change versus v0.8.56.
12
13
  - v0.8.56 (2026-05-09) — CI green-up for v0.8.55. macOS smoke runner failed `watcher.test.js: surface onChange for non-ignored file`; the test bumped from 80ms→300ms in v0.8.52, but macOS `fs.watch` event delivery under SMOKE_PARALLEL=64 + CI runner contention can still exceed 300ms. Bumped all four delay sites to 1500ms. Linux/Windows continue to pass on the first event-loop turn; the longer budget only matters when macOS is contended. No primitive surface change versus v0.8.55.
13
14
  - v0.8.55 (2026-05-09) — CI green-up for v0.8.54. The v0.8.54 npm-publish hit `rate-limit-cluster.test.js: cluster: 4th request blocked with 429` failing under CI contention. The test's `_waitMicrotasks(3)` helper chained 3 `setImmediate` ticks between `fire()` calls, and the cluster-backend's async `take()` against the DB hadn't finished bumping the counter yet — the 4th `fire()` read a stale count, looked like a pass, and the assertion failed. Bumped to 20 ticks (default + every call site). Linux/Alpine container runners pass on the first attempt; the budget only matters under SMOKE_PARALLEL=64 contention. No primitive surface change versus v0.8.54.
@@ -0,0 +1,290 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.db.collection
4
+ * @nav Data
5
+ * @title Collection
6
+ * @order 210
7
+ * @card Mongo-style facade over `b.db.from(name)`. Wraps the
8
+ * chainable Query builder in `{ insert, find, findOne,
9
+ * update, remove, count, paginate }` for codebases
10
+ * migrating from MongoDB or for primitives that prefer
11
+ * the document-store call shape.
12
+ *
13
+ * @intro
14
+ * `b.db.collection(name)` returns a small adapter that maps Mongo-
15
+ * shape calls onto the framework's query-builder primitives:
16
+ *
17
+ * b.db.collection("users").findOne({ email: "alice@x.com" });
18
+ * → b.db.from("users").where({ email: "alice@x.com" }).first();
19
+ *
20
+ * b.db.collection("users").update({ _id }, { $set: { name } });
21
+ * → b.db.from("users").where({ _id }).updateOne({ name });
22
+ *
23
+ * b.db.collection("users").update({ _id }, { $inc: { failed: 1 } });
24
+ * → b.db.from("users").where({ _id }).increment("failed", 1);
25
+ *
26
+ * Operators migrating from a Mongo-shaped codebase (HermitStash and
27
+ * peers) can drop in this facade without rewriting every call site
28
+ * to the chainable builder. New code typically reaches for the
29
+ * builder directly — `b.db.from(...)` is more expressive and
30
+ * doesn't pretend to be Mongo.
31
+ *
32
+ * Supported update operators: `$set` (assign), `$inc` (atomic
33
+ * increment per column — composes `Query.increment`), `$unset`
34
+ * (set to NULL).
35
+ */
36
+
37
+ var lazyRequire = require("./lazy-require");
38
+
39
+ // db.js → db-collection.js → db.from() would create a require cycle.
40
+ // Defer the lookup to call-time so the binding lands after both
41
+ // modules finish loading.
42
+ var db = lazyRequire(function () { return require("./db"); });
43
+
44
+ function _validateQueryShape(query) {
45
+ if (!query || typeof query !== "object" || Array.isArray(query)) {
46
+ throw new TypeError("collection: query must be a plain object");
47
+ }
48
+ }
49
+
50
+ function _applyQuery(builder, query) {
51
+ // Mongo-shape supports `field: value` for equality and `field:
52
+ // { $gt: x }` / `{ $lt: x }` / `{ $gte: x }` / `{ $lte: x }` /
53
+ // `{ $ne: x }` / `{ $in: [...] }` / `{ $like: "pattern" }` for
54
+ // operators. Anything else throws — refuse silently translating
55
+ // unknown operators into something that might match more rows
56
+ // than intended.
57
+ var keys = Object.keys(query);
58
+ for (var i = 0; i < keys.length; i += 1) {
59
+ var k = keys[i];
60
+ var v = query[k];
61
+ if (v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date)) {
62
+ var opKeys = Object.keys(v);
63
+ for (var j = 0; j < opKeys.length; j += 1) {
64
+ var op = opKeys[j];
65
+ var val = v[op];
66
+ switch (op) {
67
+ case "$eq": builder.where(k, "=", val); break;
68
+ case "$ne": builder.where(k, "!=", val); break;
69
+ case "$gt": builder.where(k, ">", val); break;
70
+ case "$gte": builder.where(k, ">=", val); break;
71
+ case "$lt": builder.where(k, "<", val); break;
72
+ case "$lte": builder.where(k, "<=", val); break;
73
+ case "$in":
74
+ if (!Array.isArray(val)) {
75
+ throw new TypeError("collection: $in requires an array (got " + typeof val + ")");
76
+ }
77
+ builder.where(k, "IN", val);
78
+ break;
79
+ case "$like":
80
+ if (typeof val !== "string") {
81
+ throw new TypeError("collection: $like requires a string");
82
+ }
83
+ builder.where(k, "LIKE", val);
84
+ break;
85
+ default:
86
+ throw new TypeError("collection: unsupported query operator '" + op +
87
+ "' on field '" + k + "' (allowed: $eq / $ne / $gt / $gte / $lt / $lte / $in / $like)");
88
+ }
89
+ }
90
+ } else {
91
+ builder.where(k, "=", v);
92
+ }
93
+ }
94
+ }
95
+
96
+ function _splitUpdateOperators(update) {
97
+ // Allow either Mongo-shape `{ $set: {...}, $inc: {...} }` OR plain
98
+ // `{ field: value, ... }` (treated as $set). Returns a tuple of
99
+ // sets / increments / unsets so the caller can dispatch.
100
+ if (!update || typeof update !== "object" || Array.isArray(update)) {
101
+ throw new TypeError("collection: update must be a plain object");
102
+ }
103
+ var keys = Object.keys(update);
104
+ var hasOperator = keys.some(function (k) { return k.charAt(0) === "$"; });
105
+ if (!hasOperator) {
106
+ return { sets: update, incs: null, unsets: null };
107
+ }
108
+ var sets = null;
109
+ var incs = null;
110
+ var unsets = null;
111
+ for (var i = 0; i < keys.length; i += 1) {
112
+ var k = keys[i];
113
+ if (k === "$set") {
114
+ if (!update[k] || typeof update[k] !== "object") {
115
+ throw new TypeError("collection: $set value must be an object");
116
+ }
117
+ sets = update[k];
118
+ } else if (k === "$inc") {
119
+ if (!update[k] || typeof update[k] !== "object") {
120
+ throw new TypeError("collection: $inc value must be an object");
121
+ }
122
+ incs = update[k];
123
+ } else if (k === "$unset") {
124
+ if (!update[k] || typeof update[k] !== "object") {
125
+ throw new TypeError("collection: $unset value must be an object");
126
+ }
127
+ unsets = update[k];
128
+ } else {
129
+ throw new TypeError("collection: unsupported update operator '" + k +
130
+ "' (allowed: $set / $inc / $unset; or pass a plain object for an implicit $set)");
131
+ }
132
+ }
133
+ return { sets: sets, incs: incs, unsets: unsets };
134
+ }
135
+
136
+ /**
137
+ * @primitive b.db.collection
138
+ * @signature b.db.collection(name)
139
+ * @since 0.8.58
140
+ * @status stable
141
+ * @related b.db.from, b.db
142
+ *
143
+ * Returns a Mongo-style adapter for the named table. Each method
144
+ * dispatches to `b.db.from(name)` under the hood; sealed-column
145
+ * semantics, derived-hash translation, and audit emission carry
146
+ * through unchanged.
147
+ *
148
+ * @example
149
+ * var b = require("@blamejs/core");
150
+ * await b.db.init({ dataDir: "/tmp/data", schema: [{
151
+ * name: "users",
152
+ * columns: { _id: "TEXT PRIMARY KEY", email: "TEXT", failed: "INTEGER NOT NULL DEFAULT 0" },
153
+ * }] });
154
+ * var users = b.db.collection("users");
155
+ * users.insert({ _id: "u1", email: "alice@x.com" });
156
+ * users.findOne({ email: "alice@x.com" });
157
+ * users.update({ _id: "u1" }, { $inc: { failed: 1 } });
158
+ * users.update({ _id: "u1" }, { $set: { failed: 0 } });
159
+ * users.remove({ _id: "u1" });
160
+ */
161
+ function collection(name) {
162
+ if (typeof name !== "string" || name.length === 0) {
163
+ throw new TypeError("collection(name): name must be a non-empty string");
164
+ }
165
+ return {
166
+ name: name,
167
+
168
+ // Insert one document. Returns the inserted row with `_id` filled
169
+ // in (if absent on input). Composes Query.insertOne.
170
+ insert: function (doc) {
171
+ return db().from(name).insertOne(doc);
172
+ },
173
+
174
+ // Insert many. Returns array of inserted rows.
175
+ insertMany: function (docs) {
176
+ return db().from(name).insertMany(docs);
177
+ },
178
+
179
+ // Find rows matching the query. Returns an array. Pass `opts.limit`
180
+ // / `opts.offset` / `opts.orderBy` / `opts.orderDir` for paging.
181
+ find: function (query, opts) {
182
+ _validateQueryShape(query || {});
183
+ var q = db().from(name);
184
+ _applyQuery(q, query || {});
185
+ if (opts && opts.orderBy) q.orderBy(opts.orderBy, opts.orderDir || "asc");
186
+ if (opts && opts.limit !== undefined) q.limit(opts.limit);
187
+ if (opts && opts.offset !== undefined) q.offset(opts.offset);
188
+ return q.all();
189
+ },
190
+
191
+ // Find one row, or null. Equivalent to `.find(...).all()[0]` but
192
+ // emits `LIMIT 1` so the engine doesn't materialise the rest.
193
+ findOne: function (query) {
194
+ _validateQueryShape(query);
195
+ var q = db().from(name);
196
+ _applyQuery(q, query);
197
+ return q.first() || null;
198
+ },
199
+
200
+ // Update rows matching the query. Accepts Mongo `{ $set, $inc,
201
+ // $unset }` operator form OR a plain field-map (treated as $set).
202
+ // Returns the number of rows changed.
203
+ //
204
+ // `$inc` composes Query.increment so the SQL is
205
+ // UPDATE table SET col = COALESCE(col, 0) + ? WHERE ...
206
+ // — atomic across concurrent writers, no fetch/mutate/store race.
207
+ update: function (query, update, opts) {
208
+ _validateQueryShape(query || {});
209
+ var split = _splitUpdateOperators(update);
210
+ var single = !(opts && opts.many === true);
211
+ var changed = 0;
212
+
213
+ // $inc — apply increments per column. Each call shares the
214
+ // where-clause but is its own UPDATE statement (one SQL per
215
+ // bumped column). The where filter must be re-built per call
216
+ // because Query is single-shot.
217
+ if (split.incs) {
218
+ var incCols = Object.keys(split.incs);
219
+ for (var i = 0; i < incCols.length; i += 1) {
220
+ var qInc = db().from(name);
221
+ _applyQuery(qInc, query || {});
222
+ var delta = split.incs[incCols[i]];
223
+ if (typeof delta !== "number" || !Number.isInteger(delta)) {
224
+ throw new TypeError("collection.update: $inc.'" + incCols[i] + "' must be an integer");
225
+ }
226
+ changed += qInc.increment(incCols[i], delta);
227
+ }
228
+ }
229
+
230
+ // $set / plain-object form — single UPDATE with the merged
231
+ // changes object.
232
+ var setObj = null;
233
+ if (split.sets) setObj = Object.assign({}, split.sets);
234
+ if (split.unsets) {
235
+ if (!setObj) setObj = {};
236
+ Object.keys(split.unsets).forEach(function (k) { setObj[k] = null; });
237
+ }
238
+ if (setObj && Object.keys(setObj).length > 0) {
239
+ var qSet = db().from(name);
240
+ _applyQuery(qSet, query || {});
241
+ if (single) {
242
+ changed += (qSet.updateOne(setObj) ? 1 : 0);
243
+ } else {
244
+ changed += qSet.updateMany(setObj);
245
+ }
246
+ }
247
+
248
+ return changed;
249
+ },
250
+
251
+ // Convenience — `updateMany(query, update)` shorthand for
252
+ // `update(query, update, { many: true })`.
253
+ updateMany: function (query, update) {
254
+ return this.update(query, update, { many: true });
255
+ },
256
+
257
+ // Remove rows matching the query. Returns the number of rows
258
+ // deleted. Default deletes ONE row; pass `{ many: true }` to
259
+ // delete all matches (matches the framework's `deleteMany` rule
260
+ // — no unconditional deletes).
261
+ remove: function (query, opts) {
262
+ _validateQueryShape(query || {});
263
+ var q = db().from(name);
264
+ _applyQuery(q, query || {});
265
+ if (opts && opts.many === true) {
266
+ return q.deleteMany();
267
+ }
268
+ return q.deleteOne() ? 1 : 0;
269
+ },
270
+
271
+ // Count rows matching the query.
272
+ count: function (query) {
273
+ _validateQueryShape(query || {});
274
+ var q = db().from(name);
275
+ _applyQuery(q, query || {});
276
+ return q.count();
277
+ },
278
+
279
+ // Paginate — `{ items, total, limit, offset, page, totalPages }`.
280
+ // Composes Query.paginate.
281
+ paginate: function (query, opts) {
282
+ _validateQueryShape(query || {});
283
+ var q = db().from(name);
284
+ _applyQuery(q, query || {});
285
+ return q.paginate(opts || {});
286
+ },
287
+ };
288
+ }
289
+
290
+ module.exports = { collection: collection };
package/lib/db-query.js CHANGED
@@ -484,6 +484,175 @@ class Query {
484
484
  return this._delete(false);
485
485
  }
486
486
 
487
+ // Atomic counter increment.
488
+ //
489
+ // `from(table).where(filter).increment("col", 1)` emits
490
+ // `UPDATE table SET col = col + ? WHERE ...` so concurrent writers
491
+ // can't collide on a fetch/mutate/store sequence (which would lose
492
+ // increments under racing transactions). Pass a negative delta to
493
+ // decrement.
494
+ //
495
+ // Returns the number of rows changed (matches updateMany shape).
496
+ increment(column, delta) {
497
+ if (typeof column !== "string" || column.length === 0) {
498
+ throw new Error("increment(column, delta): column must be a non-empty string");
499
+ }
500
+ _validateField(column);
501
+ if (delta === undefined) delta = 1;
502
+ if (typeof delta !== "number" || !Number.isFinite(delta) || !Number.isInteger(delta)) {
503
+ throw new Error("increment(column, delta): delta must be a finite integer (default 1)");
504
+ }
505
+ if (this._where.length === 0) {
506
+ throw new Error("refusing unconditional increment — call where(...) first");
507
+ }
508
+ var whereSql = this._where.join(" AND ");
509
+ var qt = this._quotedTable();
510
+ var qc = '"' + column + '"';
511
+ // Use COALESCE so a NULL counter starts at 0 instead of producing
512
+ // NULL + delta = NULL silently (which would silently drop the
513
+ // operation under SQLite's NULL-arithmetic rules).
514
+ var sql = "UPDATE " + qt + " SET " + qc + " = COALESCE(" + qc + ", 0) + ? WHERE " + whereSql;
515
+ var allParams = [delta].concat(this._whereParams);
516
+ var stmt = this._db.prepare(sql);
517
+ var info = stmt.run.apply(stmt, allParams);
518
+ return info.changes;
519
+ }
520
+
521
+ // `.where(closure)` for grouped expressions, including OR
522
+ // composition. Pass a function `(qb) => qb.eq(col, val).orEq(...)`;
523
+ // the inner closure builds an expression that becomes a single
524
+ // parenthesised AND-leaf in the outer where chain.
525
+ //
526
+ // The closure receives a `WhereBuilder` exposing `.eq` / `.neq` /
527
+ // `.gt` / `.gte` / `.lt` / `.lte` / `.in` / `.like` plus `.orEq`,
528
+ // `.orNeq`, `.orGt`, `.orGte`, `.orLt`, `.orLte`, `.orIn`,
529
+ // `.orLike`, and `.raw(sql, params)`. Each non-`or` call ANDs the
530
+ // expression; each `or*` call ORs it.
531
+ whereGroup(closure) {
532
+ if (typeof closure !== "function") {
533
+ throw new Error("whereGroup(closure): expected function (qb) => ...");
534
+ }
535
+ var sub = new WhereBuilder();
536
+ closure(sub);
537
+ var built = sub.build();
538
+ if (!built.sql) return this;
539
+ this._where.push("(" + built.sql + ")");
540
+ for (var i = 0; i < built.params.length; i++) this._whereParams.push(built.params[i]);
541
+ return this;
542
+ }
543
+
544
+ // Top-level OR — extends the existing where-chain so
545
+ // `.where(a).orWhere(b)` produces `WHERE (a) OR (b)` rather than
546
+ // `WHERE (a) AND (b)`. Accepts the same arg shapes as `.where`:
547
+ // object-literal map, `(field, value)`, `(field, op, value)`, or a
548
+ // `(qb) => ...` closure.
549
+ orWhere(fieldOrObjOrFn, op, value) {
550
+ if (this._where.length === 0) {
551
+ throw new Error("orWhere(...): no prior where(...) — start the chain with where(...)");
552
+ }
553
+ if (typeof fieldOrObjOrFn === "function") {
554
+ var sub = new WhereBuilder();
555
+ fieldOrObjOrFn(sub);
556
+ var built = sub.build();
557
+ if (!built.sql) return this;
558
+ var prev = this._where.pop();
559
+ this._where.push("(" + prev + " OR (" + built.sql + "))");
560
+ for (var i = 0; i < built.params.length; i++) this._whereParams.push(built.params[i]);
561
+ return this;
562
+ }
563
+ // For non-closure shapes, build a transient single-leaf Query and
564
+ // splice it. We compile to a `WhereBuilder` for symmetry.
565
+ var sub2 = new WhereBuilder();
566
+ if (fieldOrObjOrFn !== null && typeof fieldOrObjOrFn === "object" && !Array.isArray(fieldOrObjOrFn)) {
567
+ Object.keys(fieldOrObjOrFn).forEach(function (k) { sub2.eq(k, fieldOrObjOrFn[k]); });
568
+ } else if (op === undefined) {
569
+ sub2.eq(fieldOrObjOrFn, /* value */ arguments[1]);
570
+ } else {
571
+ sub2._push("AND", fieldOrObjOrFn, op, value);
572
+ }
573
+ var built2 = sub2.build();
574
+ if (!built2.sql) return this;
575
+ var prev2 = this._where.pop();
576
+ this._where.push("(" + prev2 + " OR (" + built2.sql + "))");
577
+ for (var j = 0; j < built2.params.length; j++) this._whereParams.push(built2.params[j]);
578
+ return this;
579
+ }
580
+
581
+ // `.search(fields, term)` — chainable LIKE-OR helper. Adds
582
+ // `(field1 LIKE ? OR field2 LIKE ? ...)` ANDed onto the existing
583
+ // where-chain. Empty term is a no-op (so `?search=` from a query-
584
+ // string flows through cleanly).
585
+ //
586
+ // `term` is wrapped with `%` on both sides for substring match by
587
+ // default; pass `{ match: "prefix" }` for `term%` only or
588
+ // `{ match: "exact" }` to LIKE the term verbatim (for operators
589
+ // who need to keep `%`/`_` in the user-supplied query).
590
+ search(fields, term, opts) {
591
+ if (!Array.isArray(fields) || fields.length === 0) {
592
+ throw new Error("search(fields, term): fields must be a non-empty array of column names");
593
+ }
594
+ fields.forEach(_validateField);
595
+ if (term === undefined || term === null) return this;
596
+ if (typeof term !== "string") {
597
+ throw new Error("search(fields, term): term must be a string");
598
+ }
599
+ if (term.length === 0) return this;
600
+ var match = (opts && opts.match) || "substring";
601
+ // Escape the operator's term so SQL LIKE wildcards in user input
602
+ // don't widen the match. Use `~` as the ESCAPE char (SQLite's
603
+ // ESCAPE clause requires a single character — picking `~` rather
604
+ // than `\` avoids JS-string-literal escaping headaches; `~` rarely
605
+ // appears in user-supplied search terms).
606
+ var escaped = String(term).replace(/[~%_]/g, function (c) { return "~" + c; });
607
+ var pattern;
608
+ if (match === "exact") pattern = escaped;
609
+ else if (match === "prefix") pattern = escaped + "%";
610
+ else if (match === "substring") pattern = "%" + escaped + "%";
611
+ else throw new Error("search: opts.match must be 'substring' | 'prefix' | 'exact'");
612
+ var clauses = fields.map(function (f) { return '"' + f + '" LIKE ? ESCAPE \'~\''; });
613
+ var sql = "(" + clauses.join(" OR ") + ")";
614
+ var params = fields.map(function () { return pattern; });
615
+ this._where.push(sql);
616
+ for (var i = 0; i < params.length; i++) this._whereParams.push(params[i]);
617
+ return this;
618
+ }
619
+
620
+ // `.paginate(opts)` — page envelope. Composes the existing
621
+ // `.orderBy().limit().offset().all()` + a separate `.count()` so
622
+ // operators get `{ items, total, limit, offset, page, totalPages }`
623
+ // in one call.
624
+ //
625
+ // Defaults: `limit = 25`, `offset = 0`. `orderBy` is required when
626
+ // the underlying query has no order — otherwise SQLite returns
627
+ // rows in storage order (not stable across page calls).
628
+ paginate(opts) {
629
+ opts = opts || {};
630
+ var limit = opts.limit === undefined ? 25 : opts.limit;
631
+ var offset = opts.offset === undefined ? 0 : opts.offset;
632
+ if (!Number.isInteger(limit) || limit <= 0 || limit > 1000) { // allow:raw-byte-literal — paginate page-size cap, not bytes
633
+ throw new Error("paginate: limit must be a positive integer ≤ 1000 (default 25)");
634
+ }
635
+ if (!Number.isInteger(offset) || offset < 0) {
636
+ throw new Error("paginate: offset must be a non-negative integer");
637
+ }
638
+ if (opts.orderBy) {
639
+ var dir = opts.orderDir || (opts.orderDirection || "asc");
640
+ this.orderBy(opts.orderBy, dir);
641
+ }
642
+ var total = this.count();
643
+ var items = this.limit(limit).offset(offset).all();
644
+ var totalPages = Math.max(1, Math.ceil(total / limit));
645
+ var page = Math.floor(offset / limit) + 1;
646
+ return {
647
+ items: items,
648
+ total: total,
649
+ limit: limit,
650
+ offset: offset,
651
+ page: page,
652
+ totalPages: totalPages,
653
+ };
654
+ }
655
+
487
656
  _delete(single) {
488
657
  if (this._where.length === 0) {
489
658
  throw new Error("refusing unconditional delete — call where(...) first");
@@ -503,6 +672,82 @@ class Query {
503
672
  }
504
673
  }
505
674
 
675
+ // WhereBuilder — sub-expression builder used by Query.whereGroup() and
676
+ // Query.orWhere((qb) => ...) to compose grouped AND/OR predicates that
677
+ // the bare .where() chain (which only ANDs) can't express.
678
+ //
679
+ // Each `.eq` / `.neq` / `.gt` / `.gte` / `.lt` / `.lte` / `.in` /
680
+ // `.like` call ANDs an expression; `.orEq` / `.orNeq` / `.orGt` /
681
+ // `.orGte` / `.orLt` / `.orLte` / `.orIn` / `.orLike` ORs an
682
+ // expression. `.raw(sql, params)` AND's an arbitrary fragment.
683
+ //
684
+ // `.build()` returns `{ sql, params }`. Empty builder → `{ sql: "",
685
+ // params: [] }`.
686
+ class WhereBuilder {
687
+ constructor() {
688
+ this._parts = []; // [{ joiner: "AND"|"OR", sql: "...", params: [...] }]
689
+ }
690
+ _push(joiner, field, op, value) {
691
+ if (typeof field !== "string" || field.length === 0) {
692
+ throw new Error("WhereBuilder: field must be a non-empty string");
693
+ }
694
+ _validateField(field);
695
+ var qf = '"' + field + '"';
696
+ if (op === "IN" || op === "NOT IN") {
697
+ if (!Array.isArray(value) || value.length === 0) {
698
+ throw new Error("WhereBuilder: " + op + " requires a non-empty array of values");
699
+ }
700
+ var placeholders = value.map(function () { return "?"; }).join(", ");
701
+ this._parts.push({ joiner: joiner, sql: qf + " " + op + " (" + placeholders + ")", params: value.slice() });
702
+ return this;
703
+ }
704
+ if (!ALLOWED_OPS.has(op)) {
705
+ throw new Error("WhereBuilder: invalid operator '" + op + "'");
706
+ }
707
+ this._parts.push({ joiner: joiner, sql: qf + " " + op + " ?", params: [value] });
708
+ return this;
709
+ }
710
+ eq(f, v) { return this._push("AND", f, "=", v); }
711
+ neq(f, v) { return this._push("AND", f, "!=", v); }
712
+ gt(f, v) { return this._push("AND", f, ">", v); }
713
+ gte(f, v) { return this._push("AND", f, ">=", v); }
714
+ lt(f, v) { return this._push("AND", f, "<", v); }
715
+ lte(f, v) { return this._push("AND", f, "<=", v); }
716
+ in(f, vs) { return this._push("AND", f, "IN", vs); }
717
+ like(f, v) { return this._push("AND", f, "LIKE", v); }
718
+ orEq(f, v) { return this._push("OR", f, "=", v); }
719
+ orNeq(f, v) { return this._push("OR", f, "!=", v); }
720
+ orGt(f, v) { return this._push("OR", f, ">", v); }
721
+ orGte(f, v) { return this._push("OR", f, ">=", v); }
722
+ orLt(f, v) { return this._push("OR", f, "<", v); }
723
+ orLte(f, v) { return this._push("OR", f, "<=", v); }
724
+ orIn(f, vs) { return this._push("OR", f, "IN", vs); }
725
+ orLike(f, v) { return this._push("OR", f, "LIKE", v); }
726
+ raw(sql, params) {
727
+ if (typeof sql !== "string" || sql.length === 0) {
728
+ throw new Error("WhereBuilder.raw: sql must be a non-empty string");
729
+ }
730
+ var p = Array.isArray(params) ? params : (params == null ? [] : [params]);
731
+ if (_countPlaceholders(sql) !== p.length) {
732
+ throw new Error("WhereBuilder.raw: placeholder count mismatch");
733
+ }
734
+ this._parts.push({ joiner: "AND", sql: "(" + sql + ")", params: p });
735
+ return this;
736
+ }
737
+ build() {
738
+ if (this._parts.length === 0) return { sql: "", params: [] };
739
+ var sql = this._parts[0].sql;
740
+ var params = this._parts[0].params.slice();
741
+ for (var i = 1; i < this._parts.length; i += 1) {
742
+ sql = sql + " " + this._parts[i].joiner + " " + this._parts[i].sql;
743
+ for (var j = 0; j < this._parts[i].params.length; j += 1) {
744
+ params.push(this._parts[i].params[j]);
745
+ }
746
+ }
747
+ return { sql: sql, params: params };
748
+ }
749
+ }
750
+
506
751
  // Count `?` placeholders outside string literals + comments.
507
752
  // Tracks SQL single-quoted, double-quoted, line-comment, and block-
508
753
  // comment state to avoid counting `?` characters that are part of
package/lib/db.js CHANGED
@@ -645,8 +645,12 @@ function resolveTmpDir(optsTmpDir) {
645
645
 
646
646
  // ---- DB encryption key management ----
647
647
 
648
- function loadOrCreateDbKey(dataDirPath) {
649
- var keyPath = path.join(dataDirPath, "db.key.enc");
648
+ function loadOrCreateDbKey(dataDirPath, keyPathOverride) {
649
+ // Operator opt: `opts.dbKeyPath` — useful when the encryption key
650
+ // needs to live outside `dataDir` (e.g. a separate volume mounted
651
+ // from a KMS-fronted secret store). Default places it next to the
652
+ // encrypted DB so backup capture is one-tarball.
653
+ var keyPath = keyPathOverride || path.join(dataDirPath, "db.key.enc");
650
654
  if (fs.existsSync(keyPath)) {
651
655
  var sealed = atomicFile.readSync(keyPath, { encoding: "utf8" }).trim();
652
656
  var b64 = vault.unseal(sealed);
@@ -703,6 +707,50 @@ function encryptToDisk() {
703
707
  atomicFile.writeSync(encPath, encryptPacked(fs.readFileSync(dbPath), encKey, _dbEncAad(dataDir)));
704
708
  }
705
709
 
710
+ /**
711
+ * @primitive b.db.snapshot
712
+ * @signature b.db.snapshot()
713
+ * @since 0.8.58
714
+ * @status stable
715
+ * @related b.db.flushToDisk, b.backup
716
+ *
717
+ * In-memory encrypted snapshot — same envelope shape that
718
+ * `flushToDisk` writes, just held in memory. Operators capturing a
719
+ * backup mid-flight (`b.backup` wrapping a hot DB) get a Buffer they
720
+ * can stream onward to object storage without touching the on-disk
721
+ * encPath. Forces a WAL checkpoint first so the snapshot reflects
722
+ * committed state, not pre-WAL pages.
723
+ *
724
+ * Under `atRest: 'plain'` returns the raw plaintext SQLite file as a
725
+ * Buffer (no envelope), since there's no encryption key to apply —
726
+ * operators wanting an encrypted snapshot under plain mode wrap with
727
+ * their own `b.crypto.encryptPacked` at the call site.
728
+ *
729
+ * @example
730
+ * var b = require("@blamejs/core");
731
+ * var snap = b.db.snapshot();
732
+ * await b.objectStore.put("backups/" + Date.now() + ".enc", snap);
733
+ */
734
+ function snapshot() {
735
+ _requireInit();
736
+ // WAL checkpoint flushes committed transactions into the main DB file
737
+ // so the snapshot reflects the current logical state, not just the
738
+ // pre-WAL pages.
739
+ try { runSql(database, "PRAGMA wal_checkpoint(TRUNCATE)"); } catch (_e) { /* best effort */ }
740
+ if (!fs.existsSync(dbPath)) {
741
+ throw _dbErr("db/snapshot-no-source",
742
+ "snapshot: plaintext DB at " + dbPath + " is missing — did init complete?");
743
+ }
744
+ var plain = fs.readFileSync(dbPath);
745
+ if (!encPath || !encKey) {
746
+ // atRest: 'plain' — return the raw bytes. Operators wanting an
747
+ // encrypted snapshot under plain mode wrap with their own
748
+ // b.crypto.encryptPacked at the call site.
749
+ return plain;
750
+ }
751
+ return encryptPacked(plain, encKey, _dbEncAad(dataDir));
752
+ }
753
+
706
754
  // Remove the plaintext DB + WAL/SHM sidecar files. On Windows these can't be
707
755
  // unlinked while the SQLite handle is open, so this MUST be called after
708
756
  // database.close().
@@ -846,9 +894,14 @@ async function init(opts) {
846
894
  }
847
895
  }
848
896
 
849
- encPath = path.join(dataDir, "db.enc");
897
+ // Operator overrides for the encrypted-DB on-disk path. `opts.encryptedDbPath`
898
+ // takes a fully-qualified path; `opts.encryptedDbName` overrides
899
+ // just the basename under `dataDir` (default "db.enc"). Helps when
900
+ // multiple framework-shaped instances share a dataDir.
901
+ encPath = opts.encryptedDbPath ||
902
+ path.join(dataDir, opts.encryptedDbName || "db.enc");
850
903
  dbPath = path.join(tmpDir, "blamejs-" + generateToken(C.BYTES.bytes(16)) + ".db");
851
- encKey = loadOrCreateDbKey(dataDir);
904
+ encKey = loadOrCreateDbKey(dataDir, opts.dbKeyPath);
852
905
 
853
906
  cleanStaleTmpDbs(tmpDir);
854
907
  decryptToTmp();
@@ -945,14 +998,27 @@ async function init(opts) {
945
998
  // framework would silently provision it next to the reserved
946
999
  // namespace, allowing a row-by-row look-alike attack against audit
947
1000
  // archive tooling.
1001
+ // Under `frameworkTables: false` the framework's own audit_log /
1002
+ // consent_log are NOT provisioned, so an operator naming a table
1003
+ // `audit_log` (or `consent_log`) doesn't collide. The `_blamejs_*`
1004
+ // prefix stays reserved unconditionally — those names are
1005
+ // hard-claimed by other framework primitives (sessions, jobs,
1006
+ // migrations, rate-limit-counters, …) which still get provisioned
1007
+ // by their respective subsystems.
1008
+ var frameworkTablesEarly = opts.frameworkTables !== false;
1009
+ var FRAMEWORK_NAMED_RESERVED = frameworkTablesEarly
1010
+ ? RESERVED_TABLE_NAMES
1011
+ : new Set(); // empty — fall back to the prefix check only
948
1012
  for (var ri = 0; ri < opts.schema.length; ri++) {
949
1013
  var appName = opts.schema[ri].name;
950
- if (RESERVED_TABLE_NAMES.has(appName) ||
1014
+ if (FRAMEWORK_NAMED_RESERVED.has(appName) ||
951
1015
  (typeof appName === "string" && appName.indexOf("_blamejs_") === 0)) {
952
1016
  throw new DbError("db/reserved-table-name",
953
1017
  "table name '" + appName + "' is reserved by the framework. " +
954
1018
  "Pick a different name (the framework provisions audit_log, consent_log, " +
955
- "and any '_blamejs_*'-prefixed tables automatically).");
1019
+ "and any '_blamejs_*'-prefixed tables automatically). " +
1020
+ "Pass opts.frameworkTables: false to skip provisioning audit_log/consent_log " +
1021
+ "when the host application owns its own audit chain.");
956
1022
  }
957
1023
  }
958
1024
 
@@ -1019,10 +1085,31 @@ async function init(opts) {
1019
1085
  }
1020
1086
  }
1021
1087
 
1088
+ // Operator opt-out for the framework's own tables + audit/consent
1089
+ // chain machinery + WORM assertion + audit-signing bootstrap. Set
1090
+ // `frameworkTables: false` when the host application maintains its
1091
+ // own audit/consent semantics and just wants the framework's
1092
+ // primitives (vault / db / cryptoField / etc.) without the bundled
1093
+ // chain tables. When OFF, every framework-table-dependent step
1094
+ // below is a no-op. Append-only triggers are scoped to the
1095
+ // framework tables only, so they're skipped too.
1096
+ //
1097
+ // `auditSigning: false` is a finer-grained gate — keep the
1098
+ // framework tables but skip the audit-signing-key bootstrap (HS-
1099
+ // shape deployments that already manage their own signing key).
1100
+ //
1101
+ // Defaults match v0.8.57 behavior: both ON.
1102
+ var frameworkTablesEnabled = opts.frameworkTables !== false;
1103
+ var auditSigningEnabled = opts.auditSigning !== false;
1104
+
1022
1105
  // Build the full schema = framework-baked tables + app tables.
1023
1106
  // Framework tables come FIRST so audit_log/consent_log exist before any
1024
- // app migration can reference them.
1025
- var fullSchema = FRAMEWORK_SCHEMA.concat(opts.schema);
1107
+ // app migration can reference them. When `frameworkTables: false`,
1108
+ // skip the concat so the operator's own `audit_log` (or whatever
1109
+ // shape) doesn't collide with the framework's.
1110
+ var fullSchema = frameworkTablesEnabled
1111
+ ? FRAMEWORK_SCHEMA.concat(opts.schema)
1112
+ : opts.schema.slice();
1026
1113
 
1027
1114
  // Register schema with field-crypto + capture table metadata snapshot
1028
1115
  // (framework tables included so getTableMetadata covers everything).
@@ -1055,8 +1142,8 @@ async function init(opts) {
1055
1142
  // or malicious tampering — independent of the API surface's discipline.
1056
1143
  // Operator-driven retention purge (when implemented) must drop these
1057
1144
  // triggers explicitly inside a transaction, perform the purge, and
1058
- // recreate them.
1059
- _installAppendOnlyTriggers(database);
1145
+ // recreate them. Skipped under `frameworkTables: false`.
1146
+ if (frameworkTablesEnabled) _installAppendOnlyTriggers(database);
1060
1147
 
1061
1148
  // Imperative migrations (run once each, in order)
1062
1149
  if (opts.migrationDir) {
@@ -1081,29 +1168,33 @@ async function init(opts) {
1081
1168
  // means tamper-evidence has been compromised — the framework refuses
1082
1169
  // to continue under any circumstances. Recovery is operator-driven
1083
1170
  // (restore from backup or manual chain rebuild); the framework only
1084
- // detects-and-fails.
1085
- var auditResult = await audit.verify();
1086
- if (!auditResult.ok) {
1087
- // Fire the breach event BEFORE throwing so operator listeners get
1088
- // a chance at sync I/O (file flag, console alert) before init
1089
- // unwinds.
1090
- events.emit(events.EVENTS.AUDIT_CHAIN_BREAK, { table: "audit_log", result: auditResult });
1091
- throw _dbErr("db/audit-chain-break",
1092
- "FATAL: audit_log chain integrity broken at row " + auditResult.breakAt +
1093
- " (" + auditResult.reason + "); break row _id: " + auditResult.breakRowId +
1094
- "; expected: " + auditResult.expected + "; actual: " + auditResult.actual +
1095
- ". Refusing to boot. Compliance requires that any tamper-detection signal halt service. " +
1096
- "Recovery is manual: restore from backup, or rebuild the audit chain from a verified earlier snapshot.");
1097
- }
1098
- var consentResult = await consent.verify();
1099
- if (!consentResult.ok) {
1100
- events.emit(events.EVENTS.AUDIT_CHAIN_BREAK, { table: "consent_log", result: consentResult });
1101
- throw _dbErr("db/consent-chain-break",
1102
- "FATAL: consent_log chain integrity broken at row " + consentResult.breakAt +
1103
- " (" + consentResult.reason + "); break row _id: " + consentResult.breakRowId +
1104
- ". Refusing to boot.");
1105
- }
1106
- log("audit chain ok (" + auditResult.rowsVerified + " rows), consent chain ok (" + consentResult.rowsVerified + " rows)");
1171
+ // detects-and-fails. Skipped under `frameworkTables: false` (the
1172
+ // framework's audit_log / consent_log don't exist for an operator
1173
+ // running their own audit subsystem).
1174
+ if (frameworkTablesEnabled) {
1175
+ var auditResult = await audit.verify();
1176
+ if (!auditResult.ok) {
1177
+ // Fire the breach event BEFORE throwing so operator listeners
1178
+ // get a chance at sync I/O (file flag, console alert) before
1179
+ // init unwinds.
1180
+ events.emit(events.EVENTS.AUDIT_CHAIN_BREAK, { table: "audit_log", result: auditResult });
1181
+ throw _dbErr("db/audit-chain-break",
1182
+ "FATAL: audit_log chain integrity broken at row " + auditResult.breakAt +
1183
+ " (" + auditResult.reason + "); break row _id: " + auditResult.breakRowId +
1184
+ "; expected: " + auditResult.expected + "; actual: " + auditResult.actual +
1185
+ ". Refusing to boot. Compliance requires that any tamper-detection signal halt service. " +
1186
+ "Recovery is manual: restore from backup, or rebuild the audit chain from a verified earlier snapshot.");
1187
+ }
1188
+ var consentResult = await consent.verify();
1189
+ if (!consentResult.ok) {
1190
+ events.emit(events.EVENTS.AUDIT_CHAIN_BREAK, { table: "consent_log", result: consentResult });
1191
+ throw _dbErr("db/consent-chain-break",
1192
+ "FATAL: consent_log chain integrity broken at row " + consentResult.breakAt +
1193
+ " (" + consentResult.reason + "); break row _id: " + consentResult.breakRowId +
1194
+ ". Refusing to boot.");
1195
+ }
1196
+ log("audit chain ok (" + auditResult.rowsVerified + " rows), consent chain ok (" + consentResult.rowsVerified + " rows)");
1197
+ }
1107
1198
 
1108
1199
  // ---- Rollback detection (audit.tip sidecar) ----
1109
1200
  // The framework writes <dataDir>/audit.tip on each checkpoint. At boot we
@@ -1116,11 +1207,15 @@ async function init(opts) {
1116
1207
  // MUST have declared row-level WORM on at least one business-record
1117
1208
  // table. Refuse boot otherwise so missing-declaration drift is
1118
1209
  // surfaced at start-up, not on the first delete.
1119
- try { _assertWormUnderPosture(); }
1120
- catch (e) {
1121
- // The assertion throws under regulated postures; let it
1122
- // propagate. Outside regulated postures it's a no-op.
1123
- throw e;
1210
+ // Skipped under `frameworkTables: false` — WORM declarations are
1211
+ // an operator-side concern when the framework isn't owning audit.
1212
+ if (frameworkTablesEnabled) {
1213
+ try { _assertWormUnderPosture(); }
1214
+ catch (e) {
1215
+ // The assertion throws under regulated postures; let it
1216
+ // propagate. Outside regulated postures it's a no-op.
1217
+ throw e;
1218
+ }
1124
1219
  }
1125
1220
 
1126
1221
  // ---- Audit-signing key + checkpoint subsystem ----
@@ -1132,37 +1227,46 @@ async function init(opts) {
1132
1227
  // SHAKE-family hash posture); ML-DSA-87 is the throughput-focused
1133
1228
  // opt-in. Existing key files take their algorithm from disk; this
1134
1229
  // option only matters on first generation.
1135
- var auditSigningMode = (opts.auditSigning && opts.auditSigning.mode)
1136
- ? opts.auditSigning.mode
1137
- : safeEnv.readVar("BLAMEJS_AUDIT_SIGNING_MODE", {
1138
- default: "wrapped",
1139
- enum: ["wrapped", "plaintext"],
1140
- });
1141
- var auditSigningAlg = opts.auditSigning && opts.auditSigning.algorithm
1142
- ? opts.auditSigning.algorithm
1143
- : null;
1144
- await auditSign.init({
1145
- dataDir: dataDir,
1146
- mode: auditSigningMode,
1147
- algorithm: auditSigningAlg || undefined,
1148
- });
1149
-
1150
- // Verify all existing checkpoint signatures (defense against signature
1151
- // forgery attempt + key-rotation gone wrong). Refuse to boot on failure.
1152
- var ckptResult = await audit.verifyCheckpoints();
1153
- if (!ckptResult.ok) {
1154
- events.emit(events.EVENTS.AUDIT_CHECKPOINT_BREAK, { result: ckptResult });
1155
- throw _dbErr("db/audit-checkpoint-break",
1156
- "FATAL: audit checkpoint verification failed at row " +
1157
- ckptResult.breakAt + " (" + ckptResult.reason + "); checkpoint _id: " +
1158
- ckptResult.checkpointId + ". Refusing to boot. Either the audit-signing key " +
1159
- "was rotated without retaining the prior pubkey, or a forged checkpoint was inserted.");
1230
+ // Operator opt-out via `auditSigning: false` skips the signing
1231
+ // bootstrap entirely. Also implicitly skipped when frameworkTables
1232
+ // are off (no audit_log to sign checkpoints over).
1233
+ if (auditSigningEnabled && frameworkTablesEnabled) {
1234
+ var auditSigningMode = (opts.auditSigning && opts.auditSigning.mode)
1235
+ ? opts.auditSigning.mode
1236
+ : safeEnv.readVar("BLAMEJS_AUDIT_SIGNING_MODE", {
1237
+ default: "wrapped",
1238
+ enum: ["wrapped", "plaintext"],
1239
+ });
1240
+ var auditSigningAlg = opts.auditSigning && opts.auditSigning.algorithm
1241
+ ? opts.auditSigning.algorithm
1242
+ : null;
1243
+ await auditSign.init({
1244
+ dataDir: dataDir,
1245
+ mode: auditSigningMode,
1246
+ algorithm: auditSigningAlg || undefined,
1247
+ });
1160
1248
  }
1161
- log("audit checkpoints ok (" + ckptResult.checkpointsVerified + " signed)");
1162
1249
 
1163
- // Anchor a fresh checkpoint at boot if there's any new audit activity
1164
- // since the last checkpoint (else no-op).
1165
- await audit.checkpoint({ skipIfUnchanged: true });
1250
+ // Verify all existing checkpoint signatures (defense against
1251
+ // signature forgery attempt + key-rotation gone wrong). Refuse to
1252
+ // boot on failure. Skipped under `frameworkTables: false` /
1253
+ // `auditSigning: false`.
1254
+ if (frameworkTablesEnabled && auditSigningEnabled) {
1255
+ var ckptResult = await audit.verifyCheckpoints();
1256
+ if (!ckptResult.ok) {
1257
+ events.emit(events.EVENTS.AUDIT_CHECKPOINT_BREAK, { result: ckptResult });
1258
+ throw _dbErr("db/audit-checkpoint-break",
1259
+ "FATAL: audit checkpoint verification failed at row " +
1260
+ ckptResult.breakAt + " (" + ckptResult.reason + "); checkpoint _id: " +
1261
+ ckptResult.checkpointId + ". Refusing to boot. Either the audit-signing key " +
1262
+ "was rotated without retaining the prior pubkey, or a forged checkpoint was inserted.");
1263
+ }
1264
+ log("audit checkpoints ok (" + ckptResult.checkpointsVerified + " signed)");
1265
+
1266
+ // Anchor a fresh checkpoint at boot if there's any new audit
1267
+ // activity since the last checkpoint (else no-op).
1268
+ await audit.checkpoint({ skipIfUnchanged: true });
1269
+ }
1166
1270
 
1167
1271
  // ---- NTP drift check ----
1168
1272
  // Best-effort; unreachable NTP doesn't fail boot, but >= 1hr drift does
@@ -2761,6 +2865,7 @@ module.exports = {
2761
2865
  getActivePosture: getActivePosture,
2762
2866
  vacuumAfterErase: vacuumAfterErase,
2763
2867
  from: from,
2868
+ collection: require("./db-collection").collection, // allow:inline-require — db-collection lazy-requires db.js back; the inline require here breaks the cycle without needing a stub
2764
2869
  prepare: prepare,
2765
2870
  stream: stream,
2766
2871
  // D-M5 — runtime read-only accessor so Query.stream picks up the
@@ -2782,6 +2887,7 @@ module.exports = {
2782
2887
  // the snapshot source. Safe to call any time; no-op when no encPath
2783
2888
  // (plain mode) or when the plaintext DB doesn't exist.
2784
2889
  flushToDisk: encryptToDisk,
2890
+ snapshot: snapshot,
2785
2891
  // integrityCheck — runs PRAGMA integrity_check against the live db
2786
2892
  // and returns "ok" on success, an array of corruption lines
2787
2893
  // otherwise. Operators wire this into a periodic monitor or a
package/lib/mtls-ca.js CHANGED
@@ -97,14 +97,24 @@ var DEFAULT_PATHS = {
97
97
 
98
98
  var VALID_SEAL_MODES = { required: 1, disabled: 1 };
99
99
 
100
+ // Resolve relative path entries under `dataDir`; pass absolute paths
101
+ // through unchanged. The pre-v0.8.58 shape always joined under
102
+ // dataDir, which silently overrode an operator-supplied absolute
103
+ // path (e.g. `MTLS_CA_KEY=/etc/ssl/ca.key` → `<dataDir>/etc/ssl/ca.key`).
104
+ // Standard Node `path.join` semantics already preserve absolute
105
+ // arguments — the always-join was an oversight, not by design.
106
+ function _absoluteOrUnderDataDir(dataDir, p) {
107
+ return path.isAbsolute(p) ? p : path.join(dataDir, p);
108
+ }
109
+
100
110
  function _resolvePaths(dataDir, paths) {
101
111
  var p = Object.assign({}, DEFAULT_PATHS, paths || {});
102
112
  return {
103
- caKey: path.join(dataDir, p.caKey),
104
- caKeySealed: path.join(dataDir, p.caKeySealed),
105
- caCert: path.join(dataDir, p.caCert),
106
- revocations: path.join(dataDir, p.revocations),
107
- crl: path.join(dataDir, p.crl),
113
+ caKey: _absoluteOrUnderDataDir(dataDir, p.caKey),
114
+ caKeySealed: _absoluteOrUnderDataDir(dataDir, p.caKeySealed),
115
+ caCert: _absoluteOrUnderDataDir(dataDir, p.caCert),
116
+ revocations: _absoluteOrUnderDataDir(dataDir, p.revocations),
117
+ crl: _absoluteOrUnderDataDir(dataDir, p.crl),
108
118
  };
109
119
  }
110
120
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.57",
3
+ "version": "0.8.58",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:e7403a73-fc4f-40d0-ac54-feff61e9d996",
5
+ "serialNumber": "urn:uuid:61ec0413-0749-49b2-a43c-f6557db2156d",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-09T22:59:08.864Z",
8
+ "timestamp": "2026-05-09T23:55:32.085Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.8.57",
22
+ "bom-ref": "@blamejs/core@0.8.58",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.57",
25
+ "version": "0.8.58",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.8.57",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.58",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.8.57",
57
+ "ref": "@blamejs/core@0.8.58",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]