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 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 is killed.
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
- ### Strings, TTL, and key operations
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
- // Key existence and deletion
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
- await client.hSet('user:1', { name: 'Martin', age: '42', city: 'BCN' });
174
-
175
- console.log(await client.hGet('user:1', 'name')); // → "Martin"
176
-
177
- const user = await client.hGetAll('user:1');
178
- console.log(user); // → { name: "Martin", age: "42", city: "BCN" }
179
-
180
- await client.hIncrBy('user:1', 'age', 1);
181
- console.log(await client.hGet('user:1', 'age')); // → "43"
182
-
183
- console.log(await client.hExists('user:1', 'email')); // false
184
- ```
185
-
186
- ### Sets
187
-
188
- ```javascript
189
- await client.sAdd('tags', ['node', 'sqlite', 'redis']);
190
- console.log(await client.sMembers('tags')); // → ["node", "sqlite", "redis"]
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
- ## Compatibility matrix
317
-
318
- 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.
319
-
320
- ### Supported (v1)
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 RESPLites latency is acceptable. The SPEC_F flow (dirty-key tracker, bulk import, cutover) is designed for that scenario with minimal downtime.
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 --resume
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resplite",
3
- "version": "1.1.12",
3
+ "version": "1.2.2",
4
4
  "description": "A RESP2 server with practical Redis compatibility, backed by SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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: !!args.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 (bulk: resume from checkpoint)');
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
  }
@@ -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) for blocking commands
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) return result;
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
- return { error: msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
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
- const dbPath = process.env.RESPLITE_DB || DEFAULT_DB_PATH;
19
- const port = parseInt(process.env.RESPLITE_PORT || String(DEFAULT_PORT), 10);
20
- const pragmaTemplate = process.env.RESPLITE_PRAGMA_TEMPLATE || 'default';
21
-
22
- const db = openDb(dbPath, { pragmaTemplate });
23
- const cache = createCache({ enabled: true });
24
- const engine = createEngine({ db, cache });
25
- const sweeper = createExpirationSweeper({
26
- db,
27
- clock: () => Date.now(),
28
- sweepIntervalMs: 1000,
29
- maxKeysPerSweep: 500,
30
- });
31
- sweeper.start();
32
-
33
- const server = createServer({ engine, port });
34
-
35
- server.listen(port, () => {
36
- console.log(`RESPLite listening on port ${port}, db: ${dbPath}`);
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
+ }
@@ -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 = false,
63
+ resume = true,
64
64
  onProgress,
65
65
  } = options;
66
66
 
@@ -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
- * Supports resume (checkpoint-based) and optional progress callback.
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 = false, onProgress } = {}) {
120
+ async bulk({ resume = true, onProgress } = {}) {
121
121
  const id = requireRunId();
122
122
  const client = await getClient();
123
123
  return runBulkImport(client, to, id, {
@@ -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
  }
@@ -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
  });