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.
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Unit tests for migrate-search (SPEC_F §F.10).
3
+ *
4
+ * Uses a fake Redis client (no real Redis required) and real SQLite (tmpDbPath).
5
+ */
6
+
7
+ import { describe, it } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { runMigrateSearch, mapFields, buildDocFields } from '../../src/migration/migrate-search.js';
10
+ import { openDb } from '../../src/storage/sqlite/db.js';
11
+ import { getIndexMeta, getIndexCounts, search, suggestionGet } from '../../src/storage/sqlite/search.js';
12
+ import { tmpDbPath } from '../helpers/tmp.js';
13
+
14
+ // ── Fake Redis client builder ─────────────────────────────────────────────────
15
+
16
+ /**
17
+ * Build a minimal fake Redis client for migrate-search tests.
18
+ * Specify per-index data; everything else returns empty defaults.
19
+ *
20
+ * @param {object} opts
21
+ * @param {string[]} [opts.indexNames] FT._LIST response
22
+ * @param {object} [opts.indexInfo] indexName → { keyType?, prefixes?, attributes[] }
23
+ * @param {object} [opts.hashKeys] key → { field: value } (HGETALL responses)
24
+ * @param {string[]} [opts.scanKeys] Keys returned by SCAN
25
+ * @param {object} [opts.suggestions] indexName → [[term, score], ...]
26
+ */
27
+ function makeFakeRedis({
28
+ indexNames = [],
29
+ indexInfo = {},
30
+ hashKeys = {},
31
+ scanKeys = [],
32
+ suggestions = {},
33
+ } = {}) {
34
+ return {
35
+ async sendCommand(cmd) {
36
+ const [command, ...args] = cmd.map(String);
37
+
38
+ if (command === 'FT._LIST') {
39
+ return indexNames;
40
+ }
41
+
42
+ if (command === 'FT.INFO') {
43
+ const name = args[0];
44
+ const info = indexInfo[name];
45
+ if (!info) throw new Error(`ERR no such index`);
46
+ const { keyType = 'HASH', prefixes = ['doc:'], attributes = [] } = info;
47
+ // Return flat array format (same as older RediSearch / sendCommand)
48
+ const attrsArray = attributes.map((a) => [
49
+ 'identifier', a.identifier ?? a.name,
50
+ 'attribute', a.attribute ?? a.name,
51
+ 'type', a.type,
52
+ ]);
53
+ return [
54
+ 'index_name', name,
55
+ 'index_definition', [
56
+ 'key_type', keyType,
57
+ 'prefixes', prefixes,
58
+ ],
59
+ 'attributes', attrsArray,
60
+ ];
61
+ }
62
+
63
+ if (command === 'FT.SUGGET') {
64
+ const name = args[0];
65
+ const sugs = suggestions[name] ?? [];
66
+ // Returns [term, score, term, score, ...]
67
+ return sugs.flatMap(([term, score]) => [term, String(score)]);
68
+ }
69
+
70
+ return null;
71
+ },
72
+
73
+ async scan(cursor, { MATCH } = {}) {
74
+ // Simple: return all keys on first call, cursor=0 to signal end
75
+ if (cursor !== 0) return { cursor: 0, keys: [] };
76
+ const pattern = MATCH ? MATCH.replace(/\*$/, '') : '';
77
+ const matching = scanKeys.filter((k) => k.startsWith(pattern));
78
+ return { cursor: 0, keys: matching };
79
+ },
80
+
81
+ async hGetAll(key) {
82
+ return hashKeys[key] ?? {};
83
+ },
84
+ };
85
+ }
86
+
87
+ // ── mapFields ─────────────────────────────────────────────────────────────────
88
+
89
+ describe('mapFields', () => {
90
+ it('maps TEXT fields directly', () => {
91
+ const { fields, fieldMap, warnings } = mapFields([
92
+ { identifier: 'title', attribute: 'title', type: 'TEXT' },
93
+ { identifier: 'payload', attribute: 'payload', type: 'TEXT' },
94
+ ]);
95
+ assert.ok(fields.some((f) => f.name === 'title'));
96
+ assert.ok(fields.some((f) => f.name === 'payload'));
97
+ assert.equal(warnings.length, 0);
98
+ assert.equal(fieldMap.get('title'), 'title');
99
+ assert.equal(fieldMap.get('payload'), 'payload');
100
+ });
101
+
102
+ it('maps TAG and NUMERIC to TEXT with warnings', () => {
103
+ const { fields, warnings } = mapFields([
104
+ { identifier: 'tag_field', attribute: 'tag_field', type: 'TAG' },
105
+ { identifier: 'num_field', attribute: 'num_field', type: 'NUMERIC' },
106
+ ]);
107
+ assert.ok(fields.some((f) => f.name === 'tag_field'));
108
+ assert.ok(fields.some((f) => f.name === 'num_field'));
109
+ assert.equal(warnings.filter((w) => w.includes('TAG')).length, 1);
110
+ assert.equal(warnings.filter((w) => w.includes('NUMERIC')).length, 1);
111
+ });
112
+
113
+ it('skips GEO and VECTOR fields with warnings', () => {
114
+ const { fields, warnings } = mapFields([
115
+ { identifier: 'loc', attribute: 'loc', type: 'GEO' },
116
+ { identifier: 'vec', attribute: 'vec', type: 'VECTOR' },
117
+ ]);
118
+ assert.equal(fields.filter((f) => f.name === 'loc' || f.name === 'vec').length, 0);
119
+ assert.equal(warnings.filter((w) => w.includes('GEO')).length, 1);
120
+ assert.equal(warnings.filter((w) => w.includes('VECTOR')).length, 1);
121
+ });
122
+
123
+ it('always ensures a payload field exists', () => {
124
+ const { fields } = mapFields([
125
+ { identifier: 'title', attribute: 'title', type: 'TEXT' },
126
+ ]);
127
+ assert.ok(fields.some((f) => f.name === 'payload'));
128
+ });
129
+
130
+ it('does not duplicate payload when already present', () => {
131
+ const { fields } = mapFields([
132
+ { identifier: 'payload', attribute: 'payload', type: 'TEXT' },
133
+ ]);
134
+ assert.equal(fields.filter((f) => f.name === 'payload').length, 1);
135
+ });
136
+
137
+ it('sanitises attribute names with special characters', () => {
138
+ const { fields } = mapFields([
139
+ { identifier: 'my field!', attribute: 'my field!', type: 'TEXT' },
140
+ ]);
141
+ const names = fields.map((f) => f.name);
142
+ assert.ok(names.every((n) => /^[A-Za-z0-9:_-]+$/.test(n)), `invalid name in: ${names}`);
143
+ });
144
+
145
+ it('handles empty attributes array (adds only payload)', () => {
146
+ const { fields, warnings } = mapFields([]);
147
+ assert.equal(fields.length, 1);
148
+ assert.equal(fields[0].name, 'payload');
149
+ assert.equal(warnings.length, 0);
150
+ });
151
+ });
152
+
153
+ // ── buildDocFields ────────────────────────────────────────────────────────────
154
+
155
+ describe('buildDocFields', () => {
156
+ it('maps hash data to schema fields', () => {
157
+ const fieldMap = new Map([['title', 'title'], ['body', 'body'], ['payload', 'payload']]);
158
+ const schemaFields = [
159
+ { name: 'title' }, { name: 'body' }, { name: 'payload' },
160
+ ];
161
+ const result = buildDocFields({ title: 'Hello', body: 'World', payload: 'HP' }, fieldMap, schemaFields);
162
+ assert.equal(result.title, 'Hello');
163
+ assert.equal(result.body, 'World');
164
+ assert.equal(result.payload, 'HP');
165
+ });
166
+
167
+ it('defaults missing hash fields to empty string', () => {
168
+ const fieldMap = new Map([['title', 'title']]);
169
+ const schemaFields = [{ name: 'title' }, { name: 'payload' }];
170
+ const result = buildDocFields({}, fieldMap, schemaFields);
171
+ assert.equal(result.title, '');
172
+ assert.equal(result.payload, '');
173
+ });
174
+
175
+ it('synthesises payload from other fields when absent', () => {
176
+ const fieldMap = new Map([['title', 'title'], ['body', 'body']]);
177
+ const schemaFields = [{ name: 'title' }, { name: 'body' }, { name: 'payload' }];
178
+ const result = buildDocFields({ title: 'Foo', body: 'Bar' }, fieldMap, schemaFields);
179
+ assert.ok(result.payload.includes('Foo'));
180
+ assert.ok(result.payload.includes('Bar'));
181
+ });
182
+
183
+ it('does not overwrite explicit payload with synthesised value', () => {
184
+ const fieldMap = new Map([['payload', 'payload'], ['title', 'title']]);
185
+ const schemaFields = [{ name: 'payload' }, { name: 'title' }];
186
+ const result = buildDocFields({ payload: 'explicit', title: 'Other' }, fieldMap, schemaFields);
187
+ assert.equal(result.payload, 'explicit');
188
+ });
189
+ });
190
+
191
+ // ── runMigrateSearch — core behaviour ─────────────────────────────────────────
192
+
193
+ describe('runMigrateSearch', () => {
194
+ it('returns empty indices when FT._LIST returns []', async () => {
195
+ const redis = makeFakeRedis({ indexNames: [] });
196
+ const result = await runMigrateSearch(redis, tmpDbPath());
197
+ assert.deepEqual(result.indices, []);
198
+ assert.equal(result.aborted, false);
199
+ });
200
+
201
+ it('skips index with invalid name', async () => {
202
+ const redis = makeFakeRedis({ indexNames: ['1invalid-name'] });
203
+ const result = await runMigrateSearch(redis, tmpDbPath());
204
+ assert.equal(result.indices.length, 1);
205
+ assert.ok(result.indices[0].error);
206
+ assert.match(result.indices[0].error, /not valid in RespLite/);
207
+ });
208
+
209
+ it('skips non-HASH index type with error', async () => {
210
+ const redis = makeFakeRedis({
211
+ indexNames: ['jsonidx'],
212
+ indexInfo: { jsonidx: { keyType: 'JSON', prefixes: ['doc:'], attributes: [] } },
213
+ });
214
+ const result = await runMigrateSearch(redis, tmpDbPath());
215
+ assert.equal(result.indices.length, 1);
216
+ assert.ok(result.indices[0].error);
217
+ assert.match(result.indices[0].error, /JSON/);
218
+ });
219
+
220
+ it('creates index and imports TEXT documents', async () => {
221
+ const dbPath = tmpDbPath();
222
+ const redis = makeFakeRedis({
223
+ indexNames: ['products'],
224
+ indexInfo: {
225
+ products: {
226
+ prefixes: ['prod:'],
227
+ attributes: [
228
+ { identifier: 'name', attribute: 'name', type: 'TEXT' },
229
+ { identifier: 'payload', attribute: 'payload', type: 'TEXT' },
230
+ ],
231
+ },
232
+ },
233
+ scanKeys: ['prod:1', 'prod:2'],
234
+ hashKeys: {
235
+ 'prod:1': { name: 'Widget A', payload: 'A great widget' },
236
+ 'prod:2': { name: 'Widget B', payload: 'Another widget' },
237
+ },
238
+ });
239
+
240
+ const result = await runMigrateSearch(redis, dbPath);
241
+ assert.equal(result.indices.length, 1);
242
+ const idx = result.indices[0];
243
+ assert.equal(idx.name, 'products');
244
+ assert.equal(idx.created, true);
245
+ assert.equal(idx.docsImported, 2);
246
+ assert.equal(idx.docErrors, 0);
247
+ assert.equal(idx.error, undefined);
248
+
249
+ // Verify index and docs exist in the DB
250
+ const db = openDb(dbPath, { pragmaTemplate: 'minimal' });
251
+ const meta = getIndexMeta(db, 'products');
252
+ assert.ok(meta.schema.fields.some((f) => f.name === 'name'));
253
+ const counts = getIndexCounts(db, 'products');
254
+ assert.equal(counts.num_docs, 2);
255
+ db.close();
256
+ });
257
+
258
+ it('maps TAG and NUMERIC fields to TEXT', async () => {
259
+ const dbPath = tmpDbPath();
260
+ const redis = makeFakeRedis({
261
+ indexNames: ['mixed'],
262
+ indexInfo: {
263
+ mixed: {
264
+ prefixes: ['m:'],
265
+ attributes: [
266
+ { identifier: 'label', attribute: 'label', type: 'TAG' },
267
+ { identifier: 'price', attribute: 'price', type: 'NUMERIC' },
268
+ { identifier: 'payload',attribute: 'payload',type: 'TEXT' },
269
+ ],
270
+ },
271
+ },
272
+ scanKeys: ['m:1'],
273
+ hashKeys: { 'm:1': { label: 'red', price: '9.99', payload: 'item' } },
274
+ });
275
+
276
+ const result = await runMigrateSearch(redis, dbPath);
277
+ const idx = result.indices[0];
278
+ assert.equal(idx.docsImported, 1);
279
+ assert.equal(idx.warnings.filter((w) => w.includes('TAG')).length, 1);
280
+ assert.equal(idx.warnings.filter((w) => w.includes('NUMERIC')).length, 1);
281
+
282
+ const db = openDb(dbPath, { pragmaTemplate: 'minimal' });
283
+ const meta = getIndexMeta(db, 'mixed');
284
+ assert.ok(meta.schema.fields.every((f) => f.type === 'TEXT'));
285
+ db.close();
286
+ });
287
+
288
+ it('synthesises payload when hash has no payload field', async () => {
289
+ const dbPath = tmpDbPath();
290
+ const redis = makeFakeRedis({
291
+ indexNames: ['nopl'],
292
+ indexInfo: {
293
+ nopl: {
294
+ prefixes: ['np:'],
295
+ attributes: [
296
+ { identifier: 'title', attribute: 'title', type: 'TEXT' },
297
+ ],
298
+ },
299
+ },
300
+ scanKeys: ['np:1'],
301
+ hashKeys: { 'np:1': { title: 'A document title' } },
302
+ });
303
+
304
+ const result = await runMigrateSearch(redis, dbPath);
305
+ assert.equal(result.indices[0].docsImported, 1);
306
+
307
+ const db = openDb(dbPath, { pragmaTemplate: 'minimal' });
308
+ const r = search(db, 'nopl', 'document', { noContent: false });
309
+ assert.equal(r.total, 1);
310
+ db.close();
311
+ });
312
+
313
+ it('skips empty hash keys (docsSkipped++)', async () => {
314
+ const redis = makeFakeRedis({
315
+ indexNames: ['empties'],
316
+ indexInfo: { empties: { prefixes: ['e:'], attributes: [] } },
317
+ scanKeys: ['e:1', 'e:2'],
318
+ hashKeys: {
319
+ 'e:1': {}, // empty → skipped
320
+ 'e:2': null, // null → skipped
321
+ },
322
+ });
323
+ const result = await runMigrateSearch(redis, tmpDbPath());
324
+ const idx = result.indices[0];
325
+ assert.equal(idx.docsImported, 0);
326
+ assert.equal(idx.docsSkipped, 2);
327
+ });
328
+
329
+ it('skipExisting=true skips already-existing index', async () => {
330
+ const dbPath = tmpDbPath();
331
+ // First run — creates index
332
+ const redis = makeFakeRedis({
333
+ indexNames: ['myidx'],
334
+ indexInfo: { myidx: { prefixes: ['x:'], attributes: [{ identifier: 'payload', attribute: 'payload', type: 'TEXT' }] } },
335
+ scanKeys: ['x:1'],
336
+ hashKeys: { 'x:1': { payload: 'hello' } },
337
+ });
338
+ await runMigrateSearch(redis, dbPath, { skipExisting: true });
339
+
340
+ // Second run — should skip
341
+ const result2 = await runMigrateSearch(redis, dbPath, { skipExisting: true });
342
+ assert.equal(result2.indices[0].skipped, true);
343
+ assert.equal(result2.indices[0].created, false);
344
+ assert.equal(result2.indices[0].error, undefined);
345
+ });
346
+
347
+ it('skipExisting=false errors on existing index', async () => {
348
+ const dbPath = tmpDbPath();
349
+ const redis = makeFakeRedis({
350
+ indexNames: ['dup'],
351
+ indexInfo: { dup: { prefixes: ['d:'], attributes: [{ identifier: 'payload', attribute: 'payload', type: 'TEXT' }] } },
352
+ scanKeys: [],
353
+ hashKeys: {},
354
+ });
355
+ await runMigrateSearch(redis, dbPath);
356
+ const result2 = await runMigrateSearch(redis, dbPath, { skipExisting: false });
357
+ assert.ok(result2.indices[0].error);
358
+ assert.match(result2.indices[0].error, /already exists/);
359
+ });
360
+
361
+ it('onlyIndices filters which indices are migrated', async () => {
362
+ const redis = makeFakeRedis({
363
+ indexNames: ['a', 'b', 'c'],
364
+ indexInfo: {
365
+ a: { prefixes: ['a:'], attributes: [{ identifier: 'payload', attribute: 'payload', type: 'TEXT' }] },
366
+ b: { prefixes: ['b:'], attributes: [{ identifier: 'payload', attribute: 'payload', type: 'TEXT' }] },
367
+ c: { prefixes: ['c:'], attributes: [{ identifier: 'payload', attribute: 'payload', type: 'TEXT' }] },
368
+ },
369
+ scanKeys: [],
370
+ hashKeys: {},
371
+ });
372
+ const result = await runMigrateSearch(redis, tmpDbPath(), { onlyIndices: ['a', 'c'] });
373
+ const names = result.indices.map((i) => i.name);
374
+ assert.deepEqual(names, ['a', 'c']);
375
+ });
376
+
377
+ it('imports suggestions', async () => {
378
+ const dbPath = tmpDbPath();
379
+ const redis = makeFakeRedis({
380
+ indexNames: ['sug_test'],
381
+ indexInfo: {
382
+ sug_test: {
383
+ prefixes: ['s:'],
384
+ attributes: [{ identifier: 'payload', attribute: 'payload', type: 'TEXT' }],
385
+ },
386
+ },
387
+ scanKeys: [],
388
+ hashKeys: {},
389
+ suggestions: {
390
+ sug_test: [['apple', 10], ['apply', 5], ['banana', 1]],
391
+ },
392
+ });
393
+
394
+ const result = await runMigrateSearch(redis, dbPath, { withSuggestions: true });
395
+ assert.equal(result.indices[0].sugsImported, 3);
396
+
397
+ const db = openDb(dbPath, { pragmaTemplate: 'minimal' });
398
+ const sugs = suggestionGet(db, 'sug_test', 'app', { max: 10 });
399
+ assert.ok(sugs.length >= 2);
400
+ assert.ok(sugs.includes('apple'));
401
+ assert.ok(sugs.includes('apply'));
402
+ db.close();
403
+ });
404
+
405
+ it('withSuggestions=false skips suggestion import', async () => {
406
+ const dbPath = tmpDbPath();
407
+ const redis = makeFakeRedis({
408
+ indexNames: ['nosug'],
409
+ indexInfo: { nosug: { prefixes: ['n:'], attributes: [{ identifier: 'payload', attribute: 'payload', type: 'TEXT' }] } },
410
+ suggestions: { nosug: [['term', 1]] },
411
+ scanKeys: [],
412
+ hashKeys: {},
413
+ });
414
+ const result = await runMigrateSearch(redis, dbPath, { withSuggestions: false });
415
+ assert.equal(result.indices[0].sugsImported, 0);
416
+ });
417
+
418
+ it('handles FT.INFO failure gracefully', async () => {
419
+ const redis = makeFakeRedis({
420
+ indexNames: ['ghost'],
421
+ indexInfo: {}, // FT.INFO will throw because no entry
422
+ });
423
+ const result = await runMigrateSearch(redis, tmpDbPath());
424
+ assert.equal(result.indices.length, 1);
425
+ assert.ok(result.indices[0].error);
426
+ assert.match(result.indices[0].error, /FT\.INFO failed/);
427
+ });
428
+
429
+ it('handles SCAN with empty prefix (no prefix restriction)', async () => {
430
+ const dbPath = tmpDbPath();
431
+ const redis = makeFakeRedis({
432
+ indexNames: ['allkeys'],
433
+ indexInfo: {
434
+ allkeys: {
435
+ prefixes: [''], // empty prefix → match all keys
436
+ attributes: [{ identifier: 'payload', attribute: 'payload', type: 'TEXT' }],
437
+ },
438
+ },
439
+ scanKeys: ['doc:1', 'other:2'],
440
+ hashKeys: {
441
+ 'doc:1': { payload: 'first document' },
442
+ 'other:2': { payload: 'second document' },
443
+ },
444
+ });
445
+ const result = await runMigrateSearch(redis, dbPath);
446
+ assert.equal(result.indices[0].docsImported, 2);
447
+ });
448
+
449
+ it('reads document score from __score field', async () => {
450
+ const dbPath = tmpDbPath();
451
+ const redis = makeFakeRedis({
452
+ indexNames: ['scored'],
453
+ indexInfo: {
454
+ scored: {
455
+ prefixes: ['s:'],
456
+ attributes: [{ identifier: 'payload', attribute: 'payload', type: 'TEXT' }],
457
+ },
458
+ },
459
+ scanKeys: ['s:1'],
460
+ hashKeys: { 's:1': { payload: 'important', __score: '2.5' } },
461
+ });
462
+ const result = await runMigrateSearch(redis, dbPath);
463
+ assert.equal(result.indices[0].docsImported, 1);
464
+ // Verify doc exists and is searchable
465
+ const db = openDb(dbPath, { pragmaTemplate: 'minimal' });
466
+ const r = search(db, 'scored', 'important', { noContent: true });
467
+ assert.equal(r.total, 1);
468
+ db.close();
469
+ });
470
+
471
+ it('onProgress is called for each index', async () => {
472
+ const calls = [];
473
+ const redis = makeFakeRedis({
474
+ indexNames: ['idx1', 'idx2'],
475
+ indexInfo: {
476
+ idx1: { prefixes: ['i1:'], attributes: [{ identifier: 'payload', attribute: 'payload', type: 'TEXT' }] },
477
+ idx2: { prefixes: ['i2:'], attributes: [{ identifier: 'payload', attribute: 'payload', type: 'TEXT' }] },
478
+ },
479
+ scanKeys: [],
480
+ hashKeys: {},
481
+ });
482
+ await runMigrateSearch(redis, tmpDbPath(), { onProgress: (r) => calls.push(r.name) });
483
+ assert.deepEqual(calls, ['idx1', 'idx2']);
484
+ });
485
+
486
+ it('does not call onProgress for error/skip before index processing', async () => {
487
+ const calls = [];
488
+ const redis = makeFakeRedis({
489
+ indexNames: ['1bad'], // invalid name — errored before processing
490
+ });
491
+ await runMigrateSearch(redis, tmpDbPath(), {
492
+ onProgress: (r) => calls.push(r.name),
493
+ });
494
+ // Error results are pushed to results[] but onProgress is NOT called for them
495
+ assert.equal(calls.length, 0);
496
+ });
497
+ });