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 CHANGED
@@ -1,15 +1,65 @@
1
1
  # RESPLite
2
2
 
3
- A RESP2 server with practical Redis compatibility, backed by SQLite for persistent single-node workloads.
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 is not a full Redis clone. It is a RESP server with Redis-like semantics for a carefully selected subset of commands that map naturally to SQLite. Ideal for small to medium applications, persistent caches, local development, and low-ops deployments.
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
- - **Zero external services** just Node.js and a SQLite file.
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** — data survives restarts without snapshots or AOF.
12
- - **Embeddable** — start the server and connect from the same script (see examples below).
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.2",
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 templates).
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
- * The script spawns one RESPlite process per PRAGMA template (default, performance, safety, minimal)
9
- * on consecutive ports (6380, 6381, 6382, 6383 by default) and runs the same workload against each.
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 = getPragmaTemplateNames().filter((t) => t !== 'none');
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: one process per template on ports ${resplitePort}..${resplitePort + templateNames.length - 1}`);
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
+ }
@@ -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)],
@@ -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
- const before = countStmt.get(key)?.n ?? 0;
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 (countStmt.get(key)?.n ?? 0) - before;
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
+ });