resplite 1.2.4 → 1.2.8

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.
Files changed (35) hide show
  1. package/README.md +179 -274
  2. package/package.json +1 -6
  3. package/scripts/create-interface-smoke.js +32 -0
  4. package/skills/README.md +22 -0
  5. package/skills/resplite-command-vertical-slice/SKILL.md +134 -0
  6. package/skills/resplite-ft-search-workbench/SKILL.md +138 -0
  7. package/skills/resplite-migration-cutover-assistant/SKILL.md +138 -0
  8. package/spec/00-INDEX.md +37 -0
  9. package/spec/01-overview-and-goals.md +125 -0
  10. package/spec/02-protocol-and-commands.md +174 -0
  11. package/spec/03-data-model-ttl-transactions.md +157 -0
  12. package/spec/04-cache-architecture.md +171 -0
  13. package/spec/05-scan-admin-implementation.md +379 -0
  14. package/spec/06-migration-strategy-core.md +79 -0
  15. package/spec/07-type-lists.md +202 -0
  16. package/spec/08-type-sorted-sets.md +220 -0
  17. package/spec/{SPEC_D.md → 09-search-ft-commands.md} +3 -1
  18. package/spec/{SPEC_E.md → 10-blocking-commands.md} +3 -1
  19. package/spec/{SPEC_F.md → 11-migration-dirty-registry.md} +61 -147
  20. package/src/commands/object.js +17 -0
  21. package/src/commands/registry.js +2 -0
  22. package/src/engine/engine.js +11 -0
  23. package/src/migration/apply-dirty.js +8 -1
  24. package/src/migration/index.js +48 -4
  25. package/src/migration/migrate-search.js +25 -6
  26. package/src/migration/tracker.js +23 -0
  27. package/test/integration/migration-dirty-tracker.test.js +9 -4
  28. package/test/integration/object-idletime.test.js +51 -0
  29. package/test/unit/migrate-search.test.js +50 -2
  30. package/spec/SPEC_A.md +0 -1171
  31. package/spec/SPEC_B.md +0 -426
  32. package/src/cli/import-from-redis.js +0 -194
  33. package/src/cli/resplite-dirty-tracker.js +0 -92
  34. package/src/cli/resplite-import.js +0 -296
  35. package/test/contract/import-from-redis.test.js +0 -83
@@ -13,7 +13,7 @@
13
13
  * const info = await m.preflight();
14
14
  * await m.bulk({ onProgress: console.log });
15
15
  * const status = m.status();
16
- * await m.applyDirty();
16
+ * await m.applyDirty({ onProgress: console.log });
17
17
  * const result = await m.verify();
18
18
  * await m.close();
19
19
  */
@@ -26,6 +26,7 @@ import { runApplyDirty } from './apply-dirty.js';
26
26
  import { runVerify } from './verify.js';
27
27
  import { runMigrateSearch } from './migrate-search.js';
28
28
  import { getRun, getDirtyCounts } from './registry.js';
29
+ import { startDirtyTracker as startDirtyTrackerProcess } from './tracker.js';
29
30
 
30
31
  /**
31
32
  * @typedef {object} MigrationOptions
@@ -47,9 +48,11 @@ import { getRun, getDirtyCounts } from './registry.js';
47
48
  * @returns {{
48
49
  * preflight(): Promise<object>,
49
50
  * enableKeyspaceNotifications(opts?: { value?: string, merge?: boolean }): Promise<{ ok: boolean, previous: string|null, applied: string, error?: string }>,
51
+ * startDirtyTracker(opts?: { pragmaTemplate?: string, onProgress?: function }): Promise<{ running: true }>,
52
+ * stopDirtyTracker(): Promise<{ running: false }>,
50
53
  * bulk(opts?: { resume?: boolean, onProgress?: function }): Promise<object>,
51
54
  * status(): { run: object, dirty: object } | null,
52
- * applyDirty(opts?: { batchKeys?: number, maxRps?: number }): Promise<object>,
55
+ * applyDirty(opts?: { batchKeys?: number, maxRps?: number, onProgress?: function }): Promise<object>,
53
56
  * verify(opts?: { samplePct?: number, maxSample?: number }): Promise<object>,
54
57
  * migrateSearch(opts?: { onlyIndices?: string[], scanCount?: number, maxRps?: number, batchDocs?: number, maxSuggestions?: number, skipExisting?: boolean, withSuggestions?: boolean, onProgress?: function }): Promise<object>,
55
58
  * close(): Promise<void>,
@@ -69,6 +72,7 @@ export function createMigration({
69
72
  if (!to) throw new Error('createMigration: "to" (db path) is required');
70
73
 
71
74
  let _client = null;
75
+ let _tracker = null;
72
76
 
73
77
  async function getClient() {
74
78
  if (_client) return _client;
@@ -113,6 +117,41 @@ export function createMigration({
113
117
  return setKeyspaceEvents(client, value, { configCommand, merge });
114
118
  },
115
119
 
120
+ /**
121
+ * Start dirty-key tracking in-process for this migration controller.
122
+ * Use this to run the full minimal-downtime flow in one Node script.
123
+ *
124
+ * @param {{
125
+ * pragmaTemplate?: string,
126
+ * onProgress?: (progress: { runId: string, key: string, event: string, totalEvents: number, at: string }) => void | Promise<void>
127
+ * }} [opts]
128
+ */
129
+ async startDirtyTracker({ pragmaTemplate: pt = pragmaTemplate, onProgress } = {}) {
130
+ if (_tracker) return { running: true };
131
+ const id = requireRunId();
132
+ _tracker = await startDirtyTrackerProcess({
133
+ from,
134
+ to,
135
+ runId: id,
136
+ pragmaTemplate: pt,
137
+ configCommand,
138
+ onProgress,
139
+ });
140
+ return { running: true };
141
+ },
142
+
143
+ /**
144
+ * Stop in-process dirty-key tracking started by `startDirtyTracker`.
145
+ * Safe to call even if tracking is not running.
146
+ */
147
+ async stopDirtyTracker() {
148
+ if (_tracker) {
149
+ await _tracker.stop();
150
+ _tracker = null;
151
+ }
152
+ return { running: false };
153
+ },
154
+
116
155
  /**
117
156
  * Step 1 — Bulk import: SCAN all keys from Redis into the destination DB.
118
157
  * Resume is on by default: first run starts from 0, later runs continue from checkpoint.
@@ -152,15 +191,16 @@ export function createMigration({
152
191
  /**
153
192
  * Step 3 — Apply dirty: reconcile keys that changed in Redis during bulk import.
154
193
  *
155
- * @param {{ batchKeys?: number, maxRps?: number }} [opts]
194
+ * @param {{ batchKeys?: number, maxRps?: number, onProgress?: (run: object) => void }} [opts]
156
195
  */
157
- async applyDirty({ batchKeys: bk = batchKeys, maxRps: rps = maxRps } = {}) {
196
+ async applyDirty({ batchKeys: bk = batchKeys, maxRps: rps = maxRps, onProgress } = {}) {
158
197
  const id = requireRunId();
159
198
  const client = await getClient();
160
199
  return runApplyDirty(client, to, id, {
161
200
  pragmaTemplate,
162
201
  batch_keys: bk,
163
202
  max_rps: rps,
203
+ onProgress,
164
204
  });
165
205
  },
166
206
 
@@ -208,6 +248,10 @@ export function createMigration({
208
248
  * Disconnect from Redis. Call when done with all migration operations.
209
249
  */
210
250
  async close() {
251
+ if (_tracker) {
252
+ await _tracker.stop().catch(() => {});
253
+ _tracker = null;
254
+ }
211
255
  if (_client) {
212
256
  await _client.quit().catch(() => {});
213
257
  _client = null;
@@ -5,7 +5,7 @@
5
5
  * 1. FT._LIST → enumerate index names
6
6
  * 2. FT.INFO → read schema (prefix patterns, field attributes)
7
7
  * 3. Map RediSearch field types to RespLite TEXT fields
8
- * 4. FT.CREATE in RespLite (skip if already exists and skipExisting=true)
8
+ * 4. FT.CREATE in RespLite (or reuse the existing destination index when skipExisting=true)
9
9
  * 5. SCAN keys by prefix → HGETALL → addDocument in SQLite batches
10
10
  * 6. FT.SUGGET → import suggestions
11
11
  *
@@ -198,6 +198,18 @@ function buildDocFields(hashData, fieldMap, schemaFields) {
198
198
  return docFields;
199
199
  }
200
200
 
201
+ /**
202
+ * Read document score from Redis hash fields.
203
+ * Prefers `__score`, then `score`, and falls back to `1.0`.
204
+ *
205
+ * @param {Record<string, string>} hashData
206
+ * @returns {number}
207
+ */
208
+ function getDocScore(hashData) {
209
+ const rawScore = hashData['__score'] ?? hashData['score'];
210
+ return rawScore ? (parseFloat(rawScore) || 1.0) : 1.0;
211
+ }
212
+
201
213
  /**
202
214
  * Import suggestions from a RediSearch index via FT.SUGGET "" MAX n WITHSCORES.
203
215
  * RediSearch has no cursor for FT.SUGGET; maxSuggestions caps the import.
@@ -249,7 +261,7 @@ async function importSuggestions(redisClient, db, indexName, maxSuggestions) {
249
261
  * @param {number} [options.maxRps=0] - Max Redis requests/s (0 = unlimited).
250
262
  * @param {number} [options.batchDocs=200] - Docs per SQLite transaction.
251
263
  * @param {number} [options.maxSuggestions=10000] - Cap for FT.SUGGET import.
252
- * @param {boolean} [options.skipExisting=true] - Skip index if already in RespLite.
264
+ * @param {boolean} [options.skipExisting=true] - Reuse an existing destination index and skip FT.CREATE instead of failing.
253
265
  * @param {boolean} [options.withSuggestions=true] - Also migrate suggestions.
254
266
  * @param {(result: IndexResult) => void} [options.onProgress]
255
267
  * @returns {Promise<{ indices: IndexResult[], aborted: boolean }>}
@@ -327,6 +339,7 @@ export async function runMigrateSearch(redisClient, dbPath, options = {}) {
327
339
  if (e.message.includes('already exists')) {
328
340
  if (skipExisting) {
329
341
  skipped = true;
342
+ warnings.push(`Index "${indexName}" already exists in destination; reusing existing schema`);
330
343
  } else {
331
344
  results.push({ ...errorResult(indexName, 'Index already exists in destination'), warnings });
332
345
  continue;
@@ -341,6 +354,7 @@ export async function runMigrateSearch(redisClient, dbPath, options = {}) {
341
354
  let docsImported = 0;
342
355
  let docsSkipped = 0;
343
356
  let docErrors = 0;
357
+ const seenKeys = new Set();
344
358
 
345
359
  // Batch infrastructure: accumulate HGETALL results, flush in SQLite transactions
346
360
  const pendingHashData = new Map();
@@ -351,8 +365,7 @@ export async function runMigrateSearch(redisClient, dbPath, options = {}) {
351
365
  const hashData = pendingHashData.get(key);
352
366
  if (!hashData) continue;
353
367
  const docFields = buildDocFields(hashData, fieldMap, fields);
354
- const rawScore = hashData['__score'] ?? hashData['score'];
355
- const score = rawScore ? (parseFloat(rawScore) || 1.0) : 1.0;
368
+ const score = getDocScore(hashData);
356
369
  addDocument(db, indexName, key, score, true, docFields);
357
370
  }
358
371
  });
@@ -369,7 +382,7 @@ export async function runMigrateSearch(redisClient, dbPath, options = {}) {
369
382
  try {
370
383
  const hd = pendingHashData.get(k);
371
384
  if (!hd) continue;
372
- addDocument(db, indexName, k, 1.0, true, buildDocFields(hd, fieldMap, fields));
385
+ addDocument(db, indexName, k, getDocScore(hd), true, buildDocFields(hd, fieldMap, fields));
373
386
  docsImported++;
374
387
  } catch (_e) {
375
388
  docErrors++;
@@ -406,11 +419,17 @@ export async function runMigrateSearch(redisClient, dbPath, options = {}) {
406
419
  continue;
407
420
  }
408
421
 
422
+ if (seenKeys.has(key)) {
423
+ continue;
424
+ }
425
+
409
426
  if (!hashData || typeof hashData !== 'object' || Object.keys(hashData).length === 0) {
427
+ seenKeys.add(key);
410
428
  docsSkipped++;
411
429
  continue;
412
430
  }
413
431
 
432
+ seenKeys.add(key);
414
433
  pendingHashData.set(key, hashData);
415
434
  pendingKeys.push(key);
416
435
 
@@ -454,4 +473,4 @@ function errorResult(name, error) {
454
473
  }
455
474
 
456
475
  // ── Exported helpers (used by tests) ─────────────────────────────────────────
457
- export { mapFields, buildDocFields };
476
+ export { mapFields, buildDocFields, getDocScore };
@@ -27,6 +27,13 @@ const KEYEVENT_PATTERN = '__keyevent@0__:*';
27
27
  * @param {string} options.runId - Migration run identifier.
28
28
  * @param {string} [options.pragmaTemplate='default']
29
29
  * @param {string} [options.configCommand='CONFIG'] - CONFIG command name (in case it was renamed).
30
+ * @param {(progress: {
31
+ * runId: string,
32
+ * key: string,
33
+ * event: string,
34
+ * totalEvents: number,
35
+ * at: string
36
+ * }) => void | Promise<void>} [options.onProgress] - Called for each tracked keyspace event.
30
37
  * @returns {Promise<{ stop(): Promise<void> }>}
31
38
  * @throws {Error} If keyspace notifications are not enabled on Redis.
32
39
  */
@@ -36,6 +43,7 @@ export async function startDirtyTracker({
36
43
  runId,
37
44
  pragmaTemplate = 'default',
38
45
  configCommand = 'CONFIG',
46
+ onProgress,
39
47
  } = {}) {
40
48
  if (!to) throw new Error('startDirtyTracker: "to" (db path) is required');
41
49
  if (!runId) throw new Error('startDirtyTracker: "runId" is required');
@@ -66,12 +74,27 @@ export async function startDirtyTracker({
66
74
  );
67
75
  await subClient.connect();
68
76
 
77
+ let totalEvents = 0;
69
78
  await subClient.pSubscribe(KEYEVENT_PATTERN, (message, channel) => {
70
79
  const event = typeof channel === 'string'
71
80
  ? channel.split(':').pop()
72
81
  : String(channel ?? '').split(':').pop() || 'unknown';
73
82
  try {
74
83
  upsertDirtyKey(db, runId, message, event);
84
+ totalEvents += 1;
85
+ if (onProgress) {
86
+ Promise.resolve(
87
+ onProgress({
88
+ runId,
89
+ key: message,
90
+ event,
91
+ totalEvents,
92
+ at: new Date().toISOString(),
93
+ })
94
+ ).catch((err) => {
95
+ logError(db, runId, 'dirty_apply', 'Tracker onProgress error: ' + err.message, message);
96
+ });
97
+ }
75
98
  } catch (err) {
76
99
  logError(db, runId, 'dirty_apply', err.message, message);
77
100
  }
@@ -100,13 +100,17 @@ describe('dirty tracker integration', { timeout: 30_000 }, () => {
100
100
  const runId = `tracker-test-${Date.now()}`;
101
101
 
102
102
  const m = createMigration({ from: REDIS_URL, to: dbPath, runId });
103
+ const progressEvents = [];
103
104
 
104
105
  const ks = await m.enableKeyspaceNotifications({ value: 'KEA' });
105
106
  assert.ok(ks.ok, `Failed to enable keyspace notifications: ${ks.error}`);
106
107
 
107
- // ── Start tracker BEFORE bulk ────────────────────────────────────────
108
- const tracker = await startDirtyTracker({ from: REDIS_URL, to: dbPath, runId });
109
-
108
+ // ── Start tracker BEFORE bulk (same-script API) ─────────────────────
109
+ await m.startDirtyTracker({
110
+ onProgress: (p) => {
111
+ progressEvents.push(p);
112
+ },
113
+ });
110
114
  try {
111
115
  // ── Bulk import (captures the initial snapshot) ──────────────────
112
116
  const run = await m.bulk();
@@ -121,7 +125,7 @@ describe('dirty tracker integration', { timeout: 30_000 }, () => {
121
125
  // Give the tracker time to process the keyspace events
122
126
  await new Promise((r) => setTimeout(r, 600));
123
127
  } finally {
124
- await tracker.stop();
128
+ await m.stopDirtyTracker();
125
129
  }
126
130
 
127
131
  // ── Verify dirty keys were captured ─────────────────────────────────
@@ -130,6 +134,7 @@ describe('dirty tracker integration', { timeout: 30_000 }, () => {
130
134
  dirty.dirty + dirty.deleted >= 3,
131
135
  `Expected ≥3 dirty/deleted keys, got dirty=${dirty.dirty} deleted=${dirty.deleted}`
132
136
  );
137
+ assert.ok(progressEvents.length >= 3, `Expected tracker onProgress events, got ${progressEvents.length}`);
133
138
 
134
139
  // ── Apply-dirty: reconcile post-bulk changes ─────────────────────────
135
140
  const afterApply = await m.applyDirty();
@@ -0,0 +1,51 @@
1
+ import { describe, it, before, after } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createTestServer } from '../helpers/server.js';
4
+ import { sendCommand, argv } from '../helpers/client.js';
5
+
6
+ describe('OBJECT IDLETIME integration', () => {
7
+ let s;
8
+ let port;
9
+
10
+ before(async () => {
11
+ s = await createTestServer();
12
+ port = s.port;
13
+ });
14
+
15
+ after(async () => {
16
+ await s.closeAsync();
17
+ });
18
+
19
+ it('OBJECT IDLETIME missing key returns nil', async () => {
20
+ const reply = await sendCommand(port, argv('OBJECT', 'IDLETIME', 'nokey'));
21
+ assert.equal(reply.toString('ascii'), '$-1\r\n');
22
+ });
23
+
24
+ it('OBJECT IDLETIME returns seconds since last write', async () => {
25
+ await sendCommand(port, argv('SET', 'idlekey', 'v'));
26
+ const reply = await sendCommand(port, argv('OBJECT', 'IDLETIME', 'idlekey'));
27
+ const line = reply.toString('ascii');
28
+ assert.match(line, /^:\d+\r\n$/);
29
+ const seconds = parseInt(line.replace(/\D/g, ''), 10);
30
+ assert.ok(seconds >= 0);
31
+ });
32
+
33
+ it('OBJECT IDLETIME increases after write', async () => {
34
+ await sendCommand(port, argv('SET', 'idlekey2', 'v'));
35
+ await sendCommand(port, argv('OBJECT', 'IDLETIME', 'idlekey2'));
36
+ await new Promise((r) => setTimeout(r, 1100));
37
+ const reply = await sendCommand(port, argv('OBJECT', 'IDLETIME', 'idlekey2'));
38
+ const seconds = parseInt(reply.toString('ascii').replace(/\D/g, ''), 10);
39
+ assert.ok(seconds >= 1, 'idle time should be at least 1 second after 1.1s wait');
40
+ });
41
+
42
+ it('OBJECT wrong subcommand returns error', async () => {
43
+ const reply = await sendCommand(port, argv('OBJECT', 'REFCOUNT', 'k'));
44
+ assert.ok(reply.toString('ascii').startsWith('-ERR'));
45
+ });
46
+
47
+ it('OBJECT with wrong number of args returns error', async () => {
48
+ const reply = await sendCommand(port, argv('OBJECT', 'IDLETIME'));
49
+ assert.ok(reply.toString('ascii').startsWith('-ERR'));
50
+ });
51
+ });
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { describe, it } from 'node:test';
8
8
  import assert from 'node:assert/strict';
9
- import { runMigrateSearch, mapFields, buildDocFields } from '../../src/migration/migrate-search.js';
9
+ import { runMigrateSearch, mapFields, buildDocFields, getDocScore } from '../../src/migration/migrate-search.js';
10
10
  import { openDb } from '../../src/storage/sqlite/db.js';
11
11
  import { getIndexMeta, getIndexCounts, search, suggestionGet } from '../../src/storage/sqlite/search.js';
12
12
  import { tmpDbPath } from '../helpers/tmp.js';
@@ -188,6 +188,26 @@ describe('buildDocFields', () => {
188
188
  });
189
189
  });
190
190
 
191
+ // ── getDocScore ───────────────────────────────────────────────────────────────
192
+
193
+ describe('getDocScore', () => {
194
+ it('prefers __score over score', () => {
195
+ assert.equal(getDocScore({ __score: '2.5', score: '1.0' }), 2.5);
196
+ });
197
+
198
+ it('uses score when __score is absent', () => {
199
+ assert.equal(getDocScore({ score: '3.25' }), 3.25);
200
+ });
201
+
202
+ it('falls back to 1.0 for invalid score values', () => {
203
+ assert.equal(getDocScore({ score: 'not-a-number' }), 1.0);
204
+ });
205
+
206
+ it('falls back to 1.0 when score fields are missing', () => {
207
+ assert.equal(getDocScore({}), 1.0);
208
+ });
209
+ });
210
+
191
211
  // ── runMigrateSearch — core behaviour ─────────────────────────────────────────
192
212
 
193
213
  describe('runMigrateSearch', () => {
@@ -326,7 +346,7 @@ describe('runMigrateSearch', () => {
326
346
  assert.equal(idx.docsSkipped, 2);
327
347
  });
328
348
 
329
- it('skipExisting=true skips already-existing index', async () => {
349
+ it('skipExisting=true reuses an existing index and refreshes documents', async () => {
330
350
  const dbPath = tmpDbPath();
331
351
  // First run — creates index
332
352
  const redis = makeFakeRedis({
@@ -342,6 +362,8 @@ describe('runMigrateSearch', () => {
342
362
  assert.equal(result2.indices[0].skipped, true);
343
363
  assert.equal(result2.indices[0].created, false);
344
364
  assert.equal(result2.indices[0].error, undefined);
365
+ assert.equal(result2.indices[0].docsImported, 1);
366
+ assert.ok(result2.indices[0].warnings.some((w) => w.includes('reusing existing schema')));
345
367
  });
346
368
 
347
369
  it('skipExisting=false errors on existing index', async () => {
@@ -374,6 +396,32 @@ describe('runMigrateSearch', () => {
374
396
  assert.deepEqual(names, ['a', 'c']);
375
397
  });
376
398
 
399
+ it('deduplicates keys that match overlapping index prefixes', async () => {
400
+ const dbPath = tmpDbPath();
401
+ const redis = makeFakeRedis({
402
+ indexNames: ['overlap'],
403
+ indexInfo: {
404
+ overlap: {
405
+ prefixes: ['doc:', 'doc:special:'],
406
+ attributes: [{ identifier: 'payload', attribute: 'payload', type: 'TEXT' }],
407
+ },
408
+ },
409
+ scanKeys: ['doc:special:1'],
410
+ hashKeys: {
411
+ 'doc:special:1': { payload: 'hello overlap' },
412
+ },
413
+ });
414
+
415
+ const result = await runMigrateSearch(redis, dbPath);
416
+ assert.equal(result.indices.length, 1);
417
+ assert.equal(result.indices[0].docsImported, 1);
418
+
419
+ const db = openDb(dbPath, { pragmaTemplate: 'minimal' });
420
+ const counts = getIndexCounts(db, 'overlap');
421
+ assert.equal(counts.num_docs, 1);
422
+ db.close();
423
+ });
424
+
377
425
  it('imports suggestions', async () => {
378
426
  const dbPath = tmpDbPath();
379
427
  const redis = makeFakeRedis({