jsql-neo 6.3.6 → 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.
- package/README.md +78 -8239
- package/index.js +1 -1
- package/lib/cache.js +33 -27
- package/lib/client.js +11 -2
- package/lib/database.js +370 -56
- package/lib/date-types.js +51 -13
- package/lib/errors.js +4 -1
- package/lib/jsql_format.js +30 -21
- package/lib/mod.js +27 -6
- package/lib/mongo_server.js +143 -26
- package/lib/mysql_compat.js +57 -11
- package/lib/mysql_server.js +104 -4
- package/lib/nedb_compat.js +9 -6
- package/lib/pg_server.js +4 -11
- package/lib/plugin.js +20 -2
- package/lib/prepared.js +10 -0
- package/lib/query.js +9 -2
- package/lib/redis_server.js +88 -16
- package/lib/sql.js +856 -81
- package/lib/sqlite_compat.js +2 -1
- package/lib/sqlite_worker.js +8 -2
- package/lib/table.js +68 -26
- package/lib/tui.js +1 -1
- package/lib/web_ui.js +24 -8
- package/native/jsql-neo-native.node +0 -0
- package/package.json +10 -3
- package/test/alter-table.test.js +289 -0
- package/test/identifier-keywords.test.js +140 -0
- package/test/index-ddl.test.js +203 -0
- package/test/mongo-group.test.js +105 -0
- package/test/null-semantics.test.js +126 -0
- package/test/recursive-cte.test.js +123 -0
- package/test/savepoint.test.js +388 -0
- package/test/sql-parser.test.js +30 -5
package/lib/mysql_server.js
CHANGED
|
@@ -11,11 +11,82 @@ const fs = require('fs');
|
|
|
11
11
|
const { executeSQL, parseSQL, splitStatements, applyParams } = require('./sql');
|
|
12
12
|
const Database = require('./database');
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* 词法扫描 SQL,将字符串字面量和注释中的字符替换为空格(保持长度不变)。
|
|
16
|
+
* 这样在替换时可以通过检查对应位置是否为空格来判断是否在字符串/注释中。
|
|
17
|
+
*/
|
|
18
|
+
function _maskStringsAndComments(sql) {
|
|
19
|
+
const chars = sql.split('');
|
|
20
|
+
let i = 0;
|
|
21
|
+
while (i < chars.length) {
|
|
22
|
+
const c = chars[i];
|
|
23
|
+
// 单引号字符串
|
|
24
|
+
if (c === "'") {
|
|
25
|
+
i++;
|
|
26
|
+
while (i < chars.length && chars[i] !== "'") {
|
|
27
|
+
if (chars[i] === '\\' && i + 1 < chars.length) {
|
|
28
|
+
chars[i] = ' ';
|
|
29
|
+
chars[i + 1] = ' ';
|
|
30
|
+
i += 2;
|
|
31
|
+
} else {
|
|
32
|
+
chars[i] = ' ';
|
|
33
|
+
i++;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (i < chars.length) i++; // skip closing quote
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// 双引号字符串
|
|
40
|
+
if (c === '"') {
|
|
41
|
+
i++;
|
|
42
|
+
while (i < chars.length && chars[i] !== '"') {
|
|
43
|
+
if (chars[i] === '\\' && i + 1 < chars.length) {
|
|
44
|
+
chars[i] = ' ';
|
|
45
|
+
chars[i + 1] = ' ';
|
|
46
|
+
i += 2;
|
|
47
|
+
} else {
|
|
48
|
+
chars[i] = ' ';
|
|
49
|
+
i++;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (i < chars.length) i++; // skip closing quote
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
// 单行注释 --
|
|
56
|
+
if (c === '-' && chars[i + 1] === '-') {
|
|
57
|
+
while (i < chars.length && chars[i] !== '\n') {
|
|
58
|
+
chars[i] = ' ';
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// 多行注释 /* */
|
|
64
|
+
if (c === '/' && chars[i + 1] === '*') {
|
|
65
|
+
chars[i] = ' ';
|
|
66
|
+
chars[i + 1] = ' ';
|
|
67
|
+
i += 2;
|
|
68
|
+
while (i < chars.length && !(chars[i] === '*' && chars[i + 1] === '/')) {
|
|
69
|
+
chars[i] = ' ';
|
|
70
|
+
i++;
|
|
71
|
+
}
|
|
72
|
+
if (i < chars.length) {
|
|
73
|
+
chars[i] = ' ';
|
|
74
|
+
chars[i + 1] = ' ';
|
|
75
|
+
i += 2;
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
return chars.join('');
|
|
82
|
+
}
|
|
83
|
+
|
|
14
84
|
const SERVER_VERSION = '8.0.0-jsql-neo';
|
|
15
85
|
const CLIENT_PROTOCOL_41 = 0x00000001 << 9;
|
|
16
86
|
const CLIENT_SECURE_CONNECTION = 0x00008000;
|
|
17
87
|
const CLIENT_PLUGIN_AUTH = 0x00080000;
|
|
18
88
|
const CLIENT_CONNECT_WITH_DB = 0x00000008;
|
|
89
|
+
// CLIENT_LONG_PASSWORD 已废弃但保留协议兼容性(客户端可能发送此标志)
|
|
19
90
|
const CLIENT_LONG_PASSWORD = 0x00000001;
|
|
20
91
|
const CLIENT_TRANSACTIONS = 0x00002000;
|
|
21
92
|
const CLIENT_MULTI_STATEMENTS = 0x00010000;
|
|
@@ -239,13 +310,14 @@ function resultSetPacket(result, tableSchema, baseSeq, rawRow) {
|
|
|
239
310
|
const sampleRow = (result.rows && result.rows[0]) || null;
|
|
240
311
|
for (let ci = 0; ci < (result.columns || []).length; ci++) {
|
|
241
312
|
const name = result.columns[ci];
|
|
313
|
+
if (name === null || name === undefined) name = 'col_' + ci;
|
|
242
314
|
let sample;
|
|
243
315
|
if (sampleRow !== null) {
|
|
244
316
|
if (Array.isArray(sampleRow)) sample = sampleRow[ci];
|
|
245
317
|
else sample = sampleRow[name];
|
|
246
318
|
}
|
|
247
319
|
const col = {
|
|
248
|
-
name,
|
|
320
|
+
name: String(name),
|
|
249
321
|
table: result.table || '',
|
|
250
322
|
type: inferType(name, sample),
|
|
251
323
|
length: 1024,
|
|
@@ -255,6 +327,7 @@ function resultSetPacket(result, tableSchema, baseSeq, rawRow) {
|
|
|
255
327
|
push(eofPacket());
|
|
256
328
|
|
|
257
329
|
for (const row of result.rows || []) {
|
|
330
|
+
if (!row) continue;
|
|
258
331
|
const parts = [];
|
|
259
332
|
for (let i = 0; i < (result.columns || []).length; i++) {
|
|
260
333
|
const v = row[i];
|
|
@@ -307,7 +380,7 @@ function binaryResultSetPacket(result, tableSchema, baseSeq) {
|
|
|
307
380
|
}
|
|
308
381
|
const t = inferType(cols[ci], sample);
|
|
309
382
|
types.push(t);
|
|
310
|
-
push(columnDefinition({ name: cols[ci], table: result.table || '', type: t, length: 1024 }));
|
|
383
|
+
push(columnDefinition({ name: String(cols[ci]), table: result.table || '', type: t, length: 1024 }));
|
|
311
384
|
}
|
|
312
385
|
push(eofPacket());
|
|
313
386
|
|
|
@@ -351,11 +424,12 @@ function binaryResultSetPacket(result, tableSchema, baseSeq) {
|
|
|
351
424
|
|
|
352
425
|
for (const row of result.rows || []) {
|
|
353
426
|
const parts = [Buffer.from([0x00])];
|
|
354
|
-
const nb = Buffer.alloc(Math.ceil(cols.length / 8));
|
|
427
|
+
const nb = Buffer.alloc(Math.ceil((cols.length + 2) / 8));
|
|
355
428
|
const encoded = [];
|
|
356
429
|
for (let i = 0; i < cols.length; i++) {
|
|
357
430
|
const v = Array.isArray(row) ? row[i] : row[cols[i]];
|
|
358
431
|
if (v === null || v === undefined) {
|
|
432
|
+
// null bitmap 按列顺序编码,bit=1 表示 NULL
|
|
359
433
|
nb[Math.floor(i / 8)] |= (1 << (i % 8));
|
|
360
434
|
encoded.push(null);
|
|
361
435
|
} else {
|
|
@@ -646,6 +720,21 @@ class MysqlConnection {
|
|
|
646
720
|
continue;
|
|
647
721
|
}
|
|
648
722
|
if (c === "'" || c === '"' || c === '`') { inStr = c; continue; }
|
|
723
|
+
// P2-4: countPlaceholders 不跳过注释——注释中的 ? 不应计入占位符
|
|
724
|
+
if (c === '-' && sql[i + 1] === '-') {
|
|
725
|
+
while (i < sql.length && sql[i] !== '\n') i++;
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
if (c === '#') {
|
|
729
|
+
while (i < sql.length && sql[i] !== '\n') i++;
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
if (c === '/' && sql[i + 1] === '*') {
|
|
733
|
+
i += 2;
|
|
734
|
+
while (i + 1 < sql.length && !(sql[i] === '*' && sql[i + 1] === '/')) i++;
|
|
735
|
+
i += 2;
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
649
738
|
if (c === '?' && sql[i + 1] === '?') { i++; continue; }
|
|
650
739
|
if (c === '?') n++;
|
|
651
740
|
}
|
|
@@ -1000,9 +1089,16 @@ class MysqlServer {
|
|
|
1000
1089
|
if (!this._dbExists(db)) await this.createDatabase(db);
|
|
1001
1090
|
const engine = await this._getDatabase(db);
|
|
1002
1091
|
let s = sql;
|
|
1092
|
+
// 词法扫描:找出所有字符串字面量和注释的位置,替换时跳过它们
|
|
1093
|
+
const masked = _maskStringsAndComments(s);
|
|
1003
1094
|
for (const t of tables) {
|
|
1004
1095
|
if (t && t.indexOf('.') !== -1) {
|
|
1005
|
-
|
|
1096
|
+
const re = new RegExp('\\b' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'g');
|
|
1097
|
+
s = s.replace(re, (match, offset) => {
|
|
1098
|
+
// 如果匹配位置在字符串字面量或注释中,不替换
|
|
1099
|
+
if (masked[offset] !== ' ') return match;
|
|
1100
|
+
return t.split('.')[1];
|
|
1101
|
+
});
|
|
1006
1102
|
}
|
|
1007
1103
|
}
|
|
1008
1104
|
return { engine, sql: s };
|
|
@@ -1126,8 +1222,12 @@ class MysqlServer {
|
|
|
1126
1222
|
}
|
|
1127
1223
|
|
|
1128
1224
|
_canAccessDb(user, dbName) {
|
|
1225
|
+
// 未配置认证:允许访问
|
|
1226
|
+
if (!this.auth) return true;
|
|
1129
1227
|
const list = this._userDbList(user);
|
|
1228
|
+
// _userDbList 返回 null 表示该用户有全局访问权限
|
|
1130
1229
|
if (list === null) return true;
|
|
1230
|
+
// 空数组表示用户有受限数据库集,dbName 必须明确在列表中
|
|
1131
1231
|
return list.includes(dbName);
|
|
1132
1232
|
}
|
|
1133
1233
|
|
package/lib/nedb_compat.js
CHANGED
|
@@ -70,10 +70,10 @@ function matchOperator(value, op, expected) {
|
|
|
70
70
|
switch (op) {
|
|
71
71
|
case '$eq': return looseEqual(value, expected);
|
|
72
72
|
case '$ne': return !looseEqual(value, expected);
|
|
73
|
-
case '$lt': return value !==
|
|
74
|
-
case '$lte': return value !==
|
|
75
|
-
case '$gt': return value !==
|
|
76
|
-
case '$gte': return value !==
|
|
73
|
+
case '$lt': return value !== undefined && (value === null ? expected !== null && expected !== undefined && 0 < expected : value < expected);
|
|
74
|
+
case '$lte': return value !== undefined && (value === null ? expected !== null && expected !== undefined && 0 <= expected : value <= expected);
|
|
75
|
+
case '$gt': return value !== undefined && (value === null ? expected !== null && expected !== undefined && 0 > expected : value > expected);
|
|
76
|
+
case '$gte': return value !== undefined && (value === null ? expected !== null && expected !== undefined && 0 >= expected : value >= expected);
|
|
77
77
|
case '$in': return Array.isArray(expected) && expected.some(e => looseEqual(value, e));
|
|
78
78
|
case '$nin': return Array.isArray(expected) && !expected.some(e => looseEqual(value, e));
|
|
79
79
|
case '$exists': return expected ? value !== undefined : value === undefined;
|
|
@@ -242,8 +242,11 @@ class Cursor {
|
|
|
242
242
|
|
|
243
243
|
exec(cb) {
|
|
244
244
|
const p = this._run();
|
|
245
|
-
if (cb)
|
|
246
|
-
|
|
245
|
+
if (cb) {
|
|
246
|
+
p.then(docs => { if (this._limit === 0) cb(null, []); else cb(null, docs); }, err => cb(err));
|
|
247
|
+
return p;
|
|
248
|
+
}
|
|
249
|
+
return this._limit === 0 ? Promise.resolve([]) : p;
|
|
247
250
|
}
|
|
248
251
|
|
|
249
252
|
toArray(cb) {
|
package/lib/pg_server.js
CHANGED
|
@@ -139,7 +139,7 @@ function buildRowDescription(columns, types) {
|
|
|
139
139
|
p.nul();
|
|
140
140
|
p.i32(0); // table oid
|
|
141
141
|
p.i16(0); // column attnum
|
|
142
|
-
p.i32(
|
|
142
|
+
p.i32(pgTypeOid(types[i]));
|
|
143
143
|
p.i16(types[i] === 'integer' ? 4 : -1); // typlen
|
|
144
144
|
p.i32(-1); // typmod
|
|
145
145
|
p.i16(0); // format code (text)
|
|
@@ -726,15 +726,11 @@ class PgConnection {
|
|
|
726
726
|
}
|
|
727
727
|
|
|
728
728
|
_pgErrorCode(e) {
|
|
729
|
-
if (!e)
|
|
730
|
-
return 'XX000';
|
|
729
|
+
if (!e) return 'XX000';
|
|
731
730
|
const codeKey = e.code;
|
|
732
731
|
const m = String(e.message || '');
|
|
733
|
-
// Debug write to /tmp/pg_error_debug.txt
|
|
734
|
-
const fs = require('fs');
|
|
735
|
-
fs.writeFileSync('/tmp/pg_error_debug.txt', 'codeKey=' + codeKey + '\\nmessage=' + m + '\\n');
|
|
736
732
|
// Map JSQL error keys to PG SQLSTATE codes
|
|
737
|
-
if (codeKey === 'ER_NO_SUCH_TABLE' || /Table .* doesn
|
|
733
|
+
if (codeKey === 'ER_NO_SUCH_TABLE' || /Table .* doesn't exist/i.test(m)) return '42P01';
|
|
738
734
|
if (codeKey === 'ER_TABLE_EXISTS' || /already exists/i.test(m)) return '42P07';
|
|
739
735
|
if (codeKey === 'ER_DUP_ENTRY' || m.includes('ER_DUP_ENTRY') || /Duplicate entry/i.test(m)) return '23505';
|
|
740
736
|
if (codeKey === 'ER_BAD_FIELD_ERROR' || /Unknown column/i.test(m)) return '42703';
|
|
@@ -786,10 +782,7 @@ class PgServer {
|
|
|
786
782
|
|
|
787
783
|
_canAccessDb(user, dbName) {
|
|
788
784
|
if (this.noAuth) return true;
|
|
789
|
-
if (!this.auth)
|
|
790
|
-
// 单用户模式下仅允许该用户访问其默认库
|
|
791
|
-
return true;
|
|
792
|
-
}
|
|
785
|
+
if (!this.auth) return false;
|
|
793
786
|
const entry = this.auth[user];
|
|
794
787
|
if (!entry) return false;
|
|
795
788
|
if (entry && typeof entry === 'object' && Array.isArray(entry.databases)) {
|
package/lib/plugin.js
CHANGED
|
@@ -206,6 +206,20 @@ function applyPlugin(engine, plugin, opts) {
|
|
|
206
206
|
if (!engine._plugins) engine._plugins = [];
|
|
207
207
|
if (!engine._eventListeners) engine._eventListeners = [];
|
|
208
208
|
|
|
209
|
+
// P3-2: 重复 use() 同一插件钩子双触发——检查是否已注册
|
|
210
|
+
if (engine._plugins.some(p => p.name && p.name === plugin.name)) {
|
|
211
|
+
return engine; // 已注册,跳过
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// P3-1: plugin depends/priority 未实现——按依赖关系排序
|
|
215
|
+
if (plugin.depends && plugin.depends.length > 0) {
|
|
216
|
+
for (const dep of plugin.depends) {
|
|
217
|
+
if (!engine._plugins.some(p => p.name === dep)) {
|
|
218
|
+
throw new Error(`Plugin '${plugin.name}' depends on '${dep}' which is not loaded`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
209
223
|
registerHooks(engine, plugin);
|
|
210
224
|
|
|
211
225
|
const ctx = buildPluginContext(engine, plugin);
|
|
@@ -213,6 +227,8 @@ function applyPlugin(engine, plugin, opts) {
|
|
|
213
227
|
if (typeof plugin.onEvent === 'function') engine._eventListeners.push(plugin.onEvent);
|
|
214
228
|
|
|
215
229
|
engine._plugins.push(plugin);
|
|
230
|
+
// P3-1: 按 priority 排序插件(数值大的先执行)
|
|
231
|
+
engine._plugins.sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
|
216
232
|
if (plugin.name && plugin.api) getPluginApis(engine).set(plugin.name, plugin.api);
|
|
217
233
|
return engine;
|
|
218
234
|
}
|
|
@@ -222,7 +238,7 @@ function applyPlugin(engine, plugin, opts) {
|
|
|
222
238
|
* - 任一钩子返回 false → 中止(返回 false),引擎据此跳过本次操作;
|
|
223
239
|
* - native 引擎置 `_forbidReentry`,钩子内再次触发引擎操作会抛 ER_PLUGIN_REENTRY。
|
|
224
240
|
*/
|
|
225
|
-
function runHooks(engine, hookName, args) {
|
|
241
|
+
async function runHooks(engine, hookName, args) {
|
|
226
242
|
const hooks = engine._hooks && engine._hooks[hookName];
|
|
227
243
|
if (!hooks || hooks.length === 0) return true;
|
|
228
244
|
|
|
@@ -236,7 +252,9 @@ function runHooks(engine, hookName, args) {
|
|
|
236
252
|
}
|
|
237
253
|
try {
|
|
238
254
|
for (const fn of hooks) {
|
|
239
|
-
|
|
255
|
+
// P3-3: runHooks 异步钩子静默失效——支持异步钩子
|
|
256
|
+
const result = await fn(...args);
|
|
257
|
+
if (result === false) return false;
|
|
240
258
|
}
|
|
241
259
|
return true;
|
|
242
260
|
} finally {
|
package/lib/prepared.js
CHANGED
|
@@ -7,10 +7,20 @@ function createPrepared(engine, sql) {
|
|
|
7
7
|
if (typeof sql !== 'string' || sql.length === 0) {
|
|
8
8
|
throw new Error('prepare() requires a SQL string');
|
|
9
9
|
}
|
|
10
|
+
// P3-4: parse once 名不副实——缓存解析结果,避免每次调用都重新解析
|
|
11
|
+
let parsed = null;
|
|
12
|
+
let parseError = null;
|
|
13
|
+
try {
|
|
14
|
+
parsed = engine.parse ? engine.parse(sql) : null;
|
|
15
|
+
} catch (e) {
|
|
16
|
+
parseError = e;
|
|
17
|
+
}
|
|
10
18
|
const bound = {
|
|
11
19
|
sql,
|
|
12
20
|
async all(...params) {
|
|
13
21
|
const values = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
|
|
22
|
+
if (parseError) throw parseError;
|
|
23
|
+
if (parsed) return engine.execute(parsed, values);
|
|
14
24
|
return engine.query(sql, values);
|
|
15
25
|
},
|
|
16
26
|
async get(...params) {
|
package/lib/query.js
CHANGED
|
@@ -239,10 +239,17 @@ class Query {
|
|
|
239
239
|
rows = this._applyJoinOptimized(rows, join);
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
// UNION
|
|
242
|
+
// UNION(去重,标准 SQL 语义)
|
|
243
243
|
if (this._union) {
|
|
244
244
|
const unionRows = this._union.get();
|
|
245
|
-
|
|
245
|
+
const seen = new Set(rows.map(r => JSON.stringify(r)));
|
|
246
|
+
for (const r of unionRows) {
|
|
247
|
+
const key = JSON.stringify(r);
|
|
248
|
+
if (!seen.has(key)) {
|
|
249
|
+
seen.add(key);
|
|
250
|
+
rows.push(r);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
246
253
|
}
|
|
247
254
|
|
|
248
255
|
// GROUP BY
|
package/lib/redis_server.js
CHANGED
|
@@ -21,14 +21,14 @@ const fs = require('fs');
|
|
|
21
21
|
const path = require('path');
|
|
22
22
|
|
|
23
23
|
const TYPE_SIGNATURES = {
|
|
24
|
-
PING: 0, ECHO: 1, SET: 2, SETNX: 2, GET: 1, DEL: -1, EXISTS: -1, KEYS: 1,
|
|
24
|
+
PING: 0, ECHO: 1, SET: -2, SETNX: 2, GET: 1, DEL: -1, EXISTS: -1, KEYS: 1,
|
|
25
25
|
TYPE: 1, EXPIRE: 2, TTL: 1, PERSIST: 1,
|
|
26
26
|
INCR: 1, DECR: 1, INCRBY: 2, DECRBY: 2, APPEND: 2, STRLEN: 1,
|
|
27
27
|
MSET: -2, MGET: -1,
|
|
28
28
|
HSET: -3, HGET: 2, HGETALL: 1, HDEL: -2, HEXISTS: 2, HLEN: 1, HKEYS: 1, HVALS: 1,
|
|
29
29
|
LPUSH: -2, RPUSH: -2, LPOP: 1, RPOP: 1, LLEN: 1, LRANGE: 3, LINDEX: 2, LREM: 3,
|
|
30
30
|
SADD: -2, SREM: -2, SMEMBERS: 1, SISMEMBER: 2, SCARD: 1,
|
|
31
|
-
ZADD: -3, ZRANGE:
|
|
31
|
+
ZADD: -3, ZRANGE: 3, ZREVRANGE: 3, ZSCORE: 2, ZCARD: 1, ZREM: -2, ZINCRBY: 3,
|
|
32
32
|
DBSIZE: 0, FLUSHALL: 0, FLUSHDB: 0, SELECT: 1, INFO: 0, AUTH: 1, QUIT: 0,
|
|
33
33
|
};
|
|
34
34
|
|
|
@@ -54,7 +54,12 @@ class RedisServer {
|
|
|
54
54
|
if (!p || !fs.existsSync(p)) return;
|
|
55
55
|
try {
|
|
56
56
|
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
57
|
-
for (const [k, v] of Object.entries(raw))
|
|
57
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
58
|
+
// Restore type info: { _t: 'set', _v: [...] } → Set, { _t: 'date', _v: ... } → Date
|
|
59
|
+
if (v && v._t === 'set' && Array.isArray(v._v)) this.db.set(k, new Set(v._v));
|
|
60
|
+
else if (v && v._t === 'date') this.db.set(k, new Date(v._v));
|
|
61
|
+
else this.db.set(k, v);
|
|
62
|
+
}
|
|
58
63
|
} catch (_) {}
|
|
59
64
|
}
|
|
60
65
|
|
|
@@ -72,18 +77,24 @@ class RedisServer {
|
|
|
72
77
|
const out = {};
|
|
73
78
|
const now = Date.now();
|
|
74
79
|
for (const [k, v] of this.db) {
|
|
75
|
-
if (v.ttl && v.ttl <= now) continue;
|
|
76
|
-
|
|
80
|
+
if (v && v.ttl && v.ttl <= now) continue;
|
|
81
|
+
// Preserve type info: Set → { _t: 'set', _v: [...] }, Date → ISO string
|
|
82
|
+
if (v instanceof Set) out[k] = { _t: 'set', _v: [...v] };
|
|
83
|
+
else if (v instanceof Date) out[k] = { _t: 'date', _v: v.toISOString() };
|
|
84
|
+
else out[k] = v;
|
|
77
85
|
}
|
|
78
86
|
const tmp = p + '.tmp';
|
|
79
87
|
fs.writeFileSync(tmp, JSON.stringify(out));
|
|
80
|
-
fs.renameSync(tmp, p);
|
|
88
|
+
try { fs.renameSync(tmp, p); } catch (e) { fs.unlinkSync(tmp); }
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
stop() {
|
|
84
|
-
if (this.snapshotTimer) clearTimeout(this.snapshotTimer);
|
|
92
|
+
if (this.snapshotTimer) { clearTimeout(this.snapshotTimer); this.snapshotTimer = null; }
|
|
85
93
|
this._snapshot();
|
|
86
|
-
if (this.server)
|
|
94
|
+
if (this.server) {
|
|
95
|
+
this.server.close();
|
|
96
|
+
this.server = null;
|
|
97
|
+
}
|
|
87
98
|
}
|
|
88
99
|
|
|
89
100
|
/* ---------- value helpers ---------- */
|
|
@@ -112,8 +123,33 @@ class RedisServer {
|
|
|
112
123
|
this.selected = parseInt(args[0], 10);
|
|
113
124
|
return 'OK';
|
|
114
125
|
case 'SET': {
|
|
115
|
-
const
|
|
116
|
-
|
|
126
|
+
const key = args[0];
|
|
127
|
+
const val = args[1];
|
|
128
|
+
// Parse optional arguments: EX seconds | PX milliseconds | NX | XX
|
|
129
|
+
let ttl = null;
|
|
130
|
+
let nx = false, xx = false;
|
|
131
|
+
for (let i = 2; i < args.length; i++) {
|
|
132
|
+
const opt = args[i].toUpperCase();
|
|
133
|
+
if (opt === 'EX') {
|
|
134
|
+
const secs = parseInt(args[++i], 10);
|
|
135
|
+
if (isNaN(secs) || secs < 0) throw new Error('ERR invalid expire time in set');
|
|
136
|
+
ttl = Date.now() + secs * 1000;
|
|
137
|
+
} else if (opt === 'PX') {
|
|
138
|
+
const ms = parseInt(args[++i], 10);
|
|
139
|
+
if (isNaN(ms) || ms < 0) throw new Error('ERR invalid expire time in set');
|
|
140
|
+
ttl = Date.now() + ms;
|
|
141
|
+
} else if (opt === 'NX') {
|
|
142
|
+
nx = true;
|
|
143
|
+
} else if (opt === 'XX') {
|
|
144
|
+
xx = true;
|
|
145
|
+
} else {
|
|
146
|
+
throw new Error('ERR syntax error');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const prev = this._get(key);
|
|
150
|
+
if (nx && prev) return null;
|
|
151
|
+
if (xx && !prev) return null;
|
|
152
|
+
this.db.set(key, { type: 'string', val, ttl });
|
|
117
153
|
this._scheduleSnapshot();
|
|
118
154
|
return 'OK';
|
|
119
155
|
}
|
|
@@ -184,7 +220,9 @@ class RedisServer {
|
|
|
184
220
|
case 'EXPIRE': {
|
|
185
221
|
const v = this._get(args[0]);
|
|
186
222
|
if (!v) return 0;
|
|
187
|
-
|
|
223
|
+
const secs = parseInt(args[1], 10);
|
|
224
|
+
if (isNaN(secs)) throw new Error('ERR value is not an integer or out of range');
|
|
225
|
+
v.ttl = Date.now() + secs * 1000;
|
|
188
226
|
this._scheduleSnapshot();
|
|
189
227
|
return 1;
|
|
190
228
|
}
|
|
@@ -301,10 +339,26 @@ class RedisServer {
|
|
|
301
339
|
const target = args[2];
|
|
302
340
|
const out = [];
|
|
303
341
|
let removed = 0;
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
342
|
+
if (count > 0) {
|
|
343
|
+
// 从头删除
|
|
344
|
+
const maxRemove = count;
|
|
345
|
+
for (const el of v.val) {
|
|
346
|
+
if (el === target && removed < maxRemove) { removed++; continue; }
|
|
347
|
+
out.push(el);
|
|
348
|
+
}
|
|
349
|
+
} else if (count < 0) {
|
|
350
|
+
// 从尾删除
|
|
351
|
+
const maxRemove = Math.abs(count);
|
|
352
|
+
for (let i = v.val.length - 1; i >= 0; i--) {
|
|
353
|
+
if (v.val[i] === target && removed < maxRemove) { removed++; continue; }
|
|
354
|
+
out.unshift(v.val[i]);
|
|
355
|
+
}
|
|
356
|
+
} else {
|
|
357
|
+
// count === 0: 删除所有
|
|
358
|
+
for (const el of v.val) {
|
|
359
|
+
if (el === target) { removed++; continue; }
|
|
360
|
+
out.push(el);
|
|
361
|
+
}
|
|
308
362
|
}
|
|
309
363
|
v.val = out;
|
|
310
364
|
this._scheduleSnapshot();
|
|
@@ -438,7 +492,14 @@ class RedisServer {
|
|
|
438
492
|
return v && v.type === 'string' ? v.val : null;
|
|
439
493
|
});
|
|
440
494
|
}
|
|
441
|
-
case 'DBSIZE':
|
|
495
|
+
case 'DBSIZE': {
|
|
496
|
+
let count = 0;
|
|
497
|
+
const now = Date.now();
|
|
498
|
+
for (const v of this.db.values()) {
|
|
499
|
+
if (!v.ttl || v.ttl > now) count++;
|
|
500
|
+
}
|
|
501
|
+
return count;
|
|
502
|
+
}
|
|
442
503
|
case 'FLUSHALL': case 'FLUSHDB':
|
|
443
504
|
this.db.clear();
|
|
444
505
|
this._scheduleSnapshot();
|
|
@@ -563,6 +624,17 @@ class RedisServer {
|
|
|
563
624
|
return this._send(socket, new Error('NOAUTH Authentication required.'));
|
|
564
625
|
}
|
|
565
626
|
if (cmd === 'QUIT') { this._send(socket, 'OK'); socket.end(); return; }
|
|
627
|
+
if (cmd === 'GET') {
|
|
628
|
+
const r = this.execute(cmd, args);
|
|
629
|
+
if (r === null) {
|
|
630
|
+
this._send(socket, null);
|
|
631
|
+
} else {
|
|
632
|
+
// GET 返回值必须是 bulk string,即使内容是 "OK" 或 "PONG"
|
|
633
|
+
const buf = Buffer.from(String(r), 'utf8');
|
|
634
|
+
socket.write('$' + buf.length + '\r\n' + r + '\r\n');
|
|
635
|
+
}
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
566
638
|
const r = this.execute(cmd, args);
|
|
567
639
|
this._send(socket, r);
|
|
568
640
|
} catch (e) {
|