@blamejs/core 0.8.60 → 0.8.64

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.
@@ -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 Mongo-
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
- * Operators migrating from a Mongo-shaped codebase can drop in this
27
- * facade without rewriting every call site to the chainable builder.
28
- * New code typically reaches for the
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
- * Supported update operators: `$set` (assign), `$inc` (atomic
33
- * increment per column composes `Query.increment`), `$unset`
34
- * (set to NULL).
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 = require("./lazy-require");
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 _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
- }
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
- } else {
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: { _id: "TEXT PRIMARY KEY", email: "TEXT", failed: "INTEGER NOT NULL DEFAULT 0" },
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
- * users.insert({ _id: "u1", email: "alice@x.com" });
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
- * users.update({ _id: "u1" }, { $inc: { failed: 1 } });
158
- * users.update({ _id: "u1" }, { $set: { failed: 0 } });
159
- * users.remove({ _id: "u1" });
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
- if (typeof name !== "string" || name.length === 0) {
163
- throw new TypeError("collection(name): name must be a non-empty string");
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
- return db().from(name).insertMany(docs);
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
- return q.all();
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
- return q.first() || null;
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 / plain-object form single UPDATE with the merged
231
- // changes object.
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 qSet = db().from(name);
240
- _applyQuery(qSet, query || {});
241
- if (single) {
242
- changed += (qSet.updateOne(setObj) ? 1 : 0);
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
- changed += qSet.updateMany(setObj);
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
- return q.paginate(opts || {});
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
  }