jsql-neo 5.4.2 → 5.5.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/LICENSE +202 -202
- package/README.md +8097 -7971
- package/bin/jsql +320 -320
- package/bin/jsql-server +90 -90
- package/bin/jsql-server-control.js +177 -177
- package/index.d.ts +385 -357
- package/index.js +124 -118
- package/lib/ast.js +214 -0
- package/lib/btree.js +413 -413
- package/lib/cache.js +328 -328
- package/lib/client.js +125 -125
- package/lib/database.js +1744 -1744
- package/lib/date-types.js +182 -182
- package/lib/errors.js +89 -89
- package/lib/jsql_format.js +497 -497
- package/lib/migrate.js +254 -254
- package/lib/mod.js +292 -292
- package/lib/mongo_server.js +607 -607
- package/lib/multiserver.js +140 -140
- package/lib/mysql_compat.js +413 -413
- package/lib/mysql_server.js +1237 -1237
- package/lib/native_client.js +732 -732
- package/lib/nedb_compat.js +506 -506
- package/lib/pg_server.js +864 -864
- package/lib/plugin.js +34 -34
- package/lib/query.js +815 -815
- package/lib/redis_server.js +571 -571
- package/lib/sql.js +3460 -2925
- package/lib/sqlite_compat.js +323 -323
- package/lib/sqlite_worker.js +264 -264
- package/lib/table.js +1304 -1304
- package/lib/tui.js +502 -502
- package/lib/wasm.js +1 -1
- package/lib/wasm_client.js +718 -718
- package/lib/web_ui.js +257 -257
- package/native/jsql-neo-native.node +0 -0
- package/nativesrc/jsql-neo-core/Cargo.lock +455 -455
- package/nativesrc/jsql-neo-core/Cargo.toml +24 -24
- package/nativesrc/jsql-neo-core/src/engine/hybrid.rs +455 -455
- package/nativesrc/jsql-neo-core/src/engine/memory.rs +168 -168
- package/nativesrc/jsql-neo-core/src/engine/mod.rs +62 -62
- package/nativesrc/jsql-neo-core/src/engine/table.rs +700 -700
- package/nativesrc/jsql-neo-core/src/lib.rs +2 -2
- package/nativesrc/jsql-neo-core/src/storage/mod.rs +2 -2
- package/nativesrc/jsql-neo-core/src/storage/persistent.rs +1 -1
- package/nativesrc/jsql-neo-core/src/storage/wal.rs +85 -85
- package/nativesrc/jsql-neo-core/src/types.rs +93 -93
- package/nativesrc/jsql-neo-native/Cargo.lock +606 -606
- package/nativesrc/jsql-neo-native/Cargo.toml +16 -16
- package/nativesrc/jsql-neo-native/build.rs +3 -3
- package/nativesrc/jsql-neo-native/package.json +7 -7
- package/nativesrc/jsql-neo-native/src/lib.rs +269 -269
- package/nativesrc/jsql-neo-wasm/Cargo.lock +465 -465
- package/nativesrc/jsql-neo-wasm/Cargo.toml +15 -15
- package/nativesrc/jsql-neo-wasm/src/lib.rs +447 -447
- package/package.json +87 -86
- package/postinstall.js +158 -158
- package/test/btree.test.js +128 -128
- package/test/coverage.js +187 -187
- package/test/join.test.js +110 -110
- package/test/native.test.js +158 -158
- package/test/regress-5.1.0.js +271 -271
- package/test/smoke.js +58 -58
- package/test/sql-parser.test.js +273 -0
- package/test/wasm.test.js +110 -110
- package/wasm/browser.d.ts +89 -89
- package/wasm/browser.mjs +569 -569
- package/wasm/browser_bg.mjs +597 -597
- package/wasm/jsql_neo_wasm.d.ts +38 -38
- package/wasm/jsql_neo_wasm.js +520 -520
- package/wasm/jsql_neo_wasm_bg.wasm.d.ts +26 -26
- package/wasm/package.json +11 -11
package/test/regress-5.1.0.js
CHANGED
|
@@ -1,271 +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); });
|
|
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); });
|