resplite 1.1.12 → 1.2.2
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 +237 -203
- package/package.json +1 -1
- package/src/cli/resplite-import.js +4 -2
- package/src/commands/registry.js +24 -3
- package/src/embed.js +23 -1
- package/src/index.js +58 -20
- package/src/migration/bulk.js +2 -2
- package/src/migration/index.js +3 -3
- package/src/server/connection.js +10 -3
- package/src/server/tcp-server.js +6 -1
- package/test/integration/embed.test.js +65 -0
package/README.md
CHANGED
|
@@ -87,7 +87,7 @@ OK
|
|
|
87
87
|
|
|
88
88
|
### Standalone server script (fixed port)
|
|
89
89
|
|
|
90
|
-
Run this as a persistent background process (`node server.js`). RESPLite will listen on port 6380 and stay up until the process
|
|
90
|
+
Run this as a persistent background process (`node server.js`). RESPLite will listen on port 6380 and stay up until the process receives SIGINT (Ctrl+C) or SIGTERM; then it closes the server and exits cleanly. If you kill the process (e.g. SIGKILL or force quit), all client connections are closed as well — with the default configuration the server runs in the same process, so when the process exits the TCP server and its connections are torn down.
|
|
91
91
|
|
|
92
92
|
```javascript
|
|
93
93
|
// server.js
|
|
@@ -95,6 +95,7 @@ import { createRESPlite } from 'resplite/embed';
|
|
|
95
95
|
|
|
96
96
|
const srv = await createRESPlite({ port: 6380, db: './data.db' });
|
|
97
97
|
console.log(`RESPLite listening on ${srv.host}:${srv.port}`);
|
|
98
|
+
|
|
98
99
|
```
|
|
99
100
|
|
|
100
101
|
Then connect from any other script or process:
|
|
@@ -142,211 +143,40 @@ await client.quit();
|
|
|
142
143
|
await srv.close();
|
|
143
144
|
```
|
|
144
145
|
|
|
145
|
-
###
|
|
146
|
-
|
|
147
|
-
```javascript
|
|
148
|
-
// SET with expiration
|
|
149
|
-
await client.set('session:abc', JSON.stringify({ user: 'alice' }));
|
|
150
|
-
await client.expire('session:abc', 3600); // expire in 1 hour
|
|
151
|
-
console.log(await client.ttl('session:abc')); // → 3600 (approx)
|
|
152
|
-
|
|
153
|
-
// Atomic counters
|
|
154
|
-
await client.set('visits', '0');
|
|
155
|
-
await client.incr('visits');
|
|
156
|
-
await client.incrBy('visits', 10);
|
|
157
|
-
console.log(await client.get('visits')); // → "11"
|
|
158
|
-
|
|
159
|
-
// Multi-key operations
|
|
160
|
-
await client.mSet(['k1', 'v1', 'k2', 'v2']);
|
|
161
|
-
const values = await client.mGet(['k1', 'k2', 'missing']);
|
|
162
|
-
console.log(values); // → ["v1", "v2", null]
|
|
146
|
+
### Observability (event hooks)
|
|
163
147
|
|
|
164
|
-
|
|
165
|
-
console.log(await client.exists('k1')); // → 1
|
|
166
|
-
await client.del('k1');
|
|
167
|
-
console.log(await client.exists('k1')); // → 0
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
### Hashes
|
|
148
|
+
When embedding RESPLite you can pass optional hooks to log unknown commands, command errors, or socket errors (e.g. for `warn`/`error` in your logger). The client still receives the same RESP responses; hooks are for observability only.
|
|
171
149
|
|
|
172
150
|
```javascript
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
console.log(await client.sIsMember('tags', 'node')); // → true
|
|
192
|
-
console.log(await client.sCard('tags')); // → 3
|
|
193
|
-
|
|
194
|
-
await client.sRem('tags', 'redis');
|
|
195
|
-
console.log(await client.sCard('tags')); // → 2
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
### Lists
|
|
199
|
-
|
|
200
|
-
```javascript
|
|
201
|
-
await client.lPush('queue', ['c', 'b', 'a']); // push left: a, b, c
|
|
202
|
-
await client.rPush('queue', ['d', 'e']); // push right: d, e
|
|
203
|
-
|
|
204
|
-
console.log(await client.lLen('queue')); // → 5
|
|
205
|
-
console.log(await client.lRange('queue', 0, -1)); // → ["a", "b", "c", "d", "e"]
|
|
206
|
-
console.log(await client.lIndex('queue', 0)); // → "a"
|
|
207
|
-
|
|
208
|
-
console.log(await client.lPop('queue')); // → "a"
|
|
209
|
-
console.log(await client.rPop('queue')); // → "e"
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
### Blocking list commands (BLPOP / BRPOP)
|
|
213
|
-
|
|
214
|
-
`BLPOP` and `BRPOP` block until an element is available or a timeout (seconds) is reached. Use them for simple queues or coordination between producers and consumers.
|
|
215
|
-
|
|
216
|
-
```javascript
|
|
217
|
-
// Consumer: block up to 10 seconds for an element from "tasks" or "fallback"
|
|
218
|
-
const result = await client.blPop(['tasks', 'fallback'], 10);
|
|
219
|
-
// result is { key: 'tasks', element: 'item1' } or null on timeout
|
|
220
|
-
|
|
221
|
-
// Producer (e.g. another client or process)
|
|
222
|
-
await client.rPush('tasks', 'item1');
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
- **Timeout**: `0` = block indefinitely; `> 0` = block up to that many seconds.
|
|
226
|
-
- **Return**: `{ key, element }` on success, or `null` on timeout.
|
|
227
|
-
- **Multi-key**: Keys are checked in order; the first key that has an element wins. One push wakes at most one blocked client (FIFO per key).
|
|
228
|
-
|
|
229
|
-
### Sorted sets
|
|
230
|
-
|
|
231
|
-
```javascript
|
|
232
|
-
await client.zAdd('leaderboard', [
|
|
233
|
-
{ score: 100, value: 'alice' },
|
|
234
|
-
{ score: 250, value: 'bob' },
|
|
235
|
-
{ score: 175, value: 'carol' },
|
|
236
|
-
]);
|
|
237
|
-
|
|
238
|
-
console.log(await client.zCard('leaderboard')); // → 3
|
|
239
|
-
console.log(await client.zScore('leaderboard', 'bob')); // → 250
|
|
240
|
-
console.log(await client.zRange('leaderboard', 0, -1)); // → ["alice", "carol", "bob"]
|
|
241
|
-
console.log(await client.zRangeByScore('leaderboard', 100, 200)); // → ["alice", "carol"]
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
### Full-text search (RediSearch-like)
|
|
245
|
-
|
|
246
|
-
```javascript
|
|
247
|
-
// Create an index
|
|
248
|
-
await client.sendCommand(['FT.CREATE', 'articles', 'SCHEMA', 'payload', 'TEXT']);
|
|
249
|
-
|
|
250
|
-
// Add documents
|
|
251
|
-
await client.sendCommand([
|
|
252
|
-
'FT.ADD', 'articles', 'doc:1', '1', 'REPLACE', 'FIELDS',
|
|
253
|
-
'payload', 'Introduction to SQLite full-text search'
|
|
254
|
-
]);
|
|
255
|
-
await client.sendCommand([
|
|
256
|
-
'FT.ADD', 'articles', 'doc:2', '1', 'REPLACE', 'FIELDS',
|
|
257
|
-
'payload', 'Building a Redis-compatible server in Node.js'
|
|
258
|
-
]);
|
|
259
|
-
|
|
260
|
-
// Search
|
|
261
|
-
const results = await client.sendCommand([
|
|
262
|
-
'FT.SEARCH', 'articles', 'SQLite', 'NOCONTENT', 'LIMIT', '0', '10'
|
|
263
|
-
]);
|
|
264
|
-
console.log(results); // → [1, "doc:1"] (count + matching doc IDs)
|
|
265
|
-
|
|
266
|
-
// Autocomplete suggestions
|
|
267
|
-
await client.sendCommand(['FT.SUGADD', 'articles', 'sqlite full-text', '10']);
|
|
268
|
-
await client.sendCommand(['FT.SUGADD', 'articles', 'sqlite indexing', '5']);
|
|
269
|
-
const suggestions = await client.sendCommand(['FT.SUGGET', 'articles', 'sqlite']);
|
|
270
|
-
console.log(suggestions); // → ["sqlite full-text", "sqlite indexing"]
|
|
271
|
-
```
|
|
272
|
-
|
|
273
|
-
### Introspection and admin
|
|
274
|
-
|
|
275
|
-
```javascript
|
|
276
|
-
// Scan keys (cursor-based)
|
|
277
|
-
const scanResult = await client.scan(0);
|
|
278
|
-
console.log(scanResult); // → { cursor: 0, keys: [...] }
|
|
279
|
-
|
|
280
|
-
// Key type
|
|
281
|
-
console.log(await client.type('user:1')); // → "hash"
|
|
282
|
-
|
|
283
|
-
// Admin commands (via sendCommand)
|
|
284
|
-
const sqliteInfo = await client.sendCommand(['SQLITE.INFO']);
|
|
285
|
-
const cacheInfo = await client.sendCommand(['CACHE.INFO']);
|
|
286
|
-
const memInfo = await client.sendCommand(['MEMORY.INFO']);
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
### Data persists across restarts
|
|
290
|
-
|
|
291
|
-
```javascript
|
|
292
|
-
import { createClient } from 'redis';
|
|
293
|
-
import { createRESPlite } from 'resplite/embed';
|
|
294
|
-
|
|
295
|
-
const DB_PATH = './persistent.db';
|
|
296
|
-
|
|
297
|
-
// --- First session: write data ---
|
|
298
|
-
const srv1 = await createRESPlite({ db: DB_PATH });
|
|
299
|
-
const c1 = createClient({ socket: { port: srv1.port, host: '127.0.0.1' } });
|
|
300
|
-
await c1.connect();
|
|
301
|
-
await c1.set('persistent_key', 'survives restart');
|
|
302
|
-
await c1.hSet('user:1', { name: 'Alice' });
|
|
303
|
-
await c1.quit();
|
|
304
|
-
await srv1.close();
|
|
305
|
-
|
|
306
|
-
// --- Second session: data is still there ---
|
|
307
|
-
const srv2 = await createRESPlite({ db: DB_PATH });
|
|
308
|
-
const c2 = createClient({ socket: { port: srv2.port, host: '127.0.0.1' } });
|
|
309
|
-
await c2.connect();
|
|
310
|
-
console.log(await c2.get('persistent_key')); // → "survives restart"
|
|
311
|
-
console.log(await c2.hGet('user:1', 'name')); // → "Alice"
|
|
312
|
-
await c2.quit();
|
|
313
|
-
await srv2.close();
|
|
151
|
+
import pino from 'pino';
|
|
152
|
+
const log = pino(); // or your logger
|
|
153
|
+
|
|
154
|
+
const srv = await createRESPlite({
|
|
155
|
+
port: 6380,
|
|
156
|
+
db: './data.db',
|
|
157
|
+
hooks: {
|
|
158
|
+
onUnknownCommand({ command, argsCount, clientAddress }) {
|
|
159
|
+
log.warn({ command, argsCount, clientAddress }, 'RESPLite: unsupported command');
|
|
160
|
+
},
|
|
161
|
+
onCommandError({ command, error, clientAddress }) {
|
|
162
|
+
log.warn({ command, error, clientAddress }, 'RESPLite: command error');
|
|
163
|
+
},
|
|
164
|
+
onSocketError({ error, clientAddress }) {
|
|
165
|
+
log.error({ err: error, clientAddress }, 'RESPLite: connection error');
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
});
|
|
314
169
|
```
|
|
315
170
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
| Category | Commands |
|
|
323
|
-
|---|---|
|
|
324
|
-
| **Connection** | PING, ECHO, QUIT |
|
|
325
|
-
| **Strings** | GET, SET, MGET, MSET, DEL, EXISTS, INCR, DECR, INCRBY, DECRBY |
|
|
326
|
-
| **TTL** | EXPIRE, PEXPIRE, TTL, PTTL, PERSIST |
|
|
327
|
-
| **Hashes** | HSET, HGET, HMGET, HGETALL, HDEL, HEXISTS, HINCRBY |
|
|
328
|
-
| **Sets** | SADD, SREM, SMEMBERS, SISMEMBER, SCARD |
|
|
329
|
-
| **Lists** | LPUSH, RPUSH, LLEN, LRANGE, LINDEX, LPOP, RPOP, BLPOP, BRPOP |
|
|
330
|
-
| **Sorted sets** | ZADD, ZREM, ZCARD, ZSCORE, ZRANGE, ZRANGEBYSCORE |
|
|
331
|
-
| **Search (FT.\*)** | FT.CREATE, FT.INFO, FT.ADD, FT.DEL, FT.SEARCH, FT.SUGADD, FT.SUGGET, FT.SUGDEL |
|
|
332
|
-
| **Introspection** | TYPE, SCAN, KEYS, MONITOR |
|
|
333
|
-
| **Admin** | SQLITE.INFO, CACHE.INFO, MEMORY.INFO |
|
|
334
|
-
| **Tooling** | Redis import CLI (see Migration from Redis) |
|
|
335
|
-
|
|
336
|
-
### Not supported (v1)
|
|
337
|
-
|
|
338
|
-
- Pub/Sub (SUBSCRIBE, PUBLISH, etc.)
|
|
339
|
-
- Streams (XADD, XRANGE, etc.)
|
|
340
|
-
- Lua (EVAL, EVALSHA)
|
|
341
|
-
- Transactions (MULTI, EXEC, WATCH)
|
|
342
|
-
- BRPOPLPUSH, BLMOVE (blocking list moves)
|
|
343
|
-
- SELECT (multiple logical DBs)
|
|
344
|
-
|
|
345
|
-
Unsupported commands return: `ERR command not supported yet`.
|
|
171
|
+
| Hook | When it is called |
|
|
172
|
+
|------|--------------------|
|
|
173
|
+
| `onUnknownCommand` | Client sent a command not implemented by RESPLite (e.g. `SUBSCRIBE`, `PUBLISH`). |
|
|
174
|
+
| `onCommandError` | A command failed (wrong type, invalid args, or handler threw). |
|
|
175
|
+
| `onSocketError` | The connection socket emitted an error (e.g. `ECONNRESET`). |
|
|
346
176
|
|
|
347
177
|
## Migration from Redis
|
|
348
178
|
|
|
349
|
-
RESPLite is a good fit for migrating **non-replicated Redis** instances that have **grown large** (e.g. tens of GB) and where RESPLite
|
|
179
|
+
RESPLite is a good fit for migrating **non-replicated Redis** instances that have **grown large** (e.g. tens of GB) and where RESPLite's latency is acceptable. The SPEC_F flow (dirty-key tracker, bulk import, cutover) is designed for that scenario with minimal downtime.
|
|
350
180
|
|
|
351
181
|
Migration supports two modes:
|
|
352
182
|
|
|
@@ -420,10 +250,10 @@ notify-keyspace-events KEA
|
|
|
420
250
|
npx resplite-dirty-tracker start --run-id run_001 --from redis://10.0.0.10:6379 --to ./resplite.db --config-command MYCONFIG
|
|
421
251
|
```
|
|
422
252
|
|
|
423
|
-
3. **Bulk import** – SCAN and copy all keys; progress is checkpointed and resumable:
|
|
253
|
+
3. **Bulk import** – SCAN and copy all keys; progress is checkpointed and resumable (resume is default; re-run the same command to continue after a stop):
|
|
424
254
|
```bash
|
|
425
255
|
npx resplite-import bulk --run-id run_001 --from redis://10.0.0.10:6379 --to ./resplite.db \
|
|
426
|
-
--scan-count 1000 --max-rps 2000 --batch-keys 200 --batch-bytes 64MB
|
|
256
|
+
--scan-count 1000 --max-rps 2000 --batch-keys 200 --batch-bytes 64MB
|
|
427
257
|
```
|
|
428
258
|
|
|
429
259
|
4. **Monitor** – Check run and dirty-key counts:
|
|
@@ -448,7 +278,7 @@ notify-keyspace-events KEA
|
|
|
448
278
|
|
|
449
279
|
Then start RespLite with the migrated DB: `RESPLITE_DB=./resplite.db npm start`.
|
|
450
280
|
|
|
451
|
-
### Programmatic migration API
|
|
281
|
+
### Programmatic migration API (JavaScript)
|
|
452
282
|
|
|
453
283
|
As an alternative to the CLI, the full migration flow is available as a JavaScript API via `resplite/migration`. Useful for embedding the migration inside your own scripts or automation pipelines.
|
|
454
284
|
|
|
@@ -485,9 +315,8 @@ const ks = await m.enableKeyspaceNotifications();
|
|
|
485
315
|
// → { ok: true, previous: '', applied: 'KEA' }
|
|
486
316
|
// If CONFIG is renamed and configCommand was not set, ok=false and error explains how to fix it.
|
|
487
317
|
|
|
488
|
-
// Step 1 — Bulk import (checkpointed, resumable)
|
|
318
|
+
// Step 1 — Bulk import (checkpointed, resumable). Same script to start or continue.
|
|
489
319
|
await m.bulk({
|
|
490
|
-
resume: false, // true to resume a previous run
|
|
491
320
|
onProgress: (r) => console.log(
|
|
492
321
|
`scanned=${r.scanned_keys} migrated=${r.migrated_keys} errors=${r.error_keys}`
|
|
493
322
|
),
|
|
@@ -508,6 +337,9 @@ console.log(`verified ${result.sampled} keys — mismatches: ${result.mismatches
|
|
|
508
337
|
await m.close();
|
|
509
338
|
```
|
|
510
339
|
|
|
340
|
+
**Resume automático (por defecto)**
|
|
341
|
+
`resume` viene en `true` por defecto. Da igual si es la primera vez o una reanudación: el mismo script sirve para empezar y para continuar. La primera ejecución arranca desde cursor 0; si el proceso se corta (Ctrl+C, crash, etc.), al volver a ejecutar el script continúa desde el último checkpoint. No hace falta pasar `resume: false` la primera vez ni cambiar nada para reanudar.
|
|
342
|
+
|
|
511
343
|
The dirty-key tracker (to capture writes during bulk) still runs as a separate process via `npx resplite-dirty-tracker`. The API above handles everything else in a single script.
|
|
512
344
|
|
|
513
345
|
#### Renamed CONFIG command
|
|
@@ -548,6 +380,208 @@ import {
|
|
|
548
380
|
} from 'resplite/migration';
|
|
549
381
|
```
|
|
550
382
|
|
|
383
|
+
### Strings, TTL, and key operations
|
|
384
|
+
|
|
385
|
+
```javascript
|
|
386
|
+
// SET with expiration
|
|
387
|
+
await client.set('session:abc', JSON.stringify({ user: 'alice' }));
|
|
388
|
+
await client.expire('session:abc', 3600); // expire in 1 hour
|
|
389
|
+
console.log(await client.ttl('session:abc')); // → 3600 (approx)
|
|
390
|
+
|
|
391
|
+
// Atomic counters
|
|
392
|
+
await client.set('visits', '0');
|
|
393
|
+
await client.incr('visits');
|
|
394
|
+
await client.incrBy('visits', 10);
|
|
395
|
+
console.log(await client.get('visits')); // → "11"
|
|
396
|
+
|
|
397
|
+
// Multi-key operations
|
|
398
|
+
await client.mSet(['k1', 'v1', 'k2', 'v2']);
|
|
399
|
+
const values = await client.mGet(['k1', 'k2', 'missing']);
|
|
400
|
+
console.log(values); // → ["v1", "v2", null]
|
|
401
|
+
|
|
402
|
+
// Key existence and deletion
|
|
403
|
+
console.log(await client.exists('k1')); // → 1
|
|
404
|
+
await client.del('k1');
|
|
405
|
+
console.log(await client.exists('k1')); // → 0
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
### Hashes
|
|
409
|
+
|
|
410
|
+
```javascript
|
|
411
|
+
await client.hSet('user:1', { name: 'Martin', age: '42', city: 'BCN' });
|
|
412
|
+
|
|
413
|
+
console.log(await client.hGet('user:1', 'name')); // → "Martin"
|
|
414
|
+
|
|
415
|
+
const user = await client.hGetAll('user:1');
|
|
416
|
+
console.log(user); // → { name: "Martin", age: "42", city: "BCN" }
|
|
417
|
+
|
|
418
|
+
await client.hIncrBy('user:1', 'age', 1);
|
|
419
|
+
console.log(await client.hGet('user:1', 'age')); // → "43"
|
|
420
|
+
|
|
421
|
+
console.log(await client.hExists('user:1', 'email')); // → false
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
### Sets
|
|
425
|
+
|
|
426
|
+
```javascript
|
|
427
|
+
await client.sAdd('tags', ['node', 'sqlite', 'redis']);
|
|
428
|
+
console.log(await client.sMembers('tags')); // → ["node", "sqlite", "redis"]
|
|
429
|
+
console.log(await client.sIsMember('tags', 'node')); // → true
|
|
430
|
+
console.log(await client.sCard('tags')); // → 3
|
|
431
|
+
|
|
432
|
+
await client.sRem('tags', 'redis');
|
|
433
|
+
console.log(await client.sCard('tags')); // → 2
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
### Lists
|
|
437
|
+
|
|
438
|
+
```javascript
|
|
439
|
+
await client.lPush('queue', ['c', 'b', 'a']); // push left: a, b, c
|
|
440
|
+
await client.rPush('queue', ['d', 'e']); // push right: d, e
|
|
441
|
+
|
|
442
|
+
console.log(await client.lLen('queue')); // → 5
|
|
443
|
+
console.log(await client.lRange('queue', 0, -1)); // → ["a", "b", "c", "d", "e"]
|
|
444
|
+
console.log(await client.lIndex('queue', 0)); // → "a"
|
|
445
|
+
|
|
446
|
+
console.log(await client.lPop('queue')); // → "a"
|
|
447
|
+
console.log(await client.rPop('queue')); // → "e"
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
### Blocking list commands (BLPOP / BRPOP)
|
|
451
|
+
|
|
452
|
+
`BLPOP` and `BRPOP` block until an element is available or a timeout (seconds) is reached. Use them for simple queues or coordination between producers and consumers.
|
|
453
|
+
|
|
454
|
+
```javascript
|
|
455
|
+
// Consumer: block up to 10 seconds for an element from "tasks" or "fallback"
|
|
456
|
+
const result = await client.blPop(['tasks', 'fallback'], 10);
|
|
457
|
+
// result is { key: 'tasks', element: 'item1' } or null on timeout
|
|
458
|
+
|
|
459
|
+
// Producer (e.g. another client or process)
|
|
460
|
+
await client.rPush('tasks', 'item1');
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
- **Timeout**: `0` = block indefinitely; `> 0` = block up to that many seconds.
|
|
464
|
+
- **Return**: `{ key, element }` on success, or `null` on timeout.
|
|
465
|
+
- **Multi-key**: Keys are checked in order; the first key that has an element wins. One push wakes at most one blocked client (FIFO per key).
|
|
466
|
+
|
|
467
|
+
### Sorted sets
|
|
468
|
+
|
|
469
|
+
```javascript
|
|
470
|
+
await client.zAdd('leaderboard', [
|
|
471
|
+
{ score: 100, value: 'alice' },
|
|
472
|
+
{ score: 250, value: 'bob' },
|
|
473
|
+
{ score: 175, value: 'carol' },
|
|
474
|
+
]);
|
|
475
|
+
|
|
476
|
+
console.log(await client.zCard('leaderboard')); // → 3
|
|
477
|
+
console.log(await client.zScore('leaderboard', 'bob')); // → 250
|
|
478
|
+
console.log(await client.zRange('leaderboard', 0, -1)); // → ["alice", "carol", "bob"]
|
|
479
|
+
console.log(await client.zRangeByScore('leaderboard', 100, 200)); // → ["alice", "carol"]
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
### Full-text search (RediSearch-like)
|
|
483
|
+
|
|
484
|
+
```javascript
|
|
485
|
+
// Create an index
|
|
486
|
+
await client.sendCommand(['FT.CREATE', 'articles', 'SCHEMA', 'payload', 'TEXT']);
|
|
487
|
+
|
|
488
|
+
// Add documents
|
|
489
|
+
await client.sendCommand([
|
|
490
|
+
'FT.ADD', 'articles', 'doc:1', '1', 'REPLACE', 'FIELDS',
|
|
491
|
+
'payload', 'Introduction to SQLite full-text search'
|
|
492
|
+
]);
|
|
493
|
+
await client.sendCommand([
|
|
494
|
+
'FT.ADD', 'articles', 'doc:2', '1', 'REPLACE', 'FIELDS',
|
|
495
|
+
'payload', 'Building a Redis-compatible server in Node.js'
|
|
496
|
+
]);
|
|
497
|
+
|
|
498
|
+
// Search
|
|
499
|
+
const results = await client.sendCommand([
|
|
500
|
+
'FT.SEARCH', 'articles', 'SQLite', 'NOCONTENT', 'LIMIT', '0', '10'
|
|
501
|
+
]);
|
|
502
|
+
console.log(results); // → [1, "doc:1"] (count + matching doc IDs)
|
|
503
|
+
|
|
504
|
+
// Autocomplete suggestions
|
|
505
|
+
await client.sendCommand(['FT.SUGADD', 'articles', 'sqlite full-text', '10']);
|
|
506
|
+
await client.sendCommand(['FT.SUGADD', 'articles', 'sqlite indexing', '5']);
|
|
507
|
+
const suggestions = await client.sendCommand(['FT.SUGGET', 'articles', 'sqlite']);
|
|
508
|
+
console.log(suggestions); // → ["sqlite full-text", "sqlite indexing"]
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
### Introspection and admin
|
|
512
|
+
|
|
513
|
+
```javascript
|
|
514
|
+
// Scan keys (cursor-based)
|
|
515
|
+
const scanResult = await client.scan(0);
|
|
516
|
+
console.log(scanResult); // → { cursor: 0, keys: [...] }
|
|
517
|
+
|
|
518
|
+
// Key type
|
|
519
|
+
console.log(await client.type('user:1')); // → "hash"
|
|
520
|
+
|
|
521
|
+
// Admin commands (via sendCommand)
|
|
522
|
+
const sqliteInfo = await client.sendCommand(['SQLITE.INFO']);
|
|
523
|
+
const cacheInfo = await client.sendCommand(['CACHE.INFO']);
|
|
524
|
+
const memInfo = await client.sendCommand(['MEMORY.INFO']);
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
### Data persists across restarts
|
|
528
|
+
|
|
529
|
+
```javascript
|
|
530
|
+
import { createClient } from 'redis';
|
|
531
|
+
import { createRESPlite } from 'resplite/embed';
|
|
532
|
+
|
|
533
|
+
const DB_PATH = './persistent.db';
|
|
534
|
+
|
|
535
|
+
// --- First session: write data ---
|
|
536
|
+
const srv1 = await createRESPlite({ db: DB_PATH });
|
|
537
|
+
const c1 = createClient({ socket: { port: srv1.port, host: '127.0.0.1' } });
|
|
538
|
+
await c1.connect();
|
|
539
|
+
await c1.set('persistent_key', 'survives restart');
|
|
540
|
+
await c1.hSet('user:1', { name: 'Alice' });
|
|
541
|
+
await c1.quit();
|
|
542
|
+
await srv1.close();
|
|
543
|
+
|
|
544
|
+
// --- Second session: data is still there ---
|
|
545
|
+
const srv2 = await createRESPlite({ db: DB_PATH });
|
|
546
|
+
const c2 = createClient({ socket: { port: srv2.port, host: '127.0.0.1' } });
|
|
547
|
+
await c2.connect();
|
|
548
|
+
console.log(await c2.get('persistent_key')); // → "survives restart"
|
|
549
|
+
console.log(await c2.hGet('user:1', 'name')); // → "Alice"
|
|
550
|
+
await c2.quit();
|
|
551
|
+
await srv2.close();
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
## Compatibility matrix
|
|
555
|
+
|
|
556
|
+
RESPLite implements **47 core Redis commands** (~19% of the ~246 commands in Redis 7). The uncovered 81% is mostly entire subsystems that are out of scope by design: pub/sub (~10 commands), Streams (~20), cluster/replication (~30), Lua scripting (~5), server admin (~40), and extended variants of data-structure commands. For typical single-node application workloads — strings, hashes, sets, lists, sorted sets, key TTLs — coverage is close to the commands developers reach for daily.
|
|
557
|
+
|
|
558
|
+
### Supported (v1)
|
|
559
|
+
|
|
560
|
+
| Category | Commands |
|
|
561
|
+
|---|---|
|
|
562
|
+
| **Connection** | PING, ECHO, QUIT |
|
|
563
|
+
| **Strings** | GET, SET, MGET, MSET, DEL, EXISTS, INCR, DECR, INCRBY, DECRBY |
|
|
564
|
+
| **TTL** | EXPIRE, PEXPIRE, TTL, PTTL, PERSIST |
|
|
565
|
+
| **Hashes** | HSET, HGET, HMGET, HGETALL, HDEL, HEXISTS, HINCRBY |
|
|
566
|
+
| **Sets** | SADD, SREM, SMEMBERS, SISMEMBER, SCARD |
|
|
567
|
+
| **Lists** | LPUSH, RPUSH, LLEN, LRANGE, LINDEX, LPOP, RPOP, BLPOP, BRPOP |
|
|
568
|
+
| **Sorted sets** | ZADD, ZREM, ZCARD, ZSCORE, ZRANGE, ZRANGEBYSCORE |
|
|
569
|
+
| **Search (FT.\*)** | FT.CREATE, FT.INFO, FT.ADD, FT.DEL, FT.SEARCH, FT.SUGADD, FT.SUGGET, FT.SUGDEL |
|
|
570
|
+
| **Introspection** | TYPE, SCAN, KEYS, MONITOR |
|
|
571
|
+
| **Admin** | SQLITE.INFO, CACHE.INFO, MEMORY.INFO |
|
|
572
|
+
| **Tooling** | Redis import CLI (see Migration from Redis) |
|
|
573
|
+
|
|
574
|
+
### Not supported (v1)
|
|
575
|
+
|
|
576
|
+
- Pub/Sub (SUBSCRIBE, PUBLISH, etc.)
|
|
577
|
+
- Streams (XADD, XRANGE, etc.)
|
|
578
|
+
- Lua (EVAL, EVALSHA)
|
|
579
|
+
- Transactions (MULTI, EXEC, WATCH)
|
|
580
|
+
- BRPOPLPUSH, BLMOVE (blocking list moves)
|
|
581
|
+
- SELECT (multiple logical DBs)
|
|
582
|
+
|
|
583
|
+
Unsupported commands return: `ERR command not supported yet`.
|
|
584
|
+
|
|
551
585
|
## Scripts
|
|
552
586
|
|
|
553
587
|
| Script | Description |
|
package/package.json
CHANGED
|
@@ -47,6 +47,8 @@ function parseArgs(argv = process.argv.slice(2)) {
|
|
|
47
47
|
}
|
|
48
48
|
} else if (arg === '--resume') {
|
|
49
49
|
args.resume = true;
|
|
50
|
+
} else if (arg === '--no-resume') {
|
|
51
|
+
args.resume = false;
|
|
50
52
|
} else if (arg === '--pragma-template' && argv[i + 1]) {
|
|
51
53
|
args.pragmaTemplate = argv[++i];
|
|
52
54
|
} else if (arg === '--sample' && argv[i + 1]) {
|
|
@@ -120,7 +122,7 @@ async function cmdBulk(args) {
|
|
|
120
122
|
max_rps: args.maxRps || 0,
|
|
121
123
|
batch_keys: args.batchKeys || 200,
|
|
122
124
|
batch_bytes: args.batchBytes || 64 * 1024 * 1024,
|
|
123
|
-
resume:
|
|
125
|
+
resume: args.resume !== false, // default true: start from 0 or continue from checkpoint
|
|
124
126
|
onProgress: (r) => {
|
|
125
127
|
console.log(` scanned=${r.scanned_keys} migrated=${r.migrated_keys} skipped=${r.skipped_keys} errors=${r.error_keys} cursor=${r.scan_cursor}`);
|
|
126
128
|
},
|
|
@@ -210,7 +212,7 @@ async function main() {
|
|
|
210
212
|
console.error(' --max-rps N (bulk, apply-dirty)');
|
|
211
213
|
console.error(' --batch-keys N (default 200)');
|
|
212
214
|
console.error(' --batch-bytes N[MB|KB|GB] (default 64MB)');
|
|
213
|
-
console.error(' --resume
|
|
215
|
+
console.error(' --resume / --no-resume (bulk: default resume=on; use --no-resume to always start from 0)');
|
|
214
216
|
console.error(' --sample 0.5% (verify, default 0.5%)');
|
|
215
217
|
process.exit(1);
|
|
216
218
|
}
|
package/src/commands/registry.js
CHANGED
|
@@ -135,7 +135,7 @@ const HANDLERS = new Map([
|
|
|
135
135
|
* Dispatch command. Full argv: [commandNameBuf, ...argBuffers].
|
|
136
136
|
* @param {object} engine
|
|
137
137
|
* @param {Buffer[]} argv - first element is command name, rest are arguments
|
|
138
|
-
* @param {object} [context] - optional connection context (connectionId, writeResponse
|
|
138
|
+
* @param {object} [context] - optional connection context (connectionId, clientAddress, writeResponse, onUnknownCommand, onCommandError)
|
|
139
139
|
* @returns {{ result: unknown } | { error: string } | { quit: true } | { block: object }}
|
|
140
140
|
*/
|
|
141
141
|
export function dispatch(engine, argv, context) {
|
|
@@ -146,17 +146,38 @@ export function dispatch(engine, argv, context) {
|
|
|
146
146
|
const args = argv.slice(1);
|
|
147
147
|
const handler = HANDLERS.get(cmd);
|
|
148
148
|
if (!handler) {
|
|
149
|
+
context?.onUnknownCommand?.({
|
|
150
|
+
command: cmd,
|
|
151
|
+
argsCount: args.length,
|
|
152
|
+
clientAddress: context.clientAddress ?? '',
|
|
153
|
+
connectionId: context.connectionId ?? 0,
|
|
154
|
+
});
|
|
149
155
|
return { error: unsupported() };
|
|
150
156
|
}
|
|
151
157
|
try {
|
|
152
158
|
const result = handler(engine, args, context);
|
|
153
159
|
if (result && result.quit) return result;
|
|
154
|
-
if (result && result.error)
|
|
160
|
+
if (result && result.error) {
|
|
161
|
+
context?.onCommandError?.({
|
|
162
|
+
command: cmd,
|
|
163
|
+
error: result.error,
|
|
164
|
+
clientAddress: context.clientAddress ?? '',
|
|
165
|
+
connectionId: context.connectionId ?? 0,
|
|
166
|
+
});
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
155
169
|
if (result && result.block) return result;
|
|
156
170
|
return { result };
|
|
157
171
|
} catch (err) {
|
|
158
172
|
const msg = err && err.message ? err.message : String(err);
|
|
159
|
-
|
|
173
|
+
const errorMsg = msg.startsWith('ERR ') ? msg : 'ERR ' + msg;
|
|
174
|
+
context?.onCommandError?.({
|
|
175
|
+
command: cmd,
|
|
176
|
+
error: errorMsg,
|
|
177
|
+
clientAddress: context.clientAddress ?? '',
|
|
178
|
+
connectionId: context.connectionId ?? 0,
|
|
179
|
+
});
|
|
180
|
+
return { error: errorMsg };
|
|
160
181
|
}
|
|
161
182
|
}
|
|
162
183
|
|
package/src/embed.js
CHANGED
|
@@ -16,6 +16,16 @@ import { openDb } from './storage/sqlite/db.js';
|
|
|
16
16
|
|
|
17
17
|
export { handleConnection, createEngine, openDb };
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Optional event hooks for observability (e.g. logging unknown commands or errors).
|
|
21
|
+
* All hooks are optional. Called with plain objects; do not mutate.
|
|
22
|
+
*
|
|
23
|
+
* @typedef {object} RESPliteHooks
|
|
24
|
+
* @property {(payload: { command: string, argsCount: number, clientAddress: string, connectionId: number }) => void} [onUnknownCommand] Invoked when the client sends a command not implemented by RESPLite.
|
|
25
|
+
* @property {(payload: { command: string, error: string, clientAddress: string, connectionId: number }) => void} [onCommandError] Invoked when a command handler throws or returns an error (e.g. WRONGTYPE, invalid args).
|
|
26
|
+
* @property {(payload: { error: Error, clientAddress: string, connectionId: number }) => void} [onSocketError] Invoked when a connection socket emits an error (e.g. ECONNRESET).
|
|
27
|
+
*/
|
|
28
|
+
|
|
19
29
|
/**
|
|
20
30
|
* Start an embedded RESPLite server.
|
|
21
31
|
*
|
|
@@ -24,6 +34,8 @@ export { handleConnection, createEngine, openDb };
|
|
|
24
34
|
* @param {string} [options.host='127.0.0.1'] Host to listen on.
|
|
25
35
|
* @param {number} [options.port=0] Port to listen on (0 = OS-assigned).
|
|
26
36
|
* @param {string} [options.pragmaTemplate='default'] PRAGMA preset (default|performance|safety|minimal|none).
|
|
37
|
+
* @param {RESPliteHooks} [options.hooks] Optional event hooks for observability (onUnknownCommand, onCommandError, onSocketError).
|
|
38
|
+
* @param {boolean} [options.gracefulShutdown=true] If true, register SIGTERM/SIGINT to call close(). Set false if you handle shutdown yourself to avoid double handlers.
|
|
27
39
|
* @returns {Promise<{ port: number, host: string, close: () => Promise<void> }>}
|
|
28
40
|
*/
|
|
29
41
|
export async function createRESPlite({
|
|
@@ -31,6 +43,8 @@ export async function createRESPlite({
|
|
|
31
43
|
host = '127.0.0.1',
|
|
32
44
|
port = 0,
|
|
33
45
|
pragmaTemplate = 'default',
|
|
46
|
+
hooks = {},
|
|
47
|
+
gracefulShutdown = true,
|
|
34
48
|
} = {}) {
|
|
35
49
|
const db = openDb(dbPath, { pragmaTemplate });
|
|
36
50
|
const engine = createEngine({ db });
|
|
@@ -39,7 +53,7 @@ export async function createRESPlite({
|
|
|
39
53
|
const server = net.createServer((socket) => {
|
|
40
54
|
connections.add(socket);
|
|
41
55
|
socket.once('close', () => connections.delete(socket));
|
|
42
|
-
handleConnection(socket, engine);
|
|
56
|
+
handleConnection(socket, engine, hooks);
|
|
43
57
|
});
|
|
44
58
|
await new Promise((resolve) => server.listen(port, host, resolve));
|
|
45
59
|
|
|
@@ -59,6 +73,14 @@ export async function createRESPlite({
|
|
|
59
73
|
return closePromise;
|
|
60
74
|
};
|
|
61
75
|
|
|
76
|
+
if (gracefulShutdown) {
|
|
77
|
+
const onSignal = () => {
|
|
78
|
+
close().then(() => process.exit(0));
|
|
79
|
+
};
|
|
80
|
+
process.on('SIGTERM', onSignal);
|
|
81
|
+
process.on('SIGINT', onSignal);
|
|
82
|
+
}
|
|
83
|
+
|
|
62
84
|
return {
|
|
63
85
|
port: server.address().port,
|
|
64
86
|
host,
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* RESPLite entry point. Start TCP server with SQLite backend.
|
|
3
|
+
*
|
|
4
|
+
* Can be run as CLI (node src/index.js) or used programmatically:
|
|
5
|
+
* import { startServer } from './src/index.js';
|
|
6
|
+
* startServer({ port: 6380, gracefulShutdown: false });
|
|
3
7
|
*/
|
|
4
8
|
|
|
5
9
|
import { createServer } from './server/tcp-server.js';
|
|
@@ -15,23 +19,57 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
15
19
|
const DEFAULT_DB_PATH = path.join(process.cwd(), 'data.db');
|
|
16
20
|
const DEFAULT_PORT = 6379;
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} [options]
|
|
24
|
+
* @param {number} [options.port]
|
|
25
|
+
* @param {string} [options.dbPath]
|
|
26
|
+
* @param {string} [options.pragmaTemplate]
|
|
27
|
+
* @param {boolean} [options.gracefulShutdown=true] If true, register SIGTERM/SIGINT to close server and DB. Set false if you handle shutdown yourself.
|
|
28
|
+
*/
|
|
29
|
+
export function startServer(options = {}) {
|
|
30
|
+
const dbPath = options.dbPath ?? process.env.RESPLITE_DB ?? DEFAULT_DB_PATH;
|
|
31
|
+
const port = options.port ?? parseInt(process.env.RESPLITE_PORT || String(DEFAULT_PORT), 10);
|
|
32
|
+
const pragmaTemplate = options.pragmaTemplate ?? process.env.RESPLITE_PRAGMA_TEMPLATE ?? 'default';
|
|
33
|
+
const gracefulShutdown = options.gracefulShutdown !== false;
|
|
34
|
+
|
|
35
|
+
const db = openDb(dbPath, { pragmaTemplate });
|
|
36
|
+
const cache = createCache({ enabled: true });
|
|
37
|
+
const engine = createEngine({ db, cache });
|
|
38
|
+
const sweeper = createExpirationSweeper({
|
|
39
|
+
db,
|
|
40
|
+
clock: () => Date.now(),
|
|
41
|
+
sweepIntervalMs: 1000,
|
|
42
|
+
maxKeysPerSweep: 500,
|
|
43
|
+
});
|
|
44
|
+
sweeper.start();
|
|
45
|
+
|
|
46
|
+
const connections = new Set();
|
|
47
|
+
const server = createServer({ engine, port, connections });
|
|
48
|
+
|
|
49
|
+
if (gracefulShutdown) {
|
|
50
|
+
let shuttingDown = false;
|
|
51
|
+
function shutdown() {
|
|
52
|
+
if (shuttingDown) return;
|
|
53
|
+
shuttingDown = true;
|
|
54
|
+
sweeper.stop();
|
|
55
|
+
for (const socket of connections) socket.destroy();
|
|
56
|
+
connections.clear();
|
|
57
|
+
server.close(() => {
|
|
58
|
+
db.close();
|
|
59
|
+
process.exit(0);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
process.on('SIGTERM', shutdown);
|
|
63
|
+
process.on('SIGINT', shutdown);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
server.listen(port, () => {
|
|
67
|
+
console.log(`RESPLite listening on port ${port}, db: ${dbPath}`);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const isCli = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
72
|
+
if (isCli) {
|
|
73
|
+
const noGraceful = process.argv.includes('--no-graceful-shutdown');
|
|
74
|
+
startServer({ gracefulShutdown: !noGraceful });
|
|
75
|
+
}
|
package/src/migration/bulk.js
CHANGED
|
@@ -48,7 +48,7 @@ function sleep(ms) {
|
|
|
48
48
|
* @param {number} [options.batch_keys=200]
|
|
49
49
|
* @param {number} [options.batch_bytes=64*1024*1024] - 64MB
|
|
50
50
|
* @param {number} [options.checkpoint_interval_sec=30]
|
|
51
|
-
* @param {boolean} [options.resume=false
|
|
51
|
+
* @param {boolean} [options.resume=true] - true: start from 0 or continue from checkpoint; false: always start from 0
|
|
52
52
|
* @param {function(run): void} [options.onProgress] - called after checkpoint with run row
|
|
53
53
|
*/
|
|
54
54
|
export async function runBulkImport(redisClient, dbPath, runId, options = {}) {
|
|
@@ -60,7 +60,7 @@ export async function runBulkImport(redisClient, dbPath, runId, options = {}) {
|
|
|
60
60
|
batch_keys = 200,
|
|
61
61
|
batch_bytes = 64 * 1024 * 1024,
|
|
62
62
|
checkpoint_interval_sec = 30,
|
|
63
|
-
resume =
|
|
63
|
+
resume = true,
|
|
64
64
|
onProgress,
|
|
65
65
|
} = options;
|
|
66
66
|
|
package/src/migration/index.js
CHANGED
|
@@ -113,11 +113,11 @@ export function createMigration({
|
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
115
|
* Step 1 — Bulk import: SCAN all keys from Redis into the destination DB.
|
|
116
|
-
*
|
|
116
|
+
* Resume is on by default: first run starts from 0, later runs continue from checkpoint.
|
|
117
117
|
*
|
|
118
|
-
* @param {{ resume?: boolean, onProgress?: (run: object) => void }} [opts]
|
|
118
|
+
* @param {{ resume?: boolean, onProgress?: (run: object) => void }} [opts] - resume (default true): start or continue automatically
|
|
119
119
|
*/
|
|
120
|
-
async bulk({ resume =
|
|
120
|
+
async bulk({ resume = true, onProgress } = {}) {
|
|
121
121
|
const id = requireRunId();
|
|
122
122
|
const client = await getClient();
|
|
123
123
|
return runBulkImport(client, to, id, {
|
package/src/server/connection.js
CHANGED
|
@@ -12,17 +12,22 @@ let nextConnectionId = 0;
|
|
|
12
12
|
/**
|
|
13
13
|
* @param {import('net').Socket} socket
|
|
14
14
|
* @param {object} engine
|
|
15
|
+
* @param {object} [hooks] Optional: onUnknownCommand, onCommandError, onSocketError
|
|
15
16
|
*/
|
|
16
|
-
export function handleConnection(socket, engine) {
|
|
17
|
+
export function handleConnection(socket, engine, hooks = {}) {
|
|
17
18
|
const reader = new RESPReader();
|
|
18
19
|
const connectionId = ++nextConnectionId;
|
|
20
|
+
const clientAddress = `${socket.remoteAddress ?? 'unknown'}:${socket.remotePort ?? 0}`;
|
|
19
21
|
const context = {
|
|
20
22
|
connectionId,
|
|
23
|
+
clientAddress,
|
|
21
24
|
monitorMode: false,
|
|
22
|
-
clientAddress: `${socket.remoteAddress ?? 'unknown'}:${socket.remotePort ?? 0}`,
|
|
23
25
|
writeResponse(buf) {
|
|
24
26
|
if (socket.writable) socket.write(buf);
|
|
25
27
|
},
|
|
28
|
+
onUnknownCommand: hooks.onUnknownCommand,
|
|
29
|
+
onCommandError: hooks.onCommandError,
|
|
30
|
+
onSocketError: hooks.onSocketError,
|
|
26
31
|
};
|
|
27
32
|
|
|
28
33
|
function writeResult(out) {
|
|
@@ -94,5 +99,7 @@ export function handleConnection(socket, engine) {
|
|
|
94
99
|
unregisterMonitorClient(connectionId);
|
|
95
100
|
});
|
|
96
101
|
|
|
97
|
-
socket.on('error', () => {
|
|
102
|
+
socket.on('error', (err) => {
|
|
103
|
+
context.onSocketError?.({ error: err, clientAddress: context.clientAddress, connectionId: context.connectionId });
|
|
104
|
+
});
|
|
98
105
|
}
|
package/src/server/tcp-server.js
CHANGED
|
@@ -10,10 +10,15 @@ import { handleConnection } from './connection.js';
|
|
|
10
10
|
* @param {object} options.engine
|
|
11
11
|
* @param {number} [options.port=6379]
|
|
12
12
|
* @param {string} [options.host='0.0.0.0']
|
|
13
|
+
* @param {Set<import('node:net').Socket>} [options.connections] If provided, each accepted socket is added here (for graceful shutdown).
|
|
13
14
|
* @returns {import('node:net').Server}
|
|
14
15
|
*/
|
|
15
|
-
export function createServer({ engine, port = 6379, host = '0.0.0.0' }) {
|
|
16
|
+
export function createServer({ engine, port = 6379, host = '0.0.0.0', connections = null }) {
|
|
16
17
|
const server = net.createServer((socket) => {
|
|
18
|
+
if (connections) {
|
|
19
|
+
connections.add(socket);
|
|
20
|
+
socket.once('close', () => connections.delete(socket));
|
|
21
|
+
}
|
|
17
22
|
handleConnection(socket, engine);
|
|
18
23
|
});
|
|
19
24
|
return server;
|
|
@@ -88,4 +88,69 @@ describe('createRESPlite', () => {
|
|
|
88
88
|
await client.quit();
|
|
89
89
|
await srv.close();
|
|
90
90
|
});
|
|
91
|
+
|
|
92
|
+
it('unsupported command still returns ERR command not supported yet to client', async () => {
|
|
93
|
+
const srv = await createRESPlite();
|
|
94
|
+
const client = await redisClient(srv.port);
|
|
95
|
+
try {
|
|
96
|
+
await client.sendCommand(['SUBSCRIBE', 'ch']);
|
|
97
|
+
assert.fail('expected error');
|
|
98
|
+
} catch (e) {
|
|
99
|
+
assert.ok(e.message.includes('not supported'), e.message);
|
|
100
|
+
}
|
|
101
|
+
await client.quit();
|
|
102
|
+
await srv.close();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('onUnknownCommand hook is called for unsupported commands', async () => {
|
|
106
|
+
const unknownCalls = [];
|
|
107
|
+
const srv = await createRESPlite({
|
|
108
|
+
hooks: {
|
|
109
|
+
onUnknownCommand(payload) {
|
|
110
|
+
unknownCalls.push(payload);
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
const client = await redisClient(srv.port);
|
|
115
|
+
try {
|
|
116
|
+
await client.sendCommand(['SUBSCRIBE', 'ch']);
|
|
117
|
+
} catch (_) {}
|
|
118
|
+
try {
|
|
119
|
+
await client.sendCommand(['PUBLISH', 'ch', 'x']);
|
|
120
|
+
} catch (_) {}
|
|
121
|
+
await client.quit();
|
|
122
|
+
await srv.close();
|
|
123
|
+
const commands = unknownCalls.map((c) => c.command);
|
|
124
|
+
assert.ok(commands.includes('SUBSCRIBE'), 'expected SUBSCRIBE in ' + commands.join(', '));
|
|
125
|
+
assert.ok(commands.includes('PUBLISH'), 'expected PUBLISH in ' + commands.join(', '));
|
|
126
|
+
const sub = unknownCalls.find((c) => c.command === 'SUBSCRIBE');
|
|
127
|
+
const pub = unknownCalls.find((c) => c.command === 'PUBLISH');
|
|
128
|
+
assert.equal(sub.argsCount, 1);
|
|
129
|
+
assert.equal(pub.argsCount, 2);
|
|
130
|
+
assert.equal(typeof sub.connectionId, 'number');
|
|
131
|
+
assert.ok(sub.clientAddress.length > 0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('onCommandError hook is called when command returns or throws error', async () => {
|
|
135
|
+
const errorCalls = [];
|
|
136
|
+
const srv = await createRESPlite({
|
|
137
|
+
hooks: {
|
|
138
|
+
onCommandError(payload) {
|
|
139
|
+
errorCalls.push(payload);
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
const client = await redisClient(srv.port);
|
|
144
|
+
await client.set('k', 'str');
|
|
145
|
+
try {
|
|
146
|
+
await client.hGet('k', 'f');
|
|
147
|
+
} catch (_) {}
|
|
148
|
+
await client.quit();
|
|
149
|
+
await srv.close();
|
|
150
|
+
assert.equal(errorCalls.length, 1);
|
|
151
|
+
assert.equal(errorCalls[0].command, 'HGET');
|
|
152
|
+
assert.ok(errorCalls[0].error.includes('WRONGTYPE'));
|
|
153
|
+
assert.equal(typeof errorCalls[0].connectionId, 'number');
|
|
154
|
+
assert.ok(errorCalls[0].clientAddress.length > 0);
|
|
155
|
+
});
|
|
91
156
|
});
|