resplite 1.4.4 → 1.4.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resplite",
3
- "version": "1.4.4",
3
+ "version": "1.4.8",
4
4
  "description": "A RESP2 server with practical Redis compatibility, backed by SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -346,6 +346,13 @@ async function benchZcount(client, n) {
346
346
  for (let i = 0; i < n; i++) await client.sendCommand(['ZCOUNT', key, '0', '99']);
347
347
  }
348
348
 
349
+ async function benchZcard(client, n) {
350
+ const key = 'bm:zset:card';
351
+ await client.del(key);
352
+ await client.zAdd(key, Array.from({ length: 100 }, (_, i) => ({ score: i, value: `m${i}` })));
353
+ for (let i = 0; i < n; i++) await client.sendCommand(['ZCARD', key]);
354
+ }
355
+
349
356
  async function benchZincrby(client, n) {
350
357
  const key = 'bm:zset:incr';
351
358
  await client.del(key);
@@ -353,7 +360,31 @@ async function benchZincrby(client, n) {
353
360
  for (let i = 0; i < n; i++) await client.sendCommand(['ZINCRBY', key, '1', 'member']);
354
361
  }
355
362
 
356
- async function benchZremrangebyrank(client, n) {
363
+ async function benchZremrangebyrankPure(client, n) {
364
+ const key = 'bm:zset:remrank:pure';
365
+ const seed = Array.from({ length: 100 }, (_, j) => ({ score: j, value: `m${j}` }));
366
+ const refill = Array.from({ length: 10 }, (_, j) => ({ score: j, value: `m${j}` }));
367
+ await client.del(key);
368
+ await client.zAdd(key, seed);
369
+ for (let i = 0; i < n; i++) {
370
+ await client.sendCommand(['ZREMRANGEBYRANK', key, '0', '9']);
371
+ await client.zAdd(key, refill);
372
+ }
373
+ }
374
+
375
+ async function benchZremrangebyscorePure(client, n) {
376
+ const key = 'bm:zset:remscore:pure';
377
+ const seed = Array.from({ length: 100 }, (_, j) => ({ score: j, value: `m${j}` }));
378
+ const refill = Array.from({ length: 10 }, (_, j) => ({ score: j, value: `m${j}` }));
379
+ await client.del(key);
380
+ await client.zAdd(key, seed);
381
+ for (let i = 0; i < n; i++) {
382
+ await client.sendCommand(['ZREMRANGEBYSCORE', key, '0', '9']);
383
+ await client.zAdd(key, refill);
384
+ }
385
+ }
386
+
387
+ async function benchZremrangebyrankChurn(client, n) {
357
388
  const key = 'bm:zset:remrank';
358
389
  for (let i = 0; i < n; i++) {
359
390
  await client.del(key);
@@ -362,7 +393,7 @@ async function benchZremrangebyrank(client, n) {
362
393
  }
363
394
  }
364
395
 
365
- async function benchZremrangebyscore(client, n) {
396
+ async function benchZremrangebyscoreChurn(client, n) {
366
397
  const key = 'bm:zset:remscore';
367
398
  for (let i = 0; i < n; i++) {
368
399
  await client.del(key);
@@ -390,6 +421,14 @@ async function benchSrandmember(client, n) {
390
421
  for (let i = 0; i < n; i++) await client.sendCommand(['SRANDMEMBER', key]);
391
422
  }
392
423
 
424
+ async function benchHincrby(client, n) {
425
+ const key = 'bm:hash:incr';
426
+ const field = 'counter';
427
+ await client.del(key);
428
+ await client.hSet(key, field, '0');
429
+ for (let i = 0; i < n; i++) await client.sendCommand(['HINCRBY', key, field, '1']);
430
+ }
431
+
393
432
  const FT_INDEX = 'bm_ft_idx';
394
433
  const FT_DOCS = 50;
395
434
 
@@ -447,11 +486,15 @@ const SUITES = [
447
486
  { name: 'LTRIM', fn: benchLtrim, iterScale: 1 },
448
487
  { name: 'RENAME', fn: benchRename, iterScale: 1 },
449
488
  { name: 'ZCOUNT', fn: benchZcount, iterScale: 1 },
489
+ { name: 'ZCARD(100)', fn: benchZcard, iterScale: 1 },
450
490
  { name: 'ZINCRBY', fn: benchZincrby, iterScale: 1 },
451
- { name: 'ZREMRANGEBYRANK', fn: benchZremrangebyrank, iterScale: 1 },
452
- { name: 'ZREMRANGEBYSCORE', fn: benchZremrangebyscore, iterScale: 1 },
491
+ { name: 'ZREMRANGEBYRANK (pure)', fn: benchZremrangebyrankPure, iterScale: 1 },
492
+ { name: 'ZREMRANGEBYSCORE (pure)', fn: benchZremrangebyscorePure, iterScale: 1 },
493
+ { name: 'ZREMRANGEBYRANK (churn)', fn: benchZremrangebyrankChurn, iterScale: 1 },
494
+ { name: 'ZREMRANGEBYSCORE (churn)', fn: benchZremrangebyscoreChurn, iterScale: 1 },
453
495
  { name: 'SPOP', fn: benchSpop, iterScale: 1 },
454
496
  { name: 'SRANDMEMBER', fn: benchSrandmember, iterScale: 1 },
497
+ { name: 'HINCRBY', fn: benchHincrby, iterScale: 1 },
455
498
  { name: 'FT.SEARCH', fn: benchFtSearch, iterScale: 1 },
456
499
  ];
457
500
 
@@ -91,8 +91,8 @@ Notes:
91
91
 
92
92
  * Return cardinality:
93
93
 
94
- * Prefer `SELECT COUNT(*)` (acceptable).
95
- * If performance needs: maintain count in a meta table (not needed initially).
94
+ * Prefer metadata counter (`redis_keys.zset_count`) for O(1) reads.
95
+ * Backward compatibility: if counter is missing/null, hydrate once from `SELECT COUNT(*)`.
96
96
 
97
97
  ### C.5.4 ZSCORE
98
98
 
@@ -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
+ }
@@ -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 = parseFloat(String(args[1]));
11
- const max = parseFloat(String(args[2]));
12
- if (Number.isNaN(min) || Number.isNaN(max)) {
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 = parseFloat(String(args[1]));
29
- const max = parseFloat(String(args[2]));
30
- if (Number.isNaN(min) || Number.isNaN(max)) {
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 = parseFloat(String(args[1]));
11
- const max = parseFloat(String(args[2]));
12
- if (Number.isNaN(min) || Number.isNaN(max)) {
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 = parseFloat(String(args[1]));
31
- const min = parseFloat(String(args[2]));
32
- if (Number.isNaN(max) || Number.isNaN(min)) {
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 };
@@ -567,7 +567,12 @@ export function createEngine(opts = {}) {
567
567
  if (k.equals(nk)) return;
568
568
  runInTransaction(db, () => {
569
569
  if (keys.get(nk)) keys.delete(nk);
570
- keys.set(nk, meta.type, { expiresAt: meta.expiresAt });
570
+ keys.set(nk, meta.type, {
571
+ expiresAt: meta.expiresAt,
572
+ setCount: meta.type === KEY_TYPES.SET ? meta.setCount : undefined,
573
+ hashCount: meta.type === KEY_TYPES.HASH ? meta.hashCount : undefined,
574
+ zsetCount: meta.type === KEY_TYPES.ZSET ? meta.zsetCount : undefined,
575
+ });
571
576
  switch (meta.type) {
572
577
  case KEY_TYPES.STRING:
573
578
  strings.copyKey(k, nk);
@@ -32,15 +32,31 @@ export function createHashesStorage(db, keys) {
32
32
  runInTransaction(db, () => {
33
33
  const now = options.updatedAt ?? Date.now();
34
34
  const meta = keys.get(key);
35
+ let knownCount = 0;
35
36
  if (meta) {
36
37
  if (meta.type !== KEY_TYPES.HASH) {
37
38
  throw new Error('WRONGTYPE Operation against a key holding the wrong kind of value');
38
39
  }
39
40
  keys.bumpVersion(key);
41
+ if (meta.hashCount == null) {
42
+ const row = countStmt.get(key);
43
+ knownCount = (row && row.n) || 0;
44
+ keys.setHashCount(key, knownCount, { touchUpdatedAt: false });
45
+ } else {
46
+ knownCount = meta.hashCount;
47
+ }
40
48
  } else {
41
- keys.set(key, KEY_TYPES.HASH, { updatedAt: now });
49
+ keys.set(key, KEY_TYPES.HASH, { updatedAt: now, hashCount: 0 });
42
50
  }
51
+ const existed = getStmt.get(key, field) != null;
43
52
  insertStmt.run(key, field, value);
53
+ if (!existed) {
54
+ if (meta) keys.incrHashCount(key, 1, { touchUpdatedAt: false });
55
+ else keys.setHashCount(key, 1, { touchUpdatedAt: false });
56
+ } else if (meta && meta.hashCount == null) {
57
+ // Legacy rows may have null counters; persist hydrated value.
58
+ keys.setHashCount(key, knownCount, { touchUpdatedAt: false });
59
+ }
44
60
  });
45
61
  },
46
62
 
@@ -48,39 +64,70 @@ export function createHashesStorage(db, keys) {
48
64
  runInTransaction(db, () => {
49
65
  const now = options.updatedAt ?? Date.now();
50
66
  const meta = keys.get(key);
67
+ let knownCount = 0;
51
68
  if (meta) {
52
69
  if (meta.type !== KEY_TYPES.HASH) {
53
70
  throw new Error('WRONGTYPE Operation against a key holding the wrong kind of value');
54
71
  }
55
72
  keys.bumpVersion(key);
73
+ if (meta.hashCount == null) {
74
+ const row = countStmt.get(key);
75
+ knownCount = (row && row.n) || 0;
76
+ keys.setHashCount(key, knownCount, { touchUpdatedAt: false });
77
+ } else {
78
+ knownCount = meta.hashCount;
79
+ }
56
80
  } else {
57
- keys.set(key, KEY_TYPES.HASH, { updatedAt: now });
81
+ keys.set(key, KEY_TYPES.HASH, { updatedAt: now, hashCount: 0 });
58
82
  }
83
+ let added = 0;
59
84
  for (let i = 0; i < pairs.length; i += 2) {
85
+ const existed = getStmt.get(key, pairs[i]) != null;
60
86
  insertStmt.run(key, pairs[i], pairs[i + 1]);
87
+ if (!existed) added++;
88
+ }
89
+ if (added > 0) {
90
+ if (meta) keys.incrHashCount(key, added, { touchUpdatedAt: false });
91
+ else keys.setHashCount(key, added, { touchUpdatedAt: false });
92
+ } else if (meta && meta.hashCount == null) {
93
+ // Legacy rows may have null counters; persist hydrated value.
94
+ keys.setHashCount(key, knownCount, { touchUpdatedAt: false });
61
95
  }
62
96
  });
63
97
  },
64
98
 
65
99
  delete(key, fields) {
66
100
  return runInTransaction(db, () => {
101
+ const meta = keys.get(key);
102
+ const before = meta && meta.hashCount != null ? meta.hashCount : null;
67
103
  let n = 0;
68
104
  for (const field of fields) {
69
105
  const r = deleteStmt.run(key, field);
70
106
  n += r.changes;
71
107
  }
72
- const remaining = (countStmt.get(key) || {}).n ?? 0;
108
+ const remaining = before != null ? Math.max(0, before - n) : ((countStmt.get(key) || {}).n ?? 0);
73
109
  if (remaining === 0) {
74
110
  deleteAllStmt.run(key);
75
111
  keys.delete(key);
112
+ } else if (n > 0) {
113
+ keys.setHashCount(key, remaining, { touchUpdatedAt: false });
76
114
  }
77
115
  return n;
78
116
  });
79
117
  },
80
118
 
81
119
  count(key) {
120
+ const meta = keys.get(key);
121
+ if (meta && meta.type === KEY_TYPES.HASH && meta.hashCount != null) {
122
+ return meta.hashCount;
123
+ }
82
124
  const row = countStmt.get(key);
83
- return row ? row.n : 0;
125
+ const n = row ? row.n : 0;
126
+ if (meta && meta.type === KEY_TYPES.HASH && meta.hashCount == null) {
127
+ // One-time hydration for databases created before hash_count existed.
128
+ keys.setHashCount(key, n, { touchUpdatedAt: false });
129
+ }
130
+ return n;
84
131
  },
85
132
 
86
133
  incr(key, field, delta, options = {}) {
@@ -91,15 +138,24 @@ export function createHashesStorage(db, keys) {
91
138
  throw new Error('WRONGTYPE Operation against a key holding the wrong kind of value');
92
139
  }
93
140
  if (!meta) {
94
- keys.set(key, KEY_TYPES.HASH, { updatedAt: now });
141
+ keys.set(key, KEY_TYPES.HASH, { updatedAt: now, hashCount: 0 });
95
142
  } else {
96
143
  keys.bumpVersion(key);
144
+ if (meta.hashCount == null) {
145
+ const row = countStmt.get(key);
146
+ const hydrated = (row && row.n) || 0;
147
+ keys.setHashCount(key, hydrated, { touchUpdatedAt: false });
148
+ }
97
149
  }
98
150
  const cur = getStmt.get(key, field);
99
151
  const num = cur == null ? 0 : parseInt(cur.value.toString('utf8'), 10);
100
152
  if (Number.isNaN(num)) throw new Error('ERR hash value is not an integer');
101
153
  const next = num + delta;
102
154
  insertStmt.run(key, field, Buffer.from(String(next), 'utf8'));
155
+ if (cur == null) {
156
+ if (meta) keys.incrHashCount(key, 1, { touchUpdatedAt: false });
157
+ else keys.setHashCount(key, 1, { touchUpdatedAt: false });
158
+ }
103
159
  return next;
104
160
  });
105
161
  },
@@ -107,9 +163,12 @@ export function createHashesStorage(db, keys) {
107
163
  /** Copy all field/value rows from oldKey to newKey. Caller ensures newKey exists in redis_keys. */
108
164
  copyKey(oldKey, newKey) {
109
165
  const rows = getAllStmt.all(oldKey);
110
- for (let i = 0; i < rows.length; i += 2) {
111
- insertStmt.run(newKey, rows[i], rows[i + 1]);
166
+ for (const row of rows) {
167
+ insertStmt.run(newKey, row[0], row[1]);
112
168
  }
169
+ const sourceMeta = keys.get(oldKey);
170
+ const nextCount = sourceMeta && sourceMeta.hashCount != null ? sourceMeta.hashCount : rows.length;
171
+ keys.setHashCount(newKey, nextCount, { touchUpdatedAt: false });
113
172
  },
114
173
  };
115
174
  }
@@ -9,16 +9,34 @@ 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, 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, 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 = ?, 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 = ?');
28
+ const updateHashCount = db.prepare('UPDATE redis_keys SET hash_count = ?, updated_at = ? WHERE key = ?');
29
+ const updateHashCountOnly = db.prepare('UPDATE redis_keys SET hash_count = ? WHERE key = ?');
30
+ const incrHashCount = db.prepare(
31
+ 'UPDATE redis_keys SET hash_count = COALESCE(hash_count, 0) + ?, updated_at = ? WHERE key = ?'
32
+ );
33
+ const incrHashCountOnly = db.prepare('UPDATE redis_keys SET hash_count = COALESCE(hash_count, 0) + ? WHERE key = ?');
34
+ const updateZsetCount = db.prepare('UPDATE redis_keys SET zset_count = ?, updated_at = ? WHERE key = ?');
35
+ const updateZsetCountOnly = db.prepare('UPDATE redis_keys SET zset_count = ? WHERE key = ?');
36
+ const incrZsetCount = db.prepare(
37
+ 'UPDATE redis_keys SET zset_count = COALESCE(zset_count, 0) + ?, updated_at = ? WHERE key = ?'
38
+ );
39
+ const incrZsetCountOnly = db.prepare('UPDATE redis_keys SET zset_count = COALESCE(zset_count, 0) + ? WHERE key = ?');
22
40
  const deleteByKey = db.prepare('DELETE FROM redis_keys WHERE key = ?');
23
41
  const deleteExpiredStmt = db.prepare('DELETE FROM redis_keys WHERE expires_at IS NOT NULL AND expires_at <= ?');
24
42
  const countAll = db.prepare('SELECT COUNT(*) AS n FROM redis_keys').pluck();
@@ -38,10 +56,19 @@ export function createKeysStorage(db) {
38
56
  const now = options.updatedAt ?? Date.now();
39
57
  const expiresAt = options.expiresAt ?? null;
40
58
  const existing = getByKey.get(key);
59
+ const setCount = type === KEY_TYPES.SET
60
+ ? (options.setCount ?? existing?.setCount ?? 0)
61
+ : null;
62
+ const hashCount = type === KEY_TYPES.HASH
63
+ ? (options.hashCount ?? existing?.hashCount ?? 0)
64
+ : null;
65
+ const zsetCount = type === KEY_TYPES.ZSET
66
+ ? (options.zsetCount ?? existing?.zsetCount ?? 0)
67
+ : null;
41
68
  if (existing) {
42
- updateMeta.run(type, expiresAt, now, key);
69
+ updateMeta.run(type, expiresAt, setCount, hashCount, zsetCount, now, key);
43
70
  } else {
44
- insert.run(key, type, expiresAt, now);
71
+ insert.run(key, type, expiresAt, setCount, hashCount, zsetCount, now);
45
72
  }
46
73
  },
47
74
 
@@ -53,6 +80,60 @@ export function createKeysStorage(db) {
53
80
  updateVersion.run(Date.now(), key);
54
81
  },
55
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
+
101
+ setHashCount(key, hashCount, options = {}) {
102
+ const touchUpdatedAt = options.touchUpdatedAt !== false;
103
+ if (touchUpdatedAt) {
104
+ updateHashCount.run(hashCount, options.updatedAt ?? Date.now(), key);
105
+ } else {
106
+ updateHashCountOnly.run(hashCount, key);
107
+ }
108
+ },
109
+
110
+ incrHashCount(key, delta, options = {}) {
111
+ const touchUpdatedAt = options.touchUpdatedAt !== false;
112
+ if (touchUpdatedAt) {
113
+ incrHashCount.run(delta, options.updatedAt ?? Date.now(), key);
114
+ } else {
115
+ incrHashCountOnly.run(delta, key);
116
+ }
117
+ },
118
+
119
+ setZsetCount(key, zsetCount, options = {}) {
120
+ const touchUpdatedAt = options.touchUpdatedAt !== false;
121
+ if (touchUpdatedAt) {
122
+ updateZsetCount.run(zsetCount, options.updatedAt ?? Date.now(), key);
123
+ } else {
124
+ updateZsetCountOnly.run(zsetCount, key);
125
+ }
126
+ },
127
+
128
+ incrZsetCount(key, delta, options = {}) {
129
+ const touchUpdatedAt = options.touchUpdatedAt !== false;
130
+ if (touchUpdatedAt) {
131
+ incrZsetCount.run(delta, options.updatedAt ?? Date.now(), key);
132
+ } else {
133
+ incrZsetCountOnly.run(delta, key);
134
+ }
135
+ },
136
+
56
137
  delete(key) {
57
138
  return deleteByKey.run(key);
58
139
  },
@@ -7,6 +7,9 @@ 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,
11
+ hash_count INTEGER,
12
+ zset_count INTEGER,
10
13
  version INTEGER NOT NULL DEFAULT 1,
11
14
  updated_at INTEGER NOT NULL
12
15
  );
@@ -86,4 +89,18 @@ export const KEY_TYPES = {
86
89
  */
87
90
  export function applySchema(db) {
88
91
  db.exec(SCHEMA);
92
+ // Backward-compatible migration for databases created before count columns existed.
93
+ const cols = db.prepare('PRAGMA table_info(redis_keys)').all();
94
+ const hasSetCount = cols.some((c) => c.name === 'set_count');
95
+ const hasHashCount = cols.some((c) => c.name === 'hash_count');
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
+ }
100
+ if (!hasHashCount) {
101
+ db.exec('ALTER TABLE redis_keys ADD COLUMN hash_count INTEGER;');
102
+ }
103
+ if (!hasZsetCount) {
104
+ db.exec('ALTER TABLE redis_keys ADD COLUMN zset_count INTEGER;');
105
+ }
89
106
  }
@@ -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 row = countStmt.get(key);
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
- return row ? row.n : 0;
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 row = countStmt.get(key);
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
  });
@@ -26,6 +26,12 @@ export function createZsetsStorage(db, keys) {
26
26
  `INSERT INTO redis_zsets (key, member, score) VALUES (?, ?, ?)
27
27
  ON CONFLICT(key, member) DO UPDATE SET score = excluded.score`
28
28
  );
29
+ const insertIgnoreStmt = db.prepare(
30
+ 'INSERT OR IGNORE INTO redis_zsets (key, member, score) VALUES (?, ?, ?)'
31
+ );
32
+ const updateScoreStmt = db.prepare(
33
+ 'UPDATE redis_zsets SET score = ? WHERE key = ? AND member = ?'
34
+ );
29
35
  const deleteStmt = db.prepare('DELETE FROM redis_zsets WHERE key = ? AND member = ?');
30
36
  const deleteAllStmt = db.prepare('DELETE FROM redis_zsets WHERE key = ?');
31
37
  const countStmt = db.prepare('SELECT COUNT(*) AS n FROM redis_zsets WHERE key = ?');
@@ -82,19 +88,40 @@ export function createZsetsStorage(db, keys) {
82
88
  return runInTransaction(db, () => {
83
89
  const now = options.updatedAt ?? Date.now();
84
90
  const meta = keys.get(key);
91
+ let knownCount = 0;
85
92
  if (meta) {
86
93
  if (meta.type !== KEY_TYPES.ZSET) {
87
94
  throw new Error('WRONGTYPE Operation against a key holding the wrong kind of value');
88
95
  }
89
96
  keys.bumpVersion(key);
97
+ if (meta.zsetCount == null) {
98
+ const row = countStmt.get(key);
99
+ knownCount = (row && row.n) || 0;
100
+ keys.setZsetCount(key, knownCount, { touchUpdatedAt: false });
101
+ } else {
102
+ knownCount = meta.zsetCount;
103
+ }
90
104
  } else {
91
- keys.set(key, KEY_TYPES.ZSET, { updatedAt: now });
105
+ keys.set(key, KEY_TYPES.ZSET, { updatedAt: now, zsetCount: 0 });
92
106
  }
93
107
  let newCount = 0;
94
108
  for (const { score, member } of pairs) {
95
- const existed = scoreStmt.get(key, member) != null;
96
- upsertStmt.run(key, member, score);
97
- if (!existed) newCount++;
109
+ const inserted = insertIgnoreStmt.run(key, member, score).changes;
110
+ if (inserted > 0) {
111
+ newCount++;
112
+ } else {
113
+ updateScoreStmt.run(score, key, member);
114
+ }
115
+ }
116
+ if (newCount > 0) {
117
+ if (meta) {
118
+ keys.incrZsetCount(key, newCount, { touchUpdatedAt: false });
119
+ } else {
120
+ keys.setZsetCount(key, newCount, { touchUpdatedAt: false });
121
+ }
122
+ } else if (meta && meta.zsetCount == null) {
123
+ // Legacy rows may have null counters; persist the hydrated value.
124
+ keys.setZsetCount(key, knownCount, { touchUpdatedAt: false });
98
125
  }
99
126
  return newCount;
100
127
  });
@@ -108,23 +135,35 @@ export function createZsetsStorage(db, keys) {
108
135
  */
109
136
  remove(key, members) {
110
137
  return runInTransaction(db, () => {
138
+ const meta = keys.get(key);
139
+ const before = meta && meta.zsetCount != null ? meta.zsetCount : null;
111
140
  let n = 0;
112
141
  for (const m of members) {
113
142
  n += deleteStmt.run(key, m).changes;
114
143
  }
115
- const row = countStmt.get(key);
116
- const remaining = (row && row.n) || 0;
144
+ const remaining = before != null ? Math.max(0, before - n) : ((countStmt.get(key) || {}).n || 0);
117
145
  if (remaining === 0) {
118
146
  deleteAllStmt.run(key);
119
147
  keys.delete(key);
148
+ } else if (n > 0) {
149
+ keys.setZsetCount(key, remaining, { touchUpdatedAt: false });
120
150
  }
121
151
  return n;
122
152
  });
123
153
  },
124
154
 
125
155
  count(key) {
156
+ const meta = keys.get(key);
157
+ if (meta && meta.type === KEY_TYPES.ZSET && meta.zsetCount != null) {
158
+ return meta.zsetCount;
159
+ }
126
160
  const row = countStmt.get(key);
127
- return row ? row.n : 0;
161
+ const n = row ? row.n : 0;
162
+ if (meta && meta.type === KEY_TYPES.ZSET && meta.zsetCount == null) {
163
+ // One-time hydration for databases created before zset_count existed.
164
+ keys.setZsetCount(key, n, { touchUpdatedAt: false });
165
+ }
166
+ return n;
128
167
  },
129
168
 
130
169
  score(key, member) {
@@ -270,9 +309,15 @@ export function createZsetsStorage(db, keys) {
270
309
  for (const r of rows) {
271
310
  upsertStmt.run(newKey, r.member, r.score);
272
311
  }
312
+ const sourceMeta = keys.get(oldKey);
313
+ const nextCount = sourceMeta && sourceMeta.zsetCount != null ? sourceMeta.zsetCount : rows.length;
314
+ keys.setZsetCount(newKey, nextCount, { touchUpdatedAt: false });
273
315
  },
274
316
 
275
317
  countByScore(key, min, max) {
318
+ if (min === Number.NEGATIVE_INFINITY && max === Number.POSITIVE_INFINITY) {
319
+ return this.count(key);
320
+ }
276
321
  const row = countByScoreStmt.get(key, min, max);
277
322
  return row ? row.n : 0;
278
323
  },
@@ -285,14 +330,23 @@ export function createZsetsStorage(db, keys) {
285
330
  throw new Error('WRONGTYPE Operation against a key holding the wrong kind of value');
286
331
  }
287
332
  if (!meta) {
288
- keys.set(key, KEY_TYPES.ZSET, { updatedAt: now });
333
+ keys.set(key, KEY_TYPES.ZSET, { updatedAt: now, zsetCount: 0 });
289
334
  } else {
290
335
  keys.bumpVersion(key);
336
+ if (meta.zsetCount == null) {
337
+ const row = countStmt.get(key);
338
+ const hydrated = (row && row.n) || 0;
339
+ keys.setZsetCount(key, hydrated, { touchUpdatedAt: false });
340
+ }
291
341
  }
292
342
  const cur = scoreStmt.get(key, member);
293
343
  const prev = cur == null ? 0 : cur.score;
294
344
  const next = prev + increment;
295
345
  upsertStmt.run(key, member, next);
346
+ if (cur == null) {
347
+ if (meta) keys.incrZsetCount(key, 1, { touchUpdatedAt: false });
348
+ else keys.setZsetCount(key, 1, { touchUpdatedAt: false });
349
+ }
296
350
  return formatScore(next);
297
351
  });
298
352
  },
@@ -318,6 +372,8 @@ export function createZsetsStorage(db, keys) {
318
372
  if (remaining === 0) {
319
373
  deleteAllStmt.run(key);
320
374
  keys.delete(key);
375
+ } else if (n > 0) {
376
+ keys.setZsetCount(key, remaining, { touchUpdatedAt: false });
321
377
  }
322
378
  return n;
323
379
  });
@@ -333,6 +389,7 @@ export function createZsetsStorage(db, keys) {
333
389
  keys.delete(key);
334
390
  } else if (r.changes > 0) {
335
391
  keys.bumpVersion(key);
392
+ keys.setZsetCount(key, n, { touchUpdatedAt: false });
336
393
  }
337
394
  return r.changes;
338
395
  });
@@ -1,5 +1,6 @@
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';
5
6
  import { tryParseValue } from '../../src/resp/parser.js';
@@ -56,4 +57,33 @@ describe('Hashes integration', () => {
56
57
  const reply = await sendCommand(port, argv('HLEN', 'hlen:str'));
57
58
  assert.ok(reply.toString('utf8').includes('WRONGTYPE'));
58
59
  });
60
+
61
+ it('RENAME keeps hash cardinality metadata', async () => {
62
+ await sendCommand(port, argv('HSET', 'hrename:src', 'a', '1', 'b', '2', 'c', '3'));
63
+ await sendCommand(port, argv('RENAME', 'hrename:src', 'hrename:dst'));
64
+ const reply = await sendCommand(port, argv('HLEN', 'hrename:dst'));
65
+ assert.equal(tryParseValue(reply, 0).value, 3);
66
+ });
67
+
68
+ it('legacy hash rows with null hash_count hydrate on first HLEN', async () => {
69
+ const s1 = await createTestServer();
70
+ await sendCommand(s1.port, argv('HSET', 'legacy:h', 'f1', 'v1', 'f2', 'v2'));
71
+ const dbPath = s1.dbPath;
72
+ await s1.closeAsync();
73
+ s1.db.close();
74
+
75
+ const legacyDb = new Database(dbPath);
76
+ legacyDb.prepare('UPDATE redis_keys SET hash_count = NULL WHERE key = ?').run(Buffer.from('legacy:h', 'utf8'));
77
+ legacyDb.close();
78
+
79
+ const s2 = await createTestServer({ dbPath });
80
+ const first = await sendCommand(s2.port, argv('HLEN', 'legacy:h'));
81
+ assert.equal(tryParseValue(first, 0).value, 2);
82
+ const second = await sendCommand(s2.port, argv('HLEN', 'legacy:h'));
83
+ assert.equal(tryParseValue(second, 0).value, 2);
84
+
85
+ const row = s2.db.prepare('SELECT hash_count AS n FROM redis_keys WHERE key = ?').get(Buffer.from('legacy:h', 'utf8'));
86
+ assert.equal(row.n, 2);
87
+ await s2.closeAsync();
88
+ });
59
89
  });
@@ -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,51 @@ 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('legacy set rows with null set_count hydrate on first SCARD', async () => {
56
+ const s1 = await createTestServer();
57
+ await sendCommand(s1.port, argv('SADD', 'legacy:s', 'a', 'b'));
58
+ const dbPath = s1.dbPath;
59
+ await s1.closeAsync();
60
+ s1.db.close();
61
+
62
+ const legacyDb = new Database(dbPath);
63
+ legacyDb.prepare('UPDATE redis_keys SET set_count = NULL WHERE key = ?').run(Buffer.from('legacy:s', 'utf8'));
64
+ legacyDb.close();
65
+
66
+ const s2 = await createTestServer({ dbPath });
67
+ const first = await sendCommand(s2.port, argv('SCARD', 'legacy:s'));
68
+ assert.equal(tryParseValue(first, 0).value, 2);
69
+ const second = await sendCommand(s2.port, argv('SCARD', 'legacy:s'));
70
+ assert.equal(tryParseValue(second, 0).value, 2);
71
+
72
+ const row = s2.db.prepare('SELECT set_count AS n FROM redis_keys WHERE key = ?').get(Buffer.from('legacy:s', 'utf8'));
73
+ assert.equal(row.n, 2);
74
+ await s2.closeAsync();
75
+ });
27
76
  });
@@ -1,5 +1,6 @@
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';
5
6
  import { tryParseValue } from '../../src/resp/parser.js';
@@ -40,6 +41,15 @@ describe('ZSET integration', () => {
40
41
  assert.equal(score.toString('utf8'), '20');
41
42
  });
42
43
 
44
+ it('ZADD with duplicate member in same command counts as new once and keeps last score', async () => {
45
+ const added = await sendCommand(port, argv('ZADD', 'zdup', '1', 'm', '2', 'm'));
46
+ assert.equal(tryParseValue(added, 0).value, 1);
47
+
48
+ const scoreReply = await sendCommand(port, argv('ZSCORE', 'zdup', 'm'));
49
+ const score = tryParseValue(scoreReply, 0).value;
50
+ assert.equal(score.toString('utf8'), '2');
51
+ });
52
+
43
53
  it('ZRANGE WITHSCORES returns member, score, ...', async () => {
44
54
  await sendCommand(port, argv('ZADD', 'z3', '1', 'x', '2', 'y'));
45
55
  const reply = await sendCommand(port, argv('ZRANGE', 'z3', '0', '-1', 'WITHSCORES'));
@@ -149,6 +159,37 @@ describe('ZSET integration', () => {
149
159
  assert.equal(limited[1].toString('utf8'), 'c');
150
160
  });
151
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
+
152
193
  it('ZCARD non-existent returns 0, ZSCORE non-existent returns nil', async () => {
153
194
  const cardReply = await sendCommand(port, argv('ZCARD', 'nonexistent_z'));
154
195
  assert.equal(tryParseValue(cardReply, 0).value, 0);
@@ -201,6 +242,35 @@ describe('ZSET integration', () => {
201
242
  await s2.closeAsync();
202
243
  });
203
244
 
245
+ it('RENAME keeps zset cardinality metadata', async () => {
246
+ await sendCommand(port, argv('ZADD', 'zrename_src', '1', 'a', '2', 'b', '3', 'c'));
247
+ await sendCommand(port, argv('RENAME', 'zrename_src', 'zrename_dst'));
248
+ const cardReply = await sendCommand(port, argv('ZCARD', 'zrename_dst'));
249
+ assert.equal(tryParseValue(cardReply, 0).value, 3);
250
+ });
251
+
252
+ it('legacy zset rows with null zset_count hydrate on first ZCARD', async () => {
253
+ const s1 = await createTestServer();
254
+ await sendCommand(s1.port, argv('ZADD', 'legacy_z', '1', 'one', '2', 'two'));
255
+ const dbPath = s1.dbPath;
256
+ await s1.closeAsync();
257
+ s1.db.close();
258
+
259
+ const legacyDb = new Database(dbPath);
260
+ legacyDb.prepare('UPDATE redis_keys SET zset_count = NULL WHERE key = ?').run(Buffer.from('legacy_z', 'utf8'));
261
+ legacyDb.close();
262
+
263
+ const s2 = await createTestServer({ dbPath });
264
+ const first = await sendCommand(s2.port, argv('ZCARD', 'legacy_z'));
265
+ assert.equal(tryParseValue(first, 0).value, 2);
266
+ const second = await sendCommand(s2.port, argv('ZCARD', 'legacy_z'));
267
+ assert.equal(tryParseValue(second, 0).value, 2);
268
+
269
+ const row = s2.db.prepare('SELECT zset_count AS n FROM redis_keys WHERE key = ?').get(Buffer.from('legacy_z', 'utf8'));
270
+ assert.equal(row.n, 2);
271
+ await s2.closeAsync();
272
+ });
273
+
204
274
  it('binary-safe zset members', async () => {
205
275
  const bin = Buffer.from([0x00, 0xff]);
206
276
  await sendCommand(port, argv('ZADD', 'zbin', '1', 'normal'));