resplite 1.2.0 → 1.2.4

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 receives SIGINT (Ctrl+C) or SIGTERM; then it closes the server and exits cleanly.
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
@@ -96,13 +96,6 @@ import { createRESPlite } from 'resplite/embed';
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
- async function shutdown() {
100
- await srv.close();
101
- process.exit(0);
102
- }
103
-
104
- process.on('SIGINT', shutdown);
105
- process.on('SIGTERM', shutdown);
106
99
  ```
107
100
 
108
101
  Then connect from any other script or process:
@@ -181,6 +174,222 @@ const srv = await createRESPlite({
181
174
  | `onCommandError` | A command failed (wrong type, invalid args, or handler threw). |
182
175
  | `onSocketError` | The connection socket emitted an error (e.g. `ECONNRESET`). |
183
176
 
177
+ ## Migration from Redis
178
+
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 flow (dirty-key tracker, bulk import, cutover) is designed for that scenario with minimal downtime.
180
+
181
+ Migration supports two modes:
182
+
183
+ ### Programmatic migration API (JavaScript)
184
+
185
+ 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.
186
+
187
+ ```javascript
188
+ import { createMigration } from 'resplite/migration';
189
+
190
+ const m = createMigration({
191
+ from: 'redis://127.0.0.1:6379', // source Redis URL (default)
192
+ to: './resplite.db', // destination SQLite DB path (required)
193
+ runId: 'my-migration-1', // unique run ID (required for bulk/status/applyDirty)
194
+
195
+ // optional — same defaults as the CLI:
196
+ scanCount: 1000,
197
+ batchKeys: 200,
198
+ batchBytes: 64 * 1024 * 1024, // 64 MB
199
+ maxRps: 0, // 0 = unlimited
200
+ pragmaTemplate: 'default',
201
+
202
+ // If your Redis deployment renamed CONFIG for security:
203
+ // configCommand: 'MYCONFIG',
204
+ });
205
+
206
+ // Step 0 — Preflight: inspect Redis before starting
207
+ const info = await m.preflight();
208
+ console.log('keys (estimate):', info.keyCountEstimate);
209
+ console.log('type distribution:', info.typeDistribution);
210
+ console.log('notify-keyspace-events:', info.notifyKeyspaceEvents);
211
+ console.log('CONFIG available:', info.configCommandAvailable); // false if renamed
212
+ console.log('recommended params:', info.recommended);
213
+
214
+ // Step 0b — Enable keyspace notifications (required for dirty-key tracking)
215
+ // Reads the current value and merges the new flags — existing flags are preserved.
216
+ const ks = await m.enableKeyspaceNotifications();
217
+ // → { ok: true, previous: '', applied: 'KEA' }
218
+ // If CONFIG is renamed and configCommand was not set, ok=false and error explains how to fix it.
219
+
220
+ // Step 1 — Bulk import (checkpointed, resumable). Same script to start or continue.
221
+ // Use keyCountEstimate from preflight to show progress % (estimate; actual count may change).
222
+ const total = info.keyCountEstimate || 1;
223
+ await m.bulk({
224
+ onProgress: (r) => {
225
+ const pct = total ? ((r.scanned_keys / total) * 100).toFixed(1) : '—';
226
+ console.log(
227
+ `scanned=${r.scanned_keys} migrated=${r.migrated_keys} errors=${r.error_keys} progress=${pct}%`
228
+ );
229
+ },
230
+ });
231
+
232
+ // Check status at any point (synchronous, no Redis needed)
233
+ const { run, dirty } = m.status();
234
+ console.log('bulk status:', run.status, '— dirty counts:', dirty);
235
+
236
+ // Step 2 — Apply dirty keys that changed in Redis during bulk
237
+ await m.applyDirty();
238
+
239
+ // Step 3 — Verify a sample of keys match between Redis and the destination
240
+ const result = await m.verify({ samplePct: 0.5, maxSample: 10000 });
241
+ console.log(`verified ${result.sampled} keys — mismatches: ${result.mismatches.length}`);
242
+
243
+ // Disconnect Redis when done
244
+ await m.close();
245
+ ```
246
+
247
+ **Automatic resume (default)**
248
+ `resume` defaults to `true`. It doesn't matter whether it's the first run or a resume: the same script works for both starting and continuing. The first run starts from cursor 0; if the process is interrupted (Ctrl+C, crash, etc.), running the script again continues from the last checkpoint. You don't need to pass `resume: false` on the first run or change anything to resume.
249
+
250
+ **Graceful shutdown**
251
+ On SIGINT (Ctrl+C) or SIGTERM, the bulk importer checkpoints progress, sets the run status to `aborted`, closes the SQLite database cleanly (so WAL is checkpointed and the file is not left open), then exits. You can safely interrupt a long-running bulk and resume later.
252
+
253
+ 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.
254
+
255
+ #### Renamed CONFIG command
256
+
257
+ If your Redis instance has the `CONFIG` command renamed (a common hardening practice), pass the new name to `createMigration`:
258
+
259
+ ```javascript
260
+ const m = createMigration({
261
+ from: 'redis://10.0.0.10:6379',
262
+ to: './resplite.db',
263
+ runId: 'run_001',
264
+ configCommand: 'MYCONFIG', // the renamed command
265
+ });
266
+
267
+ // preflight will use MYCONFIG GET notify-keyspace-events
268
+ const info = await m.preflight();
269
+ // info.configCommandAvailable → false if the name is wrong
270
+
271
+ // enableKeyspaceNotifications will use MYCONFIG SET notify-keyspace-events KEA
272
+ const result = await m.enableKeyspaceNotifications({ value: 'KEA' });
273
+ ```
274
+
275
+ The same flag is available in the CLI:
276
+
277
+ ```bash
278
+ npx resplite-dirty-tracker start --run-id run_001 --to ./resplite.db \
279
+ --from redis://10.0.0.10:6379 --config-command MYCONFIG
280
+ ```
281
+ ### Simple one-shot import
282
+
283
+ For small datasets or when downtime is acceptable:
284
+
285
+ ```bash
286
+ # Default: redis://127.0.0.1:6379 → ./data.db
287
+ npm run import-from-redis -- --db ./migrated.db
288
+
289
+ # Custom Redis URL
290
+ npm run import-from-redis -- --db ./migrated.db --redis-url redis://127.0.0.1:6379
291
+
292
+ # Or host/port
293
+ npm run import-from-redis -- --db ./migrated.db --host 127.0.0.1 --port 6379
294
+
295
+ # Optional: PRAGMA template for the target DB
296
+ npm run import-from-redis -- --db ./migrated.db --pragma-template performance
297
+ ```
298
+
299
+ ### Redis with authentication
300
+
301
+ Migration supports Redis instances protected by a password. Use a Redis URL that includes the password (or username and password for Redis 6+ ACL):
302
+
303
+ - **Password only:** `redis://:PASSWORD@host:port`
304
+ - **Username and password:** `redis://username:PASSWORD@host:port`
305
+
306
+ Examples:
307
+
308
+ ```bash
309
+ # One-shot import from authenticated Redis
310
+ npm run import-from-redis -- --db ./migrated.db --redis-url "redis://:mysecret@127.0.0.1:6379"
311
+
312
+ # flow: use --from with the full URL (or set RESPLITE_IMPORT_FROM)
313
+ npx resplite-import preflight --from "redis://:mysecret@10.0.0.10:6379" --to ./resplite.db
314
+ npx resplite-dirty-tracker start --run-id run_001 --from "redis://:mysecret@10.0.0.10:6379" --to ./resplite.db
315
+ ```
316
+
317
+ For one-shot import, authentication is only available when using `--redis-url`; the `--host` / `--port` options do not support a password.
318
+
319
+ **Search indices (FT.\*)**
320
+ The KV bulk migration imports only the Redis keyspace (strings, hashes, sets, lists, zsets). RediSearch index schemas and documents are migrated separately with the `migrate-search` step — see [Migrating RediSearch indices](#migrating-redisearch-indices) below.
321
+
322
+ ### Minimal-downtime migration
323
+
324
+ For large datasets (~30 GB), use the Dirty Key Registry flow so the bulk of the migration runs online and only a short cutover is needed.
325
+
326
+ **Enable keyspace notifications in Redis** (required for the dirty-key tracker). Either run at runtime:
327
+
328
+ ```bash
329
+ redis-cli CONFIG SET notify-keyspace-events KEA
330
+ ```
331
+
332
+ Or add to `redis.conf` and restart Redis:
333
+
334
+ ```
335
+ notify-keyspace-events KEA
336
+ ```
337
+
338
+ (`K` = keyspace prefix, `E` = keyevent prefix, `A` = all event types — lets the tracker see every key change and expiration.)
339
+
340
+ > **Renamed CONFIG command?** Some Redis deployments rename `CONFIG` for security. Pass `--config-command <name>` to the CLI tools, or the `configCommand` option to the JS API — see below.
341
+
342
+ 1. **Preflight** – Check Redis, key count, type distribution, and that keyspace notifications are enabled:
343
+ ```bash
344
+ npx resplite-import preflight --from redis://10.0.0.10:6379 --to ./resplite.db
345
+ ```
346
+
347
+ 2. **Start dirty-key tracker** – Captures keys modified during bulk (requires `notify-keyspace-events` in Redis):
348
+ ```bash
349
+ npx resplite-dirty-tracker start --run-id run_001 --from redis://10.0.0.10:6379 --to ./resplite.db
350
+ # If CONFIG was renamed:
351
+ npx resplite-dirty-tracker start --run-id run_001 --from redis://10.0.0.10:6379 --to ./resplite.db --config-command MYCONFIG
352
+ ```
353
+
354
+ 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):
355
+ ```bash
356
+ npx resplite-import bulk --run-id run_001 --from redis://10.0.0.10:6379 --to ./resplite.db \
357
+ --scan-count 1000 --max-rps 2000 --batch-keys 200 --batch-bytes 64MB
358
+ ```
359
+
360
+ 4. **Monitor** – Check run and dirty-key counts:
361
+ ```bash
362
+ npx resplite-import status --run-id run_001 --to ./resplite.db
363
+ ```
364
+
365
+ 5. **Cutover** – Freeze app writes to Redis, then apply remaining dirty keys:
366
+ ```bash
367
+ npx resplite-import apply-dirty --run-id run_001 --from redis://10.0.0.10:6379 --to ./resplite.db
368
+ ```
369
+
370
+ 6. **Stop tracker and switch** – Stop the tracker and point clients to RespLite:
371
+ ```bash
372
+ npx resplite-dirty-tracker stop --run-id run_001 --to ./resplite.db
373
+ ```
374
+
375
+ 7. **Verify** – Optional sampling check between Redis and destination:
376
+ ```bash
377
+ npx resplite-import verify --run-id run_001 --from redis://10.0.0.10:6379 --to ./resplite.db --sample 0.5%
378
+ ```
379
+
380
+ Then start RespLite with the migrated DB: `RESPLITE_DB=./resplite.db npm start`.
381
+
382
+ #### Low-level re-exports
383
+
384
+ If you need more control, the individual functions and registry helpers are also exported:
385
+
386
+ ```javascript
387
+ import {
388
+ runPreflight, runBulkImport, runApplyDirty, runVerify,
389
+ getRun, getDirtyCounts, createRun, setRunStatus, logError,
390
+ } from 'resplite/migration';
391
+ ```
392
+
184
393
  ### Strings, TTL, and key operations
185
394
 
186
395
  ```javascript
@@ -352,9 +561,66 @@ await c2.quit();
352
561
  await srv2.close();
353
562
  ```
354
563
 
355
- ## Compatibility matrix
564
+ ### Migrating RediSearch indices
565
+
566
+ If your Redis source uses **RediSearch** (Redis Stack or the `redis/search` module), run `migrate-search` after (or during) the KV bulk import. It reads index schemas with `FT.INFO`, creates them in RespLite, and imports documents by scanning the matching hash keys.
567
+
568
+ **CLI:**
569
+
570
+ ```bash
571
+ # Migrate all indices
572
+ npx resplite-import migrate-search \
573
+ --from redis://10.0.0.10:6379 \
574
+ --to ./resplite.db
575
+
576
+ # Migrate specific indices only
577
+ npx resplite-import migrate-search \
578
+ --from redis://10.0.0.10:6379 \
579
+ --to ./resplite.db \
580
+ --index products \
581
+ --index articles
582
+
583
+ # Options
584
+ # --scan-count N SCAN COUNT hint (default 500)
585
+ # --max-rps N throttle Redis reads
586
+ # --batch-docs N docs per SQLite transaction (default 200)
587
+ # --max-suggestions N cap for suggestion import (default 10000)
588
+ # --no-skip overwrite if the index already exists in RespLite
589
+ # --no-suggestions skip suggestion import
590
+ ```
356
591
 
357
- 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.
592
+ **Programmatic API:**
593
+
594
+ ```javascript
595
+ const m = createMigration({ from, to, runId });
596
+
597
+ const result = await m.migrateSearch({
598
+ onlyIndices: ['products', 'articles'], // omit to migrate all
599
+ batchDocs: 200,
600
+ maxSuggestions: 10000,
601
+ skipExisting: true, // default
602
+ withSuggestions: true, // default
603
+ onProgress: (r) => console.log(r.name, r.docsImported, r.warnings),
604
+ });
605
+ // result.indices: [{ name, created, skipped, docsImported, docsSkipped, docErrors, sugsImported, warnings, error? }]
606
+ // result.aborted: true if interrupted by SIGINT/SIGTERM
607
+ ```
608
+
609
+ **What gets migrated:**
610
+
611
+ | RediSearch type | RespLite | Notes |
612
+ |---|---|---|
613
+ | TEXT | TEXT | Direct |
614
+ | TAG | TEXT | Values preserved; TAG filtering lost |
615
+ | NUMERIC | TEXT | Stored as string; numeric range queries not supported |
616
+ | GEO, VECTOR, … | skipped | Warning emitted per field |
617
+
618
+ - Only **HASH**-based indices are supported. JSON (RedisJSON) indices are skipped.
619
+ - A `payload` field is added automatically if none of the source fields maps to it.
620
+ - Suggestions are imported via `FT.SUGGET "" MAX n WITHSCORES` (no cursor; capped at `maxSuggestions`).
621
+ - Graceful shutdown: Ctrl+C finishes the current document, closes SQLite cleanly, and exits with a non-zero code.
622
+
623
+ ## Compatibility matrix
358
624
 
359
625
  ### Supported (v1)
360
626
 
@@ -383,210 +649,6 @@ RESPLite implements **47 core Redis commands** (~19% of the ~246 commands in Red
383
649
 
384
650
  Unsupported commands return: `ERR command not supported yet`.
385
651
 
386
- ## Migration from Redis
387
-
388
- 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.
389
-
390
- Migration supports two modes:
391
-
392
- ### Simple one-shot import (legacy)
393
-
394
- For small datasets or when downtime is acceptable:
395
-
396
- ```bash
397
- # Default: redis://127.0.0.1:6379 → ./data.db
398
- npm run import-from-redis -- --db ./migrated.db
399
-
400
- # Custom Redis URL
401
- npm run import-from-redis -- --db ./migrated.db --redis-url redis://127.0.0.1:6379
402
-
403
- # Or host/port
404
- npm run import-from-redis -- --db ./migrated.db --host 127.0.0.1 --port 6379
405
-
406
- # Optional: PRAGMA template for the target DB
407
- npm run import-from-redis -- --db ./migrated.db --pragma-template performance
408
- ```
409
-
410
- ### Redis with authentication
411
-
412
- Migration supports Redis instances protected by a password. Use a Redis URL that includes the password (or username and password for Redis 6+ ACL):
413
-
414
- - **Password only:** `redis://:PASSWORD@host:port`
415
- - **Username and password:** `redis://username:PASSWORD@host:port`
416
-
417
- Examples:
418
-
419
- ```bash
420
- # One-shot import from authenticated Redis
421
- npm run import-from-redis -- --db ./migrated.db --redis-url "redis://:mysecret@127.0.0.1:6379"
422
-
423
- # SPEC_F flow: use --from with the full URL (or set RESPLITE_IMPORT_FROM)
424
- npx resplite-import preflight --from "redis://:mysecret@10.0.0.10:6379" --to ./resplite.db
425
- npx resplite-dirty-tracker start --run-id run_001 --from "redis://:mysecret@10.0.0.10:6379" --to ./resplite.db
426
- ```
427
-
428
- For one-shot import, authentication is only available when using `--redis-url`; the `--host` / `--port` options do not support a password.
429
-
430
- ### Minimal-downtime migration (SPEC_F)
431
-
432
- For large datasets (~30 GB), use the Dirty Key Registry flow so the bulk of the migration runs online and only a short cutover is needed.
433
-
434
- **Enable keyspace notifications in Redis** (required for the dirty-key tracker). Either run at runtime:
435
-
436
- ```bash
437
- redis-cli CONFIG SET notify-keyspace-events KEA
438
- ```
439
-
440
- Or add to `redis.conf` and restart Redis:
441
-
442
- ```
443
- notify-keyspace-events KEA
444
- ```
445
-
446
- (`K` = keyspace prefix, `E` = keyevent prefix, `A` = all event types — lets the tracker see every key change and expiration.)
447
-
448
- > **Renamed CONFIG command?** Some Redis deployments rename `CONFIG` for security. Pass `--config-command <name>` to the CLI tools, or the `configCommand` option to the JS API — see below.
449
-
450
- 1. **Preflight** – Check Redis, key count, type distribution, and that keyspace notifications are enabled:
451
- ```bash
452
- npx resplite-import preflight --from redis://10.0.0.10:6379 --to ./resplite.db
453
- ```
454
-
455
- 2. **Start dirty-key tracker** – Captures keys modified during bulk (requires `notify-keyspace-events` in Redis):
456
- ```bash
457
- npx resplite-dirty-tracker start --run-id run_001 --from redis://10.0.0.10:6379 --to ./resplite.db
458
- # If CONFIG was renamed:
459
- npx resplite-dirty-tracker start --run-id run_001 --from redis://10.0.0.10:6379 --to ./resplite.db --config-command MYCONFIG
460
- ```
461
-
462
- 3. **Bulk import** – SCAN and copy all keys; progress is checkpointed and resumable:
463
- ```bash
464
- npx resplite-import bulk --run-id run_001 --from redis://10.0.0.10:6379 --to ./resplite.db \
465
- --scan-count 1000 --max-rps 2000 --batch-keys 200 --batch-bytes 64MB --resume
466
- ```
467
-
468
- 4. **Monitor** – Check run and dirty-key counts:
469
- ```bash
470
- npx resplite-import status --run-id run_001 --to ./resplite.db
471
- ```
472
-
473
- 5. **Cutover** – Freeze app writes to Redis, then apply remaining dirty keys:
474
- ```bash
475
- npx resplite-import apply-dirty --run-id run_001 --from redis://10.0.0.10:6379 --to ./resplite.db
476
- ```
477
-
478
- 6. **Stop tracker and switch** – Stop the tracker and point clients to RespLite:
479
- ```bash
480
- npx resplite-dirty-tracker stop --run-id run_001 --to ./resplite.db
481
- ```
482
-
483
- 7. **Verify** – Optional sampling check between Redis and destination:
484
- ```bash
485
- npx resplite-import verify --run-id run_001 --from redis://10.0.0.10:6379 --to ./resplite.db --sample 0.5%
486
- ```
487
-
488
- Then start RespLite with the migrated DB: `RESPLITE_DB=./resplite.db npm start`.
489
-
490
- ### Programmatic migration API
491
-
492
- 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.
493
-
494
- ```javascript
495
- import { createMigration } from 'resplite/migration';
496
-
497
- const m = createMigration({
498
- from: 'redis://127.0.0.1:6379', // source Redis URL (default)
499
- to: './resplite.db', // destination SQLite DB path (required)
500
- runId: 'my-migration-1', // unique run ID (required for bulk/status/applyDirty)
501
-
502
- // optional — same defaults as the CLI:
503
- scanCount: 1000,
504
- batchKeys: 200,
505
- batchBytes: 64 * 1024 * 1024, // 64 MB
506
- maxRps: 0, // 0 = unlimited
507
- pragmaTemplate: 'default',
508
-
509
- // If your Redis deployment renamed CONFIG for security:
510
- // configCommand: 'MYCONFIG',
511
- });
512
-
513
- // Step 0 — Preflight: inspect Redis before starting
514
- const info = await m.preflight();
515
- console.log('keys (estimate):', info.keyCountEstimate);
516
- console.log('type distribution:', info.typeDistribution);
517
- console.log('notify-keyspace-events:', info.notifyKeyspaceEvents);
518
- console.log('CONFIG available:', info.configCommandAvailable); // false if renamed
519
- console.log('recommended params:', info.recommended);
520
-
521
- // Step 0b — Enable keyspace notifications (required for dirty-key tracking)
522
- // Reads the current value and merges the new flags — existing flags are preserved.
523
- const ks = await m.enableKeyspaceNotifications();
524
- // → { ok: true, previous: '', applied: 'KEA' }
525
- // If CONFIG is renamed and configCommand was not set, ok=false and error explains how to fix it.
526
-
527
- // Step 1 — Bulk import (checkpointed, resumable)
528
- await m.bulk({
529
- resume: false, // true to resume a previous run
530
- onProgress: (r) => console.log(
531
- `scanned=${r.scanned_keys} migrated=${r.migrated_keys} errors=${r.error_keys}`
532
- ),
533
- });
534
-
535
- // Check status at any point (synchronous, no Redis needed)
536
- const { run, dirty } = m.status();
537
- console.log('bulk status:', run.status, '— dirty counts:', dirty);
538
-
539
- // Step 2 — Apply dirty keys that changed in Redis during bulk
540
- await m.applyDirty();
541
-
542
- // Step 3 — Verify a sample of keys match between Redis and the destination
543
- const result = await m.verify({ samplePct: 0.5, maxSample: 10000 });
544
- console.log(`verified ${result.sampled} keys — mismatches: ${result.mismatches.length}`);
545
-
546
- // Disconnect Redis when done
547
- await m.close();
548
- ```
549
-
550
- 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.
551
-
552
- #### Renamed CONFIG command
553
-
554
- If your Redis instance has the `CONFIG` command renamed (a common hardening practice), pass the new name to `createMigration`:
555
-
556
- ```javascript
557
- const m = createMigration({
558
- from: 'redis://10.0.0.10:6379',
559
- to: './resplite.db',
560
- runId: 'run_001',
561
- configCommand: 'MYCONFIG', // the renamed command
562
- });
563
-
564
- // preflight will use MYCONFIG GET notify-keyspace-events
565
- const info = await m.preflight();
566
- // info.configCommandAvailable → false if the name is wrong
567
-
568
- // enableKeyspaceNotifications will use MYCONFIG SET notify-keyspace-events KEA
569
- const result = await m.enableKeyspaceNotifications({ value: 'KEA' });
570
- ```
571
-
572
- The same flag is available in the CLI:
573
-
574
- ```bash
575
- npx resplite-dirty-tracker start --run-id run_001 --to ./resplite.db \
576
- --from redis://10.0.0.10:6379 --config-command MYCONFIG
577
- ```
578
-
579
- #### Low-level re-exports
580
-
581
- If you need more control, the individual functions and registry helpers are also exported:
582
-
583
- ```javascript
584
- import {
585
- runPreflight, runBulkImport, runApplyDirty, runVerify,
586
- getRun, getDirtyCounts, createRun, setRunStatus, logError,
587
- } from 'resplite/migration';
588
- ```
589
-
590
652
  ## Scripts
591
653
 
592
654
  | Script | Description |
@@ -599,7 +661,7 @@ import {
599
661
  | `npm run test:stress` | Stress tests |
600
662
  | `npm run benchmark` | Comparative benchmark Redis vs RESPLite |
601
663
  | `npm run import-from-redis` | One-shot import from Redis into a SQLite DB |
602
- | `npx resplite-import` (preflight, bulk, status, apply-dirty, verify) | Migration CLI (SPEC_F minimal-downtime flow) |
664
+ | `npx resplite-import` (preflight, bulk, status, apply-dirty, verify) | Migration CLI (minimal-downtime flow) |
603
665
  | `npx resplite-dirty-tracker <start\|stop>` | Dirty-key tracker for migration cutover |
604
666
 
605
667
  ## Specification
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resplite",
3
- "version": "1.2.0",
3
+ "version": "1.2.4",
4
4
  "description": "A RESP2 server with practical Redis compatibility, backed by SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/spec/SPEC_F.md CHANGED
@@ -13,6 +13,7 @@
13
13
  * Perfect change-data-capture guarantees equivalent to replication logs.
14
14
  * Distributed migration across multiple import workers with strict ordering semantics.
15
15
  * Full fidelity for unsupported Redis data types (streams, modules, Lua scripts, etc.).
16
+ * **Search indices (FT.\*):** Keyspace migration (`bulk` / `apply-dirty`) copies only the Redis KV data (strings, hashes, sets, lists, zsets). RediSearch index schemas and documents are migrated separately via the `migrate-search` step (§F.10).
16
17
 
17
18
  ---
18
19
 
@@ -258,6 +259,18 @@ Persist:
258
259
 
259
260
  If interrupted, `--resume` restarts from the stored cursor.
260
261
 
262
+ ## F.7.2.1 Graceful shutdown (SIGINT / SIGTERM)
263
+
264
+ When the bulk import process receives SIGINT or SIGTERM it must:
265
+
266
+ * Stop the import loop after the current key (no partial key).
267
+ * Write a final checkpoint (cursor and counters) so progress is persisted.
268
+ * Set run status to `aborted`.
269
+ * Close the SQLite database handle so WAL is checkpointed and the file is not left open.
270
+ * Remove signal handlers and exit (rethrow so the process exits non-zero).
271
+
272
+ This ensures the destination DB is always closed cleanly when the process is killed or interrupted; the next run with `--resume` continues from the last checkpoint.
273
+
261
274
  ## F.7.3 Throughput controls
262
275
 
263
276
  The importer must support:
@@ -497,6 +510,87 @@ If dirty tracker disconnects:
497
510
 
498
511
  ---
499
512
 
513
+ # F.10 Search Index Migration (FT.* / RediSearch)
514
+
515
+ ## F.10.1 Overview
516
+
517
+ When the source is a Redis instance with **RediSearch** (Redis Stack or the `redis/search` module), search indices can be migrated with the `migrate-search` step. This step is independent of the KV bulk import and can be run at any time (before or after `bulk`).
518
+
519
+ ## F.10.2 Algorithm
520
+
521
+ For each index in the source:
522
+
523
+ 1. **`FT._LIST`** → enumerate all index names.
524
+ 2. **`FT.INFO <name>`** → read `index_definition` (key type, prefix patterns) and `attributes` (field names and types).
525
+ 3. **Schema mapping** (see §F.10.3).
526
+ 4. **`FT.CREATE`** in RespLite with the mapped schema. Skip if already exists (controlled by `skipExisting`).
527
+ 5. **SCAN** keys matching each index prefix → **HGETALL** → `addDocument` in SQLite batches.
528
+ 6. **`FT.SUGGET "" MAX n WITHSCORES`** → import suggestions into RespLite.
529
+
530
+ ## F.10.3 Field type mapping
531
+
532
+ | RediSearch type | RespLite type | Notes |
533
+ |-----------------|---------------|-------|
534
+ | TEXT | TEXT | Direct mapping |
535
+ | TAG | TEXT | Values preserved as-is; TAG filtering semantics lost |
536
+ | NUMERIC | TEXT | Values stored as strings; numeric range queries not supported |
537
+ | GEO, VECTOR, … | — | Skipped with warning |
538
+
539
+ RespLite requires a `payload` TEXT field. If none of the source fields maps to `payload`, a `payload` field is added automatically and synthesised at import time by concatenating all other text values.
540
+
541
+ ## F.10.4 Constraints
542
+
543
+ * Only **HASH**-based indices are supported (`key_type = HASH`). JSON indices (RedisJSON) are skipped with an error.
544
+ * Index names must match `[A-Za-z][A-Za-z0-9:_-]{0,63}`. Indices with invalid names are skipped with an error.
545
+ * `FT.SUGGET` has no cursor; suggestions are imported up to `maxSuggestions` (default 10 000).
546
+ * Document score is read from the `__score` or `score` hash field if present; defaults to `1.0`.
547
+
548
+ ## F.10.5 Graceful shutdown
549
+
550
+ Same pattern as `bulk` (§F.7.2.1): SIGINT/SIGTERM finishes the current document, closes the SQLite DB cleanly, and exits with a non-zero code.
551
+
552
+ ## F.10.6 CLI
553
+
554
+ ```bash
555
+ # Migrate all RediSearch indices
556
+ resplite-import migrate-search \
557
+ --from redis://10.0.0.10:6379 \
558
+ --to ./resplite.db
559
+
560
+ # Migrate specific indices only
561
+ resplite-import migrate-search \
562
+ --from redis://10.0.0.10:6379 \
563
+ --to ./resplite.db \
564
+ --index products \
565
+ --index articles
566
+
567
+ # Options
568
+ # --scan-count N SCAN COUNT hint (default 500)
569
+ # --max-rps N throttle (default unlimited)
570
+ # --batch-docs N docs per SQLite transaction (default 200)
571
+ # --max-suggestions N cap for FT.SUGGET (default 10000)
572
+ # --no-skip overwrite if index already exists
573
+ # --no-suggestions skip suggestion import
574
+ ```
575
+
576
+ ## F.10.7 Programmatic API
577
+
578
+ ```javascript
579
+ const m = createMigration({ from, to, runId });
580
+
581
+ const result = await m.migrateSearch({
582
+ onlyIndices: ['products', 'articles'], // omit for all
583
+ batchDocs: 200,
584
+ maxSuggestions: 10000,
585
+ skipExisting: true,
586
+ withSuggestions: true,
587
+ onProgress: (r) => console.log(r.name, r.docsImported, r.warnings),
588
+ });
589
+ // result: { indices: [{ name, created, skipped, docsImported, docsSkipped, docErrors, sugsImported, warnings, error? }], aborted }
590
+ ```
591
+
592
+ ---
593
+
500
594
  # F.12 Operational Guidance (Large datasets)
501
595
 
502
596
  * Use a dedicated Redis replica for reads if possible to reduce load on primary.