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.
- package/README.md +168 -275
- package/package.json +1 -6
- package/scripts/create-interface-smoke.js +32 -0
- package/skills/README.md +22 -0
- package/skills/resplite-command-vertical-slice/SKILL.md +134 -0
- package/skills/resplite-ft-search-workbench/SKILL.md +138 -0
- package/skills/resplite-migration-cutover-assistant/SKILL.md +138 -0
- package/spec/00-INDEX.md +37 -0
- package/spec/01-overview-and-goals.md +125 -0
- package/spec/02-protocol-and-commands.md +174 -0
- package/spec/03-data-model-ttl-transactions.md +157 -0
- package/spec/04-cache-architecture.md +171 -0
- package/spec/05-scan-admin-implementation.md +379 -0
- package/spec/06-migration-strategy-core.md +79 -0
- package/spec/07-type-lists.md +202 -0
- package/spec/08-type-sorted-sets.md +220 -0
- package/spec/{SPEC_D.md → 09-search-ft-commands.md} +3 -1
- package/spec/{SPEC_E.md → 10-blocking-commands.md} +3 -1
- package/spec/{SPEC_F.md → 11-migration-dirty-registry.md} +61 -147
- package/src/commands/object.js +17 -0
- package/src/commands/registry.js +2 -0
- package/src/engine/engine.js +11 -0
- package/src/migration/apply-dirty.js +8 -1
- package/src/migration/index.js +5 -4
- package/src/migration/migrate-search.js +25 -6
- package/test/integration/object-idletime.test.js +51 -0
- package/test/unit/migrate-search.test.js +50 -2
- package/spec/SPEC_A.md +0 -1171
- package/spec/SPEC_B.md +0 -426
- package/src/cli/import-from-redis.js +0 -194
- package/src/cli/resplite-dirty-tracker.js +0 -92
- package/src/cli/resplite-import.js +0 -296
- 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 (
|
|
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] -
|
|
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
|
|
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,
|
|
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
|
|
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({
|