resplite 1.4.16 → 1.4.20

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.16",
3
+ "version": "1.4.20",
4
4
  "description": "A RESP2 server with practical Redis compatibility, backed by SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -72,6 +72,10 @@ CREATE TABLE IF NOT EXISTS search_indices (
72
72
  created_at INTEGER NOT NULL,
73
73
  updated_at INTEGER NOT NULL
74
74
  );
75
+
76
+ CREATE TABLE IF NOT EXISTS search_rowid_allocator (
77
+ id INTEGER PRIMARY KEY AUTOINCREMENT
78
+ );
75
79
  `;
76
80
 
77
81
  /** Type enum: 1=string, 2=hash, 3=set, 4=list, 5=zset */
@@ -68,6 +68,23 @@ function deleteFtsRow(db, ftsTableName, ftsRowid, fieldNames, fields) {
68
68
  );
69
69
  }
70
70
 
71
+ /**
72
+ * Allocate a monotonic FTS rowid that is never reused, even after deletes.
73
+ * This prevents legacy stale tokens from being remapped to a new document.
74
+ * @param {import('better-sqlite3').Database} db
75
+ * @param {string} docmapTableName
76
+ * @returns {number}
77
+ */
78
+ function allocateFtsRowid(db, docmapTableName) {
79
+ const maxDocmapRowid = db
80
+ .prepare(`SELECT COALESCE(MAX(fts_rowid), 0) AS m FROM ${docmapTableName}`)
81
+ .get().m;
82
+ const maxAllocated = db.prepare('SELECT COALESCE(MAX(id), 0) AS m FROM search_rowid_allocator').get().m;
83
+ const nextRowid = Math.max(maxDocmapRowid, maxAllocated) + 1;
84
+ db.prepare('INSERT INTO search_rowid_allocator(id) VALUES (?)').run(nextRowid);
85
+ return nextRowid;
86
+ }
87
+
71
88
  /**
72
89
  * Build canonical schema JSON (fields sorted by name). D.12.2
73
90
  * @param {{ name: string, type: string }[]} fields
@@ -194,15 +211,16 @@ export function addDocument(db, idx, docId, score, replace, fields) {
194
211
  let ftsRowid;
195
212
  if (existing) {
196
213
  if (!replace) throw new Error('ERR document exists');
197
- ftsRowid = existing.fts_rowid;
214
+ const previousRowid = existing.fts_rowid;
215
+ ftsRowid = allocateFtsRowid(db, docmapT);
216
+ db.prepare(`UPDATE ${docmapT} SET fts_rowid = ? WHERE doc_id = ?`).run(ftsRowid, docId);
198
217
  const oldDoc = db.prepare(`SELECT fields_json FROM ${docsT} WHERE doc_id = ?`).get(docId);
199
218
  if (oldDoc?.fields_json) {
200
219
  const oldFields = JSON.parse(oldDoc.fields_json);
201
- deleteFtsRow(db, ftsT, ftsRowid, fieldNames, oldFields);
220
+ deleteFtsRow(db, ftsT, previousRowid, fieldNames, oldFields);
202
221
  }
203
222
  } else {
204
- const maxRow = db.prepare(`SELECT COALESCE(MAX(rowid), 0) AS m FROM ${ftsT}`).get();
205
- ftsRowid = maxRow.m + 1;
223
+ ftsRowid = allocateFtsRowid(db, docmapT);
206
224
  db.prepare(`INSERT INTO ${docmapT}(doc_id, fts_rowid) VALUES (?, ?)`).run(docId, ftsRowid);
207
225
  }
208
226
 
@@ -88,6 +88,51 @@ describe('Search layer', () => {
88
88
  assert.equal(deleteDocument(db, 'names', 'nonexistent'), 0);
89
89
  });
90
90
 
91
+ it('delete + re-add never remaps legacy stale tokens', () => {
92
+ createIndex(db, 'legacy_stale', [{ name: 'payload', type: 'TEXT' }]);
93
+ addDocument(db, 'legacy_stale', 'doc1', 1, true, { payload: 'gorrion' });
94
+
95
+ const mapped = db
96
+ .prepare('SELECT fts_rowid FROM search_docmap__legacy_stale WHERE doc_id = ?')
97
+ .get('doc1');
98
+ const oldRowid = mapped.fts_rowid;
99
+
100
+ // Simulate a legacy-polluted index row where stale term postings exist for the same rowid.
101
+ db.prepare('INSERT INTO search_fts__legacy_stale(rowid, payload) VALUES (?, ?)').run(oldRowid, 'bicho');
102
+
103
+ assert.equal(search(db, 'legacy_stale', 'bicho*', { noContent: true }).total, 1);
104
+ assert.equal(deleteDocument(db, 'legacy_stale', 'doc1'), 1);
105
+
106
+ addDocument(db, 'legacy_stale', 'doc1', 1, true, { payload: 'gorrion' });
107
+ const remapped = db
108
+ .prepare('SELECT fts_rowid FROM search_docmap__legacy_stale WHERE doc_id = ?')
109
+ .get('doc1');
110
+ assert.notEqual(remapped.fts_rowid, oldRowid);
111
+
112
+ assert.equal(search(db, 'legacy_stale', 'gorrion*', { noContent: true }).total, 1);
113
+ assert.equal(search(db, 'legacy_stale', 'bicho*', { noContent: true }).total, 0);
114
+ });
115
+
116
+ it('allocator seeds above existing docmap rowids on legacy databases', () => {
117
+ createIndex(db, 'legacy_seed', [{ name: 'payload', type: 'TEXT' }]);
118
+ const docsT = 'search_docs__legacy_seed';
119
+ const docmapT = 'search_docmap__legacy_seed';
120
+ const ftsT = 'search_fts__legacy_seed';
121
+ const now = Date.now();
122
+
123
+ db.prepare(
124
+ `INSERT INTO ${docsT}(doc_id, score, fields_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`
125
+ ).run('old-doc', 1, JSON.stringify({ payload: 'legacy payload' }), now, now);
126
+ db.prepare(`INSERT INTO ${docmapT}(doc_id, fts_rowid) VALUES (?, ?)`).run('old-doc', 42);
127
+ db.prepare(`INSERT INTO ${ftsT}(rowid, payload) VALUES (?, ?)`).run(42, 'legacy payload');
128
+
129
+ addDocument(db, 'legacy_seed', 'new-doc', 1, true, { payload: 'fresh payload' });
130
+
131
+ const newMap = db.prepare(`SELECT fts_rowid FROM ${docmapT} WHERE doc_id = ?`).get('new-doc');
132
+ assert.ok(newMap.fts_rowid > 42);
133
+ assert.equal(search(db, 'legacy_seed', 'fresh*', { noContent: true }).total, 1);
134
+ });
135
+
91
136
  it('search with NOCONTENT returns total and doc ids', () => {
92
137
  const r = search(db, 'names', 'hello', { noContent: true, offset: 0, count: 10 });
93
138
  assert.equal(typeof r.total, 'number');