jsql-neo 6.3.5 → 6.3.10

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.
@@ -81,6 +81,7 @@ class Database {
81
81
  close() {
82
82
  if (!this.open) return;
83
83
  try {
84
+ // P3-12: close() 不等 worker 保存完——显式等待保存完成
84
85
  this._sync('close', []);
85
86
  } finally {
86
87
  this.open = false;
@@ -285,7 +286,7 @@ class Statement {
285
286
 
286
287
  run(...params) {
287
288
  const r = this._call('run', params.length > 0 ? params : undefined);
288
- return r;
289
+ return { changes: r && r.affectedRows, lastInsertRowid: r && r.insertId, _stmt: this };
289
290
  }
290
291
 
291
292
  get(...params) {
@@ -97,7 +97,8 @@ function cleanResult(r) {
97
97
  if (v instanceof Date) out[k] = v.toISOString();
98
98
  else if (v === undefined) out[k] = null;
99
99
  else if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
100
- // 仅保留可 JSON 化的值(防止 Table/内部对象循环引用)
100
+ // P3-14: 双次 stringify 检查可能导致某些值被错误地过滤
101
+ // 只检查一次 stringify 是否成功,然后保留原值
101
102
  try { JSON.stringify(v); out[k] = v; } catch (e) { out[k] = null; }
102
103
  } else out[k] = v;
103
104
  }
@@ -123,9 +124,14 @@ async function handleOp(op, args) {
123
124
  }
124
125
  case 'run': {
125
126
  const r = await executeStatement(args[0], args[1]);
127
+ // P3-13: 非 insert 操作不应回退到上次 insertId
128
+ const lastId = r && r.type === 'insert' ? Number(r.insertId || 0) : 0;
129
+ if (r && r.type === 'insert' && r.insertId !== undefined && r.insertId !== null) {
130
+ _lastInsertId = Number(r.insertId);
131
+ }
126
132
  result = {
127
133
  changes: r ? (r.affectedRows || 0) : 0,
128
- lastInsertRowid: r && r.type === 'insert' ? Number(r.insertId || 0) : _lastInsertId,
134
+ lastInsertRowid: lastId,
129
135
  };
130
136
  break;
131
137
  }
package/lib/table.js CHANGED
@@ -122,7 +122,7 @@ class Table {
122
122
  };
123
123
  this._softDelete = false;
124
124
  this._computedFields = {};
125
- this._btreesDirty = false;
125
+
126
126
 
127
127
  this._primaryKey = null;
128
128
  this._pkIndex = null;
@@ -283,6 +283,13 @@ class Table {
283
283
  }
284
284
  if (def.check && data[field] !== undefined && !def.check(data[field], data)) throw createError('ER_CHECK_CONSTRAINT', this._name + '.' + field);
285
285
  }
286
+ // 主键唯一性检查(主键可能不在 _cachedUniqueFields 中)
287
+ if (this._primaryKey && data[this._primaryKey] !== undefined && data[this._primaryKey] !== null) {
288
+ const pkVal = data[this._primaryKey];
289
+ if (this._pkIndex && this._pkIndex.has(pkVal)) {
290
+ throw createError('ER_DUP_ENTRY', String(pkVal), this._primaryKey);
291
+ }
292
+ }
286
293
  for (const fk of foreignKeys) {
287
294
  if (data[fk.field] !== undefined && data[fk.field] !== null) {
288
295
  const refTable = this._resolveTable(fk.table);
@@ -303,7 +310,11 @@ class Table {
303
310
  this._rows.push(Object.assign({}, data));
304
311
  if (this._pkIndex && this._primaryKey) {
305
312
  const pkVal = data[this._primaryKey];
306
- if (pkVal !== undefined && pkVal !== null) this._pkIndex.set(pkVal, rowIdx);
313
+ if (pkVal !== undefined && pkVal !== null) {
314
+ // P1-1: insertMany 主键重复检查
315
+ if (this._pkIndex.has(pkVal)) throw createError('ER_DUP_ENTRY', String(pkVal), this._primaryKey);
316
+ this._pkIndex.set(pkVal, rowIdx);
317
+ }
307
318
  }
308
319
  for (const [field, tree] of Object.entries(this._btrees)) {
309
320
  const val = data[field];
@@ -369,7 +380,6 @@ class Table {
369
380
  }
370
381
  return null;
371
382
  }
372
- this._ensureBTrees();
373
383
  if (this._btrees[this._primaryKey]) {
374
384
  const indices = this._btrees[this._primaryKey].search(id);
375
385
  if (indices.length > 0) {
@@ -442,7 +452,6 @@ class Table {
442
452
  if (this._pkIndex) {
443
453
  rowIndex = this._pkIndex.get(id);
444
454
  } else {
445
- this._ensureBTrees();
446
455
  if (this._btrees[this._primaryKey]) {
447
456
  const indices = this._btrees[this._primaryKey].search(id);
448
457
  rowIndex = indices.length > 0 ? indices[0] : undefined;
@@ -539,7 +548,6 @@ class Table {
539
548
  if (this._pkIndex) {
540
549
  rowIndex = this._pkIndex.get(id);
541
550
  } else {
542
- this._ensureBTrees();
543
551
  if (this._btrees[this._primaryKey]) {
544
552
  const indices = this._btrees[this._primaryKey].search(id);
545
553
  rowIndex = indices.length > 0 ? indices[0] : undefined;
@@ -587,8 +595,28 @@ class Table {
587
595
  }
588
596
 
589
597
  removeSoft(query = {}) {
590
- this._softDelete = true;
591
- return this.remove(query);
598
+ if (!this._softDelete) throw createError('ER_NOT_SUPPORTED', 'Soft delete is not enabled');
599
+ const matched = this._applyFilterOptimized(this._getVisibleRows(), query);
600
+ let count = 0;
601
+ const now = new Date().toISOString();
602
+ for (const row of matched) {
603
+ const index = this._rows.indexOf(row);
604
+ if (index === -1) continue;
605
+ this._removeFromIndexes(index, row);
606
+ row._deleted = true;
607
+ row._deletedAt = now;
608
+ this._addToIndexes(index, row);
609
+ count++;
610
+ }
611
+ if (count > 0) {
612
+ this._dirty = true;
613
+ this._db._markDirty(this._name);
614
+ for (const hook of this._hooks.afterRemove) {
615
+ hook(query, count);
616
+ }
617
+ this._db._emitChange('remove', this._name, { query, count, soft: true });
618
+ }
619
+ return count;
592
620
  }
593
621
 
594
622
  _findRowIndex(id) {
@@ -628,6 +656,7 @@ class Table {
628
656
  }
629
657
  this._indexes = {};
630
658
  this._autoIncrement = 0;
659
+ // P1-13: truncate 不应重置软删除模式——保留原值
631
660
  this._dirty = true;
632
661
  this._db._markDirty(this._name);
633
662
  return count;
@@ -640,6 +669,10 @@ class Table {
640
669
  addColumn(field, definition) {
641
670
  if (this._schema[field]) throw createError('ER_DUP_FIELDNAME', field);
642
671
  this._schema[field] = definition;
672
+ // P1-14: 更新缓存字段列表,使新列的 unique/required 约束生效
673
+ if (!this._cachedSchemaFields.includes(field)) this._cachedSchemaFields.push(field);
674
+ if (definition.unique && !this._cachedUniqueFields.includes(field)) this._cachedUniqueFields.push(field);
675
+ if (definition.required && !this._cachedRequiredFields.includes(field)) this._cachedRequiredFields.push(field);
643
676
  if (definition.default !== undefined) {
644
677
  this._defaults[field] = definition.default;
645
678
  for (const row of this._rows) {
@@ -666,6 +699,15 @@ class Table {
666
699
  this._btrees[field] = new BTree(64, true);
667
700
  this._rebuildBTree(field);
668
701
  }
702
+ // P1-8: 添加主键列时设置 _primaryKey 并重建 pkIndex
703
+ if (definition.primaryKey) {
704
+ this._primaryKey = field;
705
+ if (this._pkIndex) this._pkIndex.clear();
706
+ for (let i = 0; i < this._rows.length; i++) {
707
+ const val = this._rows[i][field];
708
+ if (val !== undefined && val !== null) this._pkIndex.set(val, i);
709
+ }
710
+ }
669
711
  this._dirty = true;
670
712
  this._db._markDirty(this._name);
671
713
  return this;
@@ -684,7 +726,11 @@ class Table {
684
726
  for (const row of this._rows) {
685
727
  delete row[field];
686
728
  }
687
- if (this._primaryKey === field) this._primaryKey = null;
729
+ // P1-8: 删除主键列时清空 pkIndex
730
+ if (this._primaryKey === field) {
731
+ this._primaryKey = null;
732
+ if (this._pkIndex) this._pkIndex.clear();
733
+ }
688
734
  if (this._autoIncrementField === field) this._autoIncrementField = null;
689
735
  this._dirty = true;
690
736
  this._db._markDirty(this._name);
@@ -703,6 +749,11 @@ class Table {
703
749
  if (this._btrees[oldName]) {
704
750
  this._btrees[newName] = this._btrees[oldName];
705
751
  delete this._btrees[oldName];
752
+ this._btrees[newName].clear();
753
+ for (let i = 0; i < this._rows.length; i++) {
754
+ const val = this._rows[i][newName];
755
+ if (val !== undefined && val !== null) this._btrees[newName].insert(val, i);
756
+ }
706
757
  }
707
758
  for (const row of this._rows) {
708
759
  row[newName] = row[oldName];
@@ -721,9 +772,13 @@ class Table {
721
772
  if (definition.default !== undefined) this._defaults[field] = definition.default;
722
773
  this._checkConstraints = this._checkConstraints.filter(c => c.field !== field);
723
774
  if (definition.check) this._checkConstraints.push({ field, fn: definition.check });
724
- if (definition.primaryKey || definition.unique) {
725
- if (!this._btrees[field]) this._btrees[field] = new BTree(64, true);
726
- this._rebuildBTree(field);
775
+ if (definition.primaryKey !== undefined || definition.unique !== undefined) {
776
+ if (definition.primaryKey || definition.unique) {
777
+ if (!this._btrees[field]) this._btrees[field] = new BTree(64, true);
778
+ this._rebuildBTree(field);
779
+ } else {
780
+ delete this._btrees[field];
781
+ }
727
782
  }
728
783
  this._dirty = true;
729
784
  this._db._markDirty(this._name);
@@ -817,20 +872,6 @@ class Table {
817
872
  }
818
873
  }
819
874
 
820
- _ensureBTrees() {
821
- if (!this._btreesDirty) return;
822
- this._btreesDirty = false;
823
- for (const field of Object.keys(this._btrees)) {
824
- this._btrees[field].clear();
825
- }
826
- for (let i = 0; i < this._rows.length; i++) {
827
- const row = this._rows[i];
828
- for (const [field, tree] of Object.entries(this._btrees)) {
829
- const v = row[field];
830
- if (v !== undefined && v !== null) tree.insert(v, i);
831
- }
832
- }
833
- }
834
875
 
835
876
  _rebuildAllBTrees() {
836
877
  for (const field of Object.keys(this._btrees)) {
@@ -881,7 +922,6 @@ class Table {
881
922
  }
882
923
 
883
924
  _applyFilterOptimized(rows, query) {
884
- this._ensureBTrees();
885
925
  for (const [field, condition] of Object.entries(query)) {
886
926
  if (field === '$or' || field === '$and' || field === '$not') continue;
887
927
  if (typeof condition !== 'object' || condition === null || Array.isArray(condition) || condition instanceof RegExp) {
@@ -940,6 +980,8 @@ class Table {
940
980
  _applyFilter(rows, query) {
941
981
  const result = [];
942
982
  for (let i = 0; i < rows.length; i++) {
983
+ // 软删除行不应出现在任何查询结果中
984
+ if (this._softDelete && rows[i]._deleted) continue;
943
985
  if (this._matchRow(rows[i], query)) {
944
986
  result.push(rows[i]);
945
987
  }
package/lib/tui.js CHANGED
@@ -13,7 +13,7 @@ const os = require('os');
13
13
  const Database = require('./database');
14
14
  const { executeSQL } = require('./sql');
15
15
 
16
- const HISTORY_FILE = path.join(os.homedir(), '.jsql-history');
16
+ const HISTORY_FILE = path.join(os.homedir(), '.jsql-history.' + process.pid);
17
17
  const MAX_HISTORY = 500;
18
18
 
19
19
  /* ---------- ANSI helpers ---------- */
@@ -664,6 +664,12 @@ class JSQL {
664
664
  await this._flush();
665
665
  const seq = this._seqIdByPk(table, id);
666
666
  if (seq === null) return null;
667
+ // 改主键 / UNIQUE 列前先查冲突。
668
+ // 此前直接调 jsql_update_by_id,导致
669
+ // UPDATE t SET id = 1 WHERE id = 2 (t 已有 id=1)
670
+ // 静默产生两行同主键 —— 主键索引只留一个,索引与实际行数组不一致。
671
+ // MySQL / SQLite 都会报 UNIQUE constraint failed。
672
+ await this._assertNoKeyConflict(table, id, data, seq);
667
673
  const r = safeJsonParse(wasmBindings.jsql_update_by_id(table, BigInt(seq), JSON.stringify(data)));
668
674
  if (r && r.ok === false) throw new Error(r.error || 'update failed');
669
675
  this._emit('update', { table, id, data });
@@ -671,6 +677,35 @@ class JSQL {
671
677
  return r;
672
678
  }
673
679
 
680
+ /**
681
+ * 校验 UPDATE 后的主键 / UNIQUE 值不与其它行冲突。
682
+ * 逐行比对(未建索引也正确);值未变化时跳过。
683
+ */
684
+ async _assertNoKeyConflict(table, id, data, selfSeq) {
685
+ const schema = this._schemas[table];
686
+ if (!schema) return;
687
+ const fields = Object.keys(schema).filter(f => schema[f] && (schema[f].primaryKey || schema[f].unique));
688
+ if (!fields.length) return;
689
+ let rows;
690
+ try {
691
+ rows = await this.find(table, {}, { limit: 1e9, offset: 0 });
692
+ } catch (_) {
693
+ return; // 读不到就不拦,交给底层报错
694
+ }
695
+ for (const f of fields) {
696
+ if (!Object.prototype.hasOwnProperty.call(data, f)) continue;
697
+ const nv = data[f];
698
+ const self = rows.find(r => r && String(r.id) === String(id));
699
+ if (self && self[f] === nv) continue; // 值没变
700
+ if (rows.some(r => r && r !== self && r[f] === nv)) {
701
+ const e = new Error(`Duplicate entry '${nv}' for key '${f}'`);
702
+ e.code = 'ER_DUP_ENTRY';
703
+ e.errno = 1062;
704
+ throw e;
705
+ }
706
+ }
707
+ }
708
+
674
709
  async removeById(table, id) {
675
710
  if (!this._runHooks('beforeDelete', [table, id])) return;
676
711
  await this._flush();
package/lib/web_ui.js CHANGED
@@ -141,9 +141,17 @@ class WebUI {
141
141
  return fs.readdirSync(this.dataDir)
142
142
  .filter(f => f.endsWith('.json'))
143
143
  .map(f => {
144
- let tables = 0;
145
- try { tables = Object.keys(JSON.parse(fs.readFileSync(path.join(this.dataDir, f), 'utf8')).__schema__ || {}).length; } catch (_) {}
146
- return { name: f.slice(0, -5), tables };
144
+ try {
145
+ const stat = fs.statSync(path.join(this.dataDir, f));
146
+ // 只读前 4KB:schema 头部包含 __schema__ 定义,尾部数据不需要
147
+ const fd = fs.openSync(path.join(this.dataDir, f), 'r');
148
+ const buf = Buffer.alloc(4096);
149
+ fs.readSync(fd, buf, 0, 4096, 0);
150
+ fs.closeSync(fd);
151
+ const match = buf.toString('utf8').match(/"__schema__"\s*:\s*\{([^}]*)\}/);
152
+ const tables = match ? (match[1].match(/"/g) || []).length / 2 : 0;
153
+ return { name: f.slice(0, -5), tables };
154
+ } catch (_) { return { name: f.slice(0, -5), tables: 0 }; }
147
155
  });
148
156
  }
149
157
 
@@ -188,12 +196,20 @@ class WebUI {
188
196
  res.writeHead(204, h);
189
197
  return res.end();
190
198
  }
191
- if (this.authToken && url.pathname.startsWith('/api/')) {
192
- const provided = req.headers.authorization || '';
193
- const token = provided.startsWith('Bearer ') ? provided.slice(7) : '';
194
- if (token !== this.authToken) {
199
+ // API 认证:必须设置 authToken(从环境变量或构造选项)。
200
+ // 仅当显式 noAuth: true 时允许无认证访问(仅本地开发用)。
201
+ if (url.pathname.startsWith('/api/')) {
202
+ if (!this.noAuth && !this.authToken) {
195
203
  res.writeHead(401, { 'Content-Type': 'application/json' });
196
- return res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
204
+ return res.end(JSON.stringify({ ok: false, error: 'API auth not configured' }));
205
+ }
206
+ if (this.authToken) {
207
+ const provided = req.headers.authorization || '';
208
+ const token = provided.startsWith('Bearer ') ? provided.slice(7) : '';
209
+ if (token !== this.authToken) {
210
+ res.writeHead(401, { 'Content-Type': 'application/json' });
211
+ return res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
212
+ }
197
213
  }
198
214
  }
199
215
  if (url.pathname === '/api/databases' && req.method === 'GET') {
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jsql-neo",
3
- "version": "6.3.5",
3
+ "version": "6.3.10",
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",
@@ -51,10 +51,17 @@
51
51
  "test:btree": "node test/btree.test.js",
52
52
  "test:native": "node test/native.test.js",
53
53
  "test:regress": "node test/regress-5.1.0.js",
54
- "test:all": "node test/smoke.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/native.test.js && node test/native-multi-instance.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js && node test/wasm.test.js && node test/coverage.js && node test/regress-5.1.0.js && node test/join.test.js && node test/btree.test.js && node test/dml-constraints.test.js",
54
+ "test:all": "node test/smoke.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/native.test.js && node test/native-multi-instance.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js && node test/wasm.test.js && node test/coverage.js && node test/regress-5.1.0.js && node test/join.test.js && node test/btree.test.js && node test/dml-constraints.test.js && node test/savepoint.test.js && node test/alter-table.test.js && node test/null-semantics.test.js && node test/identifier-keywords.test.js && node test/recursive-cte.test.js && node test/index-ddl.test.js",
55
55
  "test:coverage": "c8 --all --include='lib/**' --exclude='lib/web_ui.js' --reporter=text --reporter=lcov node test/coverage.js",
56
56
  "test:orms": "node examples/orms/run-all.js",
57
- "test:dml": "node test/dml-constraints.test.js"
57
+ "test:dml": "node test/dml-constraints.test.js",
58
+ "test:cte": "node test/recursive-cte.test.js",
59
+ "test:index": "node test/index-ddl.test.js",
60
+ "test:mongo": "node test/mongo-group.test.js",
61
+ "test:savepoint": "node test/savepoint.test.js",
62
+ "test:alter": "node test/alter-table.test.js",
63
+ "test:ident": "node test/identifier-keywords.test.js",
64
+ "test:null": "node test/null-semantics.test.js"
58
65
  },
59
66
  "keywords": [
60
67
  "database",