resplite 1.2.6 → 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 (33) hide show
  1. package/README.md +168 -275
  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 +5 -4
  25. package/src/migration/migrate-search.js +25 -6
  26. package/test/integration/object-idletime.test.js +51 -0
  27. package/test/unit/migrate-search.test.js +50 -2
  28. package/spec/SPEC_A.md +0 -1171
  29. package/spec/SPEC_B.md +0 -426
  30. package/src/cli/import-from-redis.js +0 -194
  31. package/src/cli/resplite-dirty-tracker.js +0 -92
  32. package/src/cli/resplite-import.js +0 -296
  33. package/test/contract/import-from-redis.test.js +0 -83
@@ -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 };
@@ -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({