@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 +1 -0
- package/lib/db-collection.js +290 -0
- package/lib/db-query.js +245 -0
- package/lib/db.js +173 -67
- package/lib/mtls-ca.js +15 -5
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
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
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
|
|
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:
|
|
104
|
-
caKeySealed:
|
|
105
|
-
caCert:
|
|
106
|
-
revocations:
|
|
107
|
-
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
package/sbom.cyclonedx.json
CHANGED
|
@@ -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:
|
|
5
|
+
"serialNumber": "urn:uuid:61ec0413-0749-49b2-a43c-f6557db2156d",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.8.58",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.8.
|
|
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.
|
|
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
|
+
"ref": "@blamejs/core@0.8.58",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|