@blamejs/core 0.8.60 → 0.8.66
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 +6 -0
- package/README.md +2 -2
- package/index.js +11 -0
- package/lib/audit.js +1 -0
- package/lib/auth/ciba.js +538 -0
- package/lib/auth/oauth.js +199 -11
- package/lib/auth/oid4vci.js +588 -0
- package/lib/auth/oid4vp.js +514 -0
- package/lib/auth/openid-federation.js +523 -0
- package/lib/auth/saml.js +636 -0
- package/lib/auth/sd-jwt-vc-holder.js +30 -8
- package/lib/auth/sd-jwt-vc.js +61 -7
- package/lib/db-collection.js +402 -105
- package/lib/db-file-lifecycle.js +333 -0
- package/lib/session-stores.js +138 -0
- package/lib/session.js +437 -20
- package/lib/validate-opts.js +41 -0
- package/lib/xml-c14n.js +499 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/db-collection.js
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* the document-store call shape.
|
|
12
12
|
*
|
|
13
13
|
* @intro
|
|
14
|
-
* `b.db.collection(name)` returns a small adapter that maps
|
|
15
|
-
* shape calls onto the framework's query-builder primitives:
|
|
14
|
+
* `b.db.collection(name, opts?)` returns a small adapter that maps
|
|
15
|
+
* Mongo-shape calls onto the framework's query-builder primitives:
|
|
16
16
|
*
|
|
17
17
|
* b.db.collection("users").findOne({ email: "alice@x.com" });
|
|
18
18
|
* → b.db.from("users").where({ email: "alice@x.com" }).first();
|
|
@@ -23,80 +23,96 @@
|
|
|
23
23
|
* b.db.collection("users").update({ _id }, { $inc: { failed: 1 } });
|
|
24
24
|
* → b.db.from("users").where({ _id }).increment("failed", 1);
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* builder directly — `b.db.from(...)` is more expressive and
|
|
30
|
-
* doesn't pretend to be Mongo.
|
|
26
|
+
* Schemaless-document support — three opts compose to give a
|
|
27
|
+
* document-store-shaped collection on top of the relational
|
|
28
|
+
* schema:
|
|
31
29
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
30
|
+
* b.db.collection("users", {
|
|
31
|
+
* overflow: "data", // unknown fields fold into this JSON-text column
|
|
32
|
+
* jsonColumns: ["roles", "metadata"], // listed columns auto-parsed on read, stringified on write
|
|
33
|
+
* sealedFields: { email: "emailHash" }, // registers cryptoField derivedHash so where({email}) rewrites
|
|
34
|
+
* });
|
|
35
|
+
*
|
|
36
|
+
* Supported update operators: `$set` (assign — overflow-aware),
|
|
37
|
+
* `$inc` (atomic increment per real column — composes
|
|
38
|
+
* `Query.increment`; refused on overflow fields), `$unset` (set to
|
|
39
|
+
* NULL on real columns; remove the key from the overflow JSON).
|
|
40
|
+
*
|
|
41
|
+
* Query operators: `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte` /
|
|
42
|
+
* `$in` / `$like`. Overflow fields support `$eq` / `$ne` / `$in`
|
|
43
|
+
* only — range / LIKE require a real column with an index.
|
|
35
44
|
*/
|
|
36
45
|
|
|
37
|
-
var lazyRequire
|
|
46
|
+
var lazyRequire = require("./lazy-require");
|
|
47
|
+
var safeJson = require("./safe-json");
|
|
48
|
+
var validateOpts = require("./validate-opts");
|
|
38
49
|
|
|
39
50
|
// db.js → db-collection.js → db.from() would create a require cycle.
|
|
40
51
|
// Defer the lookup to call-time so the binding lands after both
|
|
41
52
|
// modules finish loading.
|
|
42
53
|
var db = lazyRequire(function () { return require("./db"); });
|
|
43
54
|
|
|
55
|
+
// cryptoField is loaded eagerly at top — no cycle (cryptoField does
|
|
56
|
+
// not require db-collection).
|
|
57
|
+
var cryptoField = require("./crypto-field");
|
|
58
|
+
|
|
44
59
|
function _validateQueryShape(query) {
|
|
45
60
|
if (!query || typeof query !== "object" || Array.isArray(query)) {
|
|
46
61
|
throw new TypeError("collection: query must be a plain object");
|
|
47
62
|
}
|
|
48
63
|
}
|
|
49
64
|
|
|
50
|
-
function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
}
|
|
65
|
+
function _validateConstructorOpts(opts, name) {
|
|
66
|
+
if (opts.overflow !== undefined && opts.overflow !== null) {
|
|
67
|
+
validateOpts.requireNonEmptyString(opts.overflow,
|
|
68
|
+
"collection(" + name + "): opts.overflow",
|
|
69
|
+
TypeError, "db-collection/bad-overflow");
|
|
70
|
+
}
|
|
71
|
+
validateOpts.optionalNonEmptyStringArray(opts.jsonColumns,
|
|
72
|
+
"collection(" + name + "): opts.jsonColumns",
|
|
73
|
+
TypeError, "db-collection/bad-json-columns");
|
|
74
|
+
validateOpts.optionalNonEmptyStringArray(opts.columns,
|
|
75
|
+
"collection(" + name + "): opts.columns",
|
|
76
|
+
TypeError, "db-collection/bad-columns");
|
|
77
|
+
validateOpts.optionalPlainObject(opts.sealedFields,
|
|
78
|
+
"collection(" + name + "): opts.sealedFields",
|
|
79
|
+
TypeError, "db-collection/bad-sealed-fields",
|
|
80
|
+
"must be a { plain: hashColumn } map");
|
|
81
|
+
if (opts.sealedFields !== undefined && opts.sealedFields !== null) {
|
|
82
|
+
Object.keys(opts.sealedFields).forEach(function (plain) {
|
|
83
|
+
if (typeof opts.sealedFields[plain] !== "string" || opts.sealedFields[plain].length === 0) {
|
|
84
|
+
throw new TypeError("collection(" + name + "): sealedFields['" + plain + "'] must be a hash-column name");
|
|
89
85
|
}
|
|
90
|
-
}
|
|
91
|
-
builder.where(k, "=", v);
|
|
92
|
-
}
|
|
86
|
+
});
|
|
93
87
|
}
|
|
94
88
|
}
|
|
95
89
|
|
|
90
|
+
// Merge sealedFields declarations into the cryptoField registry so the
|
|
91
|
+
// existing query-rewrite path in db-query.js (`_isSealedField` →
|
|
92
|
+
// `cryptoField.lookupHash`) picks them up. Idempotent — re-declaring
|
|
93
|
+
// the same mapping is a no-op; declaring a new mapping for an existing
|
|
94
|
+
// table extends the existing record without dropping prior fields.
|
|
95
|
+
function _registerSealedFields(table, sealedFields) {
|
|
96
|
+
var existing = cryptoField.getSchema(table) || {
|
|
97
|
+
sealedFields: [],
|
|
98
|
+
derivedHashes: {},
|
|
99
|
+
hashNamespaces: {},
|
|
100
|
+
};
|
|
101
|
+
var nextSealed = existing.sealedFields.slice();
|
|
102
|
+
var nextDerived = Object.assign({}, existing.derivedHashes);
|
|
103
|
+
Object.keys(sealedFields).forEach(function (plain) {
|
|
104
|
+
var hashCol = sealedFields[plain];
|
|
105
|
+
if (nextSealed.indexOf(plain) === -1) nextSealed.push(plain);
|
|
106
|
+
if (!nextDerived[hashCol]) nextDerived[hashCol] = { from: plain };
|
|
107
|
+
});
|
|
108
|
+
cryptoField.registerTable(table, {
|
|
109
|
+
sealedFields: nextSealed,
|
|
110
|
+
derivedHashes: nextDerived,
|
|
111
|
+
hashNamespaces: existing.hashNamespaces,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
96
115
|
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
116
|
if (!update || typeof update !== "object" || Array.isArray(update)) {
|
|
101
117
|
throw new TypeError("collection: update must be a plain object");
|
|
102
118
|
}
|
|
@@ -135,7 +151,7 @@ function _splitUpdateOperators(update) {
|
|
|
135
151
|
|
|
136
152
|
/**
|
|
137
153
|
* @primitive b.db.collection
|
|
138
|
-
* @signature b.db.collection(name)
|
|
154
|
+
* @signature b.db.collection(name, opts?)
|
|
139
155
|
* @since 0.8.58
|
|
140
156
|
* @status stable
|
|
141
157
|
* @related b.db.from, b.db
|
|
@@ -145,39 +161,265 @@ function _splitUpdateOperators(update) {
|
|
|
145
161
|
* semantics, derived-hash translation, and audit emission carry
|
|
146
162
|
* through unchanged.
|
|
147
163
|
*
|
|
164
|
+
* Pass `opts` to enable schemaless-document features:
|
|
165
|
+
*
|
|
166
|
+
* - `overflow: "data"` — unknown insert/update fields fold into the
|
|
167
|
+
* named JSON-text column. `find` / `findOne` parse that column
|
|
168
|
+
* and merge its keys back onto the row. WHERE on an unknown field
|
|
169
|
+
* rewrites to `JSON_EXTRACT(<overflow>, '$.field')` (`$eq` / `$ne`
|
|
170
|
+
* / `$in` only — range / LIKE require a real column with an
|
|
171
|
+
* index).
|
|
172
|
+
* - `jsonColumns: ["roles", "metadata"]` — listed columns are
|
|
173
|
+
* `JSON.stringify`'d on write and parsed via `b.safeJson` on read.
|
|
174
|
+
* - `sealedFields: { email: "emailHash" }` — co-locates a sealed-
|
|
175
|
+
* column / derived-hash declaration with the collection. The
|
|
176
|
+
* plaintext field is registered as sealed; the hash column is
|
|
177
|
+
* registered as a `derivedHashes[hashCol] = { from: plain }`
|
|
178
|
+
* mapping in `b.cryptoField`. Subsequent `where({ email: "x" })`
|
|
179
|
+
* calls automatically rewrite to `where({ emailHash: <hash> })`
|
|
180
|
+
* via the existing query-builder rewrite path.
|
|
181
|
+
* - `columns: ["_id", "email", ...]` — explicit column whitelist.
|
|
182
|
+
* If omitted, the framework introspects via `PRAGMA table_info`
|
|
183
|
+
* once at first use and caches.
|
|
184
|
+
*
|
|
185
|
+
* @opts
|
|
186
|
+
* {
|
|
187
|
+
* overflow?: string, // JSON-text column for unknown fields (off when absent)
|
|
188
|
+
* jsonColumns?: string[], // auto-stringify on write, auto-parse on read
|
|
189
|
+
* sealedFields?: { [plain: string]: string }, // plain column → hash column; registers via b.cryptoField
|
|
190
|
+
* columns?: string[], // explicit column whitelist (defaults to PRAGMA introspection)
|
|
191
|
+
* }
|
|
192
|
+
*
|
|
148
193
|
* @example
|
|
149
194
|
* var b = require("@blamejs/core");
|
|
150
195
|
* await b.db.init({ dataDir: "/tmp/data", schema: [{
|
|
151
196
|
* name: "users",
|
|
152
|
-
* columns: {
|
|
197
|
+
* columns: {
|
|
198
|
+
* _id: "TEXT PRIMARY KEY",
|
|
199
|
+
* email: "TEXT",
|
|
200
|
+
* emailHash: "TEXT",
|
|
201
|
+
* roles: "TEXT",
|
|
202
|
+
* data: "TEXT",
|
|
203
|
+
* },
|
|
153
204
|
* }] });
|
|
154
|
-
* var users = b.db.collection("users"
|
|
155
|
-
*
|
|
205
|
+
* var users = b.db.collection("users", {
|
|
206
|
+
* overflow: "data",
|
|
207
|
+
* jsonColumns: ["roles"],
|
|
208
|
+
* sealedFields: { email: "emailHash" },
|
|
209
|
+
* });
|
|
210
|
+
* users.insert({ _id: "u1", email: "alice@x.com", roles: ["admin"], dept: "eng", joined: "2026-01-01" });
|
|
211
|
+
* // → roles is JSON-stringified; dept + joined fold into data; email seals + emailHash derives
|
|
156
212
|
* users.findOne({ email: "alice@x.com" });
|
|
157
|
-
*
|
|
158
|
-
* users.
|
|
159
|
-
*
|
|
213
|
+
* // → { _id: "u1", email: "alice@x.com", roles: ["admin"], dept: "eng", joined: "2026-01-01" }
|
|
214
|
+
* users.find({ dept: "eng" });
|
|
215
|
+
* // → JSON_EXTRACT(data, '$.dept') = 'eng'
|
|
160
216
|
*/
|
|
161
|
-
function collection(name) {
|
|
162
|
-
|
|
163
|
-
|
|
217
|
+
function collection(name, opts) {
|
|
218
|
+
validateOpts.requireNonEmptyString(name, "collection(name): name", TypeError, "db-collection/bad-name");
|
|
219
|
+
opts = opts || {};
|
|
220
|
+
validateOpts.optionalPlainObject(opts, "collection: opts", TypeError, "db-collection/bad-opts",
|
|
221
|
+
"must be a plain object");
|
|
222
|
+
_validateConstructorOpts(opts, name);
|
|
223
|
+
|
|
224
|
+
var overflow = opts.overflow ? String(opts.overflow) : null;
|
|
225
|
+
var jsonCols = Array.isArray(opts.jsonColumns) ? opts.jsonColumns.slice() : [];
|
|
226
|
+
var sealedFields = opts.sealedFields || null;
|
|
227
|
+
var explicitCols = Array.isArray(opts.columns) ? opts.columns.slice() : null;
|
|
228
|
+
|
|
229
|
+
if (sealedFields) _registerSealedFields(name, sealedFields);
|
|
230
|
+
|
|
231
|
+
var _columnsCache = null;
|
|
232
|
+
function _columns() {
|
|
233
|
+
if (_columnsCache) return _columnsCache;
|
|
234
|
+
if (explicitCols) { _columnsCache = explicitCols.slice(); return _columnsCache; }
|
|
235
|
+
// PRAGMA table_info doesn't accept positional binding; the table
|
|
236
|
+
// name has already been validated by db.from() once a Query is
|
|
237
|
+
// built. Validate again here for the introspection path: the
|
|
238
|
+
// existing schemaless-overflow detection runs BEFORE any Query
|
|
239
|
+
// construction, so a malformed name would otherwise reach
|
|
240
|
+
// PRAGMA's identifier interpolation. Reuses the same allow-list
|
|
241
|
+
// safe-sql identifier check the chainable builder uses.
|
|
242
|
+
var safeSql = require("./safe-sql"); // allow:inline-require — keep the safe-sql edge off the module-load hot path
|
|
243
|
+
safeSql.validateIdentifier(name, { allowReserved: true });
|
|
244
|
+
var rows;
|
|
245
|
+
try { rows = db().prepare("PRAGMA table_info(\"" + name + "\")").all(); }
|
|
246
|
+
catch (e) {
|
|
247
|
+
throw new Error("collection(" + name + "): unable to introspect column list — " +
|
|
248
|
+
"either pass `columns: [...]` explicitly OR call b.db.collection() AFTER " +
|
|
249
|
+
"b.db.init() resolves. Underlying error: " + ((e && e.message) || String(e)));
|
|
250
|
+
}
|
|
251
|
+
if (!rows || rows.length === 0) {
|
|
252
|
+
throw new Error("collection(" + name + "): table has no columns OR does not exist. " +
|
|
253
|
+
"Pass `columns: [...]` explicitly to bypass introspection.");
|
|
254
|
+
}
|
|
255
|
+
_columnsCache = rows.map(function (r) { return r.name; });
|
|
256
|
+
if (overflow && _columnsCache.indexOf(overflow) === -1) {
|
|
257
|
+
throw new Error("collection(" + name + "): overflow column '" + overflow +
|
|
258
|
+
"' not present on table — declare it in your schema as TEXT");
|
|
259
|
+
}
|
|
260
|
+
var missingJson = jsonCols.filter(function (c) { return _columnsCache.indexOf(c) === -1; });
|
|
261
|
+
if (missingJson.length > 0) {
|
|
262
|
+
throw new Error("collection(" + name + "): jsonColumns reference unknown columns " +
|
|
263
|
+
JSON.stringify(missingJson));
|
|
264
|
+
}
|
|
265
|
+
return _columnsCache;
|
|
164
266
|
}
|
|
267
|
+
|
|
268
|
+
function _stringifyJsonCols(row) {
|
|
269
|
+
if (jsonCols.length === 0) return row;
|
|
270
|
+
// Force column-list resolution so jsonColumns referencing unknown
|
|
271
|
+
// columns surface at first use rather than silently producing
|
|
272
|
+
// bad SQL bindings later.
|
|
273
|
+
_columns();
|
|
274
|
+
var out = Object.assign({}, row);
|
|
275
|
+
jsonCols.forEach(function (c) {
|
|
276
|
+
if (out[c] !== undefined && out[c] !== null && typeof out[c] !== "string") {
|
|
277
|
+
out[c] = JSON.stringify(out[c]);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function _prepareWriteDoc(doc) {
|
|
284
|
+
var writeDoc = Object.assign({}, doc);
|
|
285
|
+
if (overflow) {
|
|
286
|
+
var cols = _columns();
|
|
287
|
+
var extras = {};
|
|
288
|
+
var hasExtras = false;
|
|
289
|
+
Object.keys(writeDoc).forEach(function (k) {
|
|
290
|
+
if (cols.indexOf(k) === -1) {
|
|
291
|
+
extras[k] = writeDoc[k];
|
|
292
|
+
hasExtras = true;
|
|
293
|
+
delete writeDoc[k];
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
if (hasExtras) {
|
|
297
|
+
var existing = null;
|
|
298
|
+
if (typeof writeDoc[overflow] === "object" && writeDoc[overflow] !== null && !Array.isArray(writeDoc[overflow])) {
|
|
299
|
+
existing = writeDoc[overflow];
|
|
300
|
+
}
|
|
301
|
+
writeDoc[overflow] = JSON.stringify(Object.assign({}, existing || {}, extras));
|
|
302
|
+
} else if (writeDoc[overflow] !== undefined && writeDoc[overflow] !== null && typeof writeDoc[overflow] === "object") {
|
|
303
|
+
writeDoc[overflow] = JSON.stringify(writeDoc[overflow]);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return _stringifyJsonCols(writeDoc);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function _decodeRowFromStorage(row) {
|
|
310
|
+
if (!row || typeof row !== "object") return row;
|
|
311
|
+
var out = Object.assign({}, row);
|
|
312
|
+
jsonCols.forEach(function (c) {
|
|
313
|
+
if (typeof out[c] === "string" && out[c].length > 0) {
|
|
314
|
+
try { out[c] = safeJson.parse(out[c]); }
|
|
315
|
+
catch (_e) { /* leave as string when content isn't valid JSON */ }
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
if (overflow && out[overflow] !== undefined && out[overflow] !== null) {
|
|
319
|
+
var extra = null;
|
|
320
|
+
if (typeof out[overflow] === "string" && out[overflow].length > 0) {
|
|
321
|
+
try { extra = safeJson.parse(out[overflow]); } catch (_e) { extra = null; }
|
|
322
|
+
} else if (typeof out[overflow] === "object" && !Array.isArray(out[overflow])) {
|
|
323
|
+
extra = out[overflow];
|
|
324
|
+
}
|
|
325
|
+
if (extra && typeof extra === "object" && !Array.isArray(extra)) {
|
|
326
|
+
delete out[overflow];
|
|
327
|
+
Object.keys(extra).forEach(function (k) {
|
|
328
|
+
if (out[k] === undefined) out[k] = extra[k];
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function _isOverflowField(field) {
|
|
336
|
+
if (!overflow) return false;
|
|
337
|
+
return _columns().indexOf(field) === -1;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function _applyEqualityForKey(builder, k, v) {
|
|
341
|
+
if (_isOverflowField(k)) {
|
|
342
|
+
builder.whereRaw('JSON_EXTRACT("' + overflow + '", ?) = ?', ["$." + k, v]);
|
|
343
|
+
} else {
|
|
344
|
+
builder.where(k, "=", v);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function _applyOverflowOperator(builder, k, op, val) {
|
|
349
|
+
switch (op) {
|
|
350
|
+
case "$eq": builder.whereRaw('JSON_EXTRACT("' + overflow + '", ?) = ?', ["$." + k, val]); break;
|
|
351
|
+
case "$ne": builder.whereRaw('JSON_EXTRACT("' + overflow + '", ?) != ?', ["$." + k, val]); break;
|
|
352
|
+
case "$in":
|
|
353
|
+
if (!Array.isArray(val) || val.length === 0) {
|
|
354
|
+
throw new TypeError("collection: $in on overflow field '" + k + "' requires a non-empty array");
|
|
355
|
+
}
|
|
356
|
+
var placeholders = val.map(function () { return "?"; }).join(", ");
|
|
357
|
+
var params = ["$." + k].concat(val);
|
|
358
|
+
builder.whereRaw('JSON_EXTRACT("' + overflow + '", ?) IN (' + placeholders + ")", params);
|
|
359
|
+
break;
|
|
360
|
+
default:
|
|
361
|
+
throw new TypeError("collection: overflow field '" + k + "' supports $eq / $ne / $in only " +
|
|
362
|
+
"(got '" + op + "'). Range / $like / sealed-rewrite require a real column.");
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function _applyQuery(builder, query) {
|
|
367
|
+
var keys = Object.keys(query);
|
|
368
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
369
|
+
var k = keys[i];
|
|
370
|
+
var v = query[k];
|
|
371
|
+
if (v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date)) {
|
|
372
|
+
var opKeys = Object.keys(v);
|
|
373
|
+
for (var j = 0; j < opKeys.length; j += 1) {
|
|
374
|
+
var op = opKeys[j];
|
|
375
|
+
var val = v[op];
|
|
376
|
+
if (_isOverflowField(k)) {
|
|
377
|
+
_applyOverflowOperator(builder, k, op, val);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
switch (op) {
|
|
381
|
+
case "$eq": builder.where(k, "=", val); break;
|
|
382
|
+
case "$ne": builder.where(k, "!=", val); break;
|
|
383
|
+
case "$gt": builder.where(k, ">", val); break;
|
|
384
|
+
case "$gte": builder.where(k, ">=", val); break;
|
|
385
|
+
case "$lt": builder.where(k, "<", val); break;
|
|
386
|
+
case "$lte": builder.where(k, "<=", val); break;
|
|
387
|
+
case "$in":
|
|
388
|
+
if (!Array.isArray(val)) {
|
|
389
|
+
throw new TypeError("collection: $in requires an array (got " + typeof val + ")");
|
|
390
|
+
}
|
|
391
|
+
builder.where(k, "IN", val);
|
|
392
|
+
break;
|
|
393
|
+
case "$like":
|
|
394
|
+
if (typeof val !== "string") {
|
|
395
|
+
throw new TypeError("collection: $like requires a string");
|
|
396
|
+
}
|
|
397
|
+
builder.where(k, "LIKE", val);
|
|
398
|
+
break;
|
|
399
|
+
default:
|
|
400
|
+
throw new TypeError("collection: unsupported query operator '" + op +
|
|
401
|
+
"' on field '" + k + "' (allowed: $eq / $ne / $gt / $gte / $lt / $lte / $in / $like)");
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
} else {
|
|
405
|
+
_applyEqualityForKey(builder, k, v);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
165
410
|
return {
|
|
166
411
|
name: name,
|
|
167
412
|
|
|
168
|
-
// Insert one document. Returns the inserted row with `_id` filled
|
|
169
|
-
// in (if absent on input). Composes Query.insertOne.
|
|
170
413
|
insert: function (doc) {
|
|
171
|
-
return db().from(name).insertOne(doc);
|
|
414
|
+
return db().from(name).insertOne(_prepareWriteDoc(doc));
|
|
172
415
|
},
|
|
173
416
|
|
|
174
|
-
// Insert many. Returns array of inserted rows.
|
|
175
417
|
insertMany: function (docs) {
|
|
176
|
-
|
|
418
|
+
if (!Array.isArray(docs)) throw new TypeError("collection.insertMany: docs must be an array");
|
|
419
|
+
var prepared = docs.map(_prepareWriteDoc);
|
|
420
|
+
return db().from(name).insertMany(prepared);
|
|
177
421
|
},
|
|
178
422
|
|
|
179
|
-
// Find rows matching the query. Returns an array. Pass `opts.limit`
|
|
180
|
-
// / `opts.offset` / `opts.orderBy` / `opts.orderDir` for paging.
|
|
181
423
|
find: function (query, opts) {
|
|
182
424
|
_validateQueryShape(query || {});
|
|
183
425
|
var q = db().from(name);
|
|
@@ -185,38 +427,32 @@ function collection(name) {
|
|
|
185
427
|
if (opts && opts.orderBy) q.orderBy(opts.orderBy, opts.orderDir || "asc");
|
|
186
428
|
if (opts && opts.limit !== undefined) q.limit(opts.limit);
|
|
187
429
|
if (opts && opts.offset !== undefined) q.offset(opts.offset);
|
|
188
|
-
|
|
430
|
+
var rows = q.all();
|
|
431
|
+
return rows.map(_decodeRowFromStorage);
|
|
189
432
|
},
|
|
190
433
|
|
|
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
434
|
findOne: function (query) {
|
|
194
435
|
_validateQueryShape(query);
|
|
195
436
|
var q = db().from(name);
|
|
196
437
|
_applyQuery(q, query);
|
|
197
|
-
|
|
438
|
+
var row = q.first();
|
|
439
|
+
return row ? _decodeRowFromStorage(row) : null;
|
|
198
440
|
},
|
|
199
441
|
|
|
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
442
|
update: function (query, update, opts) {
|
|
208
443
|
_validateQueryShape(query || {});
|
|
209
444
|
var split = _splitUpdateOperators(update);
|
|
210
445
|
var single = !(opts && opts.many === true);
|
|
211
446
|
var changed = 0;
|
|
212
447
|
|
|
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
448
|
if (split.incs) {
|
|
218
449
|
var incCols = Object.keys(split.incs);
|
|
219
450
|
for (var i = 0; i < incCols.length; i += 1) {
|
|
451
|
+
if (_isOverflowField(incCols[i])) {
|
|
452
|
+
throw new TypeError("collection.update: $inc on overflow field '" + incCols[i] +
|
|
453
|
+
"' is not supported — overflow fields are stored as JSON text and can't atomically increment. " +
|
|
454
|
+
"Move the field to a real INTEGER column.");
|
|
455
|
+
}
|
|
220
456
|
var qInc = db().from(name);
|
|
221
457
|
_applyQuery(qInc, query || {});
|
|
222
458
|
var delta = split.incs[incCols[i]];
|
|
@@ -227,8 +463,11 @@ function collection(name) {
|
|
|
227
463
|
}
|
|
228
464
|
}
|
|
229
465
|
|
|
230
|
-
// $set
|
|
231
|
-
//
|
|
466
|
+
// Combine $set + $unset into one effective change set, partitioned
|
|
467
|
+
// into real-column writes vs overflow-JSON writes. Real-column
|
|
468
|
+
// writes go through a single UPDATE; overflow writes are
|
|
469
|
+
// read-modify-write on the JSON column inside a transaction so
|
|
470
|
+
// concurrent updates don't lose fields.
|
|
232
471
|
var setObj = null;
|
|
233
472
|
if (split.sets) setObj = Object.assign({}, split.sets);
|
|
234
473
|
if (split.unsets) {
|
|
@@ -236,28 +475,81 @@ function collection(name) {
|
|
|
236
475
|
Object.keys(split.unsets).forEach(function (k) { setObj[k] = null; });
|
|
237
476
|
}
|
|
238
477
|
if (setObj && Object.keys(setObj).length > 0) {
|
|
239
|
-
var
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
478
|
+
var realChanges = {};
|
|
479
|
+
var overflowChanges = null;
|
|
480
|
+
var overflowDeletes = null;
|
|
481
|
+
Object.keys(setObj).forEach(function (k) {
|
|
482
|
+
if (_isOverflowField(k)) {
|
|
483
|
+
if (split.unsets && Object.prototype.hasOwnProperty.call(split.unsets, k)) {
|
|
484
|
+
if (!overflowDeletes) overflowDeletes = [];
|
|
485
|
+
overflowDeletes.push(k);
|
|
486
|
+
} else {
|
|
487
|
+
if (!overflowChanges) overflowChanges = {};
|
|
488
|
+
overflowChanges[k] = setObj[k];
|
|
489
|
+
}
|
|
490
|
+
} else {
|
|
491
|
+
realChanges[k] = setObj[k];
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
if (overflowChanges || overflowDeletes) {
|
|
496
|
+
// Read each matched row's overflow column, merge changes,
|
|
497
|
+
// write back. Single-row default; many-row when opts.many.
|
|
498
|
+
var qFetch = db().from(name);
|
|
499
|
+
_applyQuery(qFetch, query || {});
|
|
500
|
+
if (single) qFetch.limit(1);
|
|
501
|
+
var rows = qFetch.all();
|
|
502
|
+
for (var ri = 0; ri < rows.length; ri += 1) {
|
|
503
|
+
var existing = {};
|
|
504
|
+
if (rows[ri][overflow] !== undefined && rows[ri][overflow] !== null) {
|
|
505
|
+
if (typeof rows[ri][overflow] === "string" && rows[ri][overflow].length > 0) {
|
|
506
|
+
try { existing = safeJson.parse(rows[ri][overflow]) || {}; } catch (_e) { existing = {}; }
|
|
507
|
+
} else if (typeof rows[ri][overflow] === "object") {
|
|
508
|
+
existing = rows[ri][overflow] || {};
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
var nextOverflow = Object.assign({}, existing);
|
|
512
|
+
if (overflowChanges) {
|
|
513
|
+
Object.keys(overflowChanges).forEach(function (k) { nextOverflow[k] = overflowChanges[k]; });
|
|
514
|
+
}
|
|
515
|
+
if (overflowDeletes) {
|
|
516
|
+
overflowDeletes.forEach(function (k) { delete nextOverflow[k]; });
|
|
517
|
+
}
|
|
518
|
+
// The row's identity: pick a primary-key-shaped column if
|
|
519
|
+
// present, otherwise round-trip the full WHERE clause +
|
|
520
|
+
// overflow column update. Most tables have an _id.
|
|
521
|
+
var pkCol = "_id";
|
|
522
|
+
if (rows[ri][pkCol] === undefined) {
|
|
523
|
+
throw new Error("collection.update: overflow-field write requires an _id column on row " +
|
|
524
|
+
"(got: " + JSON.stringify(Object.keys(rows[ri])) + "). Add _id to the schema.");
|
|
525
|
+
}
|
|
526
|
+
var qWrite = db().from(name).where(pkCol, "=", rows[ri][pkCol]);
|
|
527
|
+
var writeChanges = {};
|
|
528
|
+
writeChanges[overflow] = JSON.stringify(nextOverflow);
|
|
529
|
+
Object.keys(realChanges).forEach(function (k) { writeChanges[k] = realChanges[k]; });
|
|
530
|
+
if (qWrite.updateOne(_stringifyJsonCols(writeChanges))) changed += 1;
|
|
531
|
+
}
|
|
243
532
|
} else {
|
|
244
|
-
|
|
533
|
+
// Pure real-column update — single UPDATE for the matching
|
|
534
|
+
// row(s).
|
|
535
|
+
var qSet = db().from(name);
|
|
536
|
+
_applyQuery(qSet, query || {});
|
|
537
|
+
var prepared = _stringifyJsonCols(realChanges);
|
|
538
|
+
if (single) {
|
|
539
|
+
changed += (qSet.updateOne(prepared) ? 1 : 0);
|
|
540
|
+
} else {
|
|
541
|
+
changed += qSet.updateMany(prepared);
|
|
542
|
+
}
|
|
245
543
|
}
|
|
246
544
|
}
|
|
247
545
|
|
|
248
546
|
return changed;
|
|
249
547
|
},
|
|
250
548
|
|
|
251
|
-
// Convenience — `updateMany(query, update)` shorthand for
|
|
252
|
-
// `update(query, update, { many: true })`.
|
|
253
549
|
updateMany: function (query, update) {
|
|
254
550
|
return this.update(query, update, { many: true });
|
|
255
551
|
},
|
|
256
552
|
|
|
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
553
|
remove: function (query, opts) {
|
|
262
554
|
_validateQueryShape(query || {});
|
|
263
555
|
var q = db().from(name);
|
|
@@ -268,7 +560,6 @@ function collection(name) {
|
|
|
268
560
|
return q.deleteOne() ? 1 : 0;
|
|
269
561
|
},
|
|
270
562
|
|
|
271
|
-
// Count rows matching the query.
|
|
272
563
|
count: function (query) {
|
|
273
564
|
_validateQueryShape(query || {});
|
|
274
565
|
var q = db().from(name);
|
|
@@ -276,13 +567,19 @@ function collection(name) {
|
|
|
276
567
|
return q.count();
|
|
277
568
|
},
|
|
278
569
|
|
|
279
|
-
// Paginate — `{ items, total, limit, offset, page, totalPages }`.
|
|
280
|
-
// Composes Query.paginate.
|
|
281
570
|
paginate: function (query, opts) {
|
|
282
571
|
_validateQueryShape(query || {});
|
|
283
572
|
var q = db().from(name);
|
|
284
573
|
_applyQuery(q, query || {});
|
|
285
|
-
|
|
574
|
+
var page = q.paginate(opts || {});
|
|
575
|
+
return {
|
|
576
|
+
items: page.items.map(_decodeRowFromStorage),
|
|
577
|
+
total: page.total,
|
|
578
|
+
limit: page.limit,
|
|
579
|
+
offset: page.offset,
|
|
580
|
+
page: page.page,
|
|
581
|
+
totalPages: page.totalPages,
|
|
582
|
+
};
|
|
286
583
|
},
|
|
287
584
|
};
|
|
288
585
|
}
|