resplite 1.2.10 → 1.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -515,21 +515,24 @@ A typical comparison is **Redis (for example, in Docker)** on one side and **RES
515
515
 
516
516
  **Example results (Redis vs RESPLite, default pragma, 10k iterations):**
517
517
 
518
- | Suite | Redis (Docker) | RESPLite (default) |
519
- |-----------------|----------------|--------------------|
520
- | PING | 8.79K/s | 37.36K/s |
521
- | SET+GET | 4.68K/s | 11.96K/s |
522
- | MSET+MGET(10) | 4.41K/s | 5.81K/s |
523
- | INCR | 9.54K/s | 18.97K/s |
524
- | HSET+HGET | 4.40K/s | 11.91K/s |
525
- | HGETALL(50) | 8.39K/s | 11.01K/s |
526
- | HLEN(50) | 9.36K/s | 31.21K/s |
527
- | SADD+SMEMBERS | 9.27K/s | 17.37K/s |
528
- | LPUSH+LRANGE | 8.34K/s | 14.27K/s |
529
- | LREM | 4.37K/s | 6.08K/s |
530
- | ZADD+ZRANGE | 7.80K/s | 17.12K/s |
531
- | SET+DEL | 4.39K/s | 9.57K/s |
532
- | FT.SEARCH | 8.36K/s | 8.22K/s |
518
+ | Suite | Redis (Docker) | RESPLite (default) |
519
+ |-------------------|----------------|--------------------|
520
+ | PING | 9.72K/s | 37.66K/s |
521
+ | SET+GET | 4.60K/s | 11.96K/s |
522
+ | MSET+MGET(10) | 4.38K/s | 5.71K/s |
523
+ | INCR | 9.76K/s | 19.15K/s |
524
+ | HSET+HGET | 4.42K/s | 11.71K/s |
525
+ | HGETALL(50) | 8.42K/s | 11.12K/s |
526
+ | HLEN(50) | 8.88K/s | 30.72K/s |
527
+ | SADD+SMEMBERS | 8.33K/s | 18.19K/s |
528
+ | LPUSH+LRANGE | 8.29K/s | 14.78K/s |
529
+ | LREM | 4.85K/s | 6.35K/s |
530
+ | ZADD+ZRANGE | 9.37K/s | 16.43K/s |
531
+ | ZADD+ZREVRANGE | 8.22K/s | 16.82K/s |
532
+ | ZRANK+ZREVRANK | 4.56K/s | 13.03K/s |
533
+ | ZREVRANGEBYSCORE | 8.88K/s | 16.88K/s |
534
+ | SET+DEL | 4.75K/s | 9.99K/s |
535
+ | FT.SEARCH | 8.39K/s | 8.81K/s |
533
536
 
534
537
  To reproduce the benchmark, run `npm run benchmark -- --template default`. Numbers depend on host and whether Redis is native or in Docker.
535
538
 
@@ -545,7 +548,7 @@ To reproduce the benchmark, run `npm run benchmark -- --template default`. Numbe
545
548
  | **Hashes** | HSET, HGET, HMGET, HGETALL, HDEL, HEXISTS, HINCRBY |
546
549
  | **Sets** | SADD, SREM, SMEMBERS, SISMEMBER, SCARD |
547
550
  | **Lists** | LPUSH, RPUSH, LLEN, LRANGE, LINDEX, LPOP, RPOP, BLPOP, BRPOP |
548
- | **Sorted sets** | ZADD, ZREM, ZCARD, ZSCORE, ZRANGE, ZRANGEBYSCORE |
551
+ | **Sorted sets** | ZADD, ZREM, ZCARD, ZSCORE, ZRANGE, ZREVRANGE, ZRANGEBYSCORE, ZREVRANGEBYSCORE, ZRANK, ZREVRANK |
549
552
  | **Search (FT.\*)** | FT.CREATE, FT.INFO, FT.ADD, FT.DEL, FT.SEARCH, FT.SUGADD, FT.SUGGET, FT.SUGDEL |
550
553
  | **Introspection** | TYPE, OBJECT IDLETIME, SCAN, KEYS, MONITOR |
551
554
  | **Admin** | SQLITE.INFO, CACHE.INFO, MEMORY.INFO |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resplite",
3
- "version": "1.2.10",
3
+ "version": "1.2.12",
4
4
  "description": "A RESP2 server with practical Redis compatibility, backed by SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -258,6 +258,34 @@ async function benchZaddZrange(client, n) {
258
258
  }
259
259
  }
260
260
 
261
+ async function benchZaddZrevrange(client, n) {
262
+ const key = 'bm:zset:rev';
263
+ for (let i = 0; i < n; i++) {
264
+ await client.zAdd(key, { score: i, value: `m${i}` });
265
+ if (i % 10 === 0) await client.zRange(key, 0, 49, { REV: true });
266
+ }
267
+ }
268
+
269
+ async function benchZrankZrevrank(client, n) {
270
+ const key = 'bm:zset:rank';
271
+ await client.del(key);
272
+ await client.zAdd(key, Array.from({ length: 100 }, (_, i) => ({ score: i, value: `m${i}` })));
273
+ for (let i = 0; i < n; i++) {
274
+ const member = `m${i % 100}`;
275
+ await client.zRank(key, member);
276
+ await client.zRevRank(key, member);
277
+ }
278
+ }
279
+
280
+ async function benchZrevrangebyscore(client, n) {
281
+ const key = 'bm:zset:byscore';
282
+ await client.del(key);
283
+ await client.zAdd(key, Array.from({ length: 100 }, (_, i) => ({ score: i, value: `m${i}` })));
284
+ for (let i = 0; i < n; i++) {
285
+ await client.sendCommand(['ZREVRANGEBYSCORE', key, '99', '0', 'LIMIT', '0', '20']);
286
+ }
287
+ }
288
+
261
289
  async function benchDel(client, n) {
262
290
  for (let i = 0; i < n; i++) {
263
291
  await client.set(`bm:del:${i}`, 'x');
@@ -311,6 +339,9 @@ const SUITES = [
311
339
  { name: 'LPUSH+LRANGE', fn: benchLpushLrange, iterScale: 1 },
312
340
  { name: 'LREM', fn: benchLrem, iterScale: 1 },
313
341
  { name: 'ZADD+ZRANGE', fn: benchZaddZrange, iterScale: 1 },
342
+ { name: 'ZADD+ZREVRANGE', fn: benchZaddZrevrange, iterScale: 1 },
343
+ { name: 'ZRANK+ZREVRANK', fn: benchZrankZrevrank, iterScale: 1 },
344
+ { name: 'ZREVRANGEBYSCORE', fn: benchZrevrangebyscore, iterScale: 1 },
314
345
  { name: 'SET+DEL', fn: benchDel, iterScale: 1 },
315
346
  { name: 'FT.SEARCH', fn: benchFtSearch, iterScale: 1 },
316
347
  ];
@@ -53,8 +53,11 @@ import * as zrem from './zrem.js';
53
53
  import * as zcard from './zcard.js';
54
54
  import * as zscore from './zscore.js';
55
55
  import * as zrange from './zrange.js';
56
- import * as zrevrange from './zrevrange.js';
57
56
  import * as zrangebyscore from './zrangebyscore.js';
57
+ import * as zrevrange from './zrevrange.js';
58
+ import * as zrevrangebyscore from './zrevrangebyscore.js';
59
+ import * as zrevrank from './zrevrank.js';
60
+ import * as zrank from './zrank.js';
58
61
  import * as sqliteInfo from './sqlite-info.js';
59
62
  import * as cacheInfo from './cache-info.js';
60
63
  import * as memoryInfo from './memory-info.js';
@@ -119,8 +122,11 @@ const HANDLERS = new Map([
119
122
  ['ZCARD', (e, a) => zcard.handleZcard(e, a)],
120
123
  ['ZSCORE', (e, a) => zscore.handleZscore(e, a)],
121
124
  ['ZRANGE', (e, a) => zrange.handleZrange(e, a)],
122
- ['ZREVRANGE', (e, a) => zrevrange.handleZrevrange(e, a)],
123
125
  ['ZRANGEBYSCORE', (e, a) => zrangebyscore.handleZrangebyscore(e, a)],
126
+ ['ZREVRANGE', (e, a) => zrevrange.handleZrevrange(e, a)],
127
+ ['ZREVRANGEBYSCORE', (e, a) => zrevrangebyscore.handleZrevrangebyscore(e, a)],
128
+ ['ZREVRANK', (e, a) => zrevrank.handleZrevrank(e, a)],
129
+ ['ZRANK', (e, a) => zrank.handleZrank(e, a)],
124
130
  ['SQLITE.INFO', (e, a) => sqliteInfo.handleSqliteInfo(e, a)],
125
131
  ['CACHE.INFO', (e, a) => cacheInfo.handleCacheInfo(e, a)],
126
132
  ['MEMORY.INFO', (e, a) => memoryInfo.handleMemoryInfo(e, a)],
@@ -0,0 +1,18 @@
1
+ /**
2
+ * ZRANK key member
3
+ * Returns the 0-based rank of member in the sorted set (low to high score).
4
+ * Returns null if key does not exist or member is not in the set.
5
+ */
6
+
7
+ export function handleZrank(engine, args) {
8
+ if (!args || args.length < 2) {
9
+ return { error: 'ERR wrong number of arguments for \'ZRANK\' command' };
10
+ }
11
+ try {
12
+ const rank = engine.zrank(args[0], args[1]);
13
+ return rank;
14
+ } catch (e) {
15
+ const msg = e && e.message ? e.message : String(e);
16
+ return { error: msg.startsWith('ERR ') ? msg : msg.startsWith('WRONGTYPE') ? msg : 'ERR ' + msg };
17
+ }
18
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * ZREVRANGEBYSCORE key max min [WITHSCORES] [LIMIT offset count]
3
+ * Same as ZRANGEBYSCORE but ordered from highest to lowest score.
4
+ * Note: Redis uses max min (first score is upper bound, second is lower bound).
5
+ */
6
+
7
+ export function handleZrevrangebyscore(engine, args) {
8
+ if (!args || args.length < 3) {
9
+ return { error: 'ERR wrong number of arguments for \'ZREVRANGEBYSCORE\' command' };
10
+ }
11
+ let withScores = false;
12
+ let limitOffset = null;
13
+ let limitCount = null;
14
+ const raw = args.slice(3);
15
+ for (let i = 0; i < raw.length; i++) {
16
+ const a = (Buffer.isBuffer(raw[i]) ? raw[i].toString('utf8') : String(raw[i])).toUpperCase();
17
+ if (a === 'WITHSCORES') {
18
+ withScores = true;
19
+ } else if (a === 'LIMIT' && i + 2 < raw.length) {
20
+ const off = parseInt(String(raw[i + 1]), 10);
21
+ const cnt = parseInt(String(raw[i + 2]), 10);
22
+ if (!Number.isNaN(off) && !Number.isNaN(cnt)) {
23
+ limitOffset = off;
24
+ limitCount = cnt;
25
+ }
26
+ i += 2;
27
+ }
28
+ }
29
+ try {
30
+ const max = parseFloat(String(args[1]));
31
+ const min = parseFloat(String(args[2]));
32
+ if (Number.isNaN(max) || Number.isNaN(min)) {
33
+ return { error: 'ERR value is not a valid float' };
34
+ }
35
+ const options = { withScores };
36
+ if (limitOffset != null && limitCount != null) {
37
+ options.offset = limitOffset;
38
+ options.limit = limitCount;
39
+ }
40
+ const arr = engine.zrevrangebyscore(args[0], max, min, options);
41
+ return arr;
42
+ } catch (e) {
43
+ const msg = e && e.message ? e.message : String(e);
44
+ return { error: msg.startsWith('ERR ') ? msg : msg.startsWith('WRONGTYPE') ? msg : 'ERR ' + msg };
45
+ }
46
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * ZREVRANK key member
3
+ * Returns the 0-based rank of member in the sorted set (high to low score).
4
+ * Returns null if key does not exist or member is not in the set.
5
+ */
6
+
7
+ export function handleZrevrank(engine, args) {
8
+ if (!args || args.length < 2) {
9
+ return { error: 'ERR wrong number of arguments for \'ZREVRANK\' command' };
10
+ }
11
+ try {
12
+ const rank = engine.zrevrank(args[0], args[1]);
13
+ return rank;
14
+ } catch (e) {
15
+ const msg = e && e.message ? e.message : String(e);
16
+ return { error: msg.startsWith('ERR ') ? msg : msg.startsWith('WRONGTYPE') ? msg : 'ERR ' + msg };
17
+ }
18
+ }
@@ -395,6 +395,22 @@ export function createEngine(opts = {}) {
395
395
  return zsets.rangeByRankReverse(k, start, stop, { withScores: options.withScores });
396
396
  },
397
397
 
398
+ zrevrank(key, member) {
399
+ const k = asKey(key);
400
+ const meta = getKeyMeta(key);
401
+ if (!meta) return null;
402
+ expectZset(meta);
403
+ return zsets.rankReverse(k, asKey(member));
404
+ },
405
+
406
+ zrank(key, member) {
407
+ const k = asKey(key);
408
+ const meta = getKeyMeta(key);
409
+ if (!meta) return null;
410
+ expectZset(meta);
411
+ return zsets.rank(k, asKey(member));
412
+ },
413
+
398
414
  zrangebyscore(key, min, max, options = {}) {
399
415
  const k = asKey(key);
400
416
  const meta = getKeyMeta(key);
@@ -407,6 +423,18 @@ export function createEngine(opts = {}) {
407
423
  });
408
424
  },
409
425
 
426
+ zrevrangebyscore(key, max, min, options = {}) {
427
+ const k = asKey(key);
428
+ const meta = getKeyMeta(key);
429
+ if (!meta) return [];
430
+ expectZset(meta);
431
+ return zsets.rangeByScoreReverse(k, max, min, {
432
+ withScores: options.withScores,
433
+ offset: options.offset ?? 0,
434
+ limit: options.limit,
435
+ });
436
+ },
437
+
410
438
  type(key) {
411
439
  const meta = getKeyMeta(key);
412
440
  return typeName(meta);
@@ -48,6 +48,20 @@ export function createZsetsStorage(db, keys) {
48
48
  ORDER BY score ASC, member ASC
49
49
  LIMIT ? OFFSET ?`
50
50
  );
51
+ const rankReverseStmt = db.prepare(
52
+ `SELECT COUNT(*) AS n FROM redis_zsets
53
+ WHERE key = ? AND (score > ? OR (score = ? AND member > ?))`
54
+ );
55
+ const rankStmt = db.prepare(
56
+ `SELECT COUNT(*) AS n FROM redis_zsets
57
+ WHERE key = ? AND (score < ? OR (score = ? AND member < ?))`
58
+ );
59
+ const rangeByScoreReverseStmt = db.prepare(
60
+ `SELECT member, score FROM redis_zsets
61
+ WHERE key = ? AND score <= ? AND score >= ?
62
+ ORDER BY score DESC, member DESC
63
+ LIMIT ? OFFSET ?`
64
+ );
51
65
 
52
66
  return {
53
67
  /**
@@ -195,5 +209,52 @@ export function createZsetsStorage(db, keys) {
195
209
  }
196
210
  return out;
197
211
  },
212
+
213
+ /**
214
+ * Rank of member in reverse order (ZREVRANK). Rank 0 = highest score.
215
+ * Returns null if key does not exist or member not in set.
216
+ * @param {Buffer} key
217
+ * @param {Buffer} member
218
+ * @returns {number | null} 0-based rank or null
219
+ */
220
+ rankReverse(key, member) {
221
+ const scoreRow = scoreStmt.get(key, member);
222
+ if (scoreRow == null) return null;
223
+ const row = rankReverseStmt.get(key, scoreRow.score, scoreRow.score, member);
224
+ return row ? row.n : 0;
225
+ },
226
+
227
+ /**
228
+ * Rank of member in ascending order (ZRANK). Rank 0 = lowest score.
229
+ * Returns null if key does not exist or member not in set.
230
+ * @param {Buffer} key
231
+ * @param {Buffer} member
232
+ * @returns {number | null} 0-based rank or null
233
+ */
234
+ rank(key, member) {
235
+ const scoreRow = scoreStmt.get(key, member);
236
+ if (scoreRow == null) return null;
237
+ const row = rankStmt.get(key, scoreRow.score, scoreRow.score, member);
238
+ return row ? row.n : 0;
239
+ },
240
+
241
+ /**
242
+ * Range by score in reverse order (ZREVRANGEBYSCORE). max/min inclusive, order score DESC, member DESC.
243
+ * @param {Buffer} key
244
+ * @param {number} max
245
+ * @param {number} min
246
+ * @param {{ withScores?: boolean, limit?: number, offset?: number }} options
247
+ */
248
+ rangeByScoreReverse(key, max, min, options = {}) {
249
+ const limit = options.limit ?? -1;
250
+ const offset = options.offset ?? 0;
251
+ const rows = rangeByScoreReverseStmt.all(key, max, min, limit < 0 ? 1e9 : limit, offset);
252
+ if (!options.withScores) return rows.map((r) => r.member);
253
+ const out = [];
254
+ for (const r of rows) {
255
+ out.push(r.member, formatScore(r.score));
256
+ }
257
+ return out;
258
+ },
198
259
  };
199
260
  }
@@ -69,6 +69,56 @@ describe('ZSET integration', () => {
69
69
  assert.equal(withArr[3].toString('utf8'), '2');
70
70
  });
71
71
 
72
+ it('ZREVRANK returns 0-based rank high-to-low and nil when missing', async () => {
73
+ await sendCommand(port, argv('ZADD', 'zrevrank', '1', 'a', '2', 'b', '3', 'c'));
74
+ const rankC = await sendCommand(port, argv('ZREVRANK', 'zrevrank', 'c'));
75
+ assert.equal(tryParseValue(rankC, 0).value, 0);
76
+ const rankB = await sendCommand(port, argv('ZREVRANK', 'zrevrank', 'b'));
77
+ assert.equal(tryParseValue(rankB, 0).value, 1);
78
+ const rankA = await sendCommand(port, argv('ZREVRANK', 'zrevrank', 'a'));
79
+ assert.equal(tryParseValue(rankA, 0).value, 2);
80
+
81
+ const noMember = await sendCommand(port, argv('ZREVRANK', 'zrevrank', 'x'));
82
+ assert.ok(noMember.toString('ascii').startsWith('$-1'));
83
+ const noKey = await sendCommand(port, argv('ZREVRANK', 'nokey', 'a'));
84
+ assert.ok(noKey.toString('ascii').startsWith('$-1'));
85
+ });
86
+
87
+ it('ZRANK returns 0-based rank low-to-high and nil when missing', async () => {
88
+ await sendCommand(port, argv('ZADD', 'zrank', '1', 'a', '2', 'b', '3', 'c'));
89
+ const rankA = await sendCommand(port, argv('ZRANK', 'zrank', 'a'));
90
+ assert.equal(tryParseValue(rankA, 0).value, 0);
91
+ const rankB = await sendCommand(port, argv('ZRANK', 'zrank', 'b'));
92
+ assert.equal(tryParseValue(rankB, 0).value, 1);
93
+ const rankC = await sendCommand(port, argv('ZRANK', 'zrank', 'c'));
94
+ assert.equal(tryParseValue(rankC, 0).value, 2);
95
+
96
+ const noMember = await sendCommand(port, argv('ZRANK', 'zrank', 'x'));
97
+ assert.ok(noMember.toString('ascii').startsWith('$-1'));
98
+ });
99
+
100
+ it('ZREVRANGEBYSCORE returns score range high-to-low and supports WITHSCORES and LIMIT', async () => {
101
+ await sendCommand(port, argv('ZADD', 'zrevscore', '1', 'a', '2', 'b', '3', 'c', '4', 'd'));
102
+ const rev = await sendCommand(port, argv('ZREVRANGEBYSCORE', 'zrevscore', '4', '2'));
103
+ const arr = tryParseValue(rev, 0).value;
104
+ assert.equal(arr.length, 3);
105
+ assert.equal(arr[0].toString('utf8'), 'd');
106
+ assert.equal(arr[1].toString('utf8'), 'c');
107
+ assert.equal(arr[2].toString('utf8'), 'b');
108
+
109
+ const withScores = await sendCommand(port, argv('ZREVRANGEBYSCORE', 'zrevscore', '10', '0', 'WITHSCORES'));
110
+ const withArr = tryParseValue(withScores, 0).value;
111
+ assert.equal(withArr.length, 8);
112
+ assert.equal(withArr[0].toString('utf8'), 'd');
113
+ assert.equal(withArr[1].toString('utf8'), '4');
114
+
115
+ const limitReply = await sendCommand(port, argv('ZREVRANGEBYSCORE', 'zrevscore', '10', '0', 'LIMIT', '1', '2'));
116
+ const limited = tryParseValue(limitReply, 0).value;
117
+ assert.equal(limited.length, 2);
118
+ assert.equal(limited[0].toString('utf8'), 'c');
119
+ assert.equal(limited[1].toString('utf8'), 'b');
120
+ });
121
+
72
122
  it('ZRANGE negative indices and start > stop', async () => {
73
123
  await sendCommand(port, argv('ZADD', 'z4', '1', 'a', '2', 'b', '3', 'c'));
74
124
  const last = await sendCommand(port, argv('ZRANGE', 'z4', '-1', '-1'));