resplite 1.4.8 → 1.4.10

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.8",
3
+ "version": "1.4.10",
4
4
  "description": "A RESP2 server with practical Redis compatibility, backed by SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -1,12 +1,9 @@
1
1
  ---
2
- name: resplite-command-vertical-slice
2
+ name: resplite
3
3
  description: Implements or extends a Redis-like command in RESPLite from spec to docs and tests. Use when the user says "add a command", "support a Redis option", "fix command compatibility", "implement ZRANGE behavior", or "update the compatibility matrix". Do not use for migration-only or FT-only work unless the change also affects the general command surface.
4
- license: MIT
5
4
  metadata:
6
- author: Cursor Agent
7
- version: 1.0.0
8
5
  category: workflow-automation
9
- tags: [resplite, redis-compatibility, commands, tests]
6
+ tags: [resplite, redis, commands, tests]
10
7
  ---
11
8
 
12
9
  # RESPLite Command Vertical Slice
@@ -1,10 +1,7 @@
1
1
  ---
2
- name: resplite-ft-search-workbench
2
+ name: resplite-ft-search
3
3
  description: Builds or refines RESPLite `FT.*` behavior and RediSearch migration mapping on top of SQLite FTS5. Use when the user says "add FT command support", "fix FT.SEARCH", "adjust SQLite FTS5 behavior", "migrate RediSearch indices", or "work on FT.CREATE or FT.ADD semantics". Do not use for unrelated command work outside the search surface.
4
- license: MIT
5
4
  metadata:
6
- author: Cursor Agent
7
- version: 1.0.0
8
5
  category: workflow-automation
9
6
  tags: [resplite, search, redisearch, sqlite, fts5]
10
7
  ---
@@ -1,10 +1,7 @@
1
1
  ---
2
- name: resplite-migration-cutover-assistant
2
+ name: resplite-migration
3
3
  description: Guides Redis to RESPLite migration work using the programmatic migration API, dirty-key tracking, cutover, and verification. Use when the user says "migrate Redis", "dirty tracker", "cutover", "resume bulk import", "verify migration", or "move RediSearch data during migration". Do not use for generic command work that does not touch the migration flow.
4
- license: MIT
5
4
  metadata:
6
- author: Cursor Agent
7
- version: 1.0.0
8
5
  category: workflow-automation
9
6
  tags: [resplite, migration, redis, cutover, verification]
10
7
  ---
@@ -209,21 +209,64 @@ export function deleteDocument(db, idx, docId) {
209
209
  }
210
210
 
211
211
  /**
212
- * Validate and build FTS5 MATCH expression. D.13.2: allow tokens [A-Za-z0-9_]+ optionally ending with *
213
- * Reject chars that break MATCH: " ' : ( ) [ ] { } \ and non-printable.
212
+ * Build a safe FTS5 MATCH expression from user query.
213
+ *
214
+ * We intentionally normalize punctuation so query tokenization is flexible and
215
+ * closer to Redis/RediSearch behavior for common free-text searches:
216
+ * - punctuation like ".", "#", "+", "/", ",", "(", ")" acts as separators
217
+ * - "?" is ignored as punctuation
218
+ * - "-" inside terms acts as separator (hello-world -> hello world)
219
+ * - "-term" (leading minus) maps to boolean NOT term
220
+ * - "@" and ":" are treated as unsupported query syntax and return syntax error
221
+ *
222
+ * Supported term chars are unicode letters/digits plus underscore, with
223
+ * optional trailing "*" for prefix queries.
224
+ *
225
+ * Reject control characters and unsafe MATCH breakers: " ' \ and non-printable.
214
226
  * @param {string} query
215
- * @returns {string} - Safe MATCH expression e.g. "martin clasen*"
227
+ * @returns {string} - Safe MATCH expression e.g. "martin NOT clasen*"
216
228
  */
217
229
  function buildMatchExpression(query) {
218
230
  if (typeof query !== 'string') throw new Error('ERR invalid query');
219
231
  const trimmed = query.trim();
220
232
  if (trimmed === '') throw new Error('ERR invalid query');
221
- if (/["':()\[\]{}\\]/.test(query) || /[\x00-\x1f]/.test(query)) throw new Error('ERR invalid query');
222
- const tokens = trimmed.split(/\s+/).filter(Boolean);
223
- for (const t of tokens) {
224
- if (!/^[A-Za-z0-9_]*\*?$/.test(t)) throw new Error('ERR invalid query');
233
+ if (/[\x00-\x1f]/.test(query) || /["'\\]/.test(query)) throw new Error('ERR invalid query');
234
+ if (query.includes('@') || query.includes(':')) throw new Error('ERR syntax error');
235
+
236
+ const normalized = trimmed
237
+ .replace(/\?/g, '')
238
+ .replace(/[^\p{L}\p{N}_*\-\s]+/gu, ' ')
239
+ .trim();
240
+ const rawTokens = normalized.split(/\s+/).filter(Boolean);
241
+ if (rawTokens.length === 0) throw new Error('ERR invalid query');
242
+
243
+ const termRe = /^[\p{L}\p{N}_]+\*?$/u;
244
+ const tokens = [];
245
+
246
+ for (const raw of rawTokens) {
247
+ if (raw.startsWith('-')) {
248
+ const neg = raw.slice(1);
249
+ if (!neg || neg.includes('-') || !termRe.test(neg)) throw new Error('ERR invalid query');
250
+ tokens.push('NOT', neg);
251
+ continue;
252
+ }
253
+ const segments = raw.split('-').filter(Boolean);
254
+ if (segments.length === 0) throw new Error('ERR invalid query');
255
+ for (const seg of segments) {
256
+ if (!termRe.test(seg)) throw new Error('ERR invalid query');
257
+ tokens.push(seg);
258
+ }
259
+ }
260
+
261
+ for (let i = 0; i < tokens.length; i += 1) {
262
+ if (tokens[i] === 'NOT') {
263
+ if (i === 0 || i === tokens.length - 1 || tokens[i + 1] === 'NOT') {
264
+ throw new Error('ERR invalid query');
265
+ }
266
+ }
225
267
  }
226
- return trimmed;
268
+
269
+ return tokens.join(' ');
227
270
  }
228
271
 
229
272
  /**
@@ -55,6 +55,72 @@ describe('Search integration', () => {
55
55
  assert.ok(arr[0] >= 0);
56
56
  });
57
57
 
58
+ it('FT.SEARCH dotted prefix query works', async () => {
59
+ const ok = await sendCommand(
60
+ port,
61
+ argv('FT.ADD', 'names', 'MAIL1', '1', 'REPLACE', 'FIELDS', 'payload', 'martin clasen martin.clasen@gmail.com')
62
+ );
63
+ assert.equal(tryParseValue(ok, 0).value, 'OK');
64
+
65
+ const reply = await sendCommand(port, argv('FT.SEARCH', 'names', 'martin.clasen*', 'NOCONTENT', 'LIMIT', '0', '10'));
66
+ const arr = tryParseValue(reply, 0).value;
67
+ assert.ok(Array.isArray(arr));
68
+ assert.ok(arr[0] >= 1);
69
+ const docIds = arr.slice(1).map((v) => (v?.toString ? v.toString('utf8') : String(v)));
70
+ assert.ok(docIds.includes('MAIL1'));
71
+ });
72
+
73
+ it('FT.SEARCH handles punctuation tokenization flexibly', async () => {
74
+ const ok = await sendCommand(
75
+ port,
76
+ argv(
77
+ 'FT.ADD',
78
+ 'names',
79
+ 'CHARS1',
80
+ '1',
81
+ 'REPLACE',
82
+ 'FIELDS',
83
+ 'payload',
84
+ 'martin-clasen martin@clasen.com #martin who? alpha+beta foo/bar baz,qux'
85
+ )
86
+ );
87
+ assert.equal(tryParseValue(ok, 0).value, 'OK');
88
+
89
+ for (const q of ['who?', 'alpha+beta', 'foo/bar', 'baz,qux', '(martin)', '#martin*']) {
90
+ const reply = await sendCommand(port, argv('FT.SEARCH', 'names', q, 'NOCONTENT', 'LIMIT', '0', '10'));
91
+ const arr = tryParseValue(reply, 0).value;
92
+ assert.ok(Array.isArray(arr));
93
+ const docIds = arr.slice(1).map((v) => (v?.toString ? v.toString('utf8') : String(v)));
94
+ assert.ok(docIds.includes('CHARS1'));
95
+ }
96
+ });
97
+
98
+ it('FT.SEARCH keeps Redis-like syntax errors for @ and :', async () => {
99
+ const atReply = await sendCommand(port, argv('FT.SEARCH', 'names', 'martin@clasen*', 'NOCONTENT'));
100
+ const atVal = tryParseValue(atReply, 0).value;
101
+ const atErr = String(atVal?.error ?? atVal);
102
+ assert.ok(atErr.includes('syntax error'));
103
+
104
+ const colonReply = await sendCommand(port, argv('FT.SEARCH', 'names', 'martin:clasen', 'NOCONTENT'));
105
+ const colonVal = tryParseValue(colonReply, 0).value;
106
+ const colonErr = String(colonVal?.error ?? colonVal);
107
+ assert.ok(colonErr.includes('syntax error'));
108
+ });
109
+
110
+ it('FT.SEARCH treats hyphen inside term as separator', async () => {
111
+ const reply = await sendCommand(port, argv('FT.SEARCH', 'names', 'martin-clasen*', 'NOCONTENT', 'LIMIT', '0', '10'));
112
+ const arr = tryParseValue(reply, 0).value;
113
+ assert.ok(Array.isArray(arr));
114
+ assert.ok(arr[0] >= 1);
115
+ });
116
+
117
+ it('FT.SEARCH keeps NOT semantics for leading minus', async () => {
118
+ const reply = await sendCommand(port, argv('FT.SEARCH', 'names', 'martin -clasen*', 'NOCONTENT', 'LIMIT', '0', '10'));
119
+ const arr = tryParseValue(reply, 0).value;
120
+ assert.ok(Array.isArray(arr));
121
+ assert.equal(arr[0], 0);
122
+ });
123
+
58
124
  it('FT.SEARCH LIMIT applies', async () => {
59
125
  const reply = await sendCommand(port, argv('FT.SEARCH', 'names', 'martin', 'NOCONTENT', 'LIMIT', '0', '1'));
60
126
  const arr = tryParseValue(reply, 0).value;
@@ -52,6 +52,34 @@ describe('Sets integration', () => {
52
52
  assert.equal(tryParseValue(reply, 0).value, 3);
53
53
  });
54
54
 
55
+ it('concurrent SADD of same member is idempotent', async () => {
56
+ const key = `scon:sadd:${Date.now()}`;
57
+ const N = 25;
58
+ const replies = await Promise.all(Array.from({ length: N }, () => sendCommand(port, argv('SADD', key, 'x'))));
59
+ const addedCounts = replies.map((reply) => tryParseValue(reply, 0).value);
60
+ const firstAdds = addedCounts.filter((n) => n === 1).length;
61
+ const duplicateAdds = addedCounts.filter((n) => n === 0).length;
62
+ assert.equal(firstAdds, 1);
63
+ assert.equal(duplicateAdds, N - 1);
64
+ const card = await sendCommand(port, argv('SCARD', key));
65
+ assert.equal(tryParseValue(card, 0).value, 1);
66
+ });
67
+
68
+ it('concurrent SPOP returns unique members and drains set', async () => {
69
+ const key = `scon:spop:${Date.now()}`;
70
+ const members = Array.from({ length: 24 }, (_, i) => `m${i}`);
71
+ await sendCommand(port, argv('SADD', key, ...members));
72
+ const poppedRaw = await Promise.all(Array.from({ length: members.length }, () => sendCommand(port, argv('SPOP', key))));
73
+ const popped = poppedRaw.map((reply) => {
74
+ const parsed = tryParseValue(reply, 0);
75
+ return parsed.value === null ? null : parsed.value.toString('utf8');
76
+ });
77
+ assert.equal(popped.includes(null), false);
78
+ assert.equal(new Set(popped).size, members.length);
79
+ const card = await sendCommand(port, argv('SCARD', key));
80
+ assert.equal(tryParseValue(card, 0).value, 0);
81
+ });
82
+
55
83
  it('legacy set rows with null set_count hydrate on first SCARD', async () => {
56
84
  const s1 = await createTestServer();
57
85
  await sendCommand(s1.port, argv('SADD', 'legacy:s', 'a', 'b'));
@@ -92,9 +92,43 @@ describe('Search layer', () => {
92
92
  assert.ok(Array.isArray(r.docIds));
93
93
  });
94
94
 
95
+ it('search dotted prefix query works', () => {
96
+ addDocument(db, 'names', 'mail1', 1, true, { payload: 'martin clasen martin.clasen@gmail.com' });
97
+ const r = search(db, 'names', 'martin.clasen*', { noContent: true });
98
+ assert.ok(r.total >= 1);
99
+ assert.ok(r.docIds.includes('mail1'));
100
+ });
101
+
102
+ it('search tokenization stays flexible across punctuation', () => {
103
+ addDocument(db, 'names', 'chars1', 1, true, {
104
+ payload: 'martin-clasen martin@clasen.com #martin who? alpha+beta foo/bar baz,qux',
105
+ });
106
+ assert.ok(search(db, 'names', 'who?', { noContent: true }).docIds.includes('chars1'));
107
+ assert.ok(search(db, 'names', 'alpha+beta', { noContent: true }).docIds.includes('chars1'));
108
+ assert.ok(search(db, 'names', 'foo/bar', { noContent: true }).docIds.includes('chars1'));
109
+ assert.ok(search(db, 'names', 'baz,qux', { noContent: true }).docIds.includes('chars1'));
110
+ assert.ok(search(db, 'names', '(martin)', { noContent: true }).docIds.includes('chars1'));
111
+ assert.ok(search(db, 'names', '#martin*', { noContent: true }).docIds.includes('chars1'));
112
+ });
113
+
114
+ it('search hyphen inside term is treated as separator', () => {
115
+ addDocument(db, 'names', 'hyphen1', 1, true, { payload: 'martin clasen' });
116
+ const r = search(db, 'names', 'martin-clasen*', { noContent: true });
117
+ assert.ok(r.total >= 1);
118
+ assert.ok(r.docIds.includes('hyphen1'));
119
+ });
120
+
121
+ it('search leading minus uses NOT operator semantics', () => {
122
+ addDocument(db, 'names', 'neg1', 1, true, { payload: 'martin clasen' });
123
+ const r = search(db, 'names', 'martin -clasen*', { noContent: true });
124
+ assert.equal(r.total, 0);
125
+ });
126
+
95
127
  it('search invalid query throws', () => {
96
128
  assert.throws(() => search(db, 'names', ''), /invalid query/);
97
129
  assert.throws(() => search(db, 'names', 'foo"bar'), /invalid query/);
130
+ assert.throws(() => search(db, 'names', 'martin@clasen*'), /syntax error/);
131
+ assert.throws(() => search(db, 'names', 'martin:clasen'), /syntax error/);
98
132
  });
99
133
 
100
134
  it('suggestionAdd returns 1 on insert, 0 on update', () => {