resplite 1.1.6 → 1.1.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 +1 -1
- package/package.json +1 -1
- package/spec/SPEC_D.md +7 -7
- package/src/commands/ft-add.js +1 -1
- package/src/commands/ft-create.js +1 -1
- package/src/commands/ft-del.js +1 -1
- package/src/commands/ft-info.js +1 -1
- package/src/commands/ft-search.js +1 -1
- package/src/commands/ft-sugadd.js +1 -1
- package/src/commands/ft-sugdel.js +1 -1
- package/src/commands/ft-sugget.js +1 -1
- package/src/commands/keys.js +67 -0
- package/src/commands/monitor.js +10 -0
- package/src/commands/registry.js +4 -0
- package/src/server/connection.js +15 -0
- package/src/server/monitor.js +59 -0
- package/src/storage/sqlite/search.js +1 -1
- package/test/integration/keys.test.js +49 -0
- package/test/integration/monitor.test.js +102 -0
- package/test/integration/search.test.js +1 -1
- package/test/unit/search.test.js +1 -1
- package/tasks/todo.md +0 -44
package/README.md
CHANGED
|
@@ -329,7 +329,7 @@ RESPLite implements **47 core Redis commands** (~19% of the ~246 commands in Red
|
|
|
329
329
|
| **Lists** | LPUSH, RPUSH, LLEN, LRANGE, LINDEX, LPOP, RPOP, BLPOP, BRPOP |
|
|
330
330
|
| **Sorted sets** | ZADD, ZREM, ZCARD, ZSCORE, ZRANGE, ZRANGEBYSCORE |
|
|
331
331
|
| **Search (FT.\*)** | FT.CREATE, FT.INFO, FT.ADD, FT.DEL, FT.SEARCH, FT.SUGADD, FT.SUGGET, FT.SUGDEL |
|
|
332
|
-
| **Introspection** | TYPE, SCAN |
|
|
332
|
+
| **Introspection** | TYPE, SCAN, KEYS, MONITOR |
|
|
333
333
|
| **Admin** | SQLITE.INFO, CACHE.INFO, MEMORY.INFO |
|
|
334
334
|
| **Tooling** | Redis import CLI (see Migration from Redis) |
|
|
335
335
|
|
package/package.json
CHANGED
package/spec/SPEC_D.md
CHANGED
|
@@ -160,7 +160,7 @@ Exact fields may evolve, but these should exist.
|
|
|
160
160
|
|
|
161
161
|
## D.5.3 Errors
|
|
162
162
|
|
|
163
|
-
* Missing index: `-
|
|
163
|
+
* Missing index: `-Unknown index name`
|
|
164
164
|
|
|
165
165
|
---
|
|
166
166
|
|
|
@@ -212,7 +212,7 @@ RESP reply: `+OK`
|
|
|
212
212
|
|
|
213
213
|
## D.6.6 Errors
|
|
214
214
|
|
|
215
|
-
* Index missing: `-
|
|
215
|
+
* Index missing: `-Unknown index name`
|
|
216
216
|
* Wrong syntax: `-ERR syntax error`
|
|
217
217
|
* Missing `FIELDS`: `-ERR syntax error`
|
|
218
218
|
* Unknown field: `-ERR unknown field`
|
|
@@ -248,7 +248,7 @@ RESP Integer:
|
|
|
248
248
|
|
|
249
249
|
## D.7.4 Errors
|
|
250
250
|
|
|
251
|
-
* Index missing: `-
|
|
251
|
+
* Index missing: `-Unknown index name`
|
|
252
252
|
|
|
253
253
|
---
|
|
254
254
|
|
|
@@ -346,7 +346,7 @@ Compute total efficiently:
|
|
|
346
346
|
|
|
347
347
|
## D.8.8 Errors
|
|
348
348
|
|
|
349
|
-
* Index missing: `-
|
|
349
|
+
* Index missing: `-Unknown index name`
|
|
350
350
|
* Bad syntax: `-ERR syntax error`
|
|
351
351
|
* Invalid LIMIT: `-ERR invalid limit`
|
|
352
352
|
* Unsafe query: `-ERR invalid query`
|
|
@@ -383,7 +383,7 @@ RESP Integer:
|
|
|
383
383
|
|
|
384
384
|
### Errors
|
|
385
385
|
|
|
386
|
-
* Index missing: `-
|
|
386
|
+
* Index missing: `-Unknown index name`
|
|
387
387
|
* Score invalid: `-ERR invalid score`
|
|
388
388
|
|
|
389
389
|
## D.9.2 FT.SUGGET
|
|
@@ -425,7 +425,7 @@ Not supported (v1):
|
|
|
425
425
|
|
|
426
426
|
### Errors
|
|
427
427
|
|
|
428
|
-
* Index missing: `-
|
|
428
|
+
* Index missing: `-Unknown index name`
|
|
429
429
|
* Bad syntax: `-ERR syntax error`
|
|
430
430
|
|
|
431
431
|
## D.9.3 FT.SUGDEL
|
|
@@ -449,7 +449,7 @@ RESP Integer:
|
|
|
449
449
|
|
|
450
450
|
### Errors
|
|
451
451
|
|
|
452
|
-
* Index missing: `-
|
|
452
|
+
* Index missing: `-Unknown index name`
|
|
453
453
|
|
|
454
454
|
---
|
|
455
455
|
|
package/src/commands/ft-add.js
CHANGED
|
@@ -20,6 +20,6 @@ export function handleFtAdd(engine, args) {
|
|
|
20
20
|
return { simple: 'OK' };
|
|
21
21
|
} catch (e) {
|
|
22
22
|
const msg = e?.message ?? String(e);
|
|
23
|
-
return { error: msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
|
|
23
|
+
return { error: msg === 'Unknown index name' ? msg : msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
|
|
24
24
|
}
|
|
25
25
|
}
|
|
@@ -15,6 +15,6 @@ export function handleFtCreate(engine, args) {
|
|
|
15
15
|
return { simple: 'OK' };
|
|
16
16
|
} catch (e) {
|
|
17
17
|
const msg = e?.message ?? String(e);
|
|
18
|
-
return { error: msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
|
|
18
|
+
return { error: msg === 'Unknown index name' ? msg : msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
|
|
19
19
|
}
|
|
20
20
|
}
|
package/src/commands/ft-del.js
CHANGED
|
@@ -15,6 +15,6 @@ export function handleFtDel(engine, args) {
|
|
|
15
15
|
return n;
|
|
16
16
|
} catch (e) {
|
|
17
17
|
const msg = e?.message ?? String(e);
|
|
18
|
-
return { error: msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
|
|
18
|
+
return { error: msg === 'Unknown index name' ? msg : msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
|
|
19
19
|
}
|
|
20
20
|
}
|
package/src/commands/ft-info.js
CHANGED
|
@@ -32,6 +32,6 @@ export function handleFtInfo(engine, args) {
|
|
|
32
32
|
];
|
|
33
33
|
} catch (e) {
|
|
34
34
|
const msg = e?.message ?? String(e);
|
|
35
|
-
return { error: msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
|
|
35
|
+
return { error: msg === 'Unknown index name' ? msg : msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -33,6 +33,6 @@ export function handleFtSearch(engine, args) {
|
|
|
33
33
|
return out;
|
|
34
34
|
} catch (e) {
|
|
35
35
|
const msg = e?.message ?? String(e);
|
|
36
|
-
return { error: msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
|
|
36
|
+
return { error: msg === 'Unknown index name' ? msg : msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
|
|
37
37
|
}
|
|
38
38
|
}
|
|
@@ -22,6 +22,6 @@ export function handleFtSugadd(engine, args) {
|
|
|
22
22
|
return n;
|
|
23
23
|
} catch (e) {
|
|
24
24
|
const msg = e?.message ?? String(e);
|
|
25
|
-
return { error: msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
|
|
25
|
+
return { error: msg === 'Unknown index name' ? msg : msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
|
|
26
26
|
}
|
|
27
27
|
}
|
|
@@ -15,6 +15,6 @@ export function handleFtSugdel(engine, args) {
|
|
|
15
15
|
return n;
|
|
16
16
|
} catch (e) {
|
|
17
17
|
const msg = e?.message ?? String(e);
|
|
18
|
-
return { error: msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
|
|
18
|
+
return { error: msg === 'Unknown index name' ? msg : msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
|
|
19
19
|
}
|
|
20
20
|
}
|
|
@@ -19,6 +19,6 @@ export function handleFtSugget(engine, args) {
|
|
|
19
19
|
return list;
|
|
20
20
|
} catch (e) {
|
|
21
21
|
const msg = e?.message ?? String(e);
|
|
22
|
-
return { error: msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
|
|
22
|
+
return { error: msg === 'Unknown index name' ? msg : msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
|
|
23
23
|
}
|
|
24
24
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KEYS pattern - return all keys matching the glob pattern.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function escapeRegexChar(ch) {
|
|
6
|
+
return /[\\^$+?.()|{}]/.test(ch) ? '\\' + ch : ch;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function globToRegExp(glob) {
|
|
10
|
+
let out = '^';
|
|
11
|
+
let i = 0;
|
|
12
|
+
while (i < glob.length) {
|
|
13
|
+
const ch = glob[i];
|
|
14
|
+
if (ch === '\\') {
|
|
15
|
+
if (i + 1 < glob.length) {
|
|
16
|
+
out += escapeRegexChar(glob[i + 1]);
|
|
17
|
+
i += 2;
|
|
18
|
+
} else {
|
|
19
|
+
out += '\\\\';
|
|
20
|
+
i += 1;
|
|
21
|
+
}
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (ch === '*') {
|
|
25
|
+
out += '.*';
|
|
26
|
+
i += 1;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (ch === '?') {
|
|
30
|
+
out += '.';
|
|
31
|
+
i += 1;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
out += escapeRegexChar(ch);
|
|
35
|
+
i += 1;
|
|
36
|
+
}
|
|
37
|
+
out += '$';
|
|
38
|
+
return new RegExp(out);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function handleKeys(engine, args) {
|
|
42
|
+
if (!args || args.length !== 1) {
|
|
43
|
+
return { error: 'ERR wrong number of arguments for \'KEYS\' command' };
|
|
44
|
+
}
|
|
45
|
+
const pattern = args[0].toString('utf8');
|
|
46
|
+
const matcher = globToRegExp(pattern);
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
const matches = [];
|
|
49
|
+
|
|
50
|
+
let cursor = '0';
|
|
51
|
+
do {
|
|
52
|
+
const scanned = engine.scan(cursor, { count: 256 });
|
|
53
|
+
const keys = scanned.keys ?? [];
|
|
54
|
+
for (const key of keys) {
|
|
55
|
+
if (!Buffer.isBuffer(key)) continue;
|
|
56
|
+
const hex = key.toString('hex');
|
|
57
|
+
if (seen.has(hex)) continue;
|
|
58
|
+
seen.add(hex);
|
|
59
|
+
if (engine.type(key) === 'none') continue;
|
|
60
|
+
const keyText = key.toString('utf8');
|
|
61
|
+
if (matcher.test(keyText)) matches.push(key);
|
|
62
|
+
}
|
|
63
|
+
cursor = String(scanned.cursor);
|
|
64
|
+
} while (cursor !== '0');
|
|
65
|
+
|
|
66
|
+
return matches;
|
|
67
|
+
}
|
package/src/commands/registry.js
CHANGED
|
@@ -46,6 +46,7 @@ import * as lrem from './lrem.js';
|
|
|
46
46
|
import * as blpop from './blpop.js';
|
|
47
47
|
import * as brpop from './brpop.js';
|
|
48
48
|
import * as scan from './scan.js';
|
|
49
|
+
import * as keys from './keys.js';
|
|
49
50
|
import * as zadd from './zadd.js';
|
|
50
51
|
import * as zrem from './zrem.js';
|
|
51
52
|
import * as zcard from './zcard.js';
|
|
@@ -63,6 +64,7 @@ import * as ftSearch from './ft-search.js';
|
|
|
63
64
|
import * as ftSugadd from './ft-sugadd.js';
|
|
64
65
|
import * as ftSugget from './ft-sugget.js';
|
|
65
66
|
import * as ftSugdel from './ft-sugdel.js';
|
|
67
|
+
import * as monitor from './monitor.js';
|
|
66
68
|
|
|
67
69
|
const HANDLERS = new Map([
|
|
68
70
|
['PING', (e, a) => ping.handlePing()],
|
|
@@ -108,6 +110,7 @@ const HANDLERS = new Map([
|
|
|
108
110
|
['BLPOP', (e, a, ctx) => blpop.handleBlpop(e, a, ctx)],
|
|
109
111
|
['BRPOP', (e, a, ctx) => brpop.handleBrpop(e, a, ctx)],
|
|
110
112
|
['SCAN', (e, a) => scan.handleScan(e, a)],
|
|
113
|
+
['KEYS', (e, a) => keys.handleKeys(e, a)],
|
|
111
114
|
['ZADD', (e, a) => zadd.handleZadd(e, a)],
|
|
112
115
|
['ZREM', (e, a) => zrem.handleZrem(e, a)],
|
|
113
116
|
['ZCARD', (e, a) => zcard.handleZcard(e, a)],
|
|
@@ -125,6 +128,7 @@ const HANDLERS = new Map([
|
|
|
125
128
|
['FT.SUGADD', (e, a) => ftSugadd.handleFtSugadd(e, a)],
|
|
126
129
|
['FT.SUGGET', (e, a) => ftSugget.handleFtSugget(e, a)],
|
|
127
130
|
['FT.SUGDEL', (e, a) => ftSugdel.handleFtSugdel(e, a)],
|
|
131
|
+
['MONITOR', (e, a, ctx) => monitor.handleMonitor(a, ctx)],
|
|
128
132
|
]);
|
|
129
133
|
|
|
130
134
|
/**
|
package/src/server/connection.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { RESPReader } from '../resp/parser.js';
|
|
6
6
|
import { dispatch } from '../commands/registry.js';
|
|
7
7
|
import { encode, encodeSimpleString, encodeError } from '../resp/encoder.js';
|
|
8
|
+
import { registerMonitorClient, unregisterMonitorClient, broadcastMonitorCommand } from './monitor.js';
|
|
8
9
|
|
|
9
10
|
let nextConnectionId = 0;
|
|
10
11
|
|
|
@@ -17,6 +18,8 @@ export function handleConnection(socket, engine) {
|
|
|
17
18
|
const connectionId = ++nextConnectionId;
|
|
18
19
|
const context = {
|
|
19
20
|
connectionId,
|
|
21
|
+
monitorMode: false,
|
|
22
|
+
clientAddress: `${socket.remoteAddress ?? 'unknown'}:${socket.remotePort ?? 0}`,
|
|
20
23
|
writeResponse(buf) {
|
|
21
24
|
if (socket.writable) socket.write(buf);
|
|
22
25
|
},
|
|
@@ -46,7 +49,18 @@ export function handleConnection(socket, engine) {
|
|
|
46
49
|
reader.feed(chunk);
|
|
47
50
|
const commands = reader.parseCommands();
|
|
48
51
|
for (const argv of commands) {
|
|
52
|
+
const cmd = argv[0] ? argv[0].toString('utf8').toUpperCase() : '';
|
|
53
|
+
if (context.monitorMode && cmd !== 'QUIT') {
|
|
54
|
+
socket.write(encodeError('ERR MONITOR mode only supports QUIT'));
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
49
57
|
const out = dispatch(engine, argv, context);
|
|
58
|
+
if (cmd === 'MONITOR' && !out.error) {
|
|
59
|
+
context.monitorMode = true;
|
|
60
|
+
registerMonitorClient(context);
|
|
61
|
+
} else if (!context.monitorMode) {
|
|
62
|
+
broadcastMonitorCommand(argv, context);
|
|
63
|
+
}
|
|
50
64
|
if (out.quit) {
|
|
51
65
|
writeResult(out);
|
|
52
66
|
return;
|
|
@@ -77,6 +91,7 @@ export function handleConnection(socket, engine) {
|
|
|
77
91
|
|
|
78
92
|
socket.on('close', () => {
|
|
79
93
|
if (engine._blockingManager) engine._blockingManager.cancel(connectionId);
|
|
94
|
+
unregisterMonitorClient(connectionId);
|
|
80
95
|
});
|
|
81
96
|
|
|
82
97
|
socket.on('error', () => {});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MONITOR support: track monitor clients and broadcast command traces.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { encodeSimpleString } from '../resp/encoder.js';
|
|
6
|
+
|
|
7
|
+
const monitorClients = new Map();
|
|
8
|
+
|
|
9
|
+
function escapeArg(value) {
|
|
10
|
+
return value
|
|
11
|
+
.replace(/\\/g, '\\\\')
|
|
12
|
+
.replace(/"/g, '\\"')
|
|
13
|
+
.replace(/\r/g, '\\r')
|
|
14
|
+
.replace(/\n/g, '\\n')
|
|
15
|
+
.replace(/\t/g, '\\t');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function formatCommand(argv) {
|
|
19
|
+
const parts = [];
|
|
20
|
+
for (const arg of argv) {
|
|
21
|
+
const text = Buffer.isBuffer(arg) ? arg.toString('utf8') : String(arg);
|
|
22
|
+
parts.push('"' + escapeArg(text) + '"');
|
|
23
|
+
}
|
|
24
|
+
return parts.join(' ');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function timestamp() {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
const sec = Math.floor(now / 1000);
|
|
30
|
+
const micros = String((now % 1000) * 1000).padStart(6, '0');
|
|
31
|
+
return sec + '.' + micros;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sourceLabel(sourceContext) {
|
|
35
|
+
const label = sourceContext && sourceContext.clientAddress ? sourceContext.clientAddress : 'unknown';
|
|
36
|
+
return '[0 ' + label + ']';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function registerMonitorClient(context) {
|
|
40
|
+
if (!context || !context.connectionId) return;
|
|
41
|
+
monitorClients.set(context.connectionId, context);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function unregisterMonitorClient(connectionId) {
|
|
45
|
+
if (!connectionId) return;
|
|
46
|
+
monitorClients.delete(connectionId);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function broadcastMonitorCommand(argv, sourceContext) {
|
|
50
|
+
if (!Array.isArray(argv) || argv.length === 0) return;
|
|
51
|
+
if (monitorClients.size === 0) return;
|
|
52
|
+
const line = timestamp() + ' ' + sourceLabel(sourceContext) + ' ' + formatCommand(argv);
|
|
53
|
+
const payload = encodeSimpleString(line);
|
|
54
|
+
for (const monitor of monitorClients.values()) {
|
|
55
|
+
if (monitor && typeof monitor.writeResponse === 'function') {
|
|
56
|
+
monitor.writeResponse(payload);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -116,7 +116,7 @@ export function getIndexMeta(db, name) {
|
|
|
116
116
|
const row = db.prepare(
|
|
117
117
|
'SELECT name, schema_json, created_at, updated_at FROM search_indices WHERE name = ?'
|
|
118
118
|
).get(name);
|
|
119
|
-
if (!row) throw new Error('
|
|
119
|
+
if (!row) throw new Error('Unknown index name');
|
|
120
120
|
const schema = JSON.parse(row.schema_json);
|
|
121
121
|
return {
|
|
122
122
|
name: row.name,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createTestServer } from '../helpers/server.js';
|
|
4
|
+
import { sendCommand, argv } from '../helpers/client.js';
|
|
5
|
+
import { tryParseValue } from '../../src/resp/parser.js';
|
|
6
|
+
|
|
7
|
+
describe('KEYS integration', () => {
|
|
8
|
+
let s;
|
|
9
|
+
let port;
|
|
10
|
+
|
|
11
|
+
before(async () => {
|
|
12
|
+
s = await createTestServer();
|
|
13
|
+
port = s.port;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
after(async () => {
|
|
17
|
+
await s.closeAsync();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('KEYS * returns matching keys', async () => {
|
|
21
|
+
await sendCommand(port, argv('SET', 'keys:k1', 'v1'));
|
|
22
|
+
await sendCommand(port, argv('SET', 'keys:k2', 'v2'));
|
|
23
|
+
await sendCommand(port, argv('SET', 'other:k3', 'v3'));
|
|
24
|
+
|
|
25
|
+
const reply = await sendCommand(port, argv('KEYS', 'keys:*'));
|
|
26
|
+
const parsed = tryParseValue(reply, 0);
|
|
27
|
+
assert.ok(Array.isArray(parsed.value));
|
|
28
|
+
const values = parsed.value.map((v) => v.toString('utf8')).sort();
|
|
29
|
+
assert.deepEqual(values, ['keys:k1', 'keys:k2']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('KEYS with ? wildcard works', async () => {
|
|
33
|
+
await sendCommand(port, argv('SET', 'q:a1', 'x'));
|
|
34
|
+
await sendCommand(port, argv('SET', 'q:b2', 'x'));
|
|
35
|
+
await sendCommand(port, argv('SET', 'q:bb2', 'x'));
|
|
36
|
+
|
|
37
|
+
const reply = await sendCommand(port, argv('KEYS', 'q:?2'));
|
|
38
|
+
const parsed = tryParseValue(reply, 0);
|
|
39
|
+
const values = parsed.value.map((v) => v.toString('utf8')).sort();
|
|
40
|
+
assert.deepEqual(values, ['q:b2']);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('KEYS wrong number of arguments returns error', async () => {
|
|
44
|
+
const reply = await sendCommand(port, argv('KEYS'));
|
|
45
|
+
const parsed = tryParseValue(reply, 0);
|
|
46
|
+
assert.ok(parsed.value && parsed.value.error);
|
|
47
|
+
assert.ok(parsed.value.error.includes('wrong number of arguments'));
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import { createTestServer } from '../helpers/server.js';
|
|
5
|
+
import { sendCommand, argv } from '../helpers/client.js';
|
|
6
|
+
import { encode } from '../../src/resp/encoder.js';
|
|
7
|
+
import { tryParseValue } from '../../src/resp/parser.js';
|
|
8
|
+
|
|
9
|
+
function createStreamingClient(port) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
let recv = Buffer.alloc(0);
|
|
12
|
+
const queue = [];
|
|
13
|
+
const waiters = [];
|
|
14
|
+
const socket = net.createConnection({ port, host: '127.0.0.1' }, () => {
|
|
15
|
+
resolve({
|
|
16
|
+
send(commandArgv) {
|
|
17
|
+
socket.write(encode(commandArgv));
|
|
18
|
+
},
|
|
19
|
+
async nextValue(timeoutMs = 2000) {
|
|
20
|
+
if (queue.length > 0) return queue.shift();
|
|
21
|
+
return new Promise((res, rej) => {
|
|
22
|
+
const timer = setTimeout(() => {
|
|
23
|
+
const idx = waiters.findIndex((w) => w.resolve === res);
|
|
24
|
+
if (idx >= 0) waiters.splice(idx, 1);
|
|
25
|
+
rej(new Error('timeout waiting for RESP value'));
|
|
26
|
+
}, timeoutMs);
|
|
27
|
+
waiters.push({
|
|
28
|
+
resolve(value) {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
res(value);
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
close() {
|
|
36
|
+
socket.destroy();
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
socket.on('data', (chunk) => {
|
|
42
|
+
recv = Buffer.concat([recv, chunk]);
|
|
43
|
+
for (;;) {
|
|
44
|
+
const parsed = tryParseValue(recv, 0);
|
|
45
|
+
if (parsed === null) break;
|
|
46
|
+
recv = recv.subarray(parsed.end);
|
|
47
|
+
if (waiters.length > 0) {
|
|
48
|
+
waiters.shift().resolve(parsed.value);
|
|
49
|
+
} else {
|
|
50
|
+
queue.push(parsed.value);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
socket.on('error', reject);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('MONITOR integration', () => {
|
|
59
|
+
let s;
|
|
60
|
+
let port;
|
|
61
|
+
|
|
62
|
+
before(async () => {
|
|
63
|
+
s = await createTestServer();
|
|
64
|
+
port = s.port;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
after(async () => {
|
|
68
|
+
await s.closeAsync();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('MONITOR streams commands from other clients', async () => {
|
|
72
|
+
const monitorClient = await createStreamingClient(port);
|
|
73
|
+
try {
|
|
74
|
+
monitorClient.send(argv('MONITOR'));
|
|
75
|
+
const first = await monitorClient.nextValue();
|
|
76
|
+
assert.equal(first, 'OK');
|
|
77
|
+
|
|
78
|
+
await sendCommand(port, argv('SET', 'mk1', 'v1'));
|
|
79
|
+
const event = await monitorClient.nextValue();
|
|
80
|
+
assert.equal(typeof event, 'string');
|
|
81
|
+
assert.match(event, /"SET"\s+"mk1"\s+"v1"/);
|
|
82
|
+
} finally {
|
|
83
|
+
monitorClient.close();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('MONITOR connection only accepts QUIT', async () => {
|
|
88
|
+
const monitorClient = await createStreamingClient(port);
|
|
89
|
+
try {
|
|
90
|
+
monitorClient.send(argv('MONITOR'));
|
|
91
|
+
assert.equal(await monitorClient.nextValue(), 'OK');
|
|
92
|
+
monitorClient.send(argv('PING'));
|
|
93
|
+
const err = await monitorClient.nextValue();
|
|
94
|
+
assert.ok(err && err.error);
|
|
95
|
+
assert.match(err.error, /MONITOR mode only supports QUIT/);
|
|
96
|
+
monitorClient.send(argv('QUIT'));
|
|
97
|
+
assert.equal(await monitorClient.nextValue(), 'OK');
|
|
98
|
+
} finally {
|
|
99
|
+
monitorClient.close();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -117,7 +117,7 @@ describe('Search integration', () => {
|
|
|
117
117
|
const reply = await sendCommand(port, argv('FT.INFO', 'nonexistent'));
|
|
118
118
|
const v = tryParseValue(reply, 0).value;
|
|
119
119
|
const err = v?.error ?? v;
|
|
120
|
-
assert.ok(String(err).includes('
|
|
120
|
+
assert.ok(String(err).includes('Unknown index name'));
|
|
121
121
|
});
|
|
122
122
|
});
|
|
123
123
|
|
package/test/unit/search.test.js
CHANGED
|
@@ -135,6 +135,6 @@ describe('Search layer', () => {
|
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
it('getIndexMeta throws for unknown index', () => {
|
|
138
|
-
assert.throws(() => getIndexMeta(db, 'nonexistent'), /
|
|
138
|
+
assert.throws(() => getIndexMeta(db, 'nonexistent'), /Unknown index name/);
|
|
139
139
|
});
|
|
140
140
|
});
|
package/tasks/todo.md
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
# Migration from Redis (SPEC §26)
|
|
2
|
-
|
|
3
|
-
## Plan
|
|
4
|
-
|
|
5
|
-
- [x] Review SPEC §26 (Migration Strategy) and §10 (Data model)
|
|
6
|
-
- [x] Implement external CLI tool (not part of RESP server)
|
|
7
|
-
- [x] Method: connect to Redis → SCAN → TYPE → fetch by type (GET / HGETALL / SMEMBERS) → PTTL → write to SQLite
|
|
8
|
-
- [x] Migratable subset: strings, hashes, sets, TTL metadata
|
|
9
|
-
- [x] Skip unsupported types (list, zset, stream, etc.) with report
|
|
10
|
-
- [x] Contract test using local Redis (skip if Redis unavailable)
|
|
11
|
-
|
|
12
|
-
## Implementation
|
|
13
|
-
|
|
14
|
-
- **CLI**: `src/cli/import-from-redis.js` — `node src/cli/import-from-redis.js --redis-url redis://127.0.0.1:6379 --db ./migrated.db`
|
|
15
|
-
- **Storage**: Reuse existing `openDb`, `createKeysStorage`, `createStringsStorage`, `createHashesStorage`, `createSetsStorage`; write via storage layer with Buffer values
|
|
16
|
-
- **Binary safety**: Redis client returns strings; coerce to Buffer (utf8) when writing to SQLite
|
|
17
|
-
|
|
18
|
-
## Verification
|
|
19
|
-
|
|
20
|
-
- Run contract test: `npm run test:contract` (includes import-from-redis test when Redis is available)
|
|
21
|
-
- Manual: populate local Redis, run CLI, start RESPlite with migrated db, verify keys/values/TTL
|
|
22
|
-
|
|
23
|
-
## Not in scope (SPEC §26.3)
|
|
24
|
-
|
|
25
|
-
- RDB parsing, AOF parsing, mirror mode, dual-write
|
|
26
|
-
|
|
27
|
-
---
|
|
28
|
-
|
|
29
|
-
# Migration with Dirty Key Registry (SPEC_F)
|
|
30
|
-
|
|
31
|
-
## Done
|
|
32
|
-
|
|
33
|
-
- [x] Migration schema: `migration_runs`, `migration_dirty_keys`, `migration_errors` in `src/storage/sqlite/migration-schema.js`
|
|
34
|
-
- [x] Registry layer: `src/migration/registry.js` (createRun, getRun, updateBulkProgress, upsertDirtyKey, getDirtyBatch, markDirtyState, logError, getDirtyCounts)
|
|
35
|
-
- [x] Bulk importer: `src/migration/bulk.js` with run_id, checkpointing, resume, max_rps, batch_keys/batch_bytes, pause/abort via status
|
|
36
|
-
- [x] Shared import-one: `src/migration/import-one.js` (fetch key from Redis + write to storages; used by bulk and apply-dirty)
|
|
37
|
-
- [x] Delta apply: `src/migration/apply-dirty.js` (apply dirty keys from registry: reimport or delete in destination)
|
|
38
|
-
- [x] Preflight: `src/migration/preflight.js` (key count, type distribution, notify-keyspace-events check, recommendations)
|
|
39
|
-
- [x] Verify: `src/migration/verify.js` (sample keys, compare Redis vs RespLite)
|
|
40
|
-
- [x] CLI `resplite-import`: `src/cli/resplite-import.js` (preflight, bulk, status, apply-dirty, verify)
|
|
41
|
-
- [x] CLI `resplite-dirty-tracker`: `src/cli/resplite-dirty-tracker.js` (start = PSUBSCRIBE keyevent, stop = update run status)
|
|
42
|
-
- [x] package.json `bin`: resplite-import, resplite-dirty-tracker
|
|
43
|
-
- [x] Unit tests: `test/unit/migration-registry.test.js`
|
|
44
|
-
- [x] README: minimal-downtime migration flow and commands
|