jsql-neo 6.5.13 → 6.5.14

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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > **One engine to rule them all** — a Rust-powered embedded database that speaks your language:
4
4
  > MySQL, PostgreSQL, MongoDB, Redis, SQL, TypeScript, **and the browser**.
5
5
 
6
- > **v6.5.13** — stable release · [github.com/vexify-org/JSQL-neo](https://github.com/vexify-org/JSQL-neo) · [jsql.vexify.top](https://jsql.vexify.top)
6
+ > **v6.5.14** — stable release · [github.com/vexify-org/JSQL-neo](https://github.com/vexify-org/JSQL-neo) · [jsql.vexify.top](https://jsql.vexify.top)
7
7
 
8
8
  ![Engines](https://img.shields.io/badge/engines-Native%20%7C%20WASM%20%7C%20Pure%20JS-7ee787)
9
9
  ![MySQL](https://img.shields.io/badge/protocol-MySQL%20compatible-1f6feb)
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * JSQL-NEO v6.5.13 — Rust-Powered Embedded Database (WASM + HTTP)
2
+ * JSQL-NEO v6.5.14 — Rust-Powered Embedded Database (WASM + HTTP)
3
3
  *
4
4
  * @example
5
5
  * const jsql = require('jsql-neo');
package/lib/btree.js CHANGED
@@ -24,8 +24,6 @@ class BTree {
24
24
  this._unique = unique;
25
25
  this._root = new BTreeNode();
26
26
  this._size = 0;
27
- this._minKeys = Math.floor(order / 2) - 1;
28
- this._minDegree = Math.ceil(order / 2);
29
27
  }
30
28
 
31
29
  get size() { return this._size; }
@@ -273,122 +271,81 @@ class BTree {
273
271
 
274
272
  remove(key, rowIndex) {
275
273
  if (!this._root) return false;
276
- const removed = this._removeFromNode(this._root, key, rowIndex);
277
- // 删掉整个键(而非仅删某个 rowIndex)后,叶子的键域可能缩小,
278
- // 沿途的分隔键副本可能已指向不存在的键 —— 自顶向下重建一次。
279
- if (removed) {
280
- this._size--;
281
- if (!this._root.leaf) this._fixSeparators(this._root);
282
- }
283
- if (this._root.keys.length === 0 && !this._root.leaf && this._root.children.length > 0) {
284
- this._root = this._root.children[0];
285
- }
286
- return removed;
274
+ const res = this._removeFromNode(this._root, key, rowIndex);
275
+ if (!res.found) return false;
276
+ if (!res.remaining) this._size--;
277
+ // 只做惰性删除 + 分隔键归一:不再在下降途中借用/合并。
278
+ // 旧实现先对路由到的子节点做 borrow/merge 再检查键是否存在,
279
+ // 删除一个不存在的键(或 rowIndex 不匹配)也会重构整棵树,
280
+ // 合并时丢掉的分隔键副本可能正好是仍然存活的数据键 —— 条目静默丢失。
281
+ this._normalize();
282
+ return !res.remaining;
287
283
  }
288
284
 
289
285
  _removeFromNode(node, key, rowIndex) {
290
286
  if (node.leaf) {
291
287
  const i = this._lowerBound(node.keys, key);
292
- if (i >= node.keys.length || node.keys[i] !== key) return false;
293
- if (!node.values[i].includes(rowIndex)) return false;
288
+ if (i >= node.keys.length || node.keys[i] !== key) return { found: false };
289
+ const pos = node.values[i].indexOf(rowIndex);
290
+ if (pos === -1) return { found: false };
294
291
  const vals = node.values[i].filter(v => v !== rowIndex);
295
292
  if (vals.length === 0) {
296
293
  node.keys.splice(i, 1);
297
294
  node.values.splice(i, 1);
298
- return true;
295
+ return { found: true, remaining: false };
299
296
  }
300
297
  node.values[i] = vals;
301
- return false;
298
+ return { found: true, remaining: true };
302
299
  }
303
300
 
304
- let i = this._route(node, key);
305
-
306
- // 下降前确保子节点有足够键,避免欠满子节点
301
+ const i = this._route(node, key);
307
302
  const child = node.children[i];
308
- if (child.keys.length <= this._minKeys) {
309
- i = this._rebalanceChild(node, i);
310
- }
311
- return this._removeFromNode(node.children[i], key, rowIndex);
303
+ if (!child) return { found: false };
304
+ return this._removeFromNode(child, key, rowIndex);
312
305
  }
313
306
 
314
- _rebalanceChild(parent, i) {
315
- if (i > 0 && parent.children[i - 1].keys.length > this._minKeys) {
316
- this._borrowFromLeft(parent, i);
317
- return i;
318
- }
319
- if (i + 1 < parent.children.length && parent.children[i + 1].keys.length > this._minKeys) {
320
- this._borrowFromRight(parent, i);
321
- return i;
322
- }
323
- if (i > 0) {
324
- this._mergeChildren(parent, i - 1);
325
- return i - 1;
326
- }
327
- this._mergeChildren(parent, i);
328
- return i;
307
+ /**
308
+ * 惰性回收:删掉节点里空的叶子/空子树,折叠只剩一个孩子的内部节点,
309
+ * 并按「分隔键 = 左子树最大键」的约定重算所有内部键,最后重建叶子链表。
310
+ * 节点欠满不影响正确性(插入时仍然按满则分裂),因此无需借用/合并。
311
+ */
312
+ _normalize() {
313
+ const root = this._cleanNode(this._root);
314
+ this._root = root || new BTreeNode();
315
+ this._relinkLeaves();
329
316
  }
330
317
 
331
- _borrowFromLeft(parent, i) {
332
- const left = parent.children[i - 1];
333
- const right = parent.children[i];
334
- if (right.leaf) {
335
- // 叶子节点:移动左叶最大键到右叶最小位置
336
- const k = left.keys.pop();
337
- const v = left.values.pop();
338
- right.keys.unshift(k);
339
- right.values.unshift(v);
340
- // 父节点分隔键应为左叶新的最大值(pop 后的最后一个键)
341
- parent.keys[i - 1] = left.keys[left.keys.length - 1];
342
- parent.values[i - 1] = left.values[left.values.length - 1];
343
- } else {
344
- right.keys.unshift(parent.keys[i - 1]);
345
- right.values.unshift(parent.values[i - 1]);
346
- parent.keys[i - 1] = left.keys.pop();
347
- parent.values[i - 1] = left.values.pop();
348
- right.children.unshift(left.children.pop());
318
+ _cleanNode(node) {
319
+ if (node.leaf) return node.keys.length === 0 ? null : node;
320
+ const kids = [];
321
+ for (const c of node.children) {
322
+ const kept = this._cleanNode(c);
323
+ if (kept) kids.push(kept);
349
324
  }
350
- }
351
-
352
- _borrowFromRight(parent, i) {
353
- const left = parent.children[i];
354
- const right = parent.children[i + 1];
355
- if (left.leaf) {
356
- // 叶子节点:用被移动的键而非旧分隔键,避免重复
357
- const k = right.keys.shift();
358
- const v = right.values.shift();
359
- left.keys.push(k);
360
- left.values.push(v);
361
- parent.keys[i] = k;
362
- parent.values[i] = v;
363
- } else {
364
- left.keys.push(parent.keys[i]);
365
- left.values.push(parent.values[i]);
366
- parent.keys[i] = right.keys.shift();
367
- parent.values[i] = right.values.shift();
368
- left.children.push(right.children.shift());
325
+ node.children = kids;
326
+ if (kids.length === 0) return null;
327
+ if (kids.length === 1) return kids[0];
328
+ node.keys = [];
329
+ node.values = [];
330
+ for (let i = 0; i < kids.length - 1; i++) {
331
+ const m = this._getMax(kids[i]);
332
+ node.keys.push(m.key);
333
+ node.values.push(m.value);
369
334
  }
335
+ return node;
370
336
  }
371
337
 
372
- /**
373
- * 修复内部节点的分隔键:keys[i] 必须是一个**确实存在于 child[i] 里**的键。
374
- *
375
- * 背景:分隔键的约定是「左子树的最大键」(叶子上存真实数据,父节点存副本)。
376
- * remove() 删掉叶子上的某个键后,如果那个键正好是父节点的分隔键副本,
377
- * 分隔键就会指向一个已经不存在的值 —— 树还能路由对,但内部键不再严格递增,
378
- * 下一次 borrow/merge 就会把它复制成重复键,直接破坏结构。
379
- *
380
- * 最小复现(BTree(4)):
381
- * +28:1 +24:4 +0:0 +14:2 -24:4 +25:4 +24:2 +28:1 -26:2
382
- * 前 8 步都正常,第 9 步把 keys=[24,25] 变成 [24,24]。
383
- */
384
- _fixSeparators(node) {
385
- if (node.leaf) return;
386
- for (let i = 0; i < node.keys.length; i++) {
387
- const m = this._getMax(node.children[i]);
388
- node.keys[i] = m.key;
389
- node.values[i] = m.value;
338
+ _relinkLeaves() {
339
+ let head = this._root;
340
+ while (head && !head.leaf) head = head.children[0];
341
+ let prev = null;
342
+ let node = head;
343
+ while (node) {
344
+ if (prev) prev.next = node;
345
+ prev = node;
346
+ node = node.next;
390
347
  }
391
- for (const c of node.children) this._fixSeparators(c);
348
+ if (prev) prev.next = null;
392
349
  }
393
350
 
394
351
  _getMax(node) {
@@ -397,27 +354,6 @@ class BTree {
397
354
  return { key: node.keys[i], value: node.values[i] };
398
355
  }
399
356
 
400
- _mergeChildren(parent, index) {
401
- const left = parent.children[index];
402
- const right = parent.children[index + 1];
403
- const midKey = parent.keys[index];
404
- const midVal = parent.values[index];
405
-
406
- // 叶子节点:分隔键是叶子的副本(已在 left 或 right 中),不能重复压入
407
- if (!left.leaf) {
408
- left.keys.push(midKey);
409
- left.values.push(midVal);
410
- }
411
- for (const __e of right.keys) left.keys.push(__e);
412
- for (const __e of right.values) left.values.push(__e);
413
- if (!left.leaf) for (const __e of right.children) left.children.push(__e);
414
- if (left.leaf) left.next = right.next;
415
-
416
- parent.keys.splice(index, 1);
417
- parent.values.splice(index, 1);
418
- parent.children.splice(index + 1, 1);
419
- }
420
-
421
357
  // ============================================================
422
358
  // 遍历
423
359
  // ============================================================
package/lib/database.js CHANGED
@@ -1344,15 +1344,11 @@ class Database {
1344
1344
  startedAt: Date.now()
1345
1345
  };
1346
1346
 
1347
- if (level === 'REPEATABLE_READ') {
1348
- // 快照:保存当前所有数据(深拷贝,保证嵌套对象可回滚)
1349
- this._transaction.snapshot = this._captureTxSnapshot();
1350
- } else {
1351
- // READ_COMMITTED:只保存 autoIncrement
1352
- for (const [name, table] of Object.entries(this._tables)) {
1353
- this._transaction.autoIncrements[name] = table._autoIncrement;
1354
- }
1355
- }
1347
+ // 无论隔离级别都要保存完整快照:ROLLBACK 必须撤销本事务写入的行。
1348
+ // 此前 READ_COMMITTED 只记 autoIncrement,回滚后行原样残留(静默不撤销)。
1349
+ // 隔离级别只影响「读取时能否看到其他事务的已提交数据」(这里是单连接内嵌库,
1350
+ // 不涉及并发可见性),不应影响回滚能力。
1351
+ this._transaction.snapshot = this._captureTxSnapshot();
1356
1352
 
1357
1353
  this._savepointStack = [];
1358
1354
  this._walWrite({ op: 'begin', level });
@@ -1374,12 +1370,6 @@ class Database {
1374
1370
 
1375
1371
  if (this._transaction.snapshot) {
1376
1372
  this._restoreTxSnapshot(this._transaction.snapshot);
1377
- } else {
1378
- // READ_COMMITTED:没有行快照,只能退回自增值
1379
- for (const [name, table] of Object.entries(this._tables)) {
1380
- const v = this._transaction.autoIncrements[name];
1381
- if (v !== undefined) table._autoIncrement = v;
1382
- }
1383
1373
  }
1384
1374
 
1385
1375
  this._walWrite({ op: 'rollback', at: this._transaction.startedAt });
@@ -1857,8 +1847,8 @@ class Database {
1857
1847
  let rows = table._applyFilterOptimized(table._getVisibleRows(), filter || {});
1858
1848
  if (limit > 0) rows = rows.slice(offset, offset + limit);
1859
1849
  else if (offset > 0) rows = rows.slice(offset);
1860
- // P1-11: 返回浅拷贝,防止调用方修改影响内部数据
1861
- rows = rows.map(r => ({ ...r }));
1850
+ // P1-11: 返回深拷贝,防止调用方修改(含嵌套对象/数组)影响内部数据
1851
+ rows = rows.map(r => Table.cloneRow(r));
1862
1852
  this._touchTable(tableName);
1863
1853
  await this._runHooks('afterFind', [tableName, { filter, opts }, rows]);
1864
1854
  return rows;
@@ -1890,8 +1880,8 @@ class Database {
1890
1880
  const row = this._resolveId(table, id);
1891
1881
  // P1-4: 软删除行对普通读取不可见
1892
1882
  if (row && row._deleted) return null;
1893
- // P1-11: 返回浅拷贝,防止调用方修改影响内部数据
1894
- const result = row ? { ...row } : null;
1883
+ // P1-11: 返回深拷贝,防止调用方修改(含嵌套对象/数组)影响内部数据
1884
+ const result = row ? Table.cloneRow(row) : null;
1895
1885
  this._touchTable(tableName);
1896
1886
  await this._runHooks('afterFind', [tableName, { id }, result]);
1897
1887
  return result;
@@ -1902,7 +1892,7 @@ class Database {
1902
1892
  if (!table) throw createError('ER_NO_SUCH_TABLE', tableName);
1903
1893
  if (!(await this._runHooks('beforeFind', [tableName, { ids }]))) return null;
1904
1894
  // P1-4: 过滤掉软删除行;P1-11: 返回浅拷贝
1905
- const rows = ids.map(id => this._resolveId(table, id)).filter(r => r !== null && !r._deleted).map(r => ({ ...r }));
1895
+ const rows = ids.map(id => this._resolveId(table, id)).filter(r => r !== null && !r._deleted).map(r => Table.cloneRow(r));
1906
1896
  this._touchTable(tableName);
1907
1897
  await this._runHooks('afterFind', [tableName, { ids }, rows]);
1908
1898
  return rows;
package/lib/errors.js CHANGED
@@ -81,11 +81,10 @@ function createError(codeKey, ...args) {
81
81
  return new JSQL_Error('UNKNOWN', `Unknown error: ${codeKey}`, { codeKey, args });
82
82
  }
83
83
 
84
- let msg = template.msg;
85
- for (let i = 0; i < args.length; i++) {
86
- // 使用函数替换避免 $& 等特殊字符被解释
87
- msg = msg.replace('%s', () => String(args[i]));
88
- }
84
+ // 单趟替换所有 %s:既避免参数值里本身含有 %s 时被后续替换再次消费造成串位,
85
+ // 也避免实参不足时消息里残留裸 %s。
86
+ let idx = 0;
87
+ const msg = template.msg.replace(/%s/g, () => (idx < args.length ? String(args[idx++]) : ''));
89
88
 
90
89
  return new JSQL_Error(template.code, msg, { args });
91
90
  }
package/lib/migrate.js CHANGED
@@ -30,8 +30,12 @@ function normalizeSchema(schema) {
30
30
  if (d.type === 'varchar' || d.type === 'text' || d.type === 'char') d.type = 'string';
31
31
  if (d.type === 'double' || d.type === 'real' || d.type === 'decimal' || d.type === 'numeric') d.type = 'float';
32
32
  if (d.type === 'bool') d.type = 'boolean';
33
- delete d.length;
34
- if (d.maxLength) { d.length = d.maxLength; delete d.maxLength; }
33
+ // 保留用户显式配置的 length;maxLength 只是别名,仅在 length 未给出时回填。
34
+ // 此前无条件 delete d.length 再仅从 maxLength 恢复,导致显式 length 丢失。
35
+ if (d.maxLength !== undefined) {
36
+ if (d.length === undefined) d.length = d.maxLength;
37
+ delete d.maxLength;
38
+ }
35
39
  out[name] = d;
36
40
  }
37
41
  return out;
@@ -461,7 +461,7 @@ class MongoServer {
461
461
  await engine.insert(coll, merged);
462
462
  value = doc.new ? { ...merged } : null;
463
463
  }
464
- return { ok: 1, value, lastErrorObject: { n: found ? 1 : (doc.upsert ? 1 : 0), updatedExisting: !!found && !doc.remove } };
464
+ return { ok: 1, value: value ? this._publicDoc(value) : value, lastErrorObject: { n: found ? 1 : (doc.upsert ? 1 : 0), updatedExisting: !!found && !doc.remove } };
465
465
  }
466
466
  case 'distinct': {
467
467
  const coll = String(doc.distinct);
@@ -496,7 +496,7 @@ class MongoServer {
496
496
  if (skip > 0) rows = rows.slice(skip);
497
497
  const limit = typeof doc.limit === 'number' ? doc.limit : 0;
498
498
  if (limit > 0) rows = rows.slice(0, limit);
499
- return { ok: 1, cursor: { id: { $long: 0 }, ns: `${this._dbOf(doc)}.${coll}`, firstBatch: rows } };
499
+ return { ok: 1, cursor: { id: { $long: 0 }, ns: `${this._dbOf(doc)}.${coll}`, firstBatch: rows.map(r => this._publicDoc(r)) } };
500
500
  }
501
501
  case 'getMore':
502
502
  return { ok: 1, cursor: { id: { $long: 0 }, ns: String(doc.collection || ''), nextBatch: [] } };
@@ -673,7 +673,7 @@ class MongoServer {
673
673
  rows = out;
674
674
  }
675
675
  }
676
- return { ok: 1, cursor: { id: { $long: 0 }, ns: `${this._dbOf(doc)}.${coll}`, firstBatch: rows } };
676
+ return { ok: 1, cursor: { id: { $long: 0 }, ns: `${this._dbOf(doc)}.${coll}`, firstBatch: rows.map(r => this._publicDoc(r)) } };
677
677
  }
678
678
  default:
679
679
  return { ok: 1, n: 0 };
@@ -684,6 +684,23 @@ class MongoServer {
684
684
  return row._id != null ? row._id : row.id;
685
685
  }
686
686
 
687
+ /**
688
+ * 剥离 JSQL 内部字段后再返回给 Mongo 客户端。
689
+ *
690
+ * _deleted(软删除标记)、_rid(内部行号)、_score(全文检索得分)
691
+ * 都只属于引擎内部实现,泄漏到文档里会污染用户数据、影响后续 $set/$unset
692
+ * 以及客户端 schema 推断。
693
+ */
694
+ _publicDoc(row) {
695
+ if (!row || typeof row !== 'object') return row;
696
+ if (row._deleted === undefined && row._rid === undefined && row._score === undefined) return row;
697
+ const out = { ...row };
698
+ delete out._deleted;
699
+ delete out._rid;
700
+ delete out._score;
701
+ return out;
702
+ }
703
+
687
704
  async _matching(engine, coll, filter) {
688
705
  const rows = await engine.find(coll, {});
689
706
  return rows.filter((r) => this._match(r, filter || {}));
@@ -578,8 +578,9 @@ class MysqlConnection {
578
578
  case 0x19: this._handleStmtClose(body); break; // COM_STMT_CLOSE
579
579
  case 0x1a: this._send(okPacket()); break; // COM_STMT_RESET
580
580
  case 0x0a: { // COM_PROCESS_INFO
581
- const seq = this.sequence;
582
- this.sequence = (this.sequence + 1) & 0xff;
581
+ // _handlePacket 已把 this.sequence 设为 cmdSeq+1,_send 会在写入后自增。
582
+ // 此前这里额外自增一次,导致首个响应包序列号比请求大 2(跳号),
583
+ // 严格校验序列号的客户端会判定协议错误并断开。
583
584
  this._send(encodeLenenc(1));
584
585
  this._send(columnDefinition({ name: 'Id', type: MYSQL_TYPE_LONG }));
585
586
  this._send(eofPacket());
@@ -77,8 +77,21 @@ function matchOperator(value, op, expected) {
77
77
  case '$in': return Array.isArray(expected) && expected.some(e => looseEqual(value, e));
78
78
  case '$nin': return Array.isArray(expected) && !expected.some(e => looseEqual(value, e));
79
79
  case '$exists': return expected ? value !== undefined : value === undefined;
80
- case '$regex':
81
- return typeof value === 'string' && new RegExp(expected.source, expected.flags || 'i').test(value);
80
+ case '$regex': {
81
+ if (typeof value !== 'string') return false;
82
+ // expected 允许两种写法:/re/flags(RegExp),或 MySQL 风格的字符串模式。
83
+ // 此前直接读 expected.source:字符串没有 source → new RegExp(undefined)
84
+ // 等价于 /(?:)/,静默匹配所有文档。
85
+ let re;
86
+ if (expected instanceof RegExp) {
87
+ re = expected;
88
+ } else if (typeof expected === 'string') {
89
+ try { re = new RegExp(expected); } catch { return false; }
90
+ } else {
91
+ return false;
92
+ }
93
+ return re.test(value);
94
+ }
82
95
  case '$size': return Array.isArray(value) && value.length === expected;
83
96
  default: return false;
84
97
  }
package/lib/pg_server.js CHANGED
@@ -792,7 +792,10 @@ class PgServer {
792
792
  if (user !== this.options.user) return undefined;
793
793
  return this.options.password || '';
794
794
  }
795
- return '';
795
+ // 未配置任何认证信息:默认拒绝连接(返回 undefined → 认证失败)。
796
+ // 此前返回 '' 会被 _startAuth 当成 trust,导致任意用户名 + 空密码即可连入,
797
+ // 与 MysqlServer 的默认拒绝策略保持一致。仅显式 noAuth: true 才放行。
798
+ return undefined;
796
799
  }
797
800
 
798
801
  _canAccessDb(user, dbName) {
package/lib/plugin.js CHANGED
@@ -104,10 +104,16 @@ function definePlugin(name, def = {}) {
104
104
  }
105
105
 
106
106
  /** 登记单个钩子;未知钩子名会自动创建,便于插件自定义扩展点 */
107
- function registerHook(engine, hook, fn) {
107
+ function registerHook(engine, hook, fn, priority = 0) {
108
108
  if (typeof fn !== 'function') return engine;
109
109
  if (!engine._hooks) engine._hooks = createHookRegistry();
110
110
  if (!engine._hooks[hook]) engine._hooks[hook] = [];
111
+ // 记录优先级:即使插件是后加载的,也应按 priority 决定执行顺序,
112
+ // 而不是简单追加在已有钩子之后(此前 sort(_plugins) 只排插件列表,
113
+ // 钩子链仍按注册顺序执行,导致 priority 实际无效)。
114
+ try {
115
+ Object.defineProperty(fn, '__jsqlHookPriority', { value: priority, configurable: true, writable: true });
116
+ } catch (e) { /* 冻结函数等无法打标,按 0 处理 */ }
111
117
  engine._hooks[hook].push(fn);
112
118
  return engine;
113
119
  }
@@ -115,9 +121,10 @@ function registerHook(engine, hook, fn) {
115
121
  /** 把一个插件声明的所有钩子登记到引擎上 */
116
122
  function registerHooks(engine, plugin) {
117
123
  if (!plugin || !plugin.hooks) return engine;
124
+ const priority = plugin.priority || 0;
118
125
  for (const [hook, val] of Object.entries(plugin.hooks)) {
119
126
  const fns = Array.isArray(val) ? val : [val];
120
- for (const fn of fns) registerHook(engine, hook, fn);
127
+ for (const fn of fns) registerHook(engine, hook, fn, priority);
121
128
  }
122
129
  return engine;
123
130
  }
@@ -253,7 +260,11 @@ function runHooks(engine, hookName, args) {
253
260
 
254
261
  return (async () => {
255
262
  try {
256
- for (const fn of hooks) {
263
+ // priority 大的先执行;同 priority 保持注册顺序(sort 在现代引擎是稳定的)
264
+ const ordered = hooks.length > 1
265
+ ? hooks.slice().sort((a, b) => (b.__jsqlHookPriority || 0) - (a.__jsqlHookPriority || 0))
266
+ : hooks;
267
+ for (const fn of ordered) {
257
268
  const result = await fn(...args);
258
269
  if (result === false) return false;
259
270
  }
package/lib/sql.js CHANGED
@@ -2811,8 +2811,15 @@ class Parser {
2811
2811
  // MySQL 中 RLIKE 是 REGEXP 的同义词
2812
2812
  if (t2.type === 'keyword' && (t2.value === 'REGEXP' || t2.value === 'RLIKE')) {
2813
2813
  this.next();
2814
- const pattern = this.parseValue();
2815
- return { type: 'regexp', operand: left, pattern, not };
2814
+ let pattern = this.parseValue();
2815
+ // `expr REGEXP BINARY 'pat'`:BINARY 前缀表示大小写敏感匹配。
2816
+ // 解析出的 pattern 会包一层 { type:'binary', operand },这里解包并标记 ci=false。
2817
+ let binary = false;
2818
+ if (pattern && typeof pattern === 'object' && pattern.type === 'binary') {
2819
+ binary = true;
2820
+ pattern = pattern.operand;
2821
+ }
2822
+ return { type: 'regexp', operand: left, pattern, not, ci: !binary };
2816
2823
  }
2817
2824
 
2818
2825
  // PG JSON 包含操作符
@@ -4476,7 +4483,14 @@ function evaluateExpr(expr, row, ctx) {
4476
4483
  switch (expr.type) {
4477
4484
  case 'and': return evaluateExpr(expr.left, row, ctx) && evaluateExpr(expr.right, row, ctx);
4478
4485
  case 'or': return evaluateExpr(expr.left, row, ctx) || evaluateExpr(expr.right, row, ctx);
4479
- case 'xor': return Boolean(evaluateExpr(expr.left, row, ctx)) !== Boolean(evaluateExpr(expr.right, row, ctx));
4486
+ case 'xor': {
4487
+ // 三值逻辑:任一操作数为 UNKNOWN 时结果也是 UNKNOWN。
4488
+ // 旧实现用 Boolean() 归一,Boolean(null)=false 会把 NULL XOR TRUE 误判为 TRUE。
4489
+ const l = evaluateExpr(expr.left, row, ctx);
4490
+ const r = evaluateExpr(expr.right, row, ctx);
4491
+ return sqlXor(l === null || l === undefined ? null : Boolean(l),
4492
+ r === null || r === undefined ? null : Boolean(r));
4493
+ }
4480
4494
  case 'not': {
4481
4495
  // 三值逻辑:NOT UNKNOWN = UNKNOWN(返回 null,在 WHERE/ON/HAVING 中视为不选中)
4482
4496
  // 必须用 resolveOperand 取值:evaluateExpr 只认布尔类节点,
@@ -4569,8 +4583,18 @@ function evaluateExpr(expr, row, ctx) {
4569
4583
  case 'regexp': {
4570
4584
  const v = resolveOperand(expr.operand, row, ctx);
4571
4585
  if (v === null || v === undefined) return false;
4572
- // REGEXP / RLIKE 历史上就是大小写不敏感的;`~` 敏感、`~*` 不敏感
4573
- const re = new RegExp(String(expr.pattern), expr.ci === false ? '' : 'i');
4586
+ // pattern 可能是字面量、列引用或表达式:统一用 resolveOperand 取值。
4587
+ // 过去 String(expr.pattern) 对 { type:'binary'|'column' } 对象只会得到
4588
+ // "[object Object]",BINARY 前缀的大小写敏感语义也因此完全失效。
4589
+ // pattern 可能是字面量、列引用或表达式:字面量(primitive)直接使用,
4590
+ // 只有 AST 节点才走 resolveOperand —— 否则字符串会被当成列名。
4591
+ const pat = (expr.pattern && typeof expr.pattern === 'object')
4592
+ ? resolveOperand(expr.pattern, row, ctx)
4593
+ : expr.pattern;
4594
+ if (pat === null || pat === undefined) return false;
4595
+ let re;
4596
+ try { re = new RegExp(String(pat), expr.ci === false ? '' : 'i'); } catch { return false; }
4597
+ // REGEXP / RLIKE 默认大小写不敏感;`~` 敏感、`~*` 不敏感;REGEXP BINARY 敏感
4574
4598
  const r = re.test(String(v));
4575
4599
  return expr.not ? !r : r;
4576
4600
  }
@@ -4590,8 +4614,11 @@ function evaluateExpr(expr, row, ctx) {
4590
4614
  // 三值逻辑:任一操作数为 NULL 时结果是 UNKNOWN(用 null 表示),
4591
4615
  // 与普通比较运算符一致;不能返回 false,否则 NOT BETWEEN 会被误判为真。
4592
4616
  if (v === null || v === undefined || lo === null || lo === undefined || hi === null || hi === undefined) return null;
4593
- const inRange = typeof v === 'number' && typeof lo === 'number' && typeof hi === 'number'
4594
- ? v >= lo && v <= hi
4617
+ // 数值(含数字字符串)必须按数值比较,否则 '10' BETWEEN 2 AND 9 会退化成
4618
+ // 字典序 ('10' >= '2' 为 false),与 MySQL 不一致。
4619
+ const nv = numericIfNumeric(v), nlo = numericIfNumeric(lo), nhi = numericIfNumeric(hi);
4620
+ const inRange = nv !== null && nlo !== null && nhi !== null
4621
+ ? nv >= nlo && nv <= nhi
4595
4622
  : String(v) >= String(lo) && String(v) <= String(hi);
4596
4623
  return expr.not ? !inRange : inRange;
4597
4624
  }
@@ -5447,11 +5474,21 @@ class SQLExecutor {
5447
5474
  }
5448
5475
  if (typeof statement.limit === 'number') candidates = candidates.slice(0, statement.limit);
5449
5476
  let count = 0;
5477
+ // UPDATE 的赋值表达式同样可能含标量子查询,SELECT 路径在 _materialize
5478
+ // 里统一物化,但 UPDATE 从未调用它 —— 子查询节点 _value 一直是 undefined,
5479
+ // resolveOperand 直接返回 NULL,`SET x = (SELECT ...)` 静默写成 NULL。
5480
+ for (const [, val] of statement.assignments) {
5481
+ if (val && typeof val === 'object') await this._materialize(val);
5482
+ }
5450
5483
  for (const row of candidates) {
5451
5484
  const id = this._rowPkId(row, pkCols);
5452
5485
  if (id !== undefined) {
5453
5486
  const data = {};
5454
5487
  for (const [col, val] of statement.assignments) {
5488
+ if (val && typeof val === 'object' && val.type) {
5489
+ // 相关子查询:按当前行重算(非相关时重算是幂等的,结果一致)
5490
+ for (const sub of this._collectSubqueries(val)) await this._evalCorrelated(sub, row);
5491
+ }
5455
5492
  data[col] = typeof val === 'object' && val !== null && val.type ? resolveOperand(val, row, this.ctx) : val;
5456
5493
  }
5457
5494
  // 必须 await:wasm / native client 的 updateById 是异步的,
package/lib/star-nag.js CHANGED
@@ -156,22 +156,33 @@ function checkStarred() {
156
156
  // 用同步 API:CLI 启动阶段还没有 event loop 可用
157
157
  try {
158
158
  const { execFileSync } = require('child_process');
159
- const out = execFileSync('curl', [
159
+ // 用 -w 追加 HTTP 状态码,按状态码判定,而不是靠 body 长度猜。
160
+ // 该端点语义:已 star → 204(空 body);未 star → 404。
161
+ // 此前把「空 body」一律当已 star:遇到 302/500 等空 body 响应或代理异常时
162
+ // 会误判为 star 过,从此永久闭嘴。
163
+ const raw = execFileSync('curl', [
160
164
  '-sS', '-m', '4',
165
+ '-w', '\n%{http_code}',
161
166
  '-H', `Authorization: Bearer ${token}`,
162
167
  '-H', 'Accept: application/vnd.github+json',
163
168
  '-H', 'X-GitHub-Api-Version: 2022-11-28',
164
169
  `https://api.github.com/user/starred/${REPO}`,
165
170
  ], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString();
166
- if (out.trim() === '') return true; // 200 + 空数组 = 列了但没有这个仓库
167
- const j = JSON.parse(out);
168
- // GitHub 出错时返回的是 {"message":"Bad credentials"} 之类,不是数组。
169
- // 那说明 token 无效/过期 —— 属于「无法确认」,不能当成「没 star」,
170
- // 否则会永远提示下去。
171
- if (j && !Array.isArray(j) && typeof j.message === 'string') return null;
172
- return Array.isArray(j) && j.length > 0;
171
+ const nl = raw.lastIndexOf('\n');
172
+ const status = Number(raw.slice(nl + 1).trim());
173
+ const body = raw.slice(0, nl).trim();
174
+ if (status === 204) return true;
175
+ if (status === 404) return false;
176
+ if (status === 200 && body) {
177
+ // 兼容返回数组的旧行为:数组里有该仓库才算 star
178
+ try {
179
+ const j = JSON.parse(body);
180
+ if (Array.isArray(j)) return j.length > 0;
181
+ } catch (e) { /* 非 JSON,无法确认 */ }
182
+ }
183
+ return null; // 401/403/3xx/5xx → 无法确认
173
184
  } catch (e) {
174
- return null; // 网络不通 / 401 / 没装 curl → 不确定
185
+ return null; // 网络不通 / 没装 curl → 不确定
175
186
  }
176
187
  }
177
188
 
package/lib/table.js CHANGED
@@ -118,6 +118,24 @@ function isSafeRegex(pattern) {
118
118
  return groups.length === 0;
119
119
  }
120
120
 
121
+ /**
122
+ * 深拷贝一行数据(普通对象/数组/Date),用于隔离内部存储与调用方。
123
+ *
124
+ * 过去 insert/find 只做 `{ ...row }` 浅拷贝:行里的嵌套对象/数组与内部存储
125
+ * 共享引用,调用方修改返回值(或插入后继续改传入对象)会静默污染数据库内容。
126
+ * 非普通对象(Map/Set/类实例等)保持原引用,避免破坏无法安全克隆的类型。
127
+ */
128
+ function cloneRow(value) {
129
+ if (value === null || typeof value !== 'object') return value;
130
+ if (value instanceof Date) return new Date(value.getTime());
131
+ if (Array.isArray(value)) return value.map(cloneRow);
132
+ const proto = Object.getPrototypeOf(value);
133
+ if (proto !== Object.prototype && proto !== null) return value;
134
+ const out = {};
135
+ for (const k of Object.keys(value)) out[k] = cloneRow(value[k]);
136
+ return out;
137
+ }
138
+
121
139
  class Table {
122
140
  constructor(name, schema, db) {
123
141
  this._name = name;
@@ -245,7 +263,7 @@ class Table {
245
263
  }
246
264
 
247
265
  const rowIndex = this._rows.length;
248
- this._rows.push({ ...data });
266
+ this._rows.push(cloneRow(data));
249
267
  this._addToIndexes(rowIndex, data);
250
268
  this._dirty = true;
251
269
  this._db._markDirty(this._name);
@@ -299,7 +317,7 @@ class Table {
299
317
  const result = new Array(N);
300
318
  for (let i = 0; i < N; i++) {
301
319
  // 在副本上做默认值/计算列/类型归一,避免污染调用方传入的原始对象
302
- let data = { ...items[i] };
320
+ let data = cloneRow(items[i]);
303
321
  for (const [f, dv] of Object.entries(defaults)) {
304
322
  if (data[f] === undefined) data[f] = typeof dv === 'function' ? dv() : dv === 'CURRENT_TIMESTAMP' ? now() : dv;
305
323
  }
@@ -308,7 +326,8 @@ class Table {
308
326
  data = this._validateDataTypes(data);
309
327
  for (const field of this._cachedSchemaFields) {
310
328
  const def = schema[field];
311
- if (def.required && (data[field] === undefined || data[field] === null)
329
+ const isPk = def.primaryKey || def.primary === true;
330
+ if ((def.required || isPk) && (data[field] === undefined || data[field] === null)
312
331
  && !(def.autoIncrement && data[field] === undefined)) throw createError('ER_BAD_NULL_ERROR', field);
313
332
  if (def.unique && data[field] !== undefined) {
314
333
  if (uniqueTracker[field].has(data[field])) throw createError('ER_DUP_ENTRY', String(data[field]), field);
@@ -346,7 +365,7 @@ class Table {
346
365
  this._autoIncrement = data[autoIncField];
347
366
  }
348
367
  const rowIdx = this._rows.length;
349
- this._rows.push(Object.assign({}, data));
368
+ this._rows.push(cloneRow(data));
350
369
  if (this._pkIndex && this._primaryKey) {
351
370
  const pkVal = data[this._primaryKey];
352
371
  if (pkVal !== undefined && pkVal !== null) {
@@ -391,7 +410,7 @@ class Table {
391
410
  if (Object.keys(query).length > 0) {
392
411
  rows = this._applyFilterOptimized(rows, query);
393
412
  }
394
- return rows.map(r => ({ ...r }));
413
+ return rows.map(r => cloneRow(r));
395
414
  }
396
415
 
397
416
  findOne(query = {}) {
@@ -399,7 +418,7 @@ class Table {
399
418
  if (Object.keys(query).length > 0) {
400
419
  rows = this._applyFilterOptimized(rows, query);
401
420
  }
402
- return rows.length > 0 ? { ...rows[0] } : null;
421
+ return rows.length > 0 ? cloneRow(rows[0]) : null;
403
422
  }
404
423
 
405
424
  exists(query = {}) {
@@ -415,7 +434,7 @@ class Table {
415
434
  const rowIndex = this._pkIndex.get(id);
416
435
  if (rowIndex !== undefined) {
417
436
  const row = this._rows[rowIndex];
418
- if (row && !row._deleted) return { ...row };
437
+ if (row && !row._deleted) return cloneRow(row);
419
438
  }
420
439
  return null;
421
440
  }
@@ -423,7 +442,7 @@ class Table {
423
442
  const indices = this._btrees[this._primaryKey].search(id);
424
443
  if (indices.length > 0) {
425
444
  const row = this._rows[indices[0]];
426
- if (row && !row._deleted) return { ...row };
445
+ if (row && !row._deleted) return cloneRow(row);
427
446
  }
428
447
  return null;
429
448
  }
@@ -431,7 +450,7 @@ class Table {
431
450
  }
432
451
 
433
452
  findAll() {
434
- return this._getVisibleRows().map(r => ({ ...r }));
453
+ return this._getVisibleRows().map(r => cloneRow(r));
435
454
  }
436
455
 
437
456
  count(query = {}) {
@@ -995,18 +1014,22 @@ class Table {
995
1014
  for (const [field, condition] of Object.entries(query)) {
996
1015
  if (field === '$or' || field === '$and' || field === '$not') continue;
997
1016
  if (typeof condition !== 'object' || condition === null || Array.isArray(condition) || condition instanceof RegExp) {
998
- if (this._btrees[field]) {
1017
+ // 索引只收非 NULL 值:condition 为 null/undefined 时必须全表扫描,
1018
+ // 否则 search(null) 命中空数组直接返回 0 行(索引列 IS NULL 漏行)。
1019
+ const nullish = condition === null || condition === undefined;
1020
+ if (!nullish && this._btrees[field]) {
999
1021
  const indices = this._btrees[field].search(condition);
1000
1022
  if (indices.length === 0) return [];
1001
1023
  return this._applyFilter(this._rowsFromIndices(indices), query);
1002
1024
  }
1003
- if (this._indexes[field] && this._indexes[field].has(condition)) {
1025
+ if (!nullish && this._indexes[field] && this._indexes[field].has(condition)) {
1004
1026
  return this._applyFilter(this._rowsFromIndices(this._indexes[field].get(condition)), query);
1005
1027
  }
1006
- if (this._indexes[field]) return [];
1028
+ if (!nullish && this._indexes[field]) return [];
1007
1029
  }
1008
1030
  if (condition && typeof condition === 'object') {
1009
- if (this._btrees[field] && Array.isArray(condition.$in) && condition.$in.length > 0) {
1031
+ if (this._btrees[field] && Array.isArray(condition.$in) && condition.$in.length > 0
1032
+ && !condition.$in.some(v => v === null || v === undefined)) {
1010
1033
  const indices = this._btrees[field].searchMany(condition.$in);
1011
1034
  if (indices.length === 0) return [];
1012
1035
  return this._applyFilter(this._rowsFromIndices(indices), query);
@@ -1355,8 +1378,9 @@ class Table {
1355
1378
  for (const [field, def] of Object.entries(this._schema)) {
1356
1379
  if (field === '_softDelete') continue;
1357
1380
 
1358
- // NOT NULL
1359
- if (def.required && (data[field] === undefined || data[field] === null)
1381
+ // NOT NULL(主键隐式 NOT NULL,否则唯一约束检查会因 NULL 短路而被绕过)
1382
+ const isPk = def.primaryKey || def.primary === true;
1383
+ if ((def.required || isPk) && (data[field] === undefined || data[field] === null)
1360
1384
  && !(def.autoIncrement && data[field] === undefined)) {
1361
1385
  throw createError('ER_BAD_NULL_ERROR', field);
1362
1386
  }
@@ -1664,4 +1688,5 @@ class Table {
1664
1688
  }
1665
1689
  }
1666
1690
 
1667
- module.exports = Table;
1691
+ module.exports = Table;
1692
+ Table.cloneRow = cloneRow;
package/lib/web_ui.js CHANGED
@@ -69,19 +69,22 @@ tr:nth-child(even) td { background: #0d1117; }
69
69
  <script>
70
70
  let dbs = [], cur = null;
71
71
  const q = async (u, o) => { const r = await fetch(u, o); return r.json(); };
72
- function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
72
+ function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
73
73
  async function load() {
74
74
  dbs = await q('/api/databases');
75
75
  const el = document.getElementById('dbs');
76
- el.innerHTML = dbs.map(d => '<div class="db" onclick="openDb(' + esc(d.name) + ')">' + esc(d.name) + ' <span class="rows">(' + d.tables + ' tbl)</span></div>').join('');
76
+ el.innerHTML = dbs.map(d => '<div class="db" data-name="' + esc(d.name) + '">' + esc(d.name) + ' <span class="rows">(' + d.tables + ' tbl)</span></div>').join('');
77
+ el.querySelectorAll('.db').forEach(e => e.addEventListener('click', () => openDb(e.dataset.name)));
77
78
  if (!cur && dbs.length) openDb(dbs[0].name);
78
79
  }
79
80
  async function openDb(n) {
80
81
  cur = n;
81
- document.querySelectorAll('.db').forEach(e => e.classList.toggle('active', e.textContent.indexOf(n) === 0));
82
+ document.querySelectorAll('.db').forEach(e => e.classList.toggle('active', e.dataset.name === n));
82
83
  const r = await q('/api/tables?db=' + encodeURIComponent(n));
83
- document.getElementById('tbls').innerHTML = r.tables.map(t =>
84
- '<div class="tbl" onclick="sel(' + esc(t.name) + ')">' + esc(t.name) + ' (' + t.count + ')</div>').join('');
84
+ const tl = document.getElementById('tbls');
85
+ tl.innerHTML = r.tables.map(t =>
86
+ '<div class="tbl" data-name="' + esc(t.name) + '">' + esc(t.name) + ' (' + t.count + ')</div>').join('');
87
+ tl.querySelectorAll('.tbl').forEach(e => e.addEventListener('click', () => sel(e.dataset.name)));
85
88
  }
86
89
  function sel(n) { document.getElementById('sql').value = 'SELECT * FROM ' + n + ' LIMIT 100'; run(); }
87
90
  async function run() {
@@ -136,7 +136,7 @@ class Emitter {
136
136
  const lo = this.emit(node.lo);
137
137
  const hi = this.emit(node.hi);
138
138
  const t = this.tmp();
139
- this.push(`let ${t} = (${v} == null ? null : (${v} >= ${lo} && ${v} <= ${hi}));`);
139
+ this.push(`let ${t} = (${v} == null || ${lo} == null || ${hi} == null ? null : (${v} >= ${lo} && ${v} <= ${hi}));`);
140
140
  return t;
141
141
  }
142
142
  case 'like': {
@@ -183,8 +183,8 @@ class Emitter {
183
183
  case '*': this.push(`let ${t} = (${l} == null || ${r} == null ? null : ${l} * ${r});`); break;
184
184
  case '/': this.push(`let ${t} = (${l} == null || ${r} == null || Number(${r}) === 0 ? null : ${l} / ${r});`); break;
185
185
  case '%': this.push(`let ${t} = (${l} == null || ${r} == null || Number(${r}) === 0 ? null : ${l} % ${r});`); break;
186
- case '=': this.push(`let ${t} = (${l} == null ? null : ${l} === ${r});`); break;
187
- case '!=': this.push(`let ${t} = (${l} == null ? null : ${l} !== ${r});`); break;
186
+ case '=': this.push(`let ${t} = (${l} == null || ${r} == null ? null : ${l} === ${r});`); break;
187
+ case '!=': this.push(`let ${t} = (${l} == null || ${r} == null ? null : ${l} !== ${r});`); break;
188
188
  case '<': this.push(`let ${t} = (${l} == null || ${r} == null ? null : ${l} < ${r});`); break;
189
189
  case '>': this.push(`let ${t} = (${l} == null || ${r} == null ? null : ${l} > ${r});`); break;
190
190
  case '<=': this.push(`let ${t} = (${l} == null || ${r} == null ? null : ${l} <= ${r});`); break;
@@ -197,15 +197,25 @@ class Emitter {
197
197
  emitIn(node) {
198
198
  const v = this.emit(node.e);
199
199
  const t = this.tmp();
200
- const not = node.neg ? '!' : '';
201
- const join = node.neg ? ' && ' : ' || ';
202
200
  if (node.list.every(n => n.k === 'lit')) {
203
201
  const name = '_set' + this.sets.length;
204
202
  this.sets.push({ name, set: new Set(node.list.map(n => n.v)) });
205
- this.push(`let ${t} = (${v} == null ? null : ${not}${name}.has(${v}));`);
203
+ // 完整三值逻辑:被查找值为 NULL → UNKNOWN;列表含 NULL 且未匹配 → UNKNOWN。
204
+ const hasNull = node.list.some(n => n.v === null);
205
+ if (node.neg) {
206
+ this.push(`let ${t} = (${v} == null ? null : (${name}.has(${v}) ? false : (${hasNull} ? null : true)));`);
207
+ } else {
208
+ this.push(`let ${t} = (${v} == null ? null : (${name}.has(${v}) ? true : (${hasNull} ? null : false)));`);
209
+ }
206
210
  } else {
207
- const parts = node.list.map(n => `(${v} != null && ${v} === ${this.emit(n)})`);
208
- this.push(`let ${t} = (${v} == null ? null : ${not}(${parts.join(join)}));`);
211
+ const elems = node.list.map(n => this.emit(n));
212
+ const anyMatch = elems.map(e => `(${v} === ${e})`).join(' || ');
213
+ const anyNull = elems.map(e => `${e} == null`).join(' || ');
214
+ if (node.neg) {
215
+ this.push(`let ${t} = (${v} == null ? null : (${anyMatch} ? false : (${anyNull} ? null : true)));`);
216
+ } else {
217
+ this.push(`let ${t} = (${v} == null ? null : (${anyMatch} ? true : (${anyNull} ? null : false)));`);
218
+ }
209
219
  }
210
220
  return t;
211
221
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsql-neo",
3
- "version": "6.5.13",
3
+ "version": "6.5.14",
4
4
  "description": "JSQL-NEO \u2014 Rust-powered embedded database with WASM, REST API, B-Tree indexes, WAL, crash recovery",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",