resplite 1.4.14 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resplite",
3
- "version": "1.4.14",
3
+ "version": "1.4.16",
4
4
  "description": "A RESP2 server with practical Redis compatibility, backed by SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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.fields.map((f) => f.name);
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
  }
@@ -154,13 +194,14 @@ export function addDocument(db, idx, docId, score, replace, fields) {
154
194
  let ftsRowid;
155
195
  if (existing) {
156
196
  if (!replace) throw new Error('ERR document exists');
157
- // FTS5 contentless bug: INSERT OR REPLACE doesn't remove old tokens.
158
- // Solution: assign a new fts_rowid to avoid token pollution.
159
- const maxRow = db.prepare(`SELECT COALESCE(MAX(fts_rowid), 0) AS m FROM ${docmapT}`).get();
160
- ftsRowid = maxRow.m + 1;
161
- db.prepare(`UPDATE ${docmapT} SET fts_rowid = ? WHERE doc_id = ?`).run(ftsRowid, docId);
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
+ }
162
203
  } else {
163
- const maxRow = db.prepare(`SELECT COALESCE(MAX(fts_rowid), 0) AS m FROM ${docmapT}`).get();
204
+ const maxRow = db.prepare(`SELECT COALESCE(MAX(rowid), 0) AS m FROM ${ftsT}`).get();
164
205
  ftsRowid = maxRow.m + 1;
165
206
  db.prepare(`INSERT INTO ${docmapT}(doc_id, fts_rowid) VALUES (?, ?)`).run(docId, ftsRowid);
166
207
  }
@@ -176,9 +217,8 @@ export function addDocument(db, idx, docId, score, replace, fields) {
176
217
  ).run(docId, score, fieldsJson, now, now);
177
218
  }
178
219
 
179
- // FTS5 contentless: insert with new rowid (old rowid becomes orphaned and won't match via docmap join).
180
- const ftsColumns = ['rowid', ...fieldNames.sort()];
181
- const ftsValues = [ftsRowid, ...fieldNames.sort().map((f) => fields[f] ?? '')];
220
+ const ftsColumns = ['rowid', ...fieldNames];
221
+ const ftsValues = [ftsRowid, ...encodeFtsFieldValues(fieldNames, fields)];
182
222
  const placeholders = ftsValues.map(() => '?').join(', ');
183
223
  const colList = ftsColumns.join(', ');
184
224
  db.prepare(`INSERT INTO ${ftsT}(${colList}) VALUES (${placeholders})`).run(...ftsValues);
@@ -218,7 +258,8 @@ export function getDocumentFields(db, idx, docId) {
218
258
  }
219
259
 
220
260
  export function deleteDocument(db, idx, docId) {
221
- getIndexMeta(db, idx);
261
+ const meta = getIndexMeta(db, idx);
262
+ const fieldNames = getSortedFieldNames(meta.schema);
222
263
  const docsT = tableName(idx, 'docs');
223
264
  const docmapT = tableName(idx, 'docmap');
224
265
  const ftsT = tableName(idx, 'fts');
@@ -226,9 +267,12 @@ export function deleteDocument(db, idx, docId) {
226
267
  const row = db.prepare(`SELECT fts_rowid FROM ${docmapT} WHERE doc_id = ?`).get(docId);
227
268
  if (!row) return 0;
228
269
 
229
- // FTS5 contentless does not support DELETE. Remove from docs and docmap; FTS row becomes orphaned
230
- // (search results join through docmap so orphaned FTS rows are not returned).
231
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
+ }
232
276
  db.prepare(`DELETE FROM ${docsT} WHERE doc_id = ?`).run(docId);
233
277
  db.prepare(`DELETE FROM ${docmapT} WHERE doc_id = ?`).run(docId);
234
278
  })();
@@ -230,6 +230,37 @@ describe('Search integration', () => {
230
230
  const g2 = tryParseValue(gorge2, 0).value;
231
231
  assert.equal(g2[0], 0, 'gorge* should NOT match martan - this is the bug');
232
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
+ });
233
264
  });
234
265
 
235
266
  describe('Search persistence', () => {