resplite 1.4.12 → 1.4.16
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/package.json
CHANGED
|
@@ -28,6 +28,46 @@ function tableName(idx, suffix) {
|
|
|
28
28
|
return `search_${suffix}__${idx}`;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Build deterministic sorted field names for FTS column order.
|
|
33
|
+
* @param {{ fields: { name: string, type: string }[] }} schema
|
|
34
|
+
* @returns {string[]}
|
|
35
|
+
*/
|
|
36
|
+
function getSortedFieldNames(schema) {
|
|
37
|
+
return schema.fields.map((f) => f.name).sort();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Encode field values in deterministic FTS column order.
|
|
42
|
+
* Missing values are normalized to empty string to match insert semantics.
|
|
43
|
+
* @param {string[]} fieldNames
|
|
44
|
+
* @param {Record<string, string>} fields
|
|
45
|
+
* @returns {string[]}
|
|
46
|
+
*/
|
|
47
|
+
function encodeFtsFieldValues(fieldNames, fields) {
|
|
48
|
+
return fieldNames.map((f) => fields[f] ?? '');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Delete a specific contentless FTS row using the special 'delete' command row.
|
|
53
|
+
* FTS5 requires passing the exact prior values for all indexed columns.
|
|
54
|
+
* @param {import('better-sqlite3').Database} db
|
|
55
|
+
* @param {string} ftsTableName
|
|
56
|
+
* @param {number} ftsRowid
|
|
57
|
+
* @param {string[]} fieldNames
|
|
58
|
+
* @param {Record<string, string>} fields
|
|
59
|
+
*/
|
|
60
|
+
function deleteFtsRow(db, ftsTableName, ftsRowid, fieldNames, fields) {
|
|
61
|
+
const values = encodeFtsFieldValues(fieldNames, fields);
|
|
62
|
+
const columns = [ftsTableName, 'rowid', ...fieldNames];
|
|
63
|
+
const placeholders = columns.map(() => '?').join(', ');
|
|
64
|
+
db.prepare(`INSERT INTO ${ftsTableName}(${columns.join(', ')}) VALUES (${placeholders})`).run(
|
|
65
|
+
'delete',
|
|
66
|
+
ftsRowid,
|
|
67
|
+
...values
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
31
71
|
/**
|
|
32
72
|
* Build canonical schema JSON (fields sorted by name). D.12.2
|
|
33
73
|
* @param {{ name: string, type: string }[]} fields
|
|
@@ -138,7 +178,7 @@ export function getIndexMeta(db, name) {
|
|
|
138
178
|
*/
|
|
139
179
|
export function addDocument(db, idx, docId, score, replace, fields) {
|
|
140
180
|
const meta = getIndexMeta(db, idx);
|
|
141
|
-
const fieldNames = meta.schema
|
|
181
|
+
const fieldNames = getSortedFieldNames(meta.schema);
|
|
142
182
|
for (const k of Object.keys(fields)) {
|
|
143
183
|
if (!fieldNames.includes(k)) throw new Error('ERR unknown field');
|
|
144
184
|
}
|
|
@@ -155,8 +195,13 @@ export function addDocument(db, idx, docId, score, replace, fields) {
|
|
|
155
195
|
if (existing) {
|
|
156
196
|
if (!replace) throw new Error('ERR document exists');
|
|
157
197
|
ftsRowid = existing.fts_rowid;
|
|
198
|
+
const oldDoc = db.prepare(`SELECT fields_json FROM ${docsT} WHERE doc_id = ?`).get(docId);
|
|
199
|
+
if (oldDoc?.fields_json) {
|
|
200
|
+
const oldFields = JSON.parse(oldDoc.fields_json);
|
|
201
|
+
deleteFtsRow(db, ftsT, ftsRowid, fieldNames, oldFields);
|
|
202
|
+
}
|
|
158
203
|
} else {
|
|
159
|
-
const maxRow = db.prepare(`SELECT COALESCE(MAX(
|
|
204
|
+
const maxRow = db.prepare(`SELECT COALESCE(MAX(rowid), 0) AS m FROM ${ftsT}`).get();
|
|
160
205
|
ftsRowid = maxRow.m + 1;
|
|
161
206
|
db.prepare(`INSERT INTO ${docmapT}(doc_id, fts_rowid) VALUES (?, ?)`).run(docId, ftsRowid);
|
|
162
207
|
}
|
|
@@ -172,12 +217,11 @@ export function addDocument(db, idx, docId, score, replace, fields) {
|
|
|
172
217
|
).run(docId, score, fieldsJson, now, now);
|
|
173
218
|
}
|
|
174
219
|
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
const ftsValues = [ftsRowid, ...fieldNames.sort().map((f) => fields[f] ?? '')];
|
|
220
|
+
const ftsColumns = ['rowid', ...fieldNames];
|
|
221
|
+
const ftsValues = [ftsRowid, ...encodeFtsFieldValues(fieldNames, fields)];
|
|
178
222
|
const placeholders = ftsValues.map(() => '?').join(', ');
|
|
179
223
|
const colList = ftsColumns.join(', ');
|
|
180
|
-
db.prepare(`INSERT
|
|
224
|
+
db.prepare(`INSERT INTO ${ftsT}(${colList}) VALUES (${placeholders})`).run(...ftsValues);
|
|
181
225
|
});
|
|
182
226
|
|
|
183
227
|
run();
|
|
@@ -214,7 +258,8 @@ export function getDocumentFields(db, idx, docId) {
|
|
|
214
258
|
}
|
|
215
259
|
|
|
216
260
|
export function deleteDocument(db, idx, docId) {
|
|
217
|
-
getIndexMeta(db, idx);
|
|
261
|
+
const meta = getIndexMeta(db, idx);
|
|
262
|
+
const fieldNames = getSortedFieldNames(meta.schema);
|
|
218
263
|
const docsT = tableName(idx, 'docs');
|
|
219
264
|
const docmapT = tableName(idx, 'docmap');
|
|
220
265
|
const ftsT = tableName(idx, 'fts');
|
|
@@ -222,9 +267,12 @@ export function deleteDocument(db, idx, docId) {
|
|
|
222
267
|
const row = db.prepare(`SELECT fts_rowid FROM ${docmapT} WHERE doc_id = ?`).get(docId);
|
|
223
268
|
if (!row) return 0;
|
|
224
269
|
|
|
225
|
-
// FTS5 contentless does not support DELETE. Remove from docs and docmap; FTS row becomes orphaned
|
|
226
|
-
// (search results join through docmap so orphaned FTS rows are not returned).
|
|
227
270
|
db.transaction(() => {
|
|
271
|
+
const docRow = db.prepare(`SELECT fields_json FROM ${docsT} WHERE doc_id = ?`).get(docId);
|
|
272
|
+
if (docRow?.fields_json) {
|
|
273
|
+
const fields = JSON.parse(docRow.fields_json);
|
|
274
|
+
deleteFtsRow(db, ftsT, row.fts_rowid, fieldNames, fields);
|
|
275
|
+
}
|
|
228
276
|
db.prepare(`DELETE FROM ${docsT} WHERE doc_id = ?`).run(docId);
|
|
229
277
|
db.prepare(`DELETE FROM ${docmapT} WHERE doc_id = ?`).run(docId);
|
|
230
278
|
})();
|
|
@@ -196,6 +196,71 @@ describe('Search integration', () => {
|
|
|
196
196
|
const err = v?.error ?? v;
|
|
197
197
|
assert.ok(String(err).includes('Unknown index name'));
|
|
198
198
|
});
|
|
199
|
+
|
|
200
|
+
it('FT.SEARCH prefix query should not match unrelated terms', async () => {
|
|
201
|
+
// Create a fresh index for this test
|
|
202
|
+
await sendCommand(port, argv('FT.CREATE', 'bugtest', 'SCHEMA', 'payload', 'TEXT'));
|
|
203
|
+
|
|
204
|
+
// Add document with "gorge" first
|
|
205
|
+
await sendCommand(port, argv('FT.ADD', 'bugtest', 'DOC1', '1', 'REPLACE', 'FIELDS', 'payload', 'gorge'));
|
|
206
|
+
|
|
207
|
+
// Verify it matches gorge*
|
|
208
|
+
const gorge1 = await sendCommand(port, argv('FT.SEARCH', 'bugtest', 'gorge*', 'NOCONTENT', 'LIMIT', '0', '10'));
|
|
209
|
+
const g1 = tryParseValue(gorge1, 0).value;
|
|
210
|
+
assert.equal(g1[0], 1, 'gorge* should match gorge');
|
|
211
|
+
assert.equal(g1[1].toString?.('utf8') ?? g1[1], 'DOC1');
|
|
212
|
+
|
|
213
|
+
// Replace with "martan"
|
|
214
|
+
await sendCommand(port, argv('FT.ADD', 'bugtest', 'DOC1', '1', 'REPLACE', 'FIELDS', 'payload', 'martan'));
|
|
215
|
+
|
|
216
|
+
// Verify FT.GET shows only martan
|
|
217
|
+
const get = await sendCommand(port, argv('FT.GET', 'bugtest', 'DOC1'));
|
|
218
|
+
const getArr = tryParseValue(get, 0).value;
|
|
219
|
+
assert.equal(getArr[0].toString?.('utf8') ?? getArr[0], 'payload');
|
|
220
|
+
assert.equal(getArr[1].toString?.('utf8') ?? getArr[1], 'martan');
|
|
221
|
+
|
|
222
|
+
// martan* should match
|
|
223
|
+
const martan = await sendCommand(port, argv('FT.SEARCH', 'bugtest', 'martan*', 'NOCONTENT', 'LIMIT', '0', '10'));
|
|
224
|
+
const m = tryParseValue(martan, 0).value;
|
|
225
|
+
assert.equal(m[0], 1, 'martan* should match martan');
|
|
226
|
+
assert.equal(m[1].toString?.('utf8') ?? m[1], 'DOC1');
|
|
227
|
+
|
|
228
|
+
// gorge* should NOT match martan
|
|
229
|
+
const gorge2 = await sendCommand(port, argv('FT.SEARCH', 'bugtest', 'gorge*', 'NOCONTENT', 'LIMIT', '0', '10'));
|
|
230
|
+
const g2 = tryParseValue(gorge2, 0).value;
|
|
231
|
+
assert.equal(g2[0], 0, 'gorge* should NOT match martan - this is the bug');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('FT.DEL followed by FT.ADD REPLACE does not keep stale tokens', async () => {
|
|
235
|
+
await sendCommand(port, argv('FT.CREATE', 'del_replace_idx', 'SCHEMA', 'payload', 'TEXT'));
|
|
236
|
+
|
|
237
|
+
const addBicho = await sendCommand(
|
|
238
|
+
port,
|
|
239
|
+
argv('FT.ADD', 'del_replace_idx', 'DY1O2', '1', 'REPLACE', 'FIELDS', 'payload', 'bicho')
|
|
240
|
+
);
|
|
241
|
+
assert.equal(tryParseValue(addBicho, 0).value, 'OK');
|
|
242
|
+
|
|
243
|
+
const del = await sendCommand(port, argv('FT.DEL', 'del_replace_idx', 'DY1O2'));
|
|
244
|
+
assert.equal(tryParseValue(del, 0).value, 1);
|
|
245
|
+
|
|
246
|
+
const addGorrion = await sendCommand(
|
|
247
|
+
port,
|
|
248
|
+
argv('FT.ADD', 'del_replace_idx', 'DY1O2', '1', 'REPLACE', 'FIELDS', 'payload', 'gorrion')
|
|
249
|
+
);
|
|
250
|
+
assert.equal(tryParseValue(addGorrion, 0).value, 'OK');
|
|
251
|
+
|
|
252
|
+
const oldPrefix = await sendCommand(port, argv('FT.SEARCH', 'del_replace_idx', 'bicho*', 'NOCONTENT', 'LIMIT', '0', '10'));
|
|
253
|
+
const oldArr = tryParseValue(oldPrefix, 0).value;
|
|
254
|
+
assert.equal(oldArr[0], 0, 'bicho* should not match after re-adding DY1O2 with gorrion');
|
|
255
|
+
|
|
256
|
+
const newPrefix = await sendCommand(
|
|
257
|
+
port,
|
|
258
|
+
argv('FT.SEARCH', 'del_replace_idx', 'gorrion*', 'NOCONTENT', 'LIMIT', '0', '10')
|
|
259
|
+
);
|
|
260
|
+
const newArr = tryParseValue(newPrefix, 0).value;
|
|
261
|
+
assert.equal(newArr[0], 1);
|
|
262
|
+
assert.equal(newArr[1].toString?.('utf8') ?? newArr[1], 'DY1O2');
|
|
263
|
+
});
|
|
199
264
|
});
|
|
200
265
|
|
|
201
266
|
describe('Search persistence', () => {
|