resplite 1.4.8 → 1.4.12
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/README.md +1 -1
- package/package.json +1 -1
- package/skills/{resplite-command-vertical-slice → resplite}/SKILL.md +2 -5
- package/skills/{resplite-ft-search-workbench → resplite-ft-search}/SKILL.md +1 -4
- package/skills/{resplite-migration-cutover-assistant → resplite-migration}/SKILL.md +1 -4
- package/spec/09-search-ft-commands.md +34 -0
- package/src/commands/ft/parser.js +16 -0
- package/src/commands/ft-get.js +21 -0
- package/src/commands/registry.js +2 -0
- package/src/storage/sqlite/search.js +74 -8
- package/test/integration/search.test.js +77 -0
- package/test/integration/sets.test.js +28 -0
- package/test/unit/ft-parser.test.js +15 -0
- package/test/unit/search.test.js +52 -0
package/README.md
CHANGED
|
@@ -578,7 +578,7 @@ To reproduce the benchmark, run `npm run benchmark -- --template default`. Numbe
|
|
|
578
578
|
| **Sets** | SADD, SREM, SMEMBERS, SISMEMBER, SCARD, SPOP, SRANDMEMBER |
|
|
579
579
|
| **Lists** | LPUSH, RPUSH, LLEN, LRANGE, LINDEX, LPOP, RPOP, LSET, LTRIM, BLPOP, BRPOP |
|
|
580
580
|
| **Sorted sets** | ZADD, ZREM, ZCARD, ZSCORE, ZRANGE, ZREVRANGE, ZRANGEBYSCORE, ZREVRANGEBYSCORE, ZRANK, ZREVRANK, ZCOUNT, ZINCRBY, ZREMRANGEBYRANK, ZREMRANGEBYSCORE |
|
|
581
|
-
| **Search (FT.\*)** | FT.CREATE, FT.INFO, FT.ADD, FT.DEL, FT.SEARCH, FT.SUGADD, FT.SUGGET, FT.SUGDEL |
|
|
581
|
+
| **Search (FT.\*)** | FT.CREATE, FT.INFO, FT.ADD, FT.DEL, FT.GET, FT.SEARCH, FT.SUGADD, FT.SUGGET, FT.SUGDEL |
|
|
582
582
|
| **Introspection** | TYPE, OBJECT IDLETIME, SCAN, KEYS, RENAME, MONITOR |
|
|
583
583
|
| **Admin** | SQLITE.INFO, CACHE.INFO, MEMORY.INFO |
|
|
584
584
|
|
package/package.json
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: resplite
|
|
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
|
|
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
|
|
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
|
|
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
|
---
|
|
@@ -11,6 +11,7 @@ Originally Appendix D. Goals, data model, FT.CREATE/ADD/DEL/SEARCH/SUG* behavior
|
|
|
11
11
|
* `FT.INFO`
|
|
12
12
|
* `FT.ADD`
|
|
13
13
|
* `FT.DEL`
|
|
14
|
+
* `FT.GET`
|
|
14
15
|
* `FT.SUGADD`
|
|
15
16
|
* `FT.SUGGET`
|
|
16
17
|
* `FT.SUGDEL`
|
|
@@ -254,6 +255,31 @@ RESP Integer:
|
|
|
254
255
|
|
|
255
256
|
---
|
|
256
257
|
|
|
258
|
+
# D.7.5 FT.GET
|
|
259
|
+
|
|
260
|
+
Aligned with RediSearch: load a single document by id from the index (not from the Redis keyspace).
|
|
261
|
+
|
|
262
|
+
## D.7.5.1 Syntax
|
|
263
|
+
|
|
264
|
+
```
|
|
265
|
+
FT.GET {index} {doc_id}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## D.7.5.2 Behavior
|
|
269
|
+
|
|
270
|
+
* If the index does not exist: error `Unknown index name`.
|
|
271
|
+
* If the document is not in `search_docs__idx`: RESP **nil** (null bulk).
|
|
272
|
+
* Otherwise: RESP **array** of field names and values, `[field, value, ...]`, in **schema field order** (same field names as `FT.ADD`).
|
|
273
|
+
* Empty string field values are returned as **nil** (same idea as RediSearch’s `HGETALL`-based reply for empty values).
|
|
274
|
+
|
|
275
|
+
## D.7.5.3 Errors
|
|
276
|
+
|
|
277
|
+
* Wrong arity / bad tokens: `-ERR syntax error`
|
|
278
|
+
* Invalid index name: `-ERR invalid index name`
|
|
279
|
+
* Index missing: `-Unknown index name`
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
257
283
|
# D.8 FT.SEARCH
|
|
258
284
|
|
|
259
285
|
## D.8.1 Required syntax (your example)
|
|
@@ -804,6 +830,14 @@ FT.DEL IndexName DocId
|
|
|
804
830
|
|
|
805
831
|
Return integer 1/0.
|
|
806
832
|
|
|
833
|
+
### FT.GET
|
|
834
|
+
|
|
835
|
+
```
|
|
836
|
+
FT.GET IndexName DocId
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
Same `DocId` rules as `FT.DEL`. Return: flat array of fields and values, or nil if the document is not indexed.
|
|
840
|
+
|
|
807
841
|
### FT.SEARCH
|
|
808
842
|
|
|
809
843
|
```
|
|
@@ -131,6 +131,22 @@ export function parseFtDel(args) {
|
|
|
131
131
|
return { indexName, docId };
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Parse FT.GET args: IndexName DocId (same doc id rules as FT.DEL)
|
|
136
|
+
* @param {Buffer[]} args
|
|
137
|
+
* @returns {{ indexName: string, docId: string } | { error: string }}
|
|
138
|
+
*/
|
|
139
|
+
export function parseFtGet(args) {
|
|
140
|
+
if (!args || args.length !== 2) return { error: 'ERR syntax error' };
|
|
141
|
+
expectUtf8(args[0]);
|
|
142
|
+
expectUtf8(args[1]);
|
|
143
|
+
const indexName = toStr(args[0]).trim();
|
|
144
|
+
const docId = toStr(args[1]);
|
|
145
|
+
if (!INDEX_NAME_RE.test(indexName)) return { error: 'ERR invalid index name' };
|
|
146
|
+
if (docId.length < 1 || docId.length > 256 || docId.includes('\u0000')) return { error: 'ERR syntax error' };
|
|
147
|
+
return { indexName, docId };
|
|
148
|
+
}
|
|
149
|
+
|
|
134
150
|
/**
|
|
135
151
|
* Parse FT.SEARCH args: IndexName Query [NOCONTENT] [LIMIT offset count]
|
|
136
152
|
* @param {Buffer[]} args
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FT.GET index doc_id — return document fields as a flat array [field, value, ...] or nil if missing.
|
|
3
|
+
* Matches RediSearch: single doc by id; unknown index errors; not indexed returns null.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { parseFtGet } from './ft/parser.js';
|
|
7
|
+
import { getDocumentFields } from '../storage/sqlite/search.js';
|
|
8
|
+
|
|
9
|
+
export function handleFtGet(engine, args) {
|
|
10
|
+
const db = engine._db;
|
|
11
|
+
if (!db) return { error: 'ERR SQLite not available' };
|
|
12
|
+
const parsed = parseFtGet(args);
|
|
13
|
+
if (parsed.error) return { error: parsed.error };
|
|
14
|
+
try {
|
|
15
|
+
const fields = getDocumentFields(db, parsed.indexName, parsed.docId);
|
|
16
|
+
return fields;
|
|
17
|
+
} catch (e) {
|
|
18
|
+
const msg = e?.message ?? String(e);
|
|
19
|
+
return { error: msg === 'Unknown index name' ? msg : msg.startsWith('ERR ') ? msg : 'ERR ' + msg };
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/commands/registry.js
CHANGED
|
@@ -79,6 +79,7 @@ import * as ftCreate from './ft-create.js';
|
|
|
79
79
|
import * as ftInfo from './ft-info.js';
|
|
80
80
|
import * as ftAdd from './ft-add.js';
|
|
81
81
|
import * as ftDel from './ft-del.js';
|
|
82
|
+
import * as ftGet from './ft-get.js';
|
|
82
83
|
import * as ftSearch from './ft-search.js';
|
|
83
84
|
import * as ftSugadd from './ft-sugadd.js';
|
|
84
85
|
import * as ftSugget from './ft-sugget.js';
|
|
@@ -164,6 +165,7 @@ const HANDLERS = new Map([
|
|
|
164
165
|
['FT.INFO', (e, a) => ftInfo.handleFtInfo(e, a)],
|
|
165
166
|
['FT.ADD', (e, a) => ftAdd.handleFtAdd(e, a)],
|
|
166
167
|
['FT.DEL', (e, a) => ftDel.handleFtDel(e, a)],
|
|
168
|
+
['FT.GET', (e, a) => ftGet.handleFtGet(e, a)],
|
|
167
169
|
['FT.SEARCH', (e, a) => ftSearch.handleFtSearch(e, a)],
|
|
168
170
|
['FT.SUGADD', (e, a) => ftSugadd.handleFtSugadd(e, a)],
|
|
169
171
|
['FT.SUGGET', (e, a) => ftSugget.handleFtSugget(e, a)],
|
|
@@ -190,6 +190,29 @@ export function addDocument(db, idx, docId, score, replace, fields) {
|
|
|
190
190
|
* @param {string} docId
|
|
191
191
|
* @returns {number}
|
|
192
192
|
*/
|
|
193
|
+
/**
|
|
194
|
+
* Load stored fields for FT.GET: flat [field, value, ...] in schema field order.
|
|
195
|
+
* RediSearch-compatible: empty string values encode as null in RESP.
|
|
196
|
+
* @param {import('better-sqlite3').Database} db
|
|
197
|
+
* @param {string} idx
|
|
198
|
+
* @param {string} docId
|
|
199
|
+
* @returns { (string|null)[] | null } - null if document is not in the index
|
|
200
|
+
*/
|
|
201
|
+
export function getDocumentFields(db, idx, docId) {
|
|
202
|
+
const meta = getIndexMeta(db, idx);
|
|
203
|
+
const docsT = tableName(idx, 'docs');
|
|
204
|
+
const row = db.prepare(`SELECT fields_json FROM ${docsT} WHERE doc_id = ?`).get(docId);
|
|
205
|
+
if (!row) return null;
|
|
206
|
+
const fields = JSON.parse(row.fields_json);
|
|
207
|
+
const flat = [];
|
|
208
|
+
for (const f of meta.schema.fields) {
|
|
209
|
+
if (!Object.prototype.hasOwnProperty.call(fields, f.name)) continue;
|
|
210
|
+
const v = fields[f.name];
|
|
211
|
+
flat.push(f.name, v === '' ? null : v);
|
|
212
|
+
}
|
|
213
|
+
return flat;
|
|
214
|
+
}
|
|
215
|
+
|
|
193
216
|
export function deleteDocument(db, idx, docId) {
|
|
194
217
|
getIndexMeta(db, idx);
|
|
195
218
|
const docsT = tableName(idx, 'docs');
|
|
@@ -209,21 +232,64 @@ export function deleteDocument(db, idx, docId) {
|
|
|
209
232
|
}
|
|
210
233
|
|
|
211
234
|
/**
|
|
212
|
-
*
|
|
213
|
-
*
|
|
235
|
+
* Build a safe FTS5 MATCH expression from user query.
|
|
236
|
+
*
|
|
237
|
+
* We intentionally normalize punctuation so query tokenization is flexible and
|
|
238
|
+
* closer to Redis/RediSearch behavior for common free-text searches:
|
|
239
|
+
* - punctuation like ".", "#", "+", "/", ",", "(", ")" acts as separators
|
|
240
|
+
* - "?" is ignored as punctuation
|
|
241
|
+
* - "-" inside terms acts as separator (hello-world -> hello world)
|
|
242
|
+
* - "-term" (leading minus) maps to boolean NOT term
|
|
243
|
+
* - "@" and ":" are treated as unsupported query syntax and return syntax error
|
|
244
|
+
*
|
|
245
|
+
* Supported term chars are unicode letters/digits plus underscore, with
|
|
246
|
+
* optional trailing "*" for prefix queries.
|
|
247
|
+
*
|
|
248
|
+
* Reject control characters and unsafe MATCH breakers: " ' \ and non-printable.
|
|
214
249
|
* @param {string} query
|
|
215
|
-
* @returns {string} - Safe MATCH expression e.g. "martin clasen*"
|
|
250
|
+
* @returns {string} - Safe MATCH expression e.g. "martin NOT clasen*"
|
|
216
251
|
*/
|
|
217
252
|
function buildMatchExpression(query) {
|
|
218
253
|
if (typeof query !== 'string') throw new Error('ERR invalid query');
|
|
219
254
|
const trimmed = query.trim();
|
|
220
255
|
if (trimmed === '') throw new Error('ERR invalid query');
|
|
221
|
-
if (/[
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
256
|
+
if (/[\x00-\x1f]/.test(query) || /["'\\]/.test(query)) throw new Error('ERR invalid query');
|
|
257
|
+
if (query.includes('@') || query.includes(':')) throw new Error('ERR syntax error');
|
|
258
|
+
|
|
259
|
+
const normalized = trimmed
|
|
260
|
+
.replace(/\?/g, '')
|
|
261
|
+
.replace(/[^\p{L}\p{N}_*\-\s]+/gu, ' ')
|
|
262
|
+
.trim();
|
|
263
|
+
const rawTokens = normalized.split(/\s+/).filter(Boolean);
|
|
264
|
+
if (rawTokens.length === 0) throw new Error('ERR invalid query');
|
|
265
|
+
|
|
266
|
+
const termRe = /^[\p{L}\p{N}_]+\*?$/u;
|
|
267
|
+
const tokens = [];
|
|
268
|
+
|
|
269
|
+
for (const raw of rawTokens) {
|
|
270
|
+
if (raw.startsWith('-')) {
|
|
271
|
+
const neg = raw.slice(1);
|
|
272
|
+
if (!neg || neg.includes('-') || !termRe.test(neg)) throw new Error('ERR invalid query');
|
|
273
|
+
tokens.push('NOT', neg);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const segments = raw.split('-').filter(Boolean);
|
|
277
|
+
if (segments.length === 0) throw new Error('ERR invalid query');
|
|
278
|
+
for (const seg of segments) {
|
|
279
|
+
if (!termRe.test(seg)) throw new Error('ERR invalid query');
|
|
280
|
+
tokens.push(seg);
|
|
281
|
+
}
|
|
225
282
|
}
|
|
226
|
-
|
|
283
|
+
|
|
284
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
285
|
+
if (tokens[i] === 'NOT') {
|
|
286
|
+
if (i === 0 || i === tokens.length - 1 || tokens[i + 1] === 'NOT') {
|
|
287
|
+
throw new Error('ERR invalid query');
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return tokens.join(' ');
|
|
227
293
|
}
|
|
228
294
|
|
|
229
295
|
/**
|
|
@@ -39,6 +39,17 @@ describe('Search integration', () => {
|
|
|
39
39
|
assert.equal(tryParseValue(ok, 0).value, 'OK');
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
it('FT.GET returns field array for indexed doc and nil when missing', async () => {
|
|
43
|
+
const miss = await sendCommand(port, argv('FT.GET', 'names', 'no-such'));
|
|
44
|
+
assert.equal(tryParseValue(miss, 0).value, null);
|
|
45
|
+
|
|
46
|
+
const hit = await sendCommand(port, argv('FT.GET', 'names', 'DY1O2'));
|
|
47
|
+
const arr = tryParseValue(hit, 0).value;
|
|
48
|
+
assert.ok(Array.isArray(arr));
|
|
49
|
+
assert.equal(arr[0].toString?.('utf8') ?? arr[0], 'payload');
|
|
50
|
+
assert.equal(arr[1].toString?.('utf8') ?? arr[1], 'martin clasen');
|
|
51
|
+
});
|
|
52
|
+
|
|
42
53
|
it('FT.SEARCH returns NOCONTENT shape and total', async () => {
|
|
43
54
|
const reply = await sendCommand(port, argv('FT.SEARCH', 'names', 'clasen', 'NOCONTENT', 'LIMIT', '0', '25'));
|
|
44
55
|
const arr = tryParseValue(reply, 0).value;
|
|
@@ -55,6 +66,72 @@ describe('Search integration', () => {
|
|
|
55
66
|
assert.ok(arr[0] >= 0);
|
|
56
67
|
});
|
|
57
68
|
|
|
69
|
+
it('FT.SEARCH dotted prefix query works', async () => {
|
|
70
|
+
const ok = await sendCommand(
|
|
71
|
+
port,
|
|
72
|
+
argv('FT.ADD', 'names', 'MAIL1', '1', 'REPLACE', 'FIELDS', 'payload', 'martin clasen martin.clasen@gmail.com')
|
|
73
|
+
);
|
|
74
|
+
assert.equal(tryParseValue(ok, 0).value, 'OK');
|
|
75
|
+
|
|
76
|
+
const reply = await sendCommand(port, argv('FT.SEARCH', 'names', 'martin.clasen*', 'NOCONTENT', 'LIMIT', '0', '10'));
|
|
77
|
+
const arr = tryParseValue(reply, 0).value;
|
|
78
|
+
assert.ok(Array.isArray(arr));
|
|
79
|
+
assert.ok(arr[0] >= 1);
|
|
80
|
+
const docIds = arr.slice(1).map((v) => (v?.toString ? v.toString('utf8') : String(v)));
|
|
81
|
+
assert.ok(docIds.includes('MAIL1'));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('FT.SEARCH handles punctuation tokenization flexibly', async () => {
|
|
85
|
+
const ok = await sendCommand(
|
|
86
|
+
port,
|
|
87
|
+
argv(
|
|
88
|
+
'FT.ADD',
|
|
89
|
+
'names',
|
|
90
|
+
'CHARS1',
|
|
91
|
+
'1',
|
|
92
|
+
'REPLACE',
|
|
93
|
+
'FIELDS',
|
|
94
|
+
'payload',
|
|
95
|
+
'martin-clasen martin@clasen.com #martin who? alpha+beta foo/bar baz,qux'
|
|
96
|
+
)
|
|
97
|
+
);
|
|
98
|
+
assert.equal(tryParseValue(ok, 0).value, 'OK');
|
|
99
|
+
|
|
100
|
+
for (const q of ['who?', 'alpha+beta', 'foo/bar', 'baz,qux', '(martin)', '#martin*']) {
|
|
101
|
+
const reply = await sendCommand(port, argv('FT.SEARCH', 'names', q, 'NOCONTENT', 'LIMIT', '0', '10'));
|
|
102
|
+
const arr = tryParseValue(reply, 0).value;
|
|
103
|
+
assert.ok(Array.isArray(arr));
|
|
104
|
+
const docIds = arr.slice(1).map((v) => (v?.toString ? v.toString('utf8') : String(v)));
|
|
105
|
+
assert.ok(docIds.includes('CHARS1'));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('FT.SEARCH keeps Redis-like syntax errors for @ and :', async () => {
|
|
110
|
+
const atReply = await sendCommand(port, argv('FT.SEARCH', 'names', 'martin@clasen*', 'NOCONTENT'));
|
|
111
|
+
const atVal = tryParseValue(atReply, 0).value;
|
|
112
|
+
const atErr = String(atVal?.error ?? atVal);
|
|
113
|
+
assert.ok(atErr.includes('syntax error'));
|
|
114
|
+
|
|
115
|
+
const colonReply = await sendCommand(port, argv('FT.SEARCH', 'names', 'martin:clasen', 'NOCONTENT'));
|
|
116
|
+
const colonVal = tryParseValue(colonReply, 0).value;
|
|
117
|
+
const colonErr = String(colonVal?.error ?? colonVal);
|
|
118
|
+
assert.ok(colonErr.includes('syntax error'));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('FT.SEARCH treats hyphen inside term as separator', async () => {
|
|
122
|
+
const reply = await sendCommand(port, argv('FT.SEARCH', 'names', 'martin-clasen*', 'NOCONTENT', 'LIMIT', '0', '10'));
|
|
123
|
+
const arr = tryParseValue(reply, 0).value;
|
|
124
|
+
assert.ok(Array.isArray(arr));
|
|
125
|
+
assert.ok(arr[0] >= 1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('FT.SEARCH keeps NOT semantics for leading minus', async () => {
|
|
129
|
+
const reply = await sendCommand(port, argv('FT.SEARCH', 'names', 'martin -clasen*', 'NOCONTENT', 'LIMIT', '0', '10'));
|
|
130
|
+
const arr = tryParseValue(reply, 0).value;
|
|
131
|
+
assert.ok(Array.isArray(arr));
|
|
132
|
+
assert.equal(arr[0], 0);
|
|
133
|
+
});
|
|
134
|
+
|
|
58
135
|
it('FT.SEARCH LIMIT applies', async () => {
|
|
59
136
|
const reply = await sendCommand(port, argv('FT.SEARCH', 'names', 'martin', 'NOCONTENT', 'LIMIT', '0', '1'));
|
|
60
137
|
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'));
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
parseFtInfo,
|
|
6
6
|
parseFtAdd,
|
|
7
7
|
parseFtDel,
|
|
8
|
+
parseFtGet,
|
|
8
9
|
parseFtSearch,
|
|
9
10
|
parseFtSugadd,
|
|
10
11
|
parseFtSugget,
|
|
@@ -117,6 +118,20 @@ describe('FT parser', () => {
|
|
|
117
118
|
});
|
|
118
119
|
});
|
|
119
120
|
|
|
121
|
+
describe('parseFtGet', () => {
|
|
122
|
+
it('accepts index and doc_id', () => {
|
|
123
|
+
const r = parseFtGet([buf('names'), buf('doc1')]);
|
|
124
|
+
assert.ok(!r.error);
|
|
125
|
+
assert.equal(r.indexName, 'names');
|
|
126
|
+
assert.equal(r.docId, 'doc1');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('rejects wrong number of args', () => {
|
|
130
|
+
assert.equal(parseFtGet([buf('a')]).error, 'ERR syntax error');
|
|
131
|
+
assert.equal(parseFtGet([buf('a'), buf('b'), buf('c')]).error, 'ERR syntax error');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
120
135
|
describe('parseFtSearch', () => {
|
|
121
136
|
it('accepts index and query', () => {
|
|
122
137
|
const r = parseFtSearch([buf('names'), buf('hello')]);
|
package/test/unit/search.test.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
getIndexMeta,
|
|
7
7
|
getIndexCounts,
|
|
8
8
|
addDocument,
|
|
9
|
+
getDocumentFields,
|
|
9
10
|
deleteDocument,
|
|
10
11
|
search,
|
|
11
12
|
suggestionAdd,
|
|
@@ -63,6 +64,23 @@ describe('Search layer', () => {
|
|
|
63
64
|
assert.throws(() => addDocument(db, 'names', 'doc3', 1, true, { payload: 'x', unknown: 'y' }), /unknown field/);
|
|
64
65
|
});
|
|
65
66
|
|
|
67
|
+
it('getDocumentFields returns null when doc missing, flat array in schema order', () => {
|
|
68
|
+
assert.equal(getDocumentFields(db, 'names', 'no-such-doc'), null);
|
|
69
|
+
addDocument(db, 'names', 'gd1', 1, true, { payload: 'hello' });
|
|
70
|
+
assert.deepEqual(getDocumentFields(db, 'names', 'gd1'), ['payload', 'hello']);
|
|
71
|
+
addDocument(db, 'names', 'gd2', 1, true, { payload: '' });
|
|
72
|
+
assert.deepEqual(getDocumentFields(db, 'names', 'gd2'), ['payload', null]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('getDocumentFields multi-field schema order', () => {
|
|
76
|
+
createIndex(db, 'mf', [
|
|
77
|
+
{ name: 'payload', type: 'TEXT' },
|
|
78
|
+
{ name: 'title', type: 'TEXT' },
|
|
79
|
+
]);
|
|
80
|
+
addDocument(db, 'mf', 'm1', 1, true, { payload: 'pval', title: 'tval' });
|
|
81
|
+
assert.deepEqual(getDocumentFields(db, 'mf', 'm1'), ['payload', 'pval', 'title', 'tval']);
|
|
82
|
+
});
|
|
83
|
+
|
|
66
84
|
it('deleteDocument returns 1 when found, 0 when not', () => {
|
|
67
85
|
addDocument(db, 'names', 'todel', 1, true, { payload: 'to delete' });
|
|
68
86
|
assert.equal(deleteDocument(db, 'names', 'todel'), 1);
|
|
@@ -92,9 +110,43 @@ describe('Search layer', () => {
|
|
|
92
110
|
assert.ok(Array.isArray(r.docIds));
|
|
93
111
|
});
|
|
94
112
|
|
|
113
|
+
it('search dotted prefix query works', () => {
|
|
114
|
+
addDocument(db, 'names', 'mail1', 1, true, { payload: 'martin clasen martin.clasen@gmail.com' });
|
|
115
|
+
const r = search(db, 'names', 'martin.clasen*', { noContent: true });
|
|
116
|
+
assert.ok(r.total >= 1);
|
|
117
|
+
assert.ok(r.docIds.includes('mail1'));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('search tokenization stays flexible across punctuation', () => {
|
|
121
|
+
addDocument(db, 'names', 'chars1', 1, true, {
|
|
122
|
+
payload: 'martin-clasen martin@clasen.com #martin who? alpha+beta foo/bar baz,qux',
|
|
123
|
+
});
|
|
124
|
+
assert.ok(search(db, 'names', 'who?', { noContent: true }).docIds.includes('chars1'));
|
|
125
|
+
assert.ok(search(db, 'names', 'alpha+beta', { noContent: true }).docIds.includes('chars1'));
|
|
126
|
+
assert.ok(search(db, 'names', 'foo/bar', { noContent: true }).docIds.includes('chars1'));
|
|
127
|
+
assert.ok(search(db, 'names', 'baz,qux', { noContent: true }).docIds.includes('chars1'));
|
|
128
|
+
assert.ok(search(db, 'names', '(martin)', { noContent: true }).docIds.includes('chars1'));
|
|
129
|
+
assert.ok(search(db, 'names', '#martin*', { noContent: true }).docIds.includes('chars1'));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('search hyphen inside term is treated as separator', () => {
|
|
133
|
+
addDocument(db, 'names', 'hyphen1', 1, true, { payload: 'martin clasen' });
|
|
134
|
+
const r = search(db, 'names', 'martin-clasen*', { noContent: true });
|
|
135
|
+
assert.ok(r.total >= 1);
|
|
136
|
+
assert.ok(r.docIds.includes('hyphen1'));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('search leading minus uses NOT operator semantics', () => {
|
|
140
|
+
addDocument(db, 'names', 'neg1', 1, true, { payload: 'martin clasen' });
|
|
141
|
+
const r = search(db, 'names', 'martin -clasen*', { noContent: true });
|
|
142
|
+
assert.equal(r.total, 0);
|
|
143
|
+
});
|
|
144
|
+
|
|
95
145
|
it('search invalid query throws', () => {
|
|
96
146
|
assert.throws(() => search(db, 'names', ''), /invalid query/);
|
|
97
147
|
assert.throws(() => search(db, 'names', 'foo"bar'), /invalid query/);
|
|
148
|
+
assert.throws(() => search(db, 'names', 'martin@clasen*'), /syntax error/);
|
|
149
|
+
assert.throws(() => search(db, 'names', 'martin:clasen'), /syntax error/);
|
|
98
150
|
});
|
|
99
151
|
|
|
100
152
|
it('suggestionAdd returns 1 on insert, 0 on update', () => {
|