jsql-neo 6.4.0-beta.1 → 6.4.0-beta.4

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.4.0-beta.1** — official release build · [github.com/vexify-org/JSQL-neo](https://github.com/vexify-org/JSQL-neo) · [jsql.vexify.top](https://jsql.vexify.top)
6
+ > **v6.4.0-beta.4** — official release build · [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.4.0-beta.1 — Rust-Powered Embedded Database (WASM + HTTP)
2
+ * JSQL-NEO v6.4.0-beta.4 — Rust-Powered Embedded Database (WASM + HTTP)
3
3
  *
4
4
  * @example
5
5
  * const jsql = require('jsql-neo');
package/lib/database.js CHANGED
@@ -56,6 +56,35 @@ function parseFieldShorthand(str) {
56
56
  return def;
57
57
  }
58
58
 
59
+ /**
60
+ * 深度相等比较两个行对象(用于无主键表的行匹配)。
61
+ * 剥离内部字段(_rid, _deleted)后逐字段比较。
62
+ */
63
+ function _rowDeepEqual(a, b) {
64
+ if (a === b) return true;
65
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
66
+ const strip = (obj) => {
67
+ const out = {};
68
+ for (const [k, v] of Object.entries(obj)) {
69
+ if (k === '_rid' || k === '_deleted') continue;
70
+ out[k] = v;
71
+ }
72
+ return out;
73
+ };
74
+ const sa = strip(a), sb = strip(b);
75
+ const ka = Object.keys(sa), kb = Object.keys(sb);
76
+ if (ka.length !== kb.length) return false;
77
+ for (const k of ka) {
78
+ if (!Object.prototype.hasOwnProperty.call(sb, k)) return false;
79
+ const va = sa[k], vb = sb[k];
80
+ if (va === vb) continue;
81
+ if (va === null || va === undefined || vb === null || vb === undefined) return false;
82
+ if (typeof va === 'number' && typeof vb === 'number' && va === vb) continue;
83
+ if (String(va) !== String(vb)) return false;
84
+ }
85
+ return true;
86
+ }
87
+
59
88
  class Database {
60
89
  /**
61
90
  * @param {string} filePath - 数据库文件/目录路径,传 null 或 ':memory:' 使用内存模式
@@ -113,6 +142,12 @@ class Database {
113
142
  this._plugins = [];
114
143
  this._hooks = createHookRegistry();
115
144
  this._eventListeners = [];
145
+
146
+ // P1: {modules:true} 自动加载插件(mod.config + ~/.jsql/plugins/)
147
+ this._autoPlugins = options.modules !== false;
148
+ if (this._autoPlugins) {
149
+ this._loadAutoPlugins();
150
+ }
116
151
  this._tableNames = new Set(Object.keys(this._tables));
117
152
  this._migrations = [];
118
153
  this._observers = []; // 响应式观察者
@@ -1570,6 +1605,80 @@ class Database {
1570
1605
  return buildPluginContext(this, plugin);
1571
1606
  }
1572
1607
 
1608
+ /**
1609
+ * P1 修复:{modules:true} 自动加载插件。
1610
+ * 1. 通过 ModuleManager 读取 ~/.config/jsql/mod.config,应用已启用的模块
1611
+ * 2. 扫描 ~/.jsql/plugins/ 加载 registry 安装的插件
1612
+ */
1613
+ _loadAutoPlugins() {
1614
+ // 1. mod.config 中的模块
1615
+ try {
1616
+ const { ModuleManager } = require('./mod');
1617
+ new ModuleManager().applyTo(this);
1618
+ } catch (e) {
1619
+ if (process.env.JSQL_DEBUG) console.warn('[database] Failed to load mod.config:', e.message);
1620
+ }
1621
+ // 2. ~/.jsql/plugins/ 中的 registry 插件
1622
+ try {
1623
+ this._loadInstalledPlugins();
1624
+ } catch (e) {
1625
+ if (process.env.JSQL_DEBUG) console.warn('[database] Failed to load installed plugins:', e.message);
1626
+ }
1627
+ }
1628
+
1629
+ /**
1630
+ * 扫描 ~/.jsql/plugins/ 目录,加载已安装的 registry 插件。
1631
+ * 兼容两种布局:根级入口文件 + plugins/ 子目录入口文件。
1632
+ */
1633
+ _loadInstalledPlugins() {
1634
+ const os = require('os');
1635
+ const fs = require('fs');
1636
+ const PLUGIN_DIR = path.join(os.homedir(), '.jsql', 'plugins');
1637
+ if (!fs.existsSync(PLUGIN_DIR)) return;
1638
+ const entries = fs.readdirSync(PLUGIN_DIR, { withFileTypes: true });
1639
+ for (const entry of entries) {
1640
+ if (!entry.isDirectory()) continue;
1641
+ const dir = path.join(PLUGIN_DIR, entry.name);
1642
+ const manifestPath = path.join(dir, 'manifest.json');
1643
+ if (!fs.existsSync(manifestPath)) continue;
1644
+ let manifest;
1645
+ try {
1646
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
1647
+ } catch { continue; }
1648
+ if (manifest.disabled) continue;
1649
+ const pluginName = manifest.name || entry.name;
1650
+ // 查找入口文件:manifest.main || manifest.entry || 'index.js'
1651
+ const entryFile = manifest.main || manifest.entry || 'index.js';
1652
+ // 兼容两种路径:根级 + plugins/ 子目录
1653
+ const candidates = [
1654
+ path.join(dir, entryFile),
1655
+ path.join(dir, entryFile + '.js'),
1656
+ path.join(dir, 'plugins', entryFile),
1657
+ path.join(dir, 'plugins', entryFile + '.js'),
1658
+ ];
1659
+ let entryPath = null;
1660
+ for (const c of candidates) {
1661
+ if (fs.existsSync(c) && fs.statSync(c).isFile()) {
1662
+ entryPath = c;
1663
+ break;
1664
+ }
1665
+ }
1666
+ if (!entryPath) continue;
1667
+ try {
1668
+ const resolved = require.resolve(entryPath);
1669
+ delete require.cache[resolved];
1670
+ const plugin = require(resolved);
1671
+ if (typeof plugin === 'function') {
1672
+ this.use({ name: pluginName, install: plugin });
1673
+ } else if (plugin && typeof plugin === 'object') {
1674
+ if (!plugin.name) plugin.name = pluginName;
1675
+ this.use(plugin);
1676
+ }
1677
+ } catch (e) {
1678
+ if (process.env.JSQL_DEBUG) console.warn('[database] Failed to load plugin', entry.name, e.message);
1679
+ }
1680
+ }
1681
+ }n
1573
1682
  on(event, fn) {
1574
1683
  return registerHook(this, event, fn);
1575
1684
  }
@@ -1702,7 +1811,15 @@ class Database {
1702
1811
  _resolveId(table, id) {
1703
1812
  const pk = this._pkOf(table);
1704
1813
  if (pk) return table._rows.find(r => r[pk] === id) || null;
1705
- return table._rows[id - 1] || null;
1814
+ // 无主键表:id 是行对象(可能是 find() 返回的浅拷贝)时,用内容匹配代替引用比较。
1815
+ // 做法:逐行比对所有字段值是否相等;是数字时当 1-based 行号。
1816
+ if (id && typeof id === 'object') {
1817
+ const keys = Object.keys(id);
1818
+ return table._rows.find(r => keys.every(k => r[k] === id[k])) || null;
1819
+ }
1820
+ const idx = Number(id);
1821
+ if (!isNaN(idx)) return table._rows[idx - 1] || null;
1822
+ return null;
1706
1823
  }
1707
1824
 
1708
1825
  find(tableName, filter, opts = {}) {
@@ -1867,20 +1984,29 @@ class Database {
1867
1984
  if (table._primaryKey) {
1868
1985
  for (const id of ids) removed += table.removeById(id);
1869
1986
  } else {
1870
- // 无主键表没有稳定的行标识,_resolveId 只能把 id 当 1-based 行号,
1871
- // 而 _rowPkId 在无主键时回退到 row.id(业务列名)——
1872
- // 两者语义不一致,导致 DELETE ... WHERE id IN (3,5) 删错行。
1873
- // 这里先按「行对象 === id」尝试(SQL 层可以直接传行引用),
1874
- // 退化到行号解析;并一次性收集目标后统一移除,避免边删边移位。
1875
- const targets = [];
1987
+ // 无主键表没有稳定的行标识,P1-11 后 find() 返回浅拷贝({...r}),
1988
+ // 对象引用与 table._rows 里的原对象不同,不能用 Set.has(row) 做引用匹配。
1989
+ // _resolveId 对无主键表做 table._rows[id-1],传入对象时 id-1=NaN → undefined。
1990
+ // 所以这里对对象 id 做深度相等匹配,对值 id 走 _resolveId(1-based 行号)。
1876
1991
  const seen = new Set();
1877
- const push = (row) => { if (row && !seen.has(row)) { seen.add(row); targets.push(row); } };
1992
+ const targets = [];
1878
1993
  for (const id of ids) {
1879
- if (id && typeof id === 'object') { push(id); continue; }
1880
- push(this._resolveId(table, id));
1994
+ let rowIndex;
1995
+ if (id && typeof id === 'object') {
1996
+ // id 是行对象(find() 返回的浅拷贝),用深度相等匹配
1997
+ rowIndex = table._rows.findIndex(r => _rowDeepEqual(r, id));
1998
+ } else {
1999
+ // id 是值(1-based 行号),_resolveId 能处理
2000
+ const original = this._resolveId(table, id);
2001
+ if (original) rowIndex = table._rows.indexOf(original);
2002
+ }
2003
+ if (rowIndex >= 0 && !seen.has(rowIndex)) {
2004
+ seen.add(rowIndex);
2005
+ targets.push(rowIndex);
2006
+ }
1881
2007
  }
1882
2008
  if (targets.length) {
1883
- table._rows = table._rows.filter(r => !seen.has(r));
2009
+ table._rows = table._rows.filter((_, idx) => !seen.has(idx));
1884
2010
  removed = targets.length;
1885
2011
  }
1886
2012
  table._rebuildPKIndex();
@@ -17,6 +17,35 @@ function safeParse(str, fallback) {
17
17
  try { return JSON.parse(str); } catch (e) { return fallback; }
18
18
  }
19
19
 
20
+ /**
21
+ * 深度相等比较两个行对象(用于无主键表行匹配)。
22
+ * 剥离内部字段(_rid, _deleted)后逐字段比较。
23
+ */
24
+ function _nativeRowDeepEqual(a, b) {
25
+ if (a === b) return true;
26
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
27
+ const strip = (obj) => {
28
+ const out = {};
29
+ for (const [k, v] of Object.entries(obj)) {
30
+ if (k === '_rid' || k === '_deleted') continue;
31
+ out[k] = v;
32
+ }
33
+ return out;
34
+ };
35
+ const sa = strip(a), sb = strip(b);
36
+ const ka = Object.keys(sa), kb = Object.keys(sb);
37
+ if (ka.length !== kb.length) return false;
38
+ for (const k of ka) {
39
+ if (!Object.prototype.hasOwnProperty.call(sb, k)) return false;
40
+ const va = sa[k], vb = sb[k];
41
+ if (va === vb) continue;
42
+ if (va === null || va === undefined || vb === null || vb === undefined) return false;
43
+ if (typeof va === 'number' && typeof vb === 'number' && va === vb) continue;
44
+ if (String(va) !== String(vb)) return false;
45
+ }
46
+ return true;
47
+ }
48
+
20
49
  /**
21
50
  * 序列化对象/数组字段(JSON 列)并给出可读错误。直接 JSON.stringify 遇到循环引用会抛
22
51
  * "Converting circular structure to JSON",指向不到具体列,这里补上表/列信息。
@@ -443,7 +472,21 @@ class JSQL {
443
472
  if (Array.isArray(r) && r.length > 0) return r[0].id;
444
473
  return null;
445
474
  }
446
- return Number(id);
475
+ // 无主键表:id 应是 1-based 行号(数字)。
476
+ // 但 sql.js DELETE 对无主键表传的是行对象(find() 返回的浅拷贝),
477
+ // Number(obj) = NaN → JSON.stringify([NaN]) = "[null]" → 静默失效。
478
+ // 这里对对象 id 做深度相等匹配,找到对应行的序列号。
479
+ if (typeof id === 'number') return id;
480
+ if (id && typeof id === 'object') {
481
+ const all = safeParse(this._n('jsqlFind', table, '{}', 1000000, 0));
482
+ if (Array.isArray(all)) {
483
+ for (const r of all) {
484
+ const row = r.fields ? { ...r.fields, id: r.id } : r;
485
+ if (_nativeRowDeepEqual(row, id)) return r.id;
486
+ }
487
+ }
488
+ }
489
+ return null;
447
490
  }
448
491
 
449
492
  async _insertBatch(table, rows) {
package/lib/sql.js CHANGED
@@ -136,8 +136,22 @@ function tokenize(sql) {
136
136
  let i = 0;
137
137
  const n = sql.length;
138
138
 
139
+ // 获取位置 i 处的完整 Unicode 字符(处理代理对,如 emoji)
140
+ // 返回 { char, length },char 是完整字符,length 是 UTF-16 码元数
141
+ function charAt(pos) {
142
+ const c = sql[pos];
143
+ const code = sql.charCodeAt(pos);
144
+ if (code >= 0xD800 && code <= 0xDBFF && pos + 1 < n) {
145
+ const next = sql.charCodeAt(pos + 1);
146
+ if (next >= 0xDC00 && next <= 0xDFFF) {
147
+ return { char: sql.slice(pos, pos + 2), length: 2 };
148
+ }
149
+ }
150
+ return { char: c, length: 1 };
151
+ }
152
+
139
153
  while (i < n) {
140
- const c = sql[i];
154
+ const { char: c, length: cLen } = charAt(i);
141
155
 
142
156
  if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; }
143
157
 
@@ -188,6 +202,37 @@ function tokenize(sql) {
188
202
  continue;
189
203
  }
190
204
 
205
+ // 16 进制字面量:0x1F / 0XFF / X'1F' / x'FF'
206
+ // 标准 SQL:0x 前缀或 X'' 包裹,解析成十进制整数
207
+ if ((c === '0' && (sql[i + 1] === 'x' || sql[i + 1] === 'X')) ||
208
+ ((c === 'X' || c === 'x') && sql[i + 1] === "'")) {
209
+ if (c === '0') {
210
+ // 0x 前缀形式:0x1F, 0XFF
211
+ let j = i + 2;
212
+ while (j < n && /[0-9A-Fa-f]/.test(sql[j])) j++;
213
+ if (j === i + 2) throw new Error(`Invalid hexadecimal literal at position ${i}`);
214
+ const hexStr = sql.slice(i + 2, j);
215
+ const numVal = parseInt(hexStr, 16);
216
+ tokens.push(new SQLToken('number', numVal, i));
217
+ i = j;
218
+ continue;
219
+ } else {
220
+ // X'...' 形式:X'1F', x'FF'
221
+ let j = i + 2;
222
+ let hexStr = '';
223
+ while (j < n && sql[j] !== "'") {
224
+ if (!/[0-9A-Fa-f]/.test(sql[j])) throw new Error(`Invalid hexadecimal literal at position ${j}`);
225
+ hexStr += sql[j];
226
+ j++;
227
+ }
228
+ if (j >= n) throw new Error(`Unterminated hexadecimal literal at position ${i}`);
229
+ const numVal = parseInt(hexStr, 16);
230
+ tokens.push(new SQLToken('number', numVal, i));
231
+ i = j + 1;
232
+ continue;
233
+ }
234
+ }
235
+
191
236
  if (/[0-9]/.test(c) || (c === '.' && /[0-9]/.test(sql[i + 1] || ''))) {
192
237
  let j = i;
193
238
  let isFloat = false;
@@ -219,9 +264,16 @@ function tokenize(sql) {
219
264
  continue;
220
265
  }
221
266
 
222
- if (/[a-zA-Z_$]/.test(c)) {
223
- let j = i;
224
- while (j < n && /[a-zA-Z0-9_$]/.test(sql[j])) j++;
267
+ // Unicode 标识符:标准 SQL 允许非 ASCII 字母做列名/表名(中文、emoji 等)
268
+ // \p{L} = Unicode 字母,\p{So} = Unicode 其他符号(含 emoji),\p{M} = 组合标记
269
+ // 注意:不包含 \p{Sm}(数学符号如 + = < >)和 \p{P}(标点如 ( ) , ;)
270
+ if (/[\p{L}\p{So}\p{Sc}\p{M}_$]/u.test(c)) {
271
+ let j = i + cLen;
272
+ while (j < n) {
273
+ const { char: jc, length: jLen } = charAt(j);
274
+ if (!/[\p{L}\p{N}\p{So}\p{Sc}\p{M}_$]/u.test(jc)) break;
275
+ j += jLen;
276
+ }
225
277
  const word = sql.slice(i, j);
226
278
  const upper = word.toUpperCase();
227
279
  const tok = new SQLToken(KEYWORDS.has(upper) ? 'keyword' : 'ident', word, i);
@@ -613,24 +665,10 @@ class Parser {
613
665
  } else if (this.isWord('FOREIGN') || this.isWord('CHECK') ||
614
666
  t.type === 'keyword' && (t.value === 'KEY' || t.value === 'INDEX')) {
615
667
  // 表级约束:FOREIGN KEY ... REFERENCES ... / CHECK (...) / KEY name (cols)
616
- // P1-24: CREATE TABLE t (key TEXT) 报错——key 被当作表级约束
617
- // 如果 KEY/INDEX 后面跟着列类型(TEXT/INT/VARCHAR 等),应解析为列定义
618
- if (t.type === 'keyword' && (t.value === 'KEY' || t.value === 'INDEX')) {
619
- const nextTok = this.peek(1);
620
- if (nextTok && (nextTok.type === 'ident' || nextTok.type === 'keyword') &&
621
- ['TEXT', 'INT', 'INTEGER', 'VARCHAR', 'CHAR', 'DATE', 'DATETIME', 'TIMESTAMP', 'BOOLEAN', 'BOOL', 'FLOAT', 'DOUBLE', 'DECIMAL', 'BLOB', 'JSON'].includes(String(nextTok.value).toUpperCase())) {
622
- // P1-24: KEY/INDEX 是保留字,不能直接用 parseTableName(),改用 nameOfToken + next()
623
- const col = nameOfToken(t);
624
- this.next(); // 消费 KEY/INDEX token
625
- const def = this.parseColumnDef();
626
- schema[col] = def;
627
- if (def.primaryKey) hasPk = true;
628
- } else {
629
- this._parseTableConstraint(schema);
630
- }
631
- } else {
632
- this._parseTableConstraint(schema);
633
- }
668
+ // KEY/INDEX 是保留字:裸写作列名(如 CREATE TABLE t (key TEXT))必须报错,
669
+ // 由 _parseTableConstraint 消费 KEY 后遇到类型 token 触发
670
+ // "Expected ',' or ')'",见 identifier-keywords 测试。
671
+ this._parseTableConstraint(schema);
634
672
  } else if (t.type === 'eof' || (t.type === 'op' && t.value === ')')) {
635
673
  break;
636
674
  } else {
@@ -4117,6 +4155,25 @@ class SQLExecutor {
4117
4155
  ? await this.engine.getTableSchema(statement.name)
4118
4156
  : (this.engine._schemas ? this.engine._schemas[statement.name] : null);
4119
4157
  if (!schema) throw new Error(`Table '${statement.name}' does not exist`);
4158
+ // P2: INSERT 未知列静默接受 → 应报 Unknown column
4159
+ // 检查 statement.columns(INSERT INTO t (col1, col2) VALUES ...)
4160
+ if (statement.columns && statement.columns.length > 0) {
4161
+ for (const col of statement.columns) {
4162
+ if (!Object.prototype.hasOwnProperty.call(schema, col)) {
4163
+ throw new Error(`Unknown column '${col}' in '${statement.name}'`);
4164
+ }
4165
+ }
4166
+ }
4167
+ // 检查 dataRows 的键(INSERT INTO t SET col1=... 或 INSERT INTO t (col1) VALUES ...)
4168
+ if (dataRows) {
4169
+ for (const row of dataRows) {
4170
+ for (const col of Object.keys(row)) {
4171
+ if (!Object.prototype.hasOwnProperty.call(schema, col)) {
4172
+ throw new Error(`Unknown column '${col}' in '${statement.name}'`);
4173
+ }
4174
+ }
4175
+ }
4176
+ }
4120
4177
  const stripDefault = (row) => {
4121
4178
  const out = {};
4122
4179
  for (const [k, v] of Object.entries(row)) {
@@ -4146,6 +4203,15 @@ class SQLExecutor {
4146
4203
  }
4147
4204
  if (dataRows === null && statement.values) {
4148
4205
  const colNames = Object.keys(schema);
4206
+ // P2: INSERT 未知列静默接受 → 应报 Unknown column
4207
+ // 检查 VALUES 数量是否超过列数(无列清单时)
4208
+ if (!statement.columns || statement.columns.length === 0) {
4209
+ for (const vals of statement.values) {
4210
+ if (vals.length > colNames.length) {
4211
+ throw new Error(`Unknown column in '${statement.name}': VALUES has ${vals.length} values but table has ${colNames.length} columns`);
4212
+ }
4213
+ }
4214
+ }
4149
4215
  const skipAuto = statement.values[0].length < colNames.length;
4150
4216
  dataRows = statement.values.map(vals => {
4151
4217
  const row = {};
@@ -286,7 +286,8 @@ class Statement {
286
286
 
287
287
  run(...params) {
288
288
  const r = this._call('run', params.length > 0 ? params : undefined);
289
- return { changes: r && r.affectedRows, lastInsertRowid: r && r.insertId, _stmt: this };
289
+ // worker 层返回的是 better-sqlite3 风格的 {changes, lastInsertRowid}
290
+ return { changes: r && typeof r.changes === 'number' ? parseInt(r.changes, 10) : 0, lastInsertRowid: r && (r.lastInsertRowid !== undefined && r.lastInsertRowid !== null) ? r.lastInsertRowid : 0, _stmt: this };
290
291
  }
291
292
 
292
293
  get(...params) {
@@ -10,6 +10,35 @@ function safeJsonParse(str) {
10
10
  try { return JSON.parse(str); } catch { return str; }
11
11
  }
12
12
 
13
+ /**
14
+ * 深度相等比较两个行对象(用于无主键表行匹配)。
15
+ * 剥离内部字段(_rid, _deleted)后逐字段比较。
16
+ */
17
+ function _wasmRowDeepEqual(a, b) {
18
+ if (a === b) return true;
19
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
20
+ const strip = (obj) => {
21
+ const out = {};
22
+ for (const [k, v] of Object.entries(obj)) {
23
+ if (k === '_rid' || k === '_deleted') continue;
24
+ out[k] = v;
25
+ }
26
+ return out;
27
+ };
28
+ const sa = strip(a), sb = strip(b);
29
+ const ka = Object.keys(sa), kb = Object.keys(sb);
30
+ if (ka.length !== kb.length) return false;
31
+ for (const k of ka) {
32
+ if (!Object.prototype.hasOwnProperty.call(sb, k)) return false;
33
+ const va = sa[k], vb = sb[k];
34
+ if (va === vb) continue;
35
+ if (va === null || va === undefined || vb === null || vb === undefined) return false;
36
+ if (typeof va === 'number' && typeof vb === 'number' && va === vb) continue;
37
+ if (String(va) !== String(vb)) return false;
38
+ }
39
+ return true;
40
+ }
41
+
13
42
  /**
14
43
  * 序列化并给出可读错误。直接 JSON.stringify 遇到循环引用会抛
15
44
  * "Converting circular structure to JSON",指向不到具体列,这里补上表/列信息。
@@ -434,7 +463,21 @@ class JSQL {
434
463
  if (Array.isArray(r) && r.length > 0) return r[0].id;
435
464
  return null;
436
465
  }
437
- return Number(id);
466
+ // 无主键表:id 应是 1-based 行号(数字)。
467
+ // 但 sql.js DELETE 对无主键表传的是行对象(find() 返回的浅拷贝),
468
+ // Number(obj) = NaN → JSON.stringify([NaN]) = "[null]" → 静默失效。
469
+ // 这里对对象 id 做深度相等匹配,找到对应行的序列号。
470
+ if (typeof id === 'number') return id;
471
+ if (id && typeof id === 'object') {
472
+ const all = safeJsonParse(wasmBindings.jsql_find(table, '{}', 1000000, 0));
473
+ if (Array.isArray(all)) {
474
+ for (const r of all) {
475
+ const row = r.fields ? { ...r.fields, id: r.id } : r;
476
+ if (_wasmRowDeepEqual(row, id)) return r.id;
477
+ }
478
+ }
479
+ }
480
+ return null;
438
481
  }
439
482
 
440
483
  async _insertBatch(table, rows) {
package/lib/web_ui.js CHANGED
@@ -116,6 +116,7 @@ class WebUI {
116
116
  this.readonly = !!opts.readonly;
117
117
  this.host = opts.host || '127.0.0.1';
118
118
  this.authToken = opts.authToken || null;
119
+ this.noAuth = !!opts.noAuth; // 显式免认证(仅本地开发/测试用)
119
120
  this.allowOrigin = opts.allowOrigin || null;
120
121
  this.echoOrigin = !opts.allowOrigin && !!this.authToken;
121
122
  this.cache = new Map();
@@ -199,17 +200,22 @@ class WebUI {
199
200
  // API 认证:必须设置 authToken(从环境变量或构造选项)。
200
201
  // 仅当显式 noAuth: true 时允许无认证访问(仅本地开发用)。
201
202
  if (url.pathname.startsWith('/api/')) {
202
- if (!this.noAuth && !this.authToken) {
203
- res.writeHead(401, { 'Content-Type': 'application/json' });
204
- return res.end(JSON.stringify({ ok: false, error: 'API auth not configured' }));
205
- }
203
+ const deny = (msg) => {
204
+ // 显式配置了 CORS(allowOrigin / echoOrigin)时,401 也要带 ACAO,
205
+ // 否则浏览器会把认证失败误判为 CORS 拦截,无法区分两者。
206
+ const headers = { 'Content-Type': 'application/json' };
207
+ if (corsOrigin) {
208
+ headers['Access-Control-Allow-Origin'] = corsOrigin;
209
+ headers['Vary'] = 'Origin';
210
+ }
211
+ res.writeHead(401, headers);
212
+ res.end(JSON.stringify({ ok: false, error: msg }));
213
+ };
214
+ if (!this.noAuth && !this.authToken) return deny('API auth not configured');
206
215
  if (this.authToken) {
207
216
  const provided = req.headers.authorization || '';
208
217
  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
- }
218
+ if (token !== this.authToken) return deny('unauthorized');
213
219
  }
214
220
  }
215
221
  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.4.0-beta.1",
3
+ "version": "6.4.0-beta.4",
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",
@@ -46,7 +46,7 @@
46
46
  ],
47
47
  "scripts": {
48
48
  "postinstall": "node postinstall.js",
49
- "test": "node test/smoke.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/query-opt.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js",
49
+ "test": "node test/smoke.js && node test/sql-parser.test.js && node test/readme-audit.test.js && node test/query-opt.test.js && node test/wal-recovery.test.js && node test/mysql-compat-hijack.test.js && node test/dml-constraints.test.js",
50
50
  "test:parser": "node test/sql-parser.test.js",
51
51
  "test:btree": "node test/btree.test.js",
52
52
  "test:native": "node test/native.test.js",
package/test/coverage.js CHANGED
@@ -91,7 +91,9 @@ function tmp(name) {
91
91
  __meta__: { version: 1 },
92
92
  users: [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }],
93
93
  }));
94
- const ui = new WebUI({ port: 0, dataDir: path.dirname(dbFile) });
94
+ // API 认证模型(3895dca 起):无 authToken 时 /api/* 一律 401。
95
+ // 本测试是功能测试,需要真实访问 API,故显式 noAuth: true。
96
+ const ui = new WebUI({ port: 0, dataDir: path.dirname(dbFile), noAuth: true });
95
97
  const port = await ui.start();
96
98
  const dbs = await (await fetch(`http://127.0.0.1:${port}/api/databases`)).json();
97
99
  ok('webui list databases', dbs.length === 1 && dbs[0].name === 'db');
@@ -198,6 +198,22 @@ async function t(name, fn) {
198
198
  return eq(await scalar(`SELECT v FROM t WHERE id = 1`), 'c');
199
199
  });
200
200
 
201
+ // P2: INSERT 未知列曾静默接受 → 应报 Unknown column
202
+ console.log('\n--- INSERT 未知列应报错 ---');
203
+ await t('INSERT 未知列(指定列清单)→ 报错', async ({ exec, mustFail }) => {
204
+ await exec(`CREATE TABLE t (id INT, v TEXT)`);
205
+ return mustFail(`INSERT INTO t (id, unknown_col) VALUES (1, 'x')`);
206
+ });
207
+ await t('INSERT 未知列(VALUES 全列)→ 报错', async ({ exec, mustFail }) => {
208
+ await exec(`CREATE TABLE t (id INT, v TEXT)`);
209
+ return mustFail(`INSERT INTO t VALUES (1, 'x', 'extra')`);
210
+ });
211
+ await t('INSERT 已知列 → 正常', async ({ exec, scalar }) => {
212
+ await exec(`CREATE TABLE t (id INT, v TEXT)`);
213
+ await exec(`INSERT INTO t (id, v) VALUES (1, 'x')`);
214
+ return eq(await scalar(`SELECT v FROM t WHERE id = 1`), 'x');
215
+ });
216
+
201
217
  console.log(`\n${pass} passed, ${fail} failed`);
202
218
  process.exit(fail === 0 ? 0 : 1);
203
219
  })();