jsql-neo 5.1.1 → 5.2.0

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
@@ -13,8 +13,6 @@
13
13
 
14
14
  ## Why JSQL-NEO?
15
15
 
16
- My web : https://jsql.vexify.top/
17
-
18
16
  Most embedded databases make you choose: *native speed*, *portable WASM*, or *a familiar file format*.
19
17
  JSQL-NEO gives you **all three in one install** — plus drop-in compatibility with the **two most popular
20
18
  database protocols in the world**.
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * JSQL-NEO v5.1.1 — Rust-Powered Embedded Database (WASM + HTTP)
2
+ * JSQL-NEO v5.1.2 — Rust-Powered Embedded Database (WASM + HTTP)
3
3
  *
4
4
  * @example
5
5
  * const jsql = require('jsql-neo');
package/lib/btree.js CHANGED
@@ -24,6 +24,7 @@ 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;
27
28
  this._minDegree = Math.ceil(order / 2);
28
29
  }
29
30
 
@@ -50,14 +51,17 @@ class BTree {
50
51
  }
51
52
 
52
53
  _insertNonFull(node, key, rowIndex) {
53
- let i = node.keys.length - 1;
54
-
55
54
  if (node.leaf) {
56
55
  // 查找插入位置
56
+ let i = node.keys.length - 1;
57
57
  while (i >= 0 && key < node.keys[i]) i--;
58
58
  i++;
59
59
 
60
60
  if (i > 0 && node.keys[i - 1] === key) {
61
+ if (this._unique) {
62
+ this._size--;
63
+ return;
64
+ }
61
65
  if (!node.values[i - 1].includes(rowIndex)) {
62
66
  node.values[i - 1].push(rowIndex);
63
67
  }
@@ -68,12 +72,10 @@ class BTree {
68
72
  node.keys.splice(i, 0, key);
69
73
  node.values.splice(i, 0, [rowIndex]);
70
74
  } else {
71
- while (i >= 0 && key < node.keys[i]) i--;
72
- i++;
73
-
75
+ let i = this._route(node, key);
74
76
  if (node.children[i] && node.children[i].keys.length === this._order - 1) {
75
77
  this._splitChild(node, i);
76
- if (key >= node.keys[i] && i + 1 < node.children.length) i++;
78
+ i = this._route(node, key);
77
79
  }
78
80
  this._insertNonFull(node.children[i], key, rowIndex);
79
81
  }
@@ -84,23 +86,22 @@ class BTree {
84
86
  const newChild = new BTreeNode(child.leaf);
85
87
  const mid = Math.floor((this._order - 1) / 2);
86
88
 
87
- // 右半部分移到新节点
88
- newChild.keys = child.keys.splice(mid + 1);
89
- newChild.values = child.values.splice(mid + 1);
90
-
91
- // 中间键提升到父节点
92
89
  const midKey = child.keys[mid];
93
90
  const midVal = child.values[mid];
94
91
 
95
- // 非叶子节点:移动子节点
96
- if (!child.leaf) {
97
- newChild.children = child.children.splice(mid + 1);
98
- }
99
-
100
- // 叶子节点:维护链表
101
92
  if (child.leaf) {
93
+ // 叶子:右半移到新节点;分隔键保留在左叶(它是真实数据键),父节点只存副本
94
+ newChild.keys = child.keys.splice(mid + 1);
95
+ newChild.values = child.values.splice(mid + 1);
102
96
  newChild.next = child.next;
103
97
  child.next = newChild;
98
+ } else {
99
+ // 内部:分隔键提升到父节点并从子节点移除,保持 children = keys + 1
100
+ newChild.keys = child.keys.splice(mid + 1);
101
+ newChild.values = child.values.splice(mid + 1);
102
+ child.keys.splice(mid, 1);
103
+ child.values.splice(mid, 1);
104
+ newChild.children = child.children.splice(mid + 1);
104
105
  }
105
106
 
106
107
  parent.keys.splice(index, 0, midKey);
@@ -113,23 +114,28 @@ class BTree {
113
114
  // ============================================================
114
115
 
115
116
  /**
116
- * 精确查找
117
- * @returns {number[]} 行号数组
117
+ * 内部节点路由:返回 key 应下降到的子节点下标。
118
+ * 命中分隔键副本时,根据左子树最大键判定真实数据所在子树
119
+ * (分隔键副本可能在左子树作为最大值,或在右子树作为最小值)。
120
+ */
121
+ _route(node, key) {
122
+ let i = 0;
123
+ while (i < node.keys.length && key > node.keys[i]) i++;
124
+ if (i < node.keys.length && node.keys[i] === key) {
125
+ return this._getMax(node.children[i]).key === key ? i : i + 1;
126
+ }
127
+ return i;
128
+ }
129
+
130
+ /**
131
+ * 精确查找(始终下探到叶子取真实数据;内部分隔键只是叶子的副本,仅用于导航)
132
+ * @returns {number[]} 行号数组;未找到时返回空数组
118
133
  */
119
134
  search(key) {
120
135
  let node = this._root;
121
- while (node) {
122
- let i = 0;
123
- while (i < node.keys.length && key > node.keys[i]) i++;
124
-
125
- if (i < node.keys.length && key === node.keys[i]) {
126
- return node.values[i];
127
- }
128
-
129
- if (node.leaf) break;
130
- node = node.children[i];
131
- }
132
- return [];
136
+ while (!node.leaf) node = node.children[this._route(node, key)];
137
+ const i = node.keys.indexOf(key);
138
+ return i === -1 ? [] : node.values[i];
133
139
  }
134
140
 
135
141
  /**
@@ -209,58 +215,85 @@ class BTree {
209
215
  }
210
216
 
211
217
  _removeFromNode(node, key, rowIndex) {
212
- let i = 0;
213
- while (i < node.keys.length && key > node.keys[i]) i++;
214
-
215
218
  if (node.leaf) {
216
- if (i < node.keys.length && node.keys[i] === key) {
217
- const vals = node.values[i].filter(v => v !== rowIndex);
218
- if (vals.length === 0) {
219
- node.keys.splice(i, 1);
220
- node.values.splice(i, 1);
221
- } else {
222
- node.values[i] = vals;
223
- }
219
+ const i = node.keys.indexOf(key);
220
+ if (i === -1) return false;
221
+ if (!node.values[i].includes(rowIndex)) return false;
222
+ const vals = node.values[i].filter(v => v !== rowIndex);
223
+ if (vals.length === 0) {
224
+ node.keys.splice(i, 1);
225
+ node.values.splice(i, 1);
224
226
  return true;
225
227
  }
228
+ node.values[i] = vals;
226
229
  return false;
227
230
  }
228
231
 
229
- if (i < node.keys.length && node.keys[i] === key) {
230
- return this._removeFromInternal(node, i, rowIndex);
231
- }
232
+ let i = this._route(node, key);
232
233
 
234
+ // 下降前确保子节点有足够键,避免欠满子节点
235
+ const child = node.children[i];
236
+ if (child.keys.length <= this._minKeys) {
237
+ i = this._rebalanceChild(node, i);
238
+ }
233
239
  return this._removeFromNode(node.children[i], key, rowIndex);
234
240
  }
235
241
 
236
- _removeFromInternal(node, index, rowIndex) {
237
- const vals = node.values[index].filter(v => v !== rowIndex);
238
- if (vals.length > 0) {
239
- node.values[index] = vals;
240
- return true;
242
+ _rebalanceChild(parent, i) {
243
+ if (i > 0 && parent.children[i - 1].keys.length > this._minKeys) {
244
+ this._borrowFromLeft(parent, i);
245
+ return i;
241
246
  }
242
-
243
- const leftChild = node.children[index];
244
- if (leftChild.keys.length >= this._minDegree) {
245
- const pred = this._getMax(leftChild);
246
- node.keys[index] = pred.key;
247
- node.values[index] = pred.value;
248
- this._removeFromNode(leftChild, pred.key, rowIndex);
249
- return true;
247
+ if (i + 1 < parent.children.length && parent.children[i + 1].keys.length > this._minKeys) {
248
+ this._borrowFromRight(parent, i);
249
+ return i;
250
+ }
251
+ if (i > 0) {
252
+ this._mergeChildren(parent, i - 1);
253
+ return i - 1;
250
254
  }
255
+ this._mergeChildren(parent, i);
256
+ return i;
257
+ }
251
258
 
252
- const rightChild = node.children[index + 1];
253
- if (rightChild.keys.length >= this._minDegree) {
254
- const succ = this._getMin(rightChild);
255
- node.keys[index] = succ.key;
256
- node.values[index] = succ.value;
257
- this._removeFromNode(rightChild, succ.key, rowIndex);
258
- return true;
259
+ _borrowFromLeft(parent, i) {
260
+ const left = parent.children[i - 1];
261
+ const right = parent.children[i];
262
+ if (right.leaf) {
263
+ // 叶子节点:分隔键是叶子的副本,用被移动的键而非旧分隔键,避免重复
264
+ const k = left.keys.pop();
265
+ const v = left.values.pop();
266
+ right.keys.unshift(k);
267
+ right.values.unshift(v);
268
+ parent.keys[i - 1] = k;
269
+ parent.values[i - 1] = v;
270
+ } else {
271
+ right.keys.unshift(parent.keys[i - 1]);
272
+ right.values.unshift(parent.values[i - 1]);
273
+ parent.keys[i - 1] = left.keys.pop();
274
+ parent.values[i - 1] = left.values.pop();
275
+ right.children.unshift(left.children.pop());
259
276
  }
277
+ }
260
278
 
261
- const origKey = node.keys[index];
262
- this._mergeChildren(node, index);
263
- return this._removeFromNode(node.children[index], origKey, rowIndex);
279
+ _borrowFromRight(parent, i) {
280
+ const left = parent.children[i];
281
+ const right = parent.children[i + 1];
282
+ if (left.leaf) {
283
+ // 叶子节点:用被移动的键而非旧分隔键,避免重复
284
+ const k = right.keys.shift();
285
+ const v = right.values.shift();
286
+ left.keys.push(k);
287
+ left.values.push(v);
288
+ parent.keys[i] = k;
289
+ parent.values[i] = v;
290
+ } else {
291
+ left.keys.push(parent.keys[i]);
292
+ left.values.push(parent.values[i]);
293
+ parent.keys[i] = right.keys.shift();
294
+ parent.values[i] = right.values.shift();
295
+ left.children.push(right.children.shift());
296
+ }
264
297
  }
265
298
 
266
299
  _getMax(node) {
@@ -269,19 +302,17 @@ class BTree {
269
302
  return { key: node.keys[i], value: node.values[i] };
270
303
  }
271
304
 
272
- _getMin(node) {
273
- while (!node.leaf) node = node.children[0];
274
- return { key: node.keys[0], value: node.values[0] };
275
- }
276
-
277
305
  _mergeChildren(parent, index) {
278
306
  const left = parent.children[index];
279
307
  const right = parent.children[index + 1];
280
308
  const midKey = parent.keys[index];
281
309
  const midVal = parent.values[index];
282
310
 
283
- left.keys.push(midKey);
284
- left.values.push(midVal);
311
+ // 叶子节点:分隔键是叶子的副本(已在 left 或 right 中),不能重复压入
312
+ if (!left.leaf) {
313
+ left.keys.push(midKey);
314
+ left.values.push(midVal);
315
+ }
285
316
  left.keys.push(...right.keys);
286
317
  left.values.push(...right.values);
287
318
  if (!left.leaf) left.children.push(...right.children);
@@ -297,26 +328,19 @@ class BTree {
297
328
  // ============================================================
298
329
 
299
330
  /**
300
- * 获取所有索引条目
331
+ * 获取所有索引条目(仅叶子数据,分隔键为内部副本,不重复计入)
301
332
  */
302
333
  entries() {
303
334
  const result = [];
304
- this._traverse(this._root, result);
305
- return result;
306
- }
307
-
308
- _traverse(node, result) {
309
- if (node.leaf) {
310
- for (let i = 0; i < node.keys.length; i++) {
311
- result.push({ key: node.keys[i], value: node.values[i] });
312
- }
313
- } else {
335
+ let node = this._root;
336
+ while (node && !node.leaf) node = node.children[0];
337
+ while (node) {
314
338
  for (let i = 0; i < node.keys.length; i++) {
315
- this._traverse(node.children[i], result);
316
339
  result.push({ key: node.keys[i], value: node.values[i] });
317
340
  }
318
- this._traverse(node.children[node.children.length - 1], result);
341
+ node = node.next;
319
342
  }
343
+ return result;
320
344
  }
321
345
 
322
346
  /**
package/lib/database.js CHANGED
@@ -20,13 +20,18 @@ const JSQLFormat = require('./jsql_format');
20
20
  */
21
21
  function parseFieldShorthand(str) {
22
22
  const def = { type: str };
23
- if (/\bprimary\s+key\b/.test(str)) { def.primaryKey = true; def.type = str.replace(/\bprimary\s+key\b/g, '').trim() || def.type; }
24
- if (/\bauto_?increment\b/.test(str)) { def.autoIncrement = true; def.type = str.replace(/\bauto_?increment\b/g, '').trim() || def.type; }
25
- if (/\bnot\s+null\b/.test(str)) { def.required = true; def.type = str.replace(/\bnot\s+null\b/g, '').trim() || def.type; }
26
- if (/\bunique\b/.test(str)) { def.unique = true; def.type = str.replace(/\bunique\b/g, '').trim() || def.type; }
27
- if (/\bdefault\s+(\S+)/.test(str)) {
28
- def.default = RegExp.$1.replace(/^['"]|['"]$/g, '');
29
- def.type = str.replace(/\bdefault\s+\S+/g, '').trim() || def.type;
23
+ const strip = (re) => {
24
+ const next = def.type.replace(re, '').trim();
25
+ def.type = next || def.type;
26
+ };
27
+ if (/\bprimary\s+key\b/i.test(def.type)) { def.primaryKey = true; strip(/\bprimary\s+key\b/gi); }
28
+ if (/\bauto_?increment\b/i.test(def.type)) { def.autoIncrement = true; strip(/\bauto_?increment\b/gi); }
29
+ if (/\bnot\s+null\b/i.test(def.type)) { def.required = true; strip(/\bnot\s+null\b/gi); }
30
+ if (/\bunique\b/i.test(def.type)) { def.unique = true; strip(/\bunique\b/gi); }
31
+ if (/\bdefault\s+(\S+)/i.test(def.type)) {
32
+ const m = def.type.match(/\bdefault\s+(\S+)/i);
33
+ def.default = m[1].replace(/^['"]|['"]$/g, '');
34
+ strip(/\bdefault\s+\S+/gi);
30
35
  }
31
36
  return def;
32
37
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsql-neo",
3
- "version": "5.1.1",
3
+ "version": "5.2.0",
4
4
  "description": "JSQL-NEO — 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",
@@ -47,6 +47,9 @@
47
47
  "scripts": {
48
48
  "postinstall": "node postinstall.js",
49
49
  "test": "node test/smoke.js",
50
+ "test:btree": "node test/btree.test.js",
51
+ "test:regress": "node test/regress-5.1.0.js",
52
+ "test:all": "node test/smoke.js && node test/coverage.js && node test/regress-5.1.0.js && node test/btree.test.js",
50
53
  "test:coverage": "c8 --all --include='lib/**' --exclude='lib/mysql_server.js' --exclude='lib/native_client.js' --exclude='lib/wasm_client.js' --exclude='lib/mysql_compat.js' --exclude='lib/nedb_compat.js' --exclude='lib/plugin.js' --reporter=text --reporter=lcov node test/coverage.js",
51
54
  "test:orms": "node examples/orms/run-all.js"
52
55
  },
@@ -0,0 +1,128 @@
1
+ /*
2
+ * B-Tree 专项回归测试 — 覆盖 GitHub Issue #3 的三个 bug 及随机混合操作不变量。
3
+ * node test/btree.test.js
4
+ */
5
+ const BTree = require('../lib/btree');
6
+
7
+ let passed = 0, failed = 0;
8
+ function ok(name, cond, extra) {
9
+ if (cond) { passed++; }
10
+ else { failed++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra) : ''); }
11
+ }
12
+
13
+ function validate(bt) {
14
+ const errs = [];
15
+ (function walk(node) {
16
+ if (node.leaf) {
17
+ if (node.children.length !== 0) errs.push('leaf has children');
18
+ for (let i = 1; i < node.keys.length; i++) if (node.keys[i] <= node.keys[i - 1]) errs.push('leaf unsorted');
19
+ } else {
20
+ if (node.children.length !== node.keys.length + 1) errs.push('internal children !== keys+1');
21
+ for (let i = 1; i < node.keys.length; i++) if (node.keys[i] <= node.keys[i - 1]) errs.push('internal unsorted');
22
+ node.children.forEach(walk);
23
+ }
24
+ })(bt._root);
25
+ return errs;
26
+ }
27
+
28
+ /* ---- Issue #3 Bug #1: 删除崩溃 ---- */
29
+ {
30
+ const bt = new BTree(4);
31
+ for (let i = 0; i < 20; i++) bt.insert(i, i);
32
+ const removed = bt.remove(0, 0);
33
+ ok('issue#3 Bug#1 remove(0,0) no crash', removed === true && bt.search(0).length === 0 && bt.size === 19);
34
+ }
35
+
36
+ /* ---- Issue #3 Bug #2: entries() 乱序重复 ---- */
37
+ {
38
+ const bt = new BTree(4);
39
+ for (let i = 0; i < 20; i++) bt.insert(i, i);
40
+ const e = bt.entries();
41
+ let dup = 0, disorder = 0;
42
+ for (let i = 0; i < e.length; i++) {
43
+ if (i > 0 && e[i].key <= e[i - 1].key) disorder++;
44
+ for (let j = i + 1; j < e.length; j++) if (e[i].key === e[j].key) dup++;
45
+ }
46
+ ok('issue#3 Bug#2 entries sorted & dedup', e.length === 20 && dup === 0 && disorder === 0, { len: e.length, dup, disorder });
47
+ }
48
+
49
+ /* ---- Issue #3 Bug #3: 唯一索引返回所有 values ---- */
50
+ {
51
+ const bt = new BTree(4, true);
52
+ bt.insert('a', 1); bt.insert('a', 2); bt.insert('a', 3);
53
+ ok('issue#3 Bug#3 unique search single value', JSON.stringify(bt.search('a')) === '[1]' && bt.size === 1);
54
+ const n = new BTree(4, false);
55
+ n.insert('a', 1); n.insert('a', 2); n.insert('b', 5);
56
+ ok('non-unique accumulates values', JSON.stringify(n.search('a')) === '[1,2]');
57
+ n.remove('a', 1); n.remove('a', 2); n.remove('a', 3);
58
+ ok('non-unique remove value keeps key until last', n.search('a').length === 0 && n.size === 1);
59
+ }
60
+
61
+ /* ---- 多值键删除只影响目标 rowIndex(size 对照 distinct keys) ---- */
62
+ {
63
+ const bt = new BTree(8);
64
+ bt.insert('x', 1); bt.insert('x', 2); bt.insert('x', 3);
65
+ bt.remove('x', 2);
66
+ ok('multi-value remove one keeps others', JSON.stringify(bt.search('x').sort()) === '[1,3]' && bt.size === 1);
67
+ }
68
+
69
+ /* ---- 随机顺序插入/删除压力 ---- */
70
+ for (const order of [4, 8, 16, 64]) {
71
+ for (let trial = 0; trial < 15; trial++) {
72
+ const bt = new BTree(order);
73
+ const n = 200;
74
+ const seq = [...Array(n).keys()];
75
+ for (let i = n - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [seq[i], seq[j]] = [seq[j], seq[i]]; }
76
+ for (const k of seq) bt.insert(k, k);
77
+ const e = validate(bt);
78
+ ok('o' + order + ' insert invariant t' + trial, e.length === 0, e.slice(0, 2));
79
+ for (const k of seq) bt.remove(k, k);
80
+ ok('o' + order + ' full removal t' + trial, bt.size === 0 && bt.entries().length === 0 && validate(bt).length === 0);
81
+ }
82
+ }
83
+
84
+ /* ---- 随机混合操作 vs 参考模型 ---- */
85
+ for (let t = 0; t < 25; t++) {
86
+ const order = [4, 8, 64][t % 3];
87
+ const bt = new BTree(order);
88
+ const ref = new Map(); // key -> Set(rowIndex)
89
+ let okAll = true;
90
+ for (let op = 0; op < 300; op++) {
91
+ const k = Math.floor(Math.random() * 40);
92
+ if (Math.random() < 0.55) {
93
+ const r = Math.floor(Math.random() * 5);
94
+ bt.insert(k, r);
95
+ if (!ref.has(k)) ref.set(k, new Set());
96
+ ref.get(k).add(r);
97
+ } else {
98
+ const r = Math.floor(Math.random() * 5);
99
+ bt.remove(k, r);
100
+ if (ref.has(k)) { ref.get(k).delete(r); if (ref.get(k).size === 0) ref.delete(k); }
101
+ }
102
+ if (op % 60 === 0) {
103
+ for (let x = 0; x < 40 && okAll; x++) {
104
+ const got = bt.search(x);
105
+ const exp = ref.has(x) ? [...ref.get(x)].sort((a, b) => a - b) : [];
106
+ const g = got ? [...got].sort((a, b) => a - b) : [];
107
+ if (JSON.stringify(g) !== JSON.stringify(exp)) okAll = false;
108
+ }
109
+ const es = bt.entries();
110
+ const emap = new Map(es.map(e => [e.key, [...e.value].sort((a, b) => a - b)]));
111
+ for (const [key, vals] of ref) {
112
+ const g = emap.get(key);
113
+ if (!g || JSON.stringify([...vals].sort()) !== JSON.stringify(g)) okAll = false;
114
+ }
115
+ const gtExp = [...ref.keys()].filter(k => k > 20).flatMap(k => [...ref.get(k)]).sort((a, b) => a - b);
116
+ const gt = bt.greaterThan(20).sort((a, b) => a - b);
117
+ if (JSON.stringify(gt) !== JSON.stringify(gtExp)) okAll = false;
118
+ const ltExp = [...ref.keys()].filter(k => k < 20).flatMap(k => [...ref.get(k)]).sort((a, b) => a - b);
119
+ const lt = bt.lessThan(20).sort((a, b) => a - b);
120
+ if (JSON.stringify(lt) !== JSON.stringify(ltExp)) okAll = false;
121
+ if (validate(bt).length) okAll = false;
122
+ }
123
+ }
124
+ ok('random mixed t' + t + ' vs reference', okAll && bt.size === ref.size, { size: bt.size, ref: ref.size });
125
+ }
126
+
127
+ console.log(failed === 0 ? `\nALL ${passed} BTREE TESTS PASSED` : `\n${failed} FAILURES (${passed} passed)`);
128
+ process.exit(failed === 0 ? 0 : 1);
@@ -0,0 +1,271 @@
1
+ /*
2
+ * Regression tests locking in the bug fixes shipped in 5.1.0 / 5.1.1.
3
+ * Zero runtime dependencies (uses Node builtins + in-repo libs only).
4
+ *
5
+ * node test/regress-5.1.0.js
6
+ *
7
+ * Covers:
8
+ * M1 $like regex-escape M2 removeById(s) index consistency
9
+ * M3 B-Tree open intervals M4 transaction deep-copy snapshot
10
+ * M5 swap-pop hash sync M6 importFromJSON shapes/overwrite
11
+ * H1/H3 parseFieldShorthand + ER_DUP_ENTRY
12
+ * S1 Redis per-socket AUTH S2/S4 WebUI CORS & authToken
13
+ * S3/S4 MySQL ACL (1044) S6 mysql_compat pool filename
14
+ * N1 CLI --version sync
15
+ */
16
+ const path = require('path');
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const net = require('net');
20
+ const { execFileSync } = require('child_process');
21
+
22
+ const ROOT = path.join(__dirname, '..');
23
+ const Database = require(path.join(ROOT, 'lib/database'));
24
+ const BTree = require(path.join(ROOT, 'lib/btree'));
25
+ const migrate = require(path.join(ROOT, 'lib/migrate'));
26
+ const { RedisServer } = require(path.join(ROOT, 'lib/redis_server'));
27
+ const { WebUI } = require(path.join(ROOT, 'lib/web_ui'));
28
+ const { MysqlServer } = require(path.join(ROOT, 'lib/mysql_server'));
29
+ const compat = require(path.join(ROOT, 'lib/mysql_compat'));
30
+
31
+ let passed = 0, failed = 0;
32
+ function ok(name, cond, extra) {
33
+ if (cond) { passed++; console.log('[OK]', name); }
34
+ else { failed++; console.log('[FAIL]', name, extra !== undefined ? '-> ' + JSON.stringify(extra) : ''); }
35
+ }
36
+
37
+ function tmp(name) {
38
+ const p = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'jsql-r-')), name);
39
+ fs.mkdirSync(path.dirname(p), { recursive: true });
40
+ return p;
41
+ }
42
+
43
+ function freePort() {
44
+ return new Promise((res) => {
45
+ const s = net.createServer();
46
+ s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); });
47
+ });
48
+ }
49
+
50
+ /* Minimal RESP client for integration tests. */
51
+ function respClient(port) {
52
+ return new Promise((resolve, reject) => {
53
+ const socket = net.connect(port, '127.0.0.1');
54
+ let buf = '';
55
+ const queue = [];
56
+ socket.on('connect', () => resolve({
57
+ cmd(line) {
58
+ return new Promise((r) => {
59
+ queue.push(r);
60
+ socket.write(line);
61
+ });
62
+ },
63
+ end() { socket.end(); },
64
+ }));
65
+ socket.on('data', (chunk) => {
66
+ buf += chunk.toString('utf8');
67
+ while (queue.length) {
68
+ const idx = buf.indexOf('\r\n');
69
+ if (idx < 0) break;
70
+ const line = buf.slice(0, idx);
71
+ buf = buf.slice(idx + 2);
72
+ queue.shift()(line);
73
+ }
74
+ });
75
+ socket.on('error', reject);
76
+ });
77
+ }
78
+
79
+ (async () => {
80
+ /* ============ M3: B-Tree strict open intervals ============ */
81
+ {
82
+ const t = new BTree();
83
+ for (const k of [1, 3, 5, 7, 9]) t.insert(k, k);
84
+ ok('btree.greaterThan excludes min', JSON.stringify(t.greaterThan(5)) === '[7,9]', t.greaterThan(5));
85
+ ok('btree.greaterThan(9) empty', JSON.stringify(t.greaterThan(9)) === '[]');
86
+ ok('btree.lessThan excludes max', JSON.stringify(t.lessThan(5)) === '[1,3]', t.lessThan(5));
87
+ ok('btree.lessThan(1) empty', JSON.stringify(t.lessThan(1)) === '[]');
88
+ }
89
+
90
+ /* ============ Core engine ============ */
91
+ const db = new Database(':memory:', { autoSave: false });
92
+ await db.start();
93
+ db.createTable('users', { id: 'integer primary key auto_increment', name: 'string', email: 'string unique', age: 'integer' });
94
+ for (const [n, a] of [['A', 10], ['B', 20], ['C', 30]]) db.insert('users', { name: n, email: n.toLowerCase() + '@x', age: a });
95
+
96
+ {
97
+ const gt = db.find('users', { age: { $gt: 20 } }).map(r => r.name);
98
+ ok('find $gt excludes boundary', JSON.stringify(gt) === '["C"]', gt);
99
+ const lt = db.find('users', { age: { $lt: 20 } }).map(r => r.name);
100
+ ok('find $lt excludes boundary', JSON.stringify(lt) === '["A"]', lt);
101
+ }
102
+
103
+ {
104
+ db.removeById('users', 2); // B (id=2) removed, C still id=3
105
+ ok('removeById with PK count', db.count('users') === 2, db.count('users'));
106
+ ok('removeById PK lookup null', db.findById('users', 2) === null);
107
+ ok('removeById B-Tree still consistent', db.find('users', { age: { $gt: 15 } }).length === 1);
108
+ }
109
+
110
+ {
111
+ db.createTable('np', { v: 'string', n: 'integer' });
112
+ for (let i = 0; i < 10; i++) db.insert('np', { v: 'v' + i, n: i });
113
+ db.createTable('h', { id: 'integer primary key auto_increment', grp: 'string', val: 'integer' });
114
+ for (let i = 0; i < 12; i++) db.insert('h', { grp: 'g' + (i % 4), val: i });
115
+ db._tables.h.createIndex('grp');
116
+
117
+ db.removeByIds('np', [1, 2]);
118
+ ok('removeByIds no-PK rebuilds indexes', db.find('np').length === 8, db.find('np').length);
119
+
120
+ const pre = db.find('h', { grp: 'g1' }).map(r => r.id).sort((a, b) => a - b);
121
+ db.removeById('h', 1); db.removeById('h', 5); db.removeById('h', 9); // the g0 rows
122
+ const post = db.find('h', { grp: 'g1' }).map(r => r.id).sort((a, b) => a - b);
123
+ ok('hash index survives swap-pop (g1 intact)', JSON.stringify(post) === JSON.stringify(pre), { pre, post });
124
+ ok('hash index has no stale g0 rows', db.find('h', { grp: 'g0' }).length === 0);
125
+ let stale = 0;
126
+ for (const list of db._tables.h._indexes.grp.values()) for (const i of list) if (!db._tables.h._rows[i]) stale++;
127
+ ok('hash index zero stale entries', stale === 0, stale);
128
+ }
129
+
130
+ {
131
+ db.createTable('docs', { id: 'integer primary key', data: 'string' });
132
+ db.insert('docs', { id: 1, data: JSON.stringify({ a: { b: 1 }, list: [1, 2, 3] }) });
133
+ db.begin();
134
+ db.updateById('docs', 1, { data: JSON.stringify({ a: { b: 2 }, list: [9] }) });
135
+ db.rollback();
136
+ const d = JSON.parse(db.findById('docs', 1).data);
137
+ ok('transaction snapshot deep-copies nested object', d.a.b === 1, d);
138
+ ok('transaction snapshot deep-copies arrays', JSON.stringify(d.list) === '[1,2,3]', d.list);
139
+ }
140
+
141
+ {
142
+ const pfs = Database.parseFieldShorthand;
143
+ const s1 = pfs('integer primary key auto_increment');
144
+ ok('parseFieldShorthand pk+ai', s1.primaryKey === true && s1.autoIncrement === true && s1.type === 'integer', s1);
145
+ const s2 = pfs('string unique not null');
146
+ ok('parseFieldShorthand unique+notnull', s2.unique === true && s2.required === true && s2.type === 'string', s2);
147
+ const s3 = pfs('string default x');
148
+ ok('parseFieldShorthand default', s3.default === 'x' && s3.type === 'string', s3);
149
+
150
+ let e = null;
151
+ try { db.insert('users', { name: 'X', email: 'a@x', age: 1 }); } catch (err) { e = err; }
152
+ ok('duplicate unique throws ER_DUP_ENTRY', e && (e.code === 1062 || String(e).includes('1062')), e && (e.code || e.message));
153
+ db.createTable('pk2', { a: 'integer primary key', b: 'string' });
154
+ db.insert('pk2', { a: 1, b: 'x' });
155
+ e = null;
156
+ try { db.insert('pk2', { a: 1, b: 'y' }); } catch (err) { e = err; }
157
+ ok('duplicate primary key throws ER_DUP_ENTRY', e && (e.code === 1062 || String(e).includes('1062')), e && (e.code || e.message));
158
+ }
159
+
160
+ {
161
+ const r = await migrate.importFromJSON(db, { table: 't1', schema: { id: 'integer primary key' }, rows: [{ id: 1 }, { id: 2 }] });
162
+ ok('importFromJSON single-table shape', r && db.count('t1') === 2);
163
+ let threw = null;
164
+ try { await migrate.importFromJSON(db, { table: 't1', schema: { id: 'integer primary key' }, rows: [{ id: 3 }] }); } catch (err) { threw = err; }
165
+ ok('importFromJSON refuses existing table without overwrite', !!threw, threw && (threw.message || String(threw)));
166
+ await migrate.importFromJSON(db, { table: 't1', schema: { id: 'integer primary key' }, rows: [{ id: 3 }] }, { overwrite: true });
167
+ ok('importFromJSON overwrite replaces table', db.count('t1') === 1, db.count('t1'));
168
+ }
169
+
170
+ {
171
+ db.createTable('lk', { s: 'string' });
172
+ for (const s of ['a.b', 'axb', 'aXb', 'abc']) db.insert('lk', { s });
173
+ const m = db.find('lk', { s: { $like: 'a.b' } }).map(r => r.s);
174
+ ok('$like escapes regex metacharacters', JSON.stringify(m) === '["a.b"]', m);
175
+ const m2 = db.find('lk', { s: { $like: 'a%b' } }).map(r => r.s);
176
+ ok('$like % wildcard still works', JSON.stringify(m2) === '["a.b","axb","aXb"]', m2);
177
+ }
178
+ await db.stop();
179
+
180
+ /* ============ CLI (N1) ============ */
181
+ {
182
+ const pkg = require(path.join(ROOT, 'package.json')).version;
183
+ const v = execFileSync(process.execPath, [path.join(ROOT, 'bin/jsql'), '--version']).toString().trim();
184
+ ok('cli --version reports package version', v === pkg, v);
185
+ const v2 = execFileSync(process.execPath, [path.join(ROOT, 'bin/jsql'), 'version']).toString().trim();
186
+ ok('cli version command matches', v2 === pkg, v2);
187
+ const help = execFileSync(process.execPath, [path.join(ROOT, 'bin/jsql'), 'ui', '--help']).toString();
188
+ ok('cli subcommand --help does not crash', help.includes('127.0.0.1') && help.includes('auth-token'));
189
+ }
190
+
191
+ /* ============ WebUI CORS + auth (N2 / S2) ============ */
192
+ {
193
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsql-wui-'));
194
+ fs.writeFileSync(path.join(dir, 'db.json'), JSON.stringify({ __schema__: { u: { id: { type: 'integer', primaryKey: true } } }, u: [{ id: 1 }] }));
195
+
196
+ const p1 = await freePort();
197
+ const w1 = new WebUI({ port: p1, host: undefined, dataDir: dir });
198
+ await w1.start();
199
+ ok('webui default host is 127.0.0.1', w1.host === '127.0.0.1', w1.host);
200
+ let r = await fetch('http://127.0.0.1:' + p1 + '/api/databases', { headers: { Origin: 'http://evil.example' } });
201
+ ok('webui no-token emits no ACAO', r.headers.get('access-control-allow-origin') === null, r.headers.get('access-control-allow-origin'));
202
+ r = await fetch('http://127.0.0.1:' + p1 + '/api/query', { method: 'OPTIONS', headers: { Origin: 'http://evil.example', 'Access-Control-Request-Method': 'POST' } });
203
+ ok('webui no-token preflight emits no ACAO', r.headers.get('access-control-allow-origin') === null);
204
+ await w1.stop();
205
+
206
+ const p2 = await freePort();
207
+ const w2 = new WebUI({ port: p2, host: '127.0.0.1', dataDir: dir, authToken: 'tok' });
208
+ await w2.start();
209
+ r = await fetch('http://127.0.0.1:' + p2 + '/api/databases', { headers: { Origin: 'http://evil.example' } });
210
+ ok('webui missing bearer returns 401', r.status === 401);
211
+ r = await fetch('http://127.0.0.1:' + p2 + '/api/databases', { headers: { Origin: 'http://good.example', Authorization: 'Bearer tok' } });
212
+ ok('webui auth echoes origin', r.status === 200 && r.headers.get('access-control-allow-origin') === 'http://good.example', { status: r.status, acao: r.headers.get('access-control-allow-origin') });
213
+ await w2.stop();
214
+
215
+ const p3 = await freePort();
216
+ const w3 = new WebUI({ port: p3, host: '127.0.0.1', dataDir: dir, allowOrigin: '*' });
217
+ await w3.start();
218
+ r = await fetch('http://127.0.0.1:' + p3 + '/api/databases', { headers: { Origin: 'http://any.example' } });
219
+ ok('webui explicit allowOrigin honored', r.headers.get('access-control-allow-origin') === '*');
220
+ await w3.stop();
221
+ fs.rmSync(dir, { recursive: true, force: true });
222
+ }
223
+
224
+ /* ============ Redis per-socket AUTH (S1) ============ */
225
+ {
226
+ const p = await freePort();
227
+ const rs = new RedisServer({ port: p, host: '127.0.0.1', password: 'pw' });
228
+ rs.listen();
229
+ await new Promise((res) => rs.server.once('listening', res));
230
+
231
+ const a = await respClient(p);
232
+ const b = await respClient(p);
233
+ const noauthA = await a.cmd('*1\r\n$4\r\nPING\r\n');
234
+ ok('redis unauth socket gets NOAUTH', noauthA.startsWith('-NOAUTH'), noauthA);
235
+ const authB = await b.cmd('*2\r\n$4\r\nAUTH\r\n$2\r\npw\r\n');
236
+ ok('redis AUTH pw succeeds', authB === '+OK', authB);
237
+ const pingB = await b.cmd('*1\r\n$4\r\nPING\r\n');
238
+ ok('redis authed socket works', pingB === '+PONG', pingB);
239
+ const pingA = await a.cmd('*1\r\n$4\r\nPING\r\n');
240
+ ok('redis auth is per-socket (other socket still NOAUTH)', pingA.startsWith('-NOAUTH'), pingA);
241
+ a.end(); b.end();
242
+ rs.server.close();
243
+ }
244
+
245
+ /* ============ MySQL ACL (S3 / S4) ============ */
246
+ {
247
+ const ms = new MysqlServer({ dataDir: ':memory:', auth: { alice: { password: 'pw', databases: ['app'] } } });
248
+ ok('mysql acl allows member db', ms._canAccessDb('alice', 'app') === true);
249
+ ok('mysql acl denies foreign db', ms._canAccessDb('alice', 'other') === false);
250
+ let code = null;
251
+ try { await ms._switchDb({ user: 'alice' }, 'other'); } catch (e) { code = e.code; }
252
+ ok('mysql cross-db switch denied (1044)', code === 1044, code);
253
+ }
254
+
255
+ /* ============ mysql_compat pool filename (S6) ============ */
256
+ {
257
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsql-cp-'));
258
+ const file = path.join(dir, 'pool.json');
259
+ const pool = compat.createPool({ filename: file });
260
+ await pool.query('CREATE TABLE t (id INT PRIMARY KEY, v VARCHAR(20))');
261
+ await pool.query("INSERT INTO t VALUES (1,'x')");
262
+ const [rows] = await pool.query('SELECT * FROM t');
263
+ ok('mysql_compat pool query roundtrip', rows.length === 1 && rows[0].v === 'x', rows);
264
+ ok('mysql_compat pool persists to filename', fs.existsSync(file) && fs.statSync(file).size > 0);
265
+ if (typeof pool.end === 'function') await pool.end();
266
+ fs.rmSync(dir, { recursive: true, force: true });
267
+ }
268
+
269
+ console.log(failed === 0 ? `\nALL ${passed} REGRESSION TESTS PASSED` : `\n${failed} FAILURES (${passed} passed)`);
270
+ process.exit(failed === 0 ? 0 : 1);
271
+ })().catch((e) => { console.error('FATAL', e); process.exit(1); });