resplite 1.1.2 → 1.1.6
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 +59 -20
- package/package.json +20 -1
- package/scripts/benchmark-redis-vs-resplite.js +41 -8
- package/src/commands/hlen.js +15 -0
- package/src/commands/lrem.js +15 -0
- package/src/commands/registry.js +4 -0
- package/src/engine/engine.js +19 -0
- package/src/storage/sqlite/lists.js +50 -0
- package/src/storage/sqlite/zsets.js +15 -2
- package/test/contract/redis-client.test.js +40 -0
- package/test/integration/hashes.test.js +25 -0
- package/test/integration/lists.test.js +52 -0
- package/test/unit/engine-hashes.test.js +21 -0
- package/test/unit/engine-lists.test.js +73 -0
package/README.md
CHANGED
|
@@ -1,15 +1,65 @@
|
|
|
1
1
|
# RESPLite
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A RESP server backed by SQLite. Compatible with `redis` clients and `redis-cli`, persistent by default, zero external daemons, and minimal memory footprint.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
RESPLite
|
|
7
|
+
RESPLite speaks **RESP** (the Redis Serialization Protocol), so your existing `redis` npm client and `redis-cli` work without changes. The storage layer is **SQLite**: WAL mode, FTS5 for full-text search, and a single `.db` file that survives restarts without snapshots or AOF.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
It is not a Redis clone. It covers a practical subset of commands that map naturally to SQLite, suited for single-node workloads where Redis' in-memory latency is not a hard requirement.
|
|
10
|
+
|
|
11
|
+
- **Zero external services** — just Node.js and a `.db` file.
|
|
10
12
|
- **Drop-in compatible** — works with the official `redis` npm client and `redis-cli`.
|
|
11
|
-
- **Persistent by default** —
|
|
12
|
-
- **Embeddable** — start the server and connect from the same script
|
|
13
|
+
- **Persistent by default** — no snapshots, no AOF, no config.
|
|
14
|
+
- **Embeddable** — start the server and connect from the same script.
|
|
15
|
+
- **Full-text search** — FT.\* commands via SQLite FTS5.
|
|
16
|
+
- **Simple queues** — lists with BLPOP/BRPOP.
|
|
17
|
+
|
|
18
|
+
### When RESPLite beats Redis in Docker
|
|
19
|
+
|
|
20
|
+
Building this project surfaced a clear finding: **Redis running inside Docker** on the same host often has **worse latency** than **RESPLite running locally**. Docker's virtual network adds overhead that disappears when the server runs in the same process/host. For single-node workloads this makes RESPLite the faster, simpler option.
|
|
21
|
+
|
|
22
|
+
The strongest use case is **migrating a non-replicated Redis instance that has grown large** (tens of GB). You don't need to manage replicas, AOF, or RDB. Once migrated, you get a single SQLite file and latency that is good enough for most workloads. The built-in migration tooling (see [Migration from Redis](#migration-from-redis)) handles datasets of that size with minimal downtime.
|
|
23
|
+
|
|
24
|
+
## Benchmark (Redis vs RESPLite)
|
|
25
|
+
|
|
26
|
+
A typical comparison is **Redis (e.g. in Docker)** on one side and **RESPLite locally** on the other. In that setup, RESPLite often shows **better latency** because it avoids Docker networking and runs in the same process/host. The benchmark below uses RESPLite with the **default** PRAGMA template only.
|
|
27
|
+
|
|
28
|
+
**Example results (Redis vs RESPLite, default pragma, 10k iterations):**
|
|
29
|
+
|
|
30
|
+
| Suite | Redis (Docker) | RESPLite (default) |
|
|
31
|
+
|-----------------|----------------|--------------------|
|
|
32
|
+
| PING | 8.79K/s | 37.36K/s |
|
|
33
|
+
| SET+GET | 4.68K/s | 11.96K/s |
|
|
34
|
+
| MSET+MGET(10) | 4.41K/s | 5.81K/s |
|
|
35
|
+
| INCR | 9.54K/s | 18.97K/s |
|
|
36
|
+
| HSET+HGET | 4.40K/s | 11.91K/s |
|
|
37
|
+
| HGETALL(50) | 8.39K/s | 11.01K/s |
|
|
38
|
+
| HLEN(50) | 9.36K/s | 31.21K/s |
|
|
39
|
+
| SADD+SMEMBERS | 9.27K/s | 17.37K/s |
|
|
40
|
+
| LPUSH+LRANGE | 8.34K/s | 14.27K/s |
|
|
41
|
+
| LREM | 4.37K/s | 6.08K/s |
|
|
42
|
+
| ZADD+ZRANGE | 7.80K/s | 17.12K/s |
|
|
43
|
+
| SET+DEL | 4.39K/s | 9.57K/s |
|
|
44
|
+
| FT.SEARCH | 8.36K/s | 8.22K/s |
|
|
45
|
+
|
|
46
|
+
*Run `npm run benchmark -- --template default` to reproduce. Numbers depend on host and whether Redis is native or in Docker.*
|
|
47
|
+
|
|
48
|
+
How to run:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Terminal 1: Redis on 6379 (e.g. docker run -p 6379:6379 redis). Terminal 2: RESPLite on 6380
|
|
52
|
+
RESPLITE_PORT=6380 npm start
|
|
53
|
+
|
|
54
|
+
# Terminal 3: run benchmark (Redis=6379, RESPLite=6380 by default)
|
|
55
|
+
npm run benchmark
|
|
56
|
+
|
|
57
|
+
# Only RESPLite with default pragma
|
|
58
|
+
npm run benchmark -- --template default
|
|
59
|
+
|
|
60
|
+
# Custom iterations and ports
|
|
61
|
+
npm run benchmark -- --iterations 10000 --redis-port 6379 --resplite-port 6380
|
|
62
|
+
```
|
|
13
63
|
|
|
14
64
|
## Install
|
|
15
65
|
|
|
@@ -265,6 +315,8 @@ await srv2.close();
|
|
|
265
315
|
|
|
266
316
|
## Compatibility matrix
|
|
267
317
|
|
|
318
|
+
RESPLite implements **47 core Redis commands** (~19% of the ~246 commands in Redis 7). The uncovered 81% is mostly entire subsystems that are out of scope by design: pub/sub (~10 commands), Streams (~20), cluster/replication (~30), Lua scripting (~5), server admin (~40), and extended variants of data-structure commands. For typical single-node application workloads — strings, hashes, sets, lists, sorted sets, key TTLs — coverage is close to the commands developers reach for daily.
|
|
319
|
+
|
|
268
320
|
### Supported (v1)
|
|
269
321
|
|
|
270
322
|
| Category | Commands |
|
|
@@ -294,6 +346,8 @@ Unsupported commands return: `ERR command not supported yet`.
|
|
|
294
346
|
|
|
295
347
|
## Migration from Redis
|
|
296
348
|
|
|
349
|
+
RESPLite is a good fit for migrating **non-replicated Redis** instances that have **grown large** (e.g. tens of GB) and where RESPLite’s latency is acceptable. The SPEC_F flow (dirty-key tracker, bulk import, cutover) is designed for that scenario with minimal downtime.
|
|
350
|
+
|
|
297
351
|
Migration supports two modes:
|
|
298
352
|
|
|
299
353
|
### Simple one-shot import (legacy)
|
|
@@ -494,21 +548,6 @@ import {
|
|
|
494
548
|
} from 'resplite/migration';
|
|
495
549
|
```
|
|
496
550
|
|
|
497
|
-
## Benchmark (Redis vs RESPLite)
|
|
498
|
-
|
|
499
|
-
Compare throughput of local Redis and RESPLite with the same workload (PING, SET/GET, hashes, sets, lists, zsets, etc.):
|
|
500
|
-
|
|
501
|
-
```bash
|
|
502
|
-
# Terminal 1: Redis on 6379 (default). Terminal 2: RESPLite on 6380
|
|
503
|
-
RESPLITE_PORT=6380 npm start
|
|
504
|
-
|
|
505
|
-
# Terminal 3: run benchmark (Redis=6379, RESPLite=6380 by default)
|
|
506
|
-
npm run benchmark
|
|
507
|
-
|
|
508
|
-
# Optional: custom iterations and ports
|
|
509
|
-
npm run benchmark -- --iterations 10000 --redis-port 6379 --resplite-port 6380
|
|
510
|
-
```
|
|
511
|
-
|
|
512
551
|
## Scripts
|
|
513
552
|
|
|
514
553
|
| Script | Description |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "resplite",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.6",
|
|
4
4
|
"description": "A RESP2 server with practical Redis compatibility, backed by SQLite",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -24,6 +24,25 @@
|
|
|
24
24
|
"benchmark": "node scripts/benchmark-redis-vs-resplite.js",
|
|
25
25
|
"test:all": "node --test 'test/**/*.test.js'"
|
|
26
26
|
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"redis",
|
|
29
|
+
"sqlite",
|
|
30
|
+
"resp",
|
|
31
|
+
"emulator",
|
|
32
|
+
"sdd",
|
|
33
|
+
"persist",
|
|
34
|
+
"cache",
|
|
35
|
+
"key-value",
|
|
36
|
+
"store",
|
|
37
|
+
"embedded",
|
|
38
|
+
"server",
|
|
39
|
+
"valkey",
|
|
40
|
+
"compatible",
|
|
41
|
+
"dragonfly",
|
|
42
|
+
"keydb",
|
|
43
|
+
"garnet",
|
|
44
|
+
"clasen"
|
|
45
|
+
],
|
|
27
46
|
"dependencies": {
|
|
28
47
|
"better-sqlite3": "^11.6.0"
|
|
29
48
|
},
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Comparative benchmark: Redis (local) vs RESPlite (all PRAGMA
|
|
3
|
+
* Comparative benchmark: Redis (local) vs RESPlite (all or one PRAGMA template).
|
|
4
4
|
*
|
|
5
5
|
* Prerequisites:
|
|
6
6
|
* - Redis running on port 6379 (default)
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* on consecutive ports
|
|
8
|
+
* By default the script spawns one RESPlite process per PRAGMA template (default, performance, safety, minimal)
|
|
9
|
+
* on consecutive ports. Use --template <name> to run only one template (e.g. default).
|
|
10
10
|
*
|
|
11
11
|
* Usage:
|
|
12
|
-
* node scripts/benchmark-redis-vs-resplite.js [--iterations N] [--redis-port P] [--resplite-port P]
|
|
12
|
+
* node scripts/benchmark-redis-vs-resplite.js [--iterations N] [--redis-port P] [--resplite-port P] [--template NAME]
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { createClient } from 'redis';
|
|
@@ -26,8 +26,11 @@ const DEFAULTS = {
|
|
|
26
26
|
iterations: 10000,
|
|
27
27
|
redisPort: 6379,
|
|
28
28
|
resplitePort: 6380,
|
|
29
|
+
template: null, // null = all templates (except none); or 'default' | 'performance' | 'safety' | 'minimal'
|
|
29
30
|
};
|
|
30
31
|
|
|
32
|
+
const VALID_TEMPLATES = ['default', 'performance', 'safety', 'minimal'];
|
|
33
|
+
|
|
31
34
|
function parseArgs() {
|
|
32
35
|
const out = { ...DEFAULTS };
|
|
33
36
|
const args = process.argv.slice(2);
|
|
@@ -38,6 +41,13 @@ function parseArgs() {
|
|
|
38
41
|
out.redisPort = parseInt(args[++i], 10);
|
|
39
42
|
} else if (args[i] === '--resplite-port' && args[i + 1]) {
|
|
40
43
|
out.resplitePort = parseInt(args[++i], 10);
|
|
44
|
+
} else if (args[i] === '--template' && args[i + 1]) {
|
|
45
|
+
const name = args[++i];
|
|
46
|
+
if (!VALID_TEMPLATES.includes(name)) {
|
|
47
|
+
console.error(`Invalid --template "${name}". Must be one of: ${VALID_TEMPLATES.join(', ')}`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
out.template = name;
|
|
41
51
|
}
|
|
42
52
|
}
|
|
43
53
|
return out;
|
|
@@ -204,6 +214,13 @@ async function benchHgetall(client, n) {
|
|
|
204
214
|
for (let i = 0; i < n; i++) await client.hGetAll(key);
|
|
205
215
|
}
|
|
206
216
|
|
|
217
|
+
async function benchHlen(client, n) {
|
|
218
|
+
const key = 'bm:hash:hlen';
|
|
219
|
+
await client.del(key);
|
|
220
|
+
await client.hSet(key, Object.fromEntries(Array.from({ length: 50 }, (_, i) => [`f${i}`, `v${i}`])));
|
|
221
|
+
for (let i = 0; i < n; i++) await client.hLen(key);
|
|
222
|
+
}
|
|
223
|
+
|
|
207
224
|
async function benchSaddSmembers(client, n) {
|
|
208
225
|
const key = 'bm:set';
|
|
209
226
|
for (let i = 0; i < n; i++) {
|
|
@@ -221,6 +238,18 @@ async function benchLpushLrange(client, n) {
|
|
|
221
238
|
}
|
|
222
239
|
}
|
|
223
240
|
|
|
241
|
+
async function benchLrem(client, n) {
|
|
242
|
+
const key = 'bm:list:lrem';
|
|
243
|
+
await client.del(key);
|
|
244
|
+
// Pre-populate with a mix of values so LREM always finds something to do.
|
|
245
|
+
// Each iteration pushes one 'target' element and removes it — net-zero list size.
|
|
246
|
+
await client.rPush(key, Array.from({ length: 20 }, (_, i) => `item-${i}`));
|
|
247
|
+
for (let i = 0; i < n; i++) {
|
|
248
|
+
await client.rPush(key, 'target');
|
|
249
|
+
await client.lRem(key, 1, 'target');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
224
253
|
async function benchZaddZrange(client, n) {
|
|
225
254
|
const key = 'bm:zset';
|
|
226
255
|
for (let i = 0; i < n; i++) {
|
|
@@ -277,8 +306,10 @@ const SUITES = [
|
|
|
277
306
|
{ name: 'INCR', fn: benchIncr, iterScale: 1 },
|
|
278
307
|
{ name: 'HSET+HGET', fn: benchHsetHget, iterScale: 1 },
|
|
279
308
|
{ name: 'HGETALL(50)', fn: benchHgetall, iterScale: 1 },
|
|
309
|
+
{ name: 'HLEN(50)', fn: benchHlen, iterScale: 1 },
|
|
280
310
|
{ name: 'SADD+SMEMBERS', fn: benchSaddSmembers, iterScale: 1 },
|
|
281
311
|
{ name: 'LPUSH+LRANGE', fn: benchLpushLrange, iterScale: 1 },
|
|
312
|
+
{ name: 'LREM', fn: benchLrem, iterScale: 1 },
|
|
282
313
|
{ name: 'ZADD+ZRANGE', fn: benchZaddZrange, iterScale: 1 },
|
|
283
314
|
{ name: 'SET+DEL', fn: benchDel, iterScale: 1 },
|
|
284
315
|
{ name: 'FT.SEARCH', fn: benchFtSearch, iterScale: 1 },
|
|
@@ -304,15 +335,17 @@ async function runSuite(redis, respliteClients, suite, iterations) {
|
|
|
304
335
|
}
|
|
305
336
|
|
|
306
337
|
async function main() {
|
|
307
|
-
const { iterations, redisPort, resplitePort } = parseArgs();
|
|
308
|
-
const templateNames =
|
|
338
|
+
const { iterations, redisPort, resplitePort, template } = parseArgs();
|
|
339
|
+
const templateNames = template
|
|
340
|
+
? [template]
|
|
341
|
+
: getPragmaTemplateNames().filter((t) => t !== 'none');
|
|
309
342
|
|
|
310
343
|
const benchTmpDir = path.join(PROJECT_ROOT, 'tmp', 'bench');
|
|
311
344
|
fs.mkdirSync(benchTmpDir, { recursive: true });
|
|
312
345
|
|
|
313
|
-
console.log('Benchmark: Redis vs RESPlite (all PRAGMA templates)');
|
|
346
|
+
console.log(template ? `Benchmark: Redis vs RESPlite (template: ${template})` : 'Benchmark: Redis vs RESPlite (all PRAGMA templates)');
|
|
314
347
|
console.log(` Redis: 127.0.0.1:${redisPort}`);
|
|
315
|
-
console.log(` RESPlite:
|
|
348
|
+
console.log(` RESPlite: ${templateNames.length} process(es) on port(s) ${resplitePort}${templateNames.length > 1 ? `..${resplitePort + templateNames.length - 1}` : ''}`);
|
|
316
349
|
console.log(` Templates: ${templateNames.join(', ')}`);
|
|
317
350
|
console.log(` Iterations per suite: ${iterations}`);
|
|
318
351
|
console.log('');
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HLEN key
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function handleHlen(engine, args) {
|
|
6
|
+
if (!args || args.length < 1) {
|
|
7
|
+
return { error: "ERR wrong number of arguments for 'HLEN' command" };
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
return engine.hlen(args[0]);
|
|
11
|
+
} catch (e) {
|
|
12
|
+
const msg = e && e.message ? e.message : String(e);
|
|
13
|
+
return { error: msg.startsWith('ERR ') ? msg : msg.startsWith('WRONGTYPE') ? msg : 'ERR ' + msg };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LREM key count element
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function handleLrem(engine, args) {
|
|
6
|
+
if (!args || args.length < 3) {
|
|
7
|
+
return { error: "ERR wrong number of arguments for 'LREM' command" };
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
return engine.lrem(args[0], args[1], args[2]);
|
|
11
|
+
} catch (e) {
|
|
12
|
+
const msg = e && e.message ? e.message : String(e);
|
|
13
|
+
return { error: msg.startsWith('ERR ') ? msg : msg.startsWith('WRONGTYPE') ? msg : 'ERR ' + msg };
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/commands/registry.js
CHANGED
|
@@ -27,6 +27,7 @@ import * as hget from './hget.js';
|
|
|
27
27
|
import * as hmget from './hmget.js';
|
|
28
28
|
import * as hgetall from './hgetall.js';
|
|
29
29
|
import * as hdel from './hdel.js';
|
|
30
|
+
import * as hlen from './hlen.js';
|
|
30
31
|
import * as hexists from './hexists.js';
|
|
31
32
|
import * as hincrby from './hincrby.js';
|
|
32
33
|
import * as sadd from './sadd.js';
|
|
@@ -41,6 +42,7 @@ import * as lrange from './lrange.js';
|
|
|
41
42
|
import * as lindex from './lindex.js';
|
|
42
43
|
import * as lpop from './lpop.js';
|
|
43
44
|
import * as rpop from './rpop.js';
|
|
45
|
+
import * as lrem from './lrem.js';
|
|
44
46
|
import * as blpop from './blpop.js';
|
|
45
47
|
import * as brpop from './brpop.js';
|
|
46
48
|
import * as scan from './scan.js';
|
|
@@ -87,6 +89,7 @@ const HANDLERS = new Map([
|
|
|
87
89
|
['HMGET', (e, a) => hmget.handleHmget(e, a)],
|
|
88
90
|
['HGETALL', (e, a) => hgetall.handleHgetall(e, a)],
|
|
89
91
|
['HDEL', (e, a) => hdel.handleHdel(e, a)],
|
|
92
|
+
['HLEN', (e, a) => hlen.handleHlen(e, a)],
|
|
90
93
|
['HEXISTS', (e, a) => hexists.handleHexists(e, a)],
|
|
91
94
|
['HINCRBY', (e, a) => hincrby.handleHincrby(e, a)],
|
|
92
95
|
['SADD', (e, a) => sadd.handleSadd(e, a)],
|
|
@@ -101,6 +104,7 @@ const HANDLERS = new Map([
|
|
|
101
104
|
['LINDEX', (e, a) => lindex.handleLindex(e, a)],
|
|
102
105
|
['LPOP', (e, a, ctx) => lpop.handleLpop(e, a)],
|
|
103
106
|
['RPOP', (e, a, ctx) => rpop.handleRpop(e, a)],
|
|
107
|
+
['LREM', (e, a) => lrem.handleLrem(e, a)],
|
|
104
108
|
['BLPOP', (e, a, ctx) => blpop.handleBlpop(e, a, ctx)],
|
|
105
109
|
['BRPOP', (e, a, ctx) => brpop.handleBrpop(e, a, ctx)],
|
|
106
110
|
['SCAN', (e, a) => scan.handleScan(e, a)],
|
package/src/engine/engine.js
CHANGED
|
@@ -209,6 +209,14 @@ export function createEngine(opts = {}) {
|
|
|
209
209
|
return hashes.delete(k, fields.map((f) => asKey(f)));
|
|
210
210
|
},
|
|
211
211
|
|
|
212
|
+
hlen(key) {
|
|
213
|
+
const k = asKey(key);
|
|
214
|
+
const meta = getKeyMeta(key);
|
|
215
|
+
if (!meta) return 0;
|
|
216
|
+
expectHash(meta);
|
|
217
|
+
return hashes.count(k);
|
|
218
|
+
},
|
|
219
|
+
|
|
212
220
|
hexists(key, field) {
|
|
213
221
|
const v = this.hget(key, field);
|
|
214
222
|
return v != null ? 1 : 0;
|
|
@@ -322,6 +330,17 @@ export function createEngine(opts = {}) {
|
|
|
322
330
|
return lists.rpop(k, count);
|
|
323
331
|
},
|
|
324
332
|
|
|
333
|
+
lrem(key, count, element) {
|
|
334
|
+
const k = asKey(key);
|
|
335
|
+
const meta = getKeyMeta(key);
|
|
336
|
+
if (!meta) return 0;
|
|
337
|
+
expectList(meta);
|
|
338
|
+
const c = parseInt(Buffer.isBuffer(count) ? count.toString() : String(count), 10);
|
|
339
|
+
if (Number.isNaN(c)) throw new Error('ERR value is not an integer or out of range');
|
|
340
|
+
const elem = Buffer.isBuffer(element) ? element : asValue(element);
|
|
341
|
+
return lists.lrem(k, c, elem);
|
|
342
|
+
},
|
|
343
|
+
|
|
325
344
|
zadd(key, scoreMemberPairs) {
|
|
326
345
|
const k = asKey(key);
|
|
327
346
|
getKeyMeta(key);
|
|
@@ -37,6 +37,10 @@ export function createListsStorage(db, keys) {
|
|
|
37
37
|
const deleteRangeStmt = db.prepare(
|
|
38
38
|
'DELETE FROM redis_list_items WHERE key = ? AND seq BETWEEN ? AND ?'
|
|
39
39
|
);
|
|
40
|
+
const selectAllWithSeqStmt = db.prepare(
|
|
41
|
+
'SELECT seq, value FROM redis_list_items WHERE key = ? ORDER BY seq ASC'
|
|
42
|
+
);
|
|
43
|
+
const deleteAllItemsStmt = db.prepare('DELETE FROM redis_list_items WHERE key = ?');
|
|
40
44
|
|
|
41
45
|
function getMeta(key) {
|
|
42
46
|
return getMetaStmt.get(key) || null;
|
|
@@ -229,5 +233,51 @@ export function createListsStorage(db, keys) {
|
|
|
229
233
|
return values;
|
|
230
234
|
});
|
|
231
235
|
},
|
|
236
|
+
|
|
237
|
+
lrem(key, count, element) {
|
|
238
|
+
return runInTransaction(db, () => {
|
|
239
|
+
const meta = getMeta(key);
|
|
240
|
+
if (!meta) return 0;
|
|
241
|
+
|
|
242
|
+
const allItems = selectAllWithSeqStmt.all(key);
|
|
243
|
+
const matches = allItems.filter((item) => {
|
|
244
|
+
const v = item.value;
|
|
245
|
+
return Buffer.isBuffer(v) && Buffer.isBuffer(element)
|
|
246
|
+
? v.equals(element)
|
|
247
|
+
: String(v) === String(element);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (matches.length === 0) return 0;
|
|
251
|
+
|
|
252
|
+
let toDelete;
|
|
253
|
+
if (count === 0) {
|
|
254
|
+
toDelete = matches;
|
|
255
|
+
} else if (count > 0) {
|
|
256
|
+
toDelete = matches.slice(0, count);
|
|
257
|
+
} else {
|
|
258
|
+
toDelete = matches.slice(count);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
for (const item of toDelete) {
|
|
262
|
+
deleteItemStmt.run(key, item.seq);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const remaining = selectAllWithSeqStmt.all(key);
|
|
266
|
+
if (remaining.length === 0) {
|
|
267
|
+
deleteMetaStmt.run(key);
|
|
268
|
+
keys.delete(key);
|
|
269
|
+
} else {
|
|
270
|
+
deleteAllItemsStmt.run(key);
|
|
271
|
+
const baseSeq = meta.headSeq;
|
|
272
|
+
for (let i = 0; i < remaining.length; i++) {
|
|
273
|
+
insertItemStmt.run(key, baseSeq + i, remaining[i].value);
|
|
274
|
+
}
|
|
275
|
+
updateMetaStmt.run(baseSeq, baseSeq + remaining.length - 1, key);
|
|
276
|
+
keys.bumpVersion(key);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return toDelete.length;
|
|
280
|
+
});
|
|
281
|
+
},
|
|
232
282
|
};
|
|
233
283
|
}
|
|
@@ -63,11 +63,13 @@ export function createZsetsStorage(db, keys) {
|
|
|
63
63
|
} else {
|
|
64
64
|
keys.set(key, KEY_TYPES.ZSET, { updatedAt: now });
|
|
65
65
|
}
|
|
66
|
-
|
|
66
|
+
let newCount = 0;
|
|
67
67
|
for (const { score, member } of pairs) {
|
|
68
|
+
const existed = scoreStmt.get(key, member) != null;
|
|
68
69
|
upsertStmt.run(key, member, score);
|
|
70
|
+
if (!existed) newCount++;
|
|
69
71
|
}
|
|
70
|
-
return
|
|
72
|
+
return newCount;
|
|
71
73
|
});
|
|
72
74
|
},
|
|
73
75
|
|
|
@@ -112,6 +114,17 @@ export function createZsetsStorage(db, keys) {
|
|
|
112
114
|
* @returns {Buffer[] | Array<Buffer|string>} members or [member, score, ...]
|
|
113
115
|
*/
|
|
114
116
|
rangeByRank(key, start, stop, options = {}) {
|
|
117
|
+
if (start >= 0 && stop >= 0) {
|
|
118
|
+
if (start > stop) return [];
|
|
119
|
+
const rows = rangeByRankStmt.all(key, stop - start + 1, start);
|
|
120
|
+
if (rows.length === 0) return [];
|
|
121
|
+
if (!options.withScores) return rows.map((r) => r.member);
|
|
122
|
+
const out = [];
|
|
123
|
+
for (const r of rows) {
|
|
124
|
+
out.push(r.member, formatScore(r.score));
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
115
128
|
const len = this.count(key);
|
|
116
129
|
if (len === 0) return [];
|
|
117
130
|
let s = start < 0 ? Math.max(0, len + start) : start;
|
|
@@ -35,4 +35,44 @@ describe('redis client compatibility', () => {
|
|
|
35
35
|
assert.equal(arr[0], 'v1');
|
|
36
36
|
assert.equal(arr[1], null);
|
|
37
37
|
});
|
|
38
|
+
|
|
39
|
+
it('HLEN returns field count', async () => {
|
|
40
|
+
await client.hSet('hlen:c1', { f1: 'v1', f2: 'v2', f3: 'v3' });
|
|
41
|
+
const n = await client.hLen('hlen:c1');
|
|
42
|
+
assert.equal(n, 3);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('HLEN on non-existent key returns 0', async () => {
|
|
46
|
+
const n = await client.hLen('hlen:c:missing');
|
|
47
|
+
assert.equal(n, 0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('LREM count=0 removes all occurrences', async () => {
|
|
51
|
+
await client.rPush('lrem:c1', ['a', 'b', 'a', 'c', 'a']);
|
|
52
|
+
const removed = await client.lRem('lrem:c1', 0, 'a');
|
|
53
|
+
assert.equal(removed, 3);
|
|
54
|
+
const items = await client.lRange('lrem:c1', 0, -1);
|
|
55
|
+
assert.deepEqual(items, ['b', 'c']);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('LREM count>0 removes from head', async () => {
|
|
59
|
+
await client.rPush('lrem:c2', ['a', 'b', 'a', 'c', 'a']);
|
|
60
|
+
const removed = await client.lRem('lrem:c2', 2, 'a');
|
|
61
|
+
assert.equal(removed, 2);
|
|
62
|
+
const items = await client.lRange('lrem:c2', 0, -1);
|
|
63
|
+
assert.deepEqual(items, ['b', 'c', 'a']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('LREM count<0 removes from tail', async () => {
|
|
67
|
+
await client.rPush('lrem:c3', ['a', 'b', 'a', 'c', 'a']);
|
|
68
|
+
const removed = await client.lRem('lrem:c3', -2, 'a');
|
|
69
|
+
assert.equal(removed, 2);
|
|
70
|
+
const items = await client.lRange('lrem:c3', 0, -1);
|
|
71
|
+
assert.deepEqual(items, ['a', 'b', 'c']);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('LREM on non-existent key returns 0', async () => {
|
|
75
|
+
const n = await client.lRem('lrem:c:missing', 1, 'x');
|
|
76
|
+
assert.equal(n, 0);
|
|
77
|
+
});
|
|
38
78
|
});
|
|
@@ -2,6 +2,7 @@ import { describe, it, before, after } from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { createTestServer } from '../helpers/server.js';
|
|
4
4
|
import { sendCommand, argv } from '../helpers/client.js';
|
|
5
|
+
import { tryParseValue } from '../../src/resp/parser.js';
|
|
5
6
|
|
|
6
7
|
describe('Hashes integration', () => {
|
|
7
8
|
let s;
|
|
@@ -31,4 +32,28 @@ describe('Hashes integration', () => {
|
|
|
31
32
|
assert.ok(s.includes('$1\r\nb\r\n'));
|
|
32
33
|
assert.ok(s.includes('$1\r\n2\r\n'));
|
|
33
34
|
});
|
|
35
|
+
|
|
36
|
+
it('HLEN returns field count', async () => {
|
|
37
|
+
await sendCommand(port, argv('HSET', 'hlen:1', 'f1', 'v1', 'f2', 'v2', 'f3', 'v3'));
|
|
38
|
+
const reply = await sendCommand(port, argv('HLEN', 'hlen:1'));
|
|
39
|
+
assert.equal(tryParseValue(reply, 0).value, 3);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('HLEN on non-existent key returns 0', async () => {
|
|
43
|
+
const reply = await sendCommand(port, argv('HLEN', 'hlen:nonexistent'));
|
|
44
|
+
assert.equal(tryParseValue(reply, 0).value, 0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('HLEN decreases after HDEL', async () => {
|
|
48
|
+
await sendCommand(port, argv('HSET', 'hlen:2', 'a', '1', 'b', '2'));
|
|
49
|
+
await sendCommand(port, argv('HDEL', 'hlen:2', 'a'));
|
|
50
|
+
const reply = await sendCommand(port, argv('HLEN', 'hlen:2'));
|
|
51
|
+
assert.equal(tryParseValue(reply, 0).value, 1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('HLEN on wrong type returns WRONGTYPE', async () => {
|
|
55
|
+
await sendCommand(port, argv('SET', 'hlen:str', 'value'));
|
|
56
|
+
const reply = await sendCommand(port, argv('HLEN', 'hlen:str'));
|
|
57
|
+
assert.ok(reply.toString('utf8').includes('WRONGTYPE'));
|
|
58
|
+
});
|
|
34
59
|
});
|
|
@@ -129,4 +129,56 @@ describe('Lists integration', () => {
|
|
|
129
129
|
assert.equal(parsed[1], 0xff);
|
|
130
130
|
assert.equal(parsed[2], 0x80);
|
|
131
131
|
});
|
|
132
|
+
|
|
133
|
+
it('LREM count=0 removes all occurrences', async () => {
|
|
134
|
+
await sendCommand(port, argv('RPUSH', 'lrem:1', 'a', 'b', 'a', 'c', 'a'));
|
|
135
|
+
const removed = tryParseValue(await sendCommand(port, argv('LREM', 'lrem:1', '0', 'a')), 0).value;
|
|
136
|
+
assert.equal(removed, 3);
|
|
137
|
+
const remaining = tryParseValue(await sendCommand(port, argv('LRANGE', 'lrem:1', '0', '-1')), 0).value;
|
|
138
|
+
assert.equal(remaining.length, 2);
|
|
139
|
+
assert.equal(remaining[0].toString('utf8'), 'b');
|
|
140
|
+
assert.equal(remaining[1].toString('utf8'), 'c');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('LREM count>0 removes from head', async () => {
|
|
144
|
+
await sendCommand(port, argv('RPUSH', 'lrem:2', 'a', 'b', 'a', 'c', 'a'));
|
|
145
|
+
const removed = tryParseValue(await sendCommand(port, argv('LREM', 'lrem:2', '2', 'a')), 0).value;
|
|
146
|
+
assert.equal(removed, 2);
|
|
147
|
+
const remaining = tryParseValue(await sendCommand(port, argv('LRANGE', 'lrem:2', '0', '-1')), 0).value;
|
|
148
|
+
assert.equal(remaining.length, 3);
|
|
149
|
+
assert.equal(remaining[0].toString('utf8'), 'b');
|
|
150
|
+
assert.equal(remaining[1].toString('utf8'), 'c');
|
|
151
|
+
assert.equal(remaining[2].toString('utf8'), 'a');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('LREM count<0 removes from tail', async () => {
|
|
155
|
+
await sendCommand(port, argv('RPUSH', 'lrem:3', 'a', 'b', 'a', 'c', 'a'));
|
|
156
|
+
const removed = tryParseValue(await sendCommand(port, argv('LREM', 'lrem:3', '-2', 'a')), 0).value;
|
|
157
|
+
assert.equal(removed, 2);
|
|
158
|
+
const remaining = tryParseValue(await sendCommand(port, argv('LRANGE', 'lrem:3', '0', '-1')), 0).value;
|
|
159
|
+
assert.equal(remaining.length, 3);
|
|
160
|
+
assert.equal(remaining[0].toString('utf8'), 'a');
|
|
161
|
+
assert.equal(remaining[1].toString('utf8'), 'b');
|
|
162
|
+
assert.equal(remaining[2].toString('utf8'), 'c');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('LREM on non-existent key returns 0', async () => {
|
|
166
|
+
const reply = tryParseValue(await sendCommand(port, argv('LREM', 'lrem:none', '1', 'x')), 0).value;
|
|
167
|
+
assert.equal(reply, 0);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('LREM when no matches returns 0', async () => {
|
|
171
|
+
await sendCommand(port, argv('RPUSH', 'lrem:4', 'a', 'b', 'c'));
|
|
172
|
+
const reply = tryParseValue(await sendCommand(port, argv('LREM', 'lrem:4', '1', 'z')), 0).value;
|
|
173
|
+
assert.equal(reply, 0);
|
|
174
|
+
const len = tryParseValue(await sendCommand(port, argv('LLEN', 'lrem:4')), 0).value;
|
|
175
|
+
assert.equal(len, 3);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('LREM removes all elements, key disappears', async () => {
|
|
179
|
+
await sendCommand(port, argv('RPUSH', 'lrem:5', 'x', 'x', 'x'));
|
|
180
|
+
await sendCommand(port, argv('LREM', 'lrem:5', '0', 'x'));
|
|
181
|
+
const lenReply = tryParseValue(await sendCommand(port, argv('LLEN', 'lrem:5')), 0).value;
|
|
182
|
+
assert.equal(lenReply, 0);
|
|
183
|
+
});
|
|
132
184
|
});
|
|
@@ -34,4 +34,25 @@ describe('Engine hashes', () => {
|
|
|
34
34
|
engine.hset('cnt', 'n', '10');
|
|
35
35
|
assert.equal(engine.hincrby('cnt', 'n', 5), 15);
|
|
36
36
|
});
|
|
37
|
+
|
|
38
|
+
it('HLEN returns number of fields', () => {
|
|
39
|
+
engine.hset('hlen:u', 'f1', 'v1', 'f2', 'v2', 'f3', 'v3');
|
|
40
|
+
assert.equal(engine.hlen('hlen:u'), 3);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('HLEN on non-existent key returns 0', () => {
|
|
44
|
+
assert.equal(engine.hlen('hlen:missing'), 0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('HLEN decreases when fields are deleted', () => {
|
|
48
|
+
engine.hset('hlen:dec', 'a', '1', 'b', '2');
|
|
49
|
+
assert.equal(engine.hlen('hlen:dec'), 2);
|
|
50
|
+
engine.hdel('hlen:dec', ['a']);
|
|
51
|
+
assert.equal(engine.hlen('hlen:dec'), 1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('HLEN throws WRONGTYPE on non-hash key', () => {
|
|
55
|
+
engine.set('hlen:str', 'value');
|
|
56
|
+
assert.throws(() => engine.hlen('hlen:str'), /WRONGTYPE/);
|
|
57
|
+
});
|
|
37
58
|
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createEngine } from '../../src/engine/engine.js';
|
|
4
|
+
import { openDb } from '../../src/storage/sqlite/db.js';
|
|
5
|
+
import { tmpDbPath } from '../helpers/tmp.js';
|
|
6
|
+
|
|
7
|
+
describe('Engine lists', () => {
|
|
8
|
+
const dbPath = tmpDbPath();
|
|
9
|
+
const db = openDb(dbPath);
|
|
10
|
+
const engine = createEngine({ db });
|
|
11
|
+
|
|
12
|
+
it('LPUSH and LLEN', () => {
|
|
13
|
+
engine.lpush('mylist', 'a', 'b', 'c');
|
|
14
|
+
assert.equal(engine.llen('mylist'), 3);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('LRANGE returns elements in order', () => {
|
|
18
|
+
const items = engine.lrange('mylist', 0, -1).map((v) => v.toString());
|
|
19
|
+
assert.deepEqual(items, ['c', 'b', 'a']);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('LREM count=0 removes all occurrences', () => {
|
|
23
|
+
engine.rpush('lrem1', 'a', 'b', 'a', 'c', 'a');
|
|
24
|
+
const n = engine.lrem('lrem1', 0, 'a');
|
|
25
|
+
assert.equal(n, 3);
|
|
26
|
+
const items = engine.lrange('lrem1', 0, -1).map((v) => v.toString());
|
|
27
|
+
assert.deepEqual(items, ['b', 'c']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('LREM count>0 removes from head', () => {
|
|
31
|
+
engine.rpush('lrem2', 'a', 'b', 'a', 'c', 'a');
|
|
32
|
+
const n = engine.lrem('lrem2', 2, 'a');
|
|
33
|
+
assert.equal(n, 2);
|
|
34
|
+
const items = engine.lrange('lrem2', 0, -1).map((v) => v.toString());
|
|
35
|
+
assert.deepEqual(items, ['b', 'c', 'a']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('LREM count<0 removes from tail', () => {
|
|
39
|
+
engine.rpush('lrem3', 'a', 'b', 'a', 'c', 'a');
|
|
40
|
+
const n = engine.lrem('lrem3', -2, 'a');
|
|
41
|
+
assert.equal(n, 2);
|
|
42
|
+
const items = engine.lrange('lrem3', 0, -1).map((v) => v.toString());
|
|
43
|
+
assert.deepEqual(items, ['a', 'b', 'c']);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('LREM on non-existent key returns 0', () => {
|
|
47
|
+
assert.equal(engine.lrem('lrem:missing', 1, 'x'), 0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('LREM with no matches returns 0 and list is unchanged', () => {
|
|
51
|
+
engine.rpush('lrem4', 'x', 'y', 'z');
|
|
52
|
+
assert.equal(engine.lrem('lrem4', 1, 'nope'), 0);
|
|
53
|
+
assert.equal(engine.llen('lrem4'), 3);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('LREM removes all elements, key disappears', () => {
|
|
57
|
+
engine.rpush('lrem5', 'x', 'x', 'x');
|
|
58
|
+
engine.lrem('lrem5', 0, 'x');
|
|
59
|
+
assert.equal(engine.type('lrem5'), 'none');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('LREM subsequent LRANGE still works correctly', () => {
|
|
63
|
+
engine.rpush('lrem6', 'a', 'b', 'c', 'b', 'd');
|
|
64
|
+
engine.lrem('lrem6', 1, 'b');
|
|
65
|
+
const items = engine.lrange('lrem6', 0, -1).map((v) => v.toString());
|
|
66
|
+
assert.deepEqual(items, ['a', 'c', 'b', 'd']);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('LREM throws WRONGTYPE on non-list key', () => {
|
|
70
|
+
engine.set('lrem:str', 'value');
|
|
71
|
+
assert.throws(() => engine.lrem('lrem:str', 1, 'x'), /WRONGTYPE/);
|
|
72
|
+
});
|
|
73
|
+
});
|