resplite 1.4.6 → 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 +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/src/commands/score-bounds.js +15 -0
- package/src/commands/zcount.js +5 -3
- package/src/commands/zrangebyscore.js +5 -3
- package/src/commands/zremrangebyscore.js +5 -3
- package/src/commands/zrevrangebyscore.js +5 -3
- package/src/engine/engine.js +1 -0
- package/src/storage/sqlite/keys.js +32 -5
- package/src/storage/sqlite/schema.js +5 -0
- package/src/storage/sqlite/search.js +51 -8
- package/src/storage/sqlite/sets.js +36 -6
- package/src/storage/sqlite/zsets.js +3 -0
- package/test/integration/search.test.js +66 -0
- package/test/integration/sets.test.js +77 -0
- package/test/integration/zsets.test.js +31 -0
- package/test/unit/search.test.js +34 -0
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
|
---
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse score bound used by sorted-set score range commands.
|
|
3
|
+
* Supports Redis-style infinities: -inf, +inf, inf.
|
|
4
|
+
*
|
|
5
|
+
* @param {Buffer|string|number} raw
|
|
6
|
+
* @returns {number|null}
|
|
7
|
+
*/
|
|
8
|
+
export function parseScoreBound(raw) {
|
|
9
|
+
const s = Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw);
|
|
10
|
+
const lower = s.toLowerCase();
|
|
11
|
+
if (lower === '-inf') return Number.NEGATIVE_INFINITY;
|
|
12
|
+
if (lower === '+inf' || lower === 'inf') return Number.POSITIVE_INFINITY;
|
|
13
|
+
const n = parseFloat(s);
|
|
14
|
+
return Number.isNaN(n) ? null : n;
|
|
15
|
+
}
|
package/src/commands/zcount.js
CHANGED
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
* ZCOUNT key min max - returns count of members in sorted set with score in [min, max].
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { parseScoreBound } from './score-bounds.js';
|
|
6
|
+
|
|
5
7
|
export function handleZcount(engine, args) {
|
|
6
8
|
if (!args || args.length < 3) {
|
|
7
9
|
return { error: 'ERR wrong number of arguments for \'ZCOUNT\' command' };
|
|
8
10
|
}
|
|
9
11
|
try {
|
|
10
|
-
const min =
|
|
11
|
-
const max =
|
|
12
|
-
if (
|
|
12
|
+
const min = parseScoreBound(args[1]);
|
|
13
|
+
const max = parseScoreBound(args[2]);
|
|
14
|
+
if (min == null || max == null) {
|
|
13
15
|
return { error: 'ERR value is not a valid float' };
|
|
14
16
|
}
|
|
15
17
|
const n = engine.zcount(args[0], min, max);
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { parseScoreBound } from './score-bounds.js';
|
|
6
|
+
|
|
5
7
|
export function handleZrangebyscore(engine, args) {
|
|
6
8
|
if (!args || args.length < 3) {
|
|
7
9
|
return { error: 'ERR wrong number of arguments for \'ZRANGEBYSCORE\' command' };
|
|
@@ -25,9 +27,9 @@ export function handleZrangebyscore(engine, args) {
|
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
29
|
try {
|
|
28
|
-
const min =
|
|
29
|
-
const max =
|
|
30
|
-
if (
|
|
30
|
+
const min = parseScoreBound(args[1]);
|
|
31
|
+
const max = parseScoreBound(args[2]);
|
|
32
|
+
if (min == null || max == null) {
|
|
31
33
|
return { error: 'ERR value is not a valid float' };
|
|
32
34
|
}
|
|
33
35
|
const options = { withScores };
|
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
* ZREMRANGEBYSCORE key min max - removes members with score in [min, max]. Returns count removed.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { parseScoreBound } from './score-bounds.js';
|
|
6
|
+
|
|
5
7
|
export function handleZremrangebyscore(engine, args) {
|
|
6
8
|
if (!args || args.length < 3) {
|
|
7
9
|
return { error: 'ERR wrong number of arguments for \'ZREMRANGEBYSCORE\' command' };
|
|
8
10
|
}
|
|
9
11
|
try {
|
|
10
|
-
const min =
|
|
11
|
-
const max =
|
|
12
|
-
if (
|
|
12
|
+
const min = parseScoreBound(args[1]);
|
|
13
|
+
const max = parseScoreBound(args[2]);
|
|
14
|
+
if (min == null || max == null) {
|
|
13
15
|
return { error: 'ERR value is not a valid float' };
|
|
14
16
|
}
|
|
15
17
|
const n = engine.zremrangebyscore(args[0], min, max);
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Note: Redis uses max min (first score is upper bound, second is lower bound).
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { parseScoreBound } from './score-bounds.js';
|
|
8
|
+
|
|
7
9
|
export function handleZrevrangebyscore(engine, args) {
|
|
8
10
|
if (!args || args.length < 3) {
|
|
9
11
|
return { error: 'ERR wrong number of arguments for \'ZREVRANGEBYSCORE\' command' };
|
|
@@ -27,9 +29,9 @@ export function handleZrevrangebyscore(engine, args) {
|
|
|
27
29
|
}
|
|
28
30
|
}
|
|
29
31
|
try {
|
|
30
|
-
const max =
|
|
31
|
-
const min =
|
|
32
|
-
if (
|
|
32
|
+
const max = parseScoreBound(args[1]);
|
|
33
|
+
const min = parseScoreBound(args[2]);
|
|
34
|
+
if (max == null || min == null) {
|
|
33
35
|
return { error: 'ERR value is not a valid float' };
|
|
34
36
|
}
|
|
35
37
|
const options = { withScores };
|
package/src/engine/engine.js
CHANGED
|
@@ -569,6 +569,7 @@ export function createEngine(opts = {}) {
|
|
|
569
569
|
if (keys.get(nk)) keys.delete(nk);
|
|
570
570
|
keys.set(nk, meta.type, {
|
|
571
571
|
expiresAt: meta.expiresAt,
|
|
572
|
+
setCount: meta.type === KEY_TYPES.SET ? meta.setCount : undefined,
|
|
572
573
|
hashCount: meta.type === KEY_TYPES.HASH ? meta.hashCount : undefined,
|
|
573
574
|
zsetCount: meta.type === KEY_TYPES.ZSET ? meta.zsetCount : undefined,
|
|
574
575
|
});
|
|
@@ -9,16 +9,22 @@ import { KEY_TYPES } from './schema.js';
|
|
|
9
9
|
*/
|
|
10
10
|
export function createKeysStorage(db) {
|
|
11
11
|
const getByKey = db.prepare(
|
|
12
|
-
'SELECT key, type, expires_at AS expiresAt, hash_count AS hashCount, zset_count AS zsetCount, version, updated_at AS updatedAt FROM redis_keys WHERE key = ?'
|
|
12
|
+
'SELECT key, type, expires_at AS expiresAt, set_count AS setCount, hash_count AS hashCount, zset_count AS zsetCount, version, updated_at AS updatedAt FROM redis_keys WHERE key = ?'
|
|
13
13
|
);
|
|
14
14
|
const insert = db.prepare(
|
|
15
|
-
`INSERT INTO redis_keys (key, type, expires_at, hash_count, zset_count, version, updated_at) VALUES (?, ?, ?, ?, ?, 1, ?)`
|
|
15
|
+
`INSERT INTO redis_keys (key, type, expires_at, set_count, hash_count, zset_count, version, updated_at) VALUES (?, ?, ?, ?, ?, ?, 1, ?)`
|
|
16
16
|
);
|
|
17
17
|
const updateMeta = db.prepare(
|
|
18
|
-
'UPDATE redis_keys SET type = ?, expires_at = ?, hash_count = ?, zset_count = ?, version = version + 1, updated_at = ? WHERE key = ?'
|
|
18
|
+
'UPDATE redis_keys SET type = ?, expires_at = ?, set_count = ?, hash_count = ?, zset_count = ?, version = version + 1, updated_at = ? WHERE key = ?'
|
|
19
19
|
);
|
|
20
20
|
const updateExpires = db.prepare('UPDATE redis_keys SET expires_at = ?, updated_at = ? WHERE key = ?');
|
|
21
21
|
const updateVersion = db.prepare('UPDATE redis_keys SET version = version + 1, updated_at = ? WHERE key = ?');
|
|
22
|
+
const updateSetCount = db.prepare('UPDATE redis_keys SET set_count = ?, updated_at = ? WHERE key = ?');
|
|
23
|
+
const updateSetCountOnly = db.prepare('UPDATE redis_keys SET set_count = ? WHERE key = ?');
|
|
24
|
+
const incrSetCount = db.prepare(
|
|
25
|
+
'UPDATE redis_keys SET set_count = COALESCE(set_count, 0) + ?, updated_at = ? WHERE key = ?'
|
|
26
|
+
);
|
|
27
|
+
const incrSetCountOnly = db.prepare('UPDATE redis_keys SET set_count = COALESCE(set_count, 0) + ? WHERE key = ?');
|
|
22
28
|
const updateHashCount = db.prepare('UPDATE redis_keys SET hash_count = ?, updated_at = ? WHERE key = ?');
|
|
23
29
|
const updateHashCountOnly = db.prepare('UPDATE redis_keys SET hash_count = ? WHERE key = ?');
|
|
24
30
|
const incrHashCount = db.prepare(
|
|
@@ -50,6 +56,9 @@ export function createKeysStorage(db) {
|
|
|
50
56
|
const now = options.updatedAt ?? Date.now();
|
|
51
57
|
const expiresAt = options.expiresAt ?? null;
|
|
52
58
|
const existing = getByKey.get(key);
|
|
59
|
+
const setCount = type === KEY_TYPES.SET
|
|
60
|
+
? (options.setCount ?? existing?.setCount ?? 0)
|
|
61
|
+
: null;
|
|
53
62
|
const hashCount = type === KEY_TYPES.HASH
|
|
54
63
|
? (options.hashCount ?? existing?.hashCount ?? 0)
|
|
55
64
|
: null;
|
|
@@ -57,9 +66,9 @@ export function createKeysStorage(db) {
|
|
|
57
66
|
? (options.zsetCount ?? existing?.zsetCount ?? 0)
|
|
58
67
|
: null;
|
|
59
68
|
if (existing) {
|
|
60
|
-
updateMeta.run(type, expiresAt, hashCount, zsetCount, now, key);
|
|
69
|
+
updateMeta.run(type, expiresAt, setCount, hashCount, zsetCount, now, key);
|
|
61
70
|
} else {
|
|
62
|
-
insert.run(key, type, expiresAt, hashCount, zsetCount, now);
|
|
71
|
+
insert.run(key, type, expiresAt, setCount, hashCount, zsetCount, now);
|
|
63
72
|
}
|
|
64
73
|
},
|
|
65
74
|
|
|
@@ -71,6 +80,24 @@ export function createKeysStorage(db) {
|
|
|
71
80
|
updateVersion.run(Date.now(), key);
|
|
72
81
|
},
|
|
73
82
|
|
|
83
|
+
setSetCount(key, setCount, options = {}) {
|
|
84
|
+
const touchUpdatedAt = options.touchUpdatedAt !== false;
|
|
85
|
+
if (touchUpdatedAt) {
|
|
86
|
+
updateSetCount.run(setCount, options.updatedAt ?? Date.now(), key);
|
|
87
|
+
} else {
|
|
88
|
+
updateSetCountOnly.run(setCount, key);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
incrSetCount(key, delta, options = {}) {
|
|
93
|
+
const touchUpdatedAt = options.touchUpdatedAt !== false;
|
|
94
|
+
if (touchUpdatedAt) {
|
|
95
|
+
incrSetCount.run(delta, options.updatedAt ?? Date.now(), key);
|
|
96
|
+
} else {
|
|
97
|
+
incrSetCountOnly.run(delta, key);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
|
|
74
101
|
setHashCount(key, hashCount, options = {}) {
|
|
75
102
|
const touchUpdatedAt = options.touchUpdatedAt !== false;
|
|
76
103
|
if (touchUpdatedAt) {
|
|
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS redis_keys (
|
|
|
7
7
|
key BLOB PRIMARY KEY,
|
|
8
8
|
type INTEGER NOT NULL,
|
|
9
9
|
expires_at INTEGER,
|
|
10
|
+
set_count INTEGER,
|
|
10
11
|
hash_count INTEGER,
|
|
11
12
|
zset_count INTEGER,
|
|
12
13
|
version INTEGER NOT NULL DEFAULT 1,
|
|
@@ -90,8 +91,12 @@ export function applySchema(db) {
|
|
|
90
91
|
db.exec(SCHEMA);
|
|
91
92
|
// Backward-compatible migration for databases created before count columns existed.
|
|
92
93
|
const cols = db.prepare('PRAGMA table_info(redis_keys)').all();
|
|
94
|
+
const hasSetCount = cols.some((c) => c.name === 'set_count');
|
|
93
95
|
const hasHashCount = cols.some((c) => c.name === 'hash_count');
|
|
94
96
|
const hasZsetCount = cols.some((c) => c.name === 'zset_count');
|
|
97
|
+
if (!hasSetCount) {
|
|
98
|
+
db.exec('ALTER TABLE redis_keys ADD COLUMN set_count INTEGER;');
|
|
99
|
+
}
|
|
95
100
|
if (!hasHashCount) {
|
|
96
101
|
db.exec('ALTER TABLE redis_keys ADD COLUMN hash_count INTEGER;');
|
|
97
102
|
}
|
|
@@ -209,21 +209,64 @@ export function deleteDocument(db, idx, docId) {
|
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
/**
|
|
212
|
-
*
|
|
213
|
-
*
|
|
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 (/[
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
268
|
+
|
|
269
|
+
return tokens.join(' ');
|
|
227
270
|
}
|
|
228
271
|
|
|
229
272
|
/**
|
|
@@ -22,34 +22,52 @@ export function createSetsStorage(db, keys) {
|
|
|
22
22
|
return runInTransaction(db, () => {
|
|
23
23
|
const now = options.updatedAt ?? Date.now();
|
|
24
24
|
const meta = keys.get(key);
|
|
25
|
+
let knownCount = 0;
|
|
25
26
|
if (meta) {
|
|
26
27
|
if (meta.type !== KEY_TYPES.SET) {
|
|
27
28
|
throw new Error('WRONGTYPE Operation against a key holding the wrong kind of value');
|
|
28
29
|
}
|
|
29
30
|
keys.bumpVersion(key);
|
|
31
|
+
if (meta.setCount == null) {
|
|
32
|
+
const row = countStmt.get(key);
|
|
33
|
+
knownCount = (row && row.n) || 0;
|
|
34
|
+
keys.setSetCount(key, knownCount, { touchUpdatedAt: false });
|
|
35
|
+
} else {
|
|
36
|
+
knownCount = meta.setCount;
|
|
37
|
+
}
|
|
30
38
|
} else {
|
|
31
|
-
keys.set(key, KEY_TYPES.SET, { updatedAt: now });
|
|
39
|
+
keys.set(key, KEY_TYPES.SET, { updatedAt: now, setCount: 0 });
|
|
32
40
|
}
|
|
33
41
|
let added = 0;
|
|
34
42
|
for (const m of members) {
|
|
35
43
|
const r = insertStmt.run(key, m);
|
|
36
44
|
if (r.changes > 0) added++;
|
|
37
45
|
}
|
|
46
|
+
if (added > 0) {
|
|
47
|
+
if (meta) keys.incrSetCount(key, added, { touchUpdatedAt: false });
|
|
48
|
+
else keys.setSetCount(key, added, { touchUpdatedAt: false });
|
|
49
|
+
} else if (meta && meta.setCount == null) {
|
|
50
|
+
// Legacy rows may have null counters; persist hydrated value.
|
|
51
|
+
keys.setSetCount(key, knownCount, { touchUpdatedAt: false });
|
|
52
|
+
}
|
|
38
53
|
return added;
|
|
39
54
|
});
|
|
40
55
|
},
|
|
41
56
|
|
|
42
57
|
remove(key, members) {
|
|
43
58
|
return runInTransaction(db, () => {
|
|
59
|
+
const meta = keys.get(key);
|
|
60
|
+
const before = meta && meta.setCount != null ? meta.setCount : null;
|
|
44
61
|
let n = 0;
|
|
45
62
|
for (const m of members) {
|
|
46
63
|
n += deleteStmt.run(key, m).changes;
|
|
47
64
|
}
|
|
48
|
-
const
|
|
49
|
-
const remaining = (row && row.n) || 0;
|
|
65
|
+
const remaining = before != null ? Math.max(0, before - n) : ((countStmt.get(key) || {}).n || 0);
|
|
50
66
|
if (remaining === 0) {
|
|
51
67
|
deleteAllStmt.run(key);
|
|
52
68
|
keys.delete(key);
|
|
69
|
+
} else if (n > 0) {
|
|
70
|
+
keys.setSetCount(key, remaining, { touchUpdatedAt: false });
|
|
53
71
|
}
|
|
54
72
|
return n;
|
|
55
73
|
});
|
|
@@ -65,8 +83,17 @@ export function createSetsStorage(db, keys) {
|
|
|
65
83
|
},
|
|
66
84
|
|
|
67
85
|
count(key) {
|
|
86
|
+
const meta = keys.get(key);
|
|
87
|
+
if (meta && meta.type === KEY_TYPES.SET && meta.setCount != null) {
|
|
88
|
+
return meta.setCount;
|
|
89
|
+
}
|
|
68
90
|
const row = countStmt.get(key);
|
|
69
|
-
|
|
91
|
+
const n = row ? row.n : 0;
|
|
92
|
+
if (meta && meta.type === KEY_TYPES.SET && meta.setCount == null) {
|
|
93
|
+
// One-time hydration for databases created before set_count existed.
|
|
94
|
+
keys.setSetCount(key, n, { touchUpdatedAt: false });
|
|
95
|
+
}
|
|
96
|
+
return n;
|
|
70
97
|
},
|
|
71
98
|
|
|
72
99
|
/** Copy all members from oldKey to newKey. Caller ensures newKey exists in redis_keys. */
|
|
@@ -75,6 +102,9 @@ export function createSetsStorage(db, keys) {
|
|
|
75
102
|
for (const r of rows) {
|
|
76
103
|
insertStmt.run(newKey, r.member);
|
|
77
104
|
}
|
|
105
|
+
const sourceMeta = keys.get(oldKey);
|
|
106
|
+
const nextCount = sourceMeta && sourceMeta.setCount != null ? sourceMeta.setCount : rows.length;
|
|
107
|
+
keys.setSetCount(newKey, nextCount, { touchUpdatedAt: false });
|
|
78
108
|
},
|
|
79
109
|
|
|
80
110
|
/** Get random members without removing. count null/1 = single; count > 0 = up to count distinct; count < 0 = |count| with replacement. */
|
|
@@ -117,13 +147,13 @@ export function createSetsStorage(db, keys) {
|
|
|
117
147
|
for (let i = 0; i < -c; i++) chosen.push(arr[Math.floor(Math.random() * arr.length)]);
|
|
118
148
|
}
|
|
119
149
|
for (const m of chosen) deleteStmt.run(key, m);
|
|
120
|
-
const
|
|
121
|
-
const remaining = (row && row.n) || 0;
|
|
150
|
+
const remaining = arr.length - chosen.length;
|
|
122
151
|
if (remaining === 0) {
|
|
123
152
|
deleteAllStmt.run(key);
|
|
124
153
|
keys.delete(key);
|
|
125
154
|
} else {
|
|
126
155
|
keys.bumpVersion(key);
|
|
156
|
+
keys.setSetCount(key, remaining, { touchUpdatedAt: false });
|
|
127
157
|
}
|
|
128
158
|
return c === 1 ? chosen[0] : chosen;
|
|
129
159
|
});
|
|
@@ -315,6 +315,9 @@ export function createZsetsStorage(db, keys) {
|
|
|
315
315
|
},
|
|
316
316
|
|
|
317
317
|
countByScore(key, min, max) {
|
|
318
|
+
if (min === Number.NEGATIVE_INFINITY && max === Number.POSITIVE_INFINITY) {
|
|
319
|
+
return this.count(key);
|
|
320
|
+
}
|
|
318
321
|
const row = countByScoreStmt.get(key, min, max);
|
|
319
322
|
return row ? row.n : 0;
|
|
320
323
|
},
|
|
@@ -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;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { describe, it, before, after } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
+
import Database from 'better-sqlite3';
|
|
3
4
|
import { createTestServer } from '../helpers/server.js';
|
|
4
5
|
import { sendCommand, argv } from '../helpers/client.js';
|
|
6
|
+
import { tryParseValue } from '../../src/resp/parser.js';
|
|
5
7
|
|
|
6
8
|
describe('Sets integration', () => {
|
|
7
9
|
let s;
|
|
@@ -24,4 +26,79 @@ describe('Sets integration', () => {
|
|
|
24
26
|
assert.ok(s.includes('$1\r\nb\r\n'));
|
|
25
27
|
assert.ok(s.includes('$1\r\nc\r\n'));
|
|
26
28
|
});
|
|
29
|
+
|
|
30
|
+
it('SCARD returns member count', async () => {
|
|
31
|
+
await sendCommand(port, argv('SADD', 'scard:1', 'a', 'b', 'c'));
|
|
32
|
+
const reply = await sendCommand(port, argv('SCARD', 'scard:1'));
|
|
33
|
+
assert.equal(tryParseValue(reply, 0).value, 3);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('SCARD on non-existent key returns 0', async () => {
|
|
37
|
+
const reply = await sendCommand(port, argv('SCARD', 'scard:none'));
|
|
38
|
+
assert.equal(tryParseValue(reply, 0).value, 0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('SCARD decreases after SREM', async () => {
|
|
42
|
+
await sendCommand(port, argv('SADD', 'scard:2', 'x', 'y'));
|
|
43
|
+
await sendCommand(port, argv('SREM', 'scard:2', 'x'));
|
|
44
|
+
const reply = await sendCommand(port, argv('SCARD', 'scard:2'));
|
|
45
|
+
assert.equal(tryParseValue(reply, 0).value, 1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('RENAME keeps set cardinality metadata', async () => {
|
|
49
|
+
await sendCommand(port, argv('SADD', 'srename:src', 'm1', 'm2', 'm3'));
|
|
50
|
+
await sendCommand(port, argv('RENAME', 'srename:src', 'srename:dst'));
|
|
51
|
+
const reply = await sendCommand(port, argv('SCARD', 'srename:dst'));
|
|
52
|
+
assert.equal(tryParseValue(reply, 0).value, 3);
|
|
53
|
+
});
|
|
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
|
+
|
|
83
|
+
it('legacy set rows with null set_count hydrate on first SCARD', async () => {
|
|
84
|
+
const s1 = await createTestServer();
|
|
85
|
+
await sendCommand(s1.port, argv('SADD', 'legacy:s', 'a', 'b'));
|
|
86
|
+
const dbPath = s1.dbPath;
|
|
87
|
+
await s1.closeAsync();
|
|
88
|
+
s1.db.close();
|
|
89
|
+
|
|
90
|
+
const legacyDb = new Database(dbPath);
|
|
91
|
+
legacyDb.prepare('UPDATE redis_keys SET set_count = NULL WHERE key = ?').run(Buffer.from('legacy:s', 'utf8'));
|
|
92
|
+
legacyDb.close();
|
|
93
|
+
|
|
94
|
+
const s2 = await createTestServer({ dbPath });
|
|
95
|
+
const first = await sendCommand(s2.port, argv('SCARD', 'legacy:s'));
|
|
96
|
+
assert.equal(tryParseValue(first, 0).value, 2);
|
|
97
|
+
const second = await sendCommand(s2.port, argv('SCARD', 'legacy:s'));
|
|
98
|
+
assert.equal(tryParseValue(second, 0).value, 2);
|
|
99
|
+
|
|
100
|
+
const row = s2.db.prepare('SELECT set_count AS n FROM redis_keys WHERE key = ?').get(Buffer.from('legacy:s', 'utf8'));
|
|
101
|
+
assert.equal(row.n, 2);
|
|
102
|
+
await s2.closeAsync();
|
|
103
|
+
});
|
|
27
104
|
});
|
|
@@ -159,6 +159,37 @@ describe('ZSET integration', () => {
|
|
|
159
159
|
assert.equal(limited[1].toString('utf8'), 'c');
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
+
it('ZCOUNT supports numeric ranges and full-range infinities', async () => {
|
|
163
|
+
await sendCommand(port, argv('ZADD', 'zc1', '1', 'a', '2', 'b', '3', 'c', '4', 'd'));
|
|
164
|
+
|
|
165
|
+
const mid = await sendCommand(port, argv('ZCOUNT', 'zc1', '2', '3'));
|
|
166
|
+
assert.equal(tryParseValue(mid, 0).value, 2);
|
|
167
|
+
|
|
168
|
+
const all = await sendCommand(port, argv('ZCOUNT', 'zc1', '-inf', '+inf'));
|
|
169
|
+
assert.equal(tryParseValue(all, 0).value, 4);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('score-range commands accept -inf/+inf bounds', async () => {
|
|
173
|
+
await sendCommand(port, argv('ZADD', 'zinf', '1', 'a', '2', 'b', '3', 'c', '4', 'd'));
|
|
174
|
+
|
|
175
|
+
const rangeAll = await sendCommand(port, argv('ZRANGEBYSCORE', 'zinf', '-inf', '+inf'));
|
|
176
|
+
const rAll = tryParseValue(rangeAll, 0).value;
|
|
177
|
+
assert.equal(rAll.length, 4);
|
|
178
|
+
assert.equal(rAll[0].toString('utf8'), 'a');
|
|
179
|
+
assert.equal(rAll[3].toString('utf8'), 'd');
|
|
180
|
+
|
|
181
|
+
const revAll = await sendCommand(port, argv('ZREVRANGEBYSCORE', 'zinf', '+inf', '-inf'));
|
|
182
|
+
const rvAll = tryParseValue(revAll, 0).value;
|
|
183
|
+
assert.equal(rvAll.length, 4);
|
|
184
|
+
assert.equal(rvAll[0].toString('utf8'), 'd');
|
|
185
|
+
assert.equal(rvAll[3].toString('utf8'), 'a');
|
|
186
|
+
|
|
187
|
+
const removed = await sendCommand(port, argv('ZREMRANGEBYSCORE', 'zinf', '-inf', '+inf'));
|
|
188
|
+
assert.equal(tryParseValue(removed, 0).value, 4);
|
|
189
|
+
const card = await sendCommand(port, argv('ZCARD', 'zinf'));
|
|
190
|
+
assert.equal(tryParseValue(card, 0).value, 0);
|
|
191
|
+
});
|
|
192
|
+
|
|
162
193
|
it('ZCARD non-existent returns 0, ZSCORE non-existent returns nil', async () => {
|
|
163
194
|
const cardReply = await sendCommand(port, argv('ZCARD', 'nonexistent_z'));
|
|
164
195
|
assert.equal(tryParseValue(cardReply, 0).value, 0);
|
package/test/unit/search.test.js
CHANGED
|
@@ -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', () => {
|