resplite 1.4.20 → 1.5.0

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.20",
3
+ "version": "1.5.0",
4
4
  "description": "A RESP2 server with practical Redis compatibility, backed by SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,54 @@
1
+ /**
2
+ * HEXPIRE key seconds [NX | XX | GT | LT] FIELDS numfields field [field ...]
3
+ * Redis 7.4 per-field TTL. Returns array of integers (-2/0/1/2) per field.
4
+ */
5
+
6
+ const CONDITIONS = new Set(['NX', 'XX', 'GT', 'LT']);
7
+
8
+ function parseFieldsTail(args, startIdx) {
9
+ if (args.length <= startIdx) return { error: 'ERR syntax error' };
10
+ const token = (Buffer.isBuffer(args[startIdx]) ? args[startIdx].toString('utf8') : String(args[startIdx])).toUpperCase();
11
+ if (token !== 'FIELDS') return { error: 'ERR syntax error' };
12
+ const numStr = args[startIdx + 1];
13
+ if (numStr == null) return { error: 'ERR syntax error' };
14
+ const n = parseInt(Buffer.isBuffer(numStr) ? numStr.toString('utf8') : String(numStr), 10);
15
+ if (!Number.isInteger(n) || n < 1) {
16
+ return { error: 'ERR numfields should be greater than 0' };
17
+ }
18
+ const fields = args.slice(startIdx + 2);
19
+ if (fields.length !== n) {
20
+ return { error: "ERR Parameter `numFields` should be equal to the number of arguments" };
21
+ }
22
+ return { fields };
23
+ }
24
+
25
+ export function handleHexpire(engine, args) {
26
+ if (!args || args.length < 5) {
27
+ return { error: "ERR wrong number of arguments for 'HEXPIRE' command" };
28
+ }
29
+ const key = args[0];
30
+ const secondsStr = Buffer.isBuffer(args[1]) ? args[1].toString('utf8') : String(args[1]);
31
+ const seconds = parseInt(secondsStr, 10);
32
+ if (!Number.isInteger(seconds)) {
33
+ return { error: 'ERR value is not an integer or out of range' };
34
+ }
35
+
36
+ let idx = 2;
37
+ let condition = null;
38
+ const maybeCond = (Buffer.isBuffer(args[idx]) ? args[idx].toString('utf8') : String(args[idx])).toUpperCase();
39
+ if (CONDITIONS.has(maybeCond)) {
40
+ condition = maybeCond;
41
+ idx += 1;
42
+ }
43
+
44
+ const parsed = parseFieldsTail(args, idx);
45
+ if (parsed.error) return parsed;
46
+
47
+ const expiresAtMs = engine._clock() + seconds * 1000;
48
+ try {
49
+ return engine.hexpire(key, expiresAtMs, parsed.fields, { condition });
50
+ } catch (e) {
51
+ const msg = e && e.message ? e.message : String(e);
52
+ return { error: msg.startsWith('ERR ') || msg.startsWith('WRONGTYPE') ? msg : 'ERR ' + msg };
53
+ }
54
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * HPERSIST key FIELDS numfields field [field ...]
3
+ * Removes per-field TTL. Returns array: -2 (missing), -1 (no TTL), 1 (cleared).
4
+ */
5
+
6
+ function parseFieldsTail(args, startIdx) {
7
+ if (args.length <= startIdx) return { error: 'ERR syntax error' };
8
+ const token = (Buffer.isBuffer(args[startIdx]) ? args[startIdx].toString('utf8') : String(args[startIdx])).toUpperCase();
9
+ if (token !== 'FIELDS') return { error: 'ERR syntax error' };
10
+ const numStr = args[startIdx + 1];
11
+ if (numStr == null) return { error: 'ERR syntax error' };
12
+ const n = parseInt(Buffer.isBuffer(numStr) ? numStr.toString('utf8') : String(numStr), 10);
13
+ if (!Number.isInteger(n) || n < 1) {
14
+ return { error: 'ERR numfields should be greater than 0' };
15
+ }
16
+ const fields = args.slice(startIdx + 2);
17
+ if (fields.length !== n) {
18
+ return { error: "ERR Parameter `numFields` should be equal to the number of arguments" };
19
+ }
20
+ return { fields };
21
+ }
22
+
23
+ export function handleHpersist(engine, args) {
24
+ if (!args || args.length < 4) {
25
+ return { error: "ERR wrong number of arguments for 'HPERSIST' command" };
26
+ }
27
+ const key = args[0];
28
+ const parsed = parseFieldsTail(args, 1);
29
+ if (parsed.error) return parsed;
30
+ try {
31
+ return engine.hpersist(key, parsed.fields);
32
+ } catch (e) {
33
+ const msg = e && e.message ? e.message : String(e);
34
+ return { error: msg.startsWith('ERR ') || msg.startsWith('WRONGTYPE') ? msg : 'ERR ' + msg };
35
+ }
36
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * HTTL key FIELDS numfields field [field ...]
3
+ * Returns array of seconds per field: -2 (missing), -1 (no TTL), else remaining seconds.
4
+ */
5
+
6
+ function parseFieldsTail(args, startIdx) {
7
+ if (args.length <= startIdx) return { error: 'ERR syntax error' };
8
+ const token = (Buffer.isBuffer(args[startIdx]) ? args[startIdx].toString('utf8') : String(args[startIdx])).toUpperCase();
9
+ if (token !== 'FIELDS') return { error: 'ERR syntax error' };
10
+ const numStr = args[startIdx + 1];
11
+ if (numStr == null) return { error: 'ERR syntax error' };
12
+ const n = parseInt(Buffer.isBuffer(numStr) ? numStr.toString('utf8') : String(numStr), 10);
13
+ if (!Number.isInteger(n) || n < 1) {
14
+ return { error: 'ERR numfields should be greater than 0' };
15
+ }
16
+ const fields = args.slice(startIdx + 2);
17
+ if (fields.length !== n) {
18
+ return { error: "ERR Parameter `numFields` should be equal to the number of arguments" };
19
+ }
20
+ return { fields };
21
+ }
22
+
23
+ export function handleHttl(engine, args) {
24
+ if (!args || args.length < 4) {
25
+ return { error: "ERR wrong number of arguments for 'HTTL' command" };
26
+ }
27
+ const key = args[0];
28
+ const parsed = parseFieldsTail(args, 1);
29
+ if (parsed.error) return parsed;
30
+ try {
31
+ return engine.httl(key, parsed.fields);
32
+ } catch (e) {
33
+ const msg = e && e.message ? e.message : String(e);
34
+ return { error: msg.startsWith('ERR ') || msg.startsWith('WRONGTYPE') ? msg : 'ERR ' + msg };
35
+ }
36
+ }
@@ -36,6 +36,9 @@ import * as hdel from './hdel.js';
36
36
  import * as hlen from './hlen.js';
37
37
  import * as hexists from './hexists.js';
38
38
  import * as hincrby from './hincrby.js';
39
+ import * as hexpireCmd from './hexpire.js';
40
+ import * as httlCmd from './httl.js';
41
+ import * as hpersistCmd from './hpersist.js';
39
42
  import * as sadd from './sadd.js';
40
43
  import * as srem from './srem.js';
41
44
  import * as smembers from './smembers.js';
@@ -122,6 +125,9 @@ const HANDLERS = new Map([
122
125
  ['HLEN', (e, a) => hlen.handleHlen(e, a)],
123
126
  ['HEXISTS', (e, a) => hexists.handleHexists(e, a)],
124
127
  ['HINCRBY', (e, a) => hincrby.handleHincrby(e, a)],
128
+ ['HEXPIRE', (e, a) => hexpireCmd.handleHexpire(e, a)],
129
+ ['HTTL', (e, a) => httlCmd.handleHttl(e, a)],
130
+ ['HPERSIST', (e, a) => hpersistCmd.handleHpersist(e, a)],
125
131
  ['SADD', (e, a) => sadd.handleSadd(e, a)],
126
132
  ['SREM', (e, a) => srem.handleSrem(e, a)],
127
133
  ['SMEMBERS', (e, a) => smembers.handleSmembers(e, a)],
@@ -16,15 +16,14 @@ import { asKey, asValue } from '../util/buffers.js';
16
16
 
17
17
  export function createEngine(opts = {}) {
18
18
  const { db, cache } = opts;
19
+ const clock = opts.clock ?? (() => Date.now());
19
20
  const keys = createKeysStorage(db);
20
21
  const strings = createStringsStorage(db, keys);
21
- const hashes = createHashesStorage(db, keys);
22
+ const hashes = createHashesStorage(db, keys, { clock });
22
23
  const sets = createSetsStorage(db, keys);
23
24
  const lists = createListsStorage(db, keys);
24
25
  const zsets = createZsetsStorage(db, keys);
25
26
 
26
- const clock = opts.clock ?? (() => Date.now());
27
-
28
27
  function _incrBy(key, delta) {
29
28
  const k = asKey(key);
30
29
  const meta = getKeyMeta(key);
@@ -254,6 +253,44 @@ export function createEngine(opts = {}) {
254
253
  return hashes.incr(k, asKey(field), amt);
255
254
  },
256
255
 
256
+ /**
257
+ * HEXPIRE: apply absolute expiresAtMs to each hash field with optional NX/XX/GT/LT.
258
+ * Returns an array of integers per spec: -2 (missing), 0 (cond), 1 (set), 2 (deleted).
259
+ */
260
+ hexpire(key, expiresAtMs, fields, { condition = null } = {}) {
261
+ const k = asKey(key);
262
+ const meta = getKeyMeta(key);
263
+ if (!meta) return fields.map(() => -2);
264
+ expectHash(meta);
265
+ return fields.map((f) => hashes.setFieldExpire(k, asKey(f), expiresAtMs, { condition }));
266
+ },
267
+
268
+ /**
269
+ * HTTL: seconds remaining per field. -2 missing, -1 no TTL, else seconds.
270
+ */
271
+ httl(key, fields) {
272
+ const k = asKey(key);
273
+ const meta = getKeyMeta(key);
274
+ if (!meta) return fields.map(() => -2);
275
+ expectHash(meta);
276
+ return fields.map((f) => {
277
+ const ms = hashes.getFieldTtl(k, asKey(f));
278
+ if (ms < 0) return ms;
279
+ return Math.floor(ms / 1000);
280
+ });
281
+ },
282
+
283
+ /**
284
+ * HPERSIST: clear field TTL. -2 missing, -1 no TTL, 1 cleared.
285
+ */
286
+ hpersist(key, fields) {
287
+ const k = asKey(key);
288
+ const meta = getKeyMeta(key);
289
+ if (!meta) return fields.map(() => -2);
290
+ expectHash(meta);
291
+ return fields.map((f) => hashes.persistField(k, asKey(f)));
292
+ },
293
+
257
294
  sadd(key, ...members) {
258
295
  const k = asKey(key);
259
296
  getKeyMeta(key);
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Active expiration: background sweeper that deletes expired keys in batches.
3
+ * Also prunes expired hash fields (redis_hash_field_ttl) and drops now-empty hashes.
3
4
  */
4
5
 
5
6
  /**
@@ -18,11 +19,38 @@ export function createExpirationSweeper(opts) {
18
19
  const deleteExpiredStmt = db.prepare(
19
20
  'DELETE FROM redis_keys WHERE key IN (SELECT key FROM redis_keys WHERE expires_at IS NOT NULL AND expires_at <= ? LIMIT ?)'
20
21
  );
22
+ const selectExpiredFieldsStmt = db.prepare(
23
+ 'SELECT key, field FROM redis_hash_field_ttl WHERE expires_at <= ? LIMIT ?'
24
+ );
25
+ const deleteHashFieldStmt = db.prepare('DELETE FROM redis_hashes WHERE key = ? AND field = ?');
26
+ const deleteFieldTtlStmt = db.prepare('DELETE FROM redis_hash_field_ttl WHERE key = ? AND field = ?');
27
+ const countHashStmt = db.prepare('SELECT COUNT(*) AS n FROM redis_hashes WHERE key = ?');
28
+ const updateHashCountStmt = db.prepare('UPDATE redis_keys SET hash_count = ? WHERE key = ?');
29
+ const deleteKeyStmt = db.prepare('DELETE FROM redis_keys WHERE key = ?');
30
+
31
+ const sweepFieldsTxn = db.transaction((pairs) => {
32
+ const affected = new Map();
33
+ for (const pair of pairs) {
34
+ deleteHashFieldStmt.run(pair.key, pair.field);
35
+ deleteFieldTtlStmt.run(pair.key, pair.field);
36
+ const seenKey = pair.key.toString('base64');
37
+ if (!affected.has(seenKey)) affected.set(seenKey, pair.key);
38
+ }
39
+ for (const key of affected.values()) {
40
+ const row = countHashStmt.get(key);
41
+ const n = row ? row.n : 0;
42
+ if (n === 0) deleteKeyStmt.run(key);
43
+ else updateHashCountStmt.run(n, key);
44
+ }
45
+ });
46
+
21
47
  let intervalId = null;
22
48
 
23
49
  function sweep() {
24
50
  const now = clock();
25
51
  deleteExpiredStmt.run(now, maxKeysPerSweep);
52
+ const pairs = selectExpiredFieldsStmt.all(now, maxKeysPerSweep);
53
+ if (pairs.length > 0) sweepFieldsTxn(pairs);
26
54
  }
27
55
 
28
56
  return {
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Hash storage: redis_hashes + coordination with redis_keys.
3
3
  * Empty hash removes the key (Section 8.6).
4
+ * Per-field TTL is tracked in redis_hash_field_ttl (epoch milliseconds);
5
+ * HSET/HINCRBY clear a field's TTL, lazy-expiration prunes stale fields.
4
6
  */
5
7
 
6
8
  import { KEY_TYPES } from './schema.js';
@@ -9,28 +11,84 @@ import { runInTransaction } from './tx.js';
9
11
  /**
10
12
  * @param {import('better-sqlite3').Database} db
11
13
  * @param {ReturnType<import('./keys.js').createKeysStorage>} keys
14
+ * @param {{ clock?: () => number }} [options]
12
15
  */
13
- export function createHashesStorage(db, keys) {
16
+ export function createHashesStorage(db, keys, options = {}) {
17
+ const clock = options.clock ?? (() => Date.now());
18
+
14
19
  const getStmt = db.prepare('SELECT value FROM redis_hashes WHERE key = ? AND field = ?');
15
20
  const getAllStmt = db.prepare('SELECT field, value FROM redis_hashes WHERE key = ?').raw(true);
21
+ const getAllLiveStmt = db
22
+ .prepare(
23
+ `SELECT h.field, h.value
24
+ FROM redis_hashes h
25
+ LEFT JOIN redis_hash_field_ttl t ON t.key = h.key AND t.field = h.field
26
+ WHERE h.key = ? AND (t.expires_at IS NULL OR t.expires_at > ?)`
27
+ )
28
+ .raw(true);
16
29
  const insertStmt = db.prepare('INSERT OR REPLACE INTO redis_hashes (key, field, value) VALUES (?, ?, ?)');
17
30
  const deleteStmt = db.prepare('DELETE FROM redis_hashes WHERE key = ? AND field = ?');
18
31
  const deleteAllStmt = db.prepare('DELETE FROM redis_hashes WHERE key = ?');
19
32
  const countStmt = db.prepare('SELECT COUNT(*) AS n FROM redis_hashes WHERE key = ?');
33
+ const countLiveStmt = db.prepare(
34
+ `SELECT COUNT(*) AS n
35
+ FROM redis_hashes h
36
+ LEFT JOIN redis_hash_field_ttl t ON t.key = h.key AND t.field = h.field
37
+ WHERE h.key = ? AND (t.expires_at IS NULL OR t.expires_at > ?)`
38
+ );
39
+ const hasAnyTtlStmt = db.prepare('SELECT 1 FROM redis_hash_field_ttl WHERE key = ? LIMIT 1').pluck();
40
+
41
+ const getFieldTtlStmt = db.prepare(
42
+ 'SELECT expires_at AS expiresAt FROM redis_hash_field_ttl WHERE key = ? AND field = ?'
43
+ );
44
+ const upsertFieldTtlStmt = db.prepare(
45
+ 'INSERT OR REPLACE INTO redis_hash_field_ttl (key, field, expires_at) VALUES (?, ?, ?)'
46
+ );
47
+ const deleteFieldTtlStmt = db.prepare('DELETE FROM redis_hash_field_ttl WHERE key = ? AND field = ?');
48
+
49
+ /**
50
+ * If the field has an expired TTL row, delete the field + TTL row, adjust count
51
+ * (and drop the whole key when empty). Returns true if the field was purged.
52
+ */
53
+ function expireFieldIfDue(key, field, now) {
54
+ const ttl = getFieldTtlStmt.get(key, field);
55
+ if (!ttl || ttl.expiresAt > now) return false;
56
+ const existed = getStmt.get(key, field) != null;
57
+ deleteFieldTtlStmt.run(key, field);
58
+ if (!existed) return true;
59
+ deleteStmt.run(key, field);
60
+ const meta = keys.get(key);
61
+ if (!meta) return true;
62
+ const before = meta.hashCount != null ? meta.hashCount : (countStmt.get(key) || { n: 0 }).n + 1;
63
+ const remaining = Math.max(0, before - 1);
64
+ if (remaining === 0) {
65
+ deleteAllStmt.run(key);
66
+ keys.delete(key);
67
+ } else {
68
+ keys.setHashCount(key, remaining, { touchUpdatedAt: false });
69
+ }
70
+ return true;
71
+ }
20
72
 
21
73
  return {
22
74
  get(key, field) {
75
+ runInTransaction(db, () => {
76
+ expireFieldIfDue(key, field, clock());
77
+ });
23
78
  const row = getStmt.get(key, field);
24
79
  return row ? row.value : null;
25
80
  },
26
81
 
27
82
  getAll(key) {
28
- return getAllStmt.all(key).flat();
83
+ const now = clock();
84
+ const hasTtl = hasAnyTtlStmt.get(key);
85
+ if (!hasTtl) return getAllStmt.all(key).flat();
86
+ return getAllLiveStmt.all(key, now).flat();
29
87
  },
30
88
 
31
89
  set(key, field, value, options = {}) {
32
90
  runInTransaction(db, () => {
33
- const now = options.updatedAt ?? Date.now();
91
+ const now = options.updatedAt ?? clock();
34
92
  const meta = keys.get(key);
35
93
  let knownCount = 0;
36
94
  if (meta) {
@@ -50,6 +108,7 @@ export function createHashesStorage(db, keys) {
50
108
  }
51
109
  const existed = getStmt.get(key, field) != null;
52
110
  insertStmt.run(key, field, value);
111
+ deleteFieldTtlStmt.run(key, field);
53
112
  if (!existed) {
54
113
  if (meta) keys.incrHashCount(key, 1, { touchUpdatedAt: false });
55
114
  else keys.setHashCount(key, 1, { touchUpdatedAt: false });
@@ -62,7 +121,7 @@ export function createHashesStorage(db, keys) {
62
121
 
63
122
  setMultiple(key, pairs, options = {}) {
64
123
  runInTransaction(db, () => {
65
- const now = options.updatedAt ?? Date.now();
124
+ const now = options.updatedAt ?? clock();
66
125
  const meta = keys.get(key);
67
126
  let knownCount = 0;
68
127
  if (meta) {
@@ -84,6 +143,7 @@ export function createHashesStorage(db, keys) {
84
143
  for (let i = 0; i < pairs.length; i += 2) {
85
144
  const existed = getStmt.get(key, pairs[i]) != null;
86
145
  insertStmt.run(key, pairs[i], pairs[i + 1]);
146
+ deleteFieldTtlStmt.run(key, pairs[i]);
87
147
  if (!existed) added++;
88
148
  }
89
149
  if (added > 0) {
@@ -103,6 +163,7 @@ export function createHashesStorage(db, keys) {
103
163
  let n = 0;
104
164
  for (const field of fields) {
105
165
  const r = deleteStmt.run(key, field);
166
+ deleteFieldTtlStmt.run(key, field);
106
167
  n += r.changes;
107
168
  }
108
169
  const remaining = before != null ? Math.max(0, before - n) : ((countStmt.get(key) || {}).n ?? 0);
@@ -118,21 +179,26 @@ export function createHashesStorage(db, keys) {
118
179
 
119
180
  count(key) {
120
181
  const meta = keys.get(key);
121
- if (meta && meta.type === KEY_TYPES.HASH && meta.hashCount != null) {
122
- return meta.hashCount;
182
+ if (!meta || meta.type !== KEY_TYPES.HASH) {
183
+ const row = countStmt.get(key);
184
+ return row ? row.n : 0;
185
+ }
186
+ const hasTtl = hasAnyTtlStmt.get(key);
187
+ if (hasTtl) {
188
+ const row = countLiveStmt.get(key, clock());
189
+ return row ? row.n : 0;
123
190
  }
191
+ if (meta.hashCount != null) return meta.hashCount;
124
192
  const row = countStmt.get(key);
125
193
  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
- }
194
+ // One-time hydration for databases created before hash_count existed.
195
+ keys.setHashCount(key, n, { touchUpdatedAt: false });
130
196
  return n;
131
197
  },
132
198
 
133
199
  incr(key, field, delta, options = {}) {
134
200
  return runInTransaction(db, () => {
135
- const now = options.updatedAt ?? Date.now();
201
+ const now = options.updatedAt ?? clock();
136
202
  const meta = keys.get(key);
137
203
  if (meta && meta.type !== KEY_TYPES.HASH) {
138
204
  throw new Error('WRONGTYPE Operation against a key holding the wrong kind of value');
@@ -147,11 +213,13 @@ export function createHashesStorage(db, keys) {
147
213
  keys.setHashCount(key, hydrated, { touchUpdatedAt: false });
148
214
  }
149
215
  }
216
+ expireFieldIfDue(key, field, now);
150
217
  const cur = getStmt.get(key, field);
151
218
  const num = cur == null ? 0 : parseInt(cur.value.toString('utf8'), 10);
152
219
  if (Number.isNaN(num)) throw new Error('ERR hash value is not an integer');
153
220
  const next = num + delta;
154
221
  insertStmt.run(key, field, Buffer.from(String(next), 'utf8'));
222
+ deleteFieldTtlStmt.run(key, field);
155
223
  if (cur == null) {
156
224
  if (meta) keys.incrHashCount(key, 1, { touchUpdatedAt: false });
157
225
  else keys.setHashCount(key, 1, { touchUpdatedAt: false });
@@ -160,6 +228,81 @@ export function createHashesStorage(db, keys) {
160
228
  });
161
229
  },
162
230
 
231
+ /**
232
+ * Per-field expiration set. Returns -2/0/1/2 per HEXPIRE spec.
233
+ * `condition` is null or one of 'NX','XX','GT','LT'.
234
+ */
235
+ setFieldExpire(key, field, expiresAtMs, { condition = null } = {}) {
236
+ return runInTransaction(db, () => {
237
+ const now = clock();
238
+ expireFieldIfDue(key, field, now);
239
+ const exists = getStmt.get(key, field) != null;
240
+ if (!exists) return -2;
241
+ const current = getFieldTtlStmt.get(key, field);
242
+ const currentMs = current ? current.expiresAt : null;
243
+ if (condition === 'NX' && currentMs != null) return 0;
244
+ if (condition === 'XX' && currentMs == null) return 0;
245
+ if (condition === 'GT') {
246
+ if (currentMs == null) return 0;
247
+ if (!(expiresAtMs > currentMs)) return 0;
248
+ }
249
+ if (condition === 'LT') {
250
+ if (currentMs != null && !(expiresAtMs < currentMs)) return 0;
251
+ }
252
+ if (expiresAtMs <= now) {
253
+ deleteStmt.run(key, field);
254
+ deleteFieldTtlStmt.run(key, field);
255
+ const meta = keys.get(key);
256
+ if (meta) {
257
+ const before = meta.hashCount != null ? meta.hashCount : (countStmt.get(key) || { n: 0 }).n + 1;
258
+ const remaining = Math.max(0, before - 1);
259
+ if (remaining === 0) {
260
+ deleteAllStmt.run(key);
261
+ keys.delete(key);
262
+ } else {
263
+ keys.setHashCount(key, remaining, { touchUpdatedAt: false });
264
+ }
265
+ }
266
+ return 2;
267
+ }
268
+ upsertFieldTtlStmt.run(key, field, expiresAtMs);
269
+ return 1;
270
+ });
271
+ },
272
+
273
+ /**
274
+ * Returns remaining ms (>= 0), -1 if field has no TTL, -2 if field missing.
275
+ */
276
+ getFieldTtl(key, field) {
277
+ const now = clock();
278
+ let removed = false;
279
+ runInTransaction(db, () => {
280
+ removed = expireFieldIfDue(key, field, now);
281
+ });
282
+ if (removed) return -2;
283
+ const exists = getStmt.get(key, field) != null;
284
+ if (!exists) return -2;
285
+ const row = getFieldTtlStmt.get(key, field);
286
+ if (!row) return -1;
287
+ return Math.max(0, row.expiresAt - now);
288
+ },
289
+
290
+ /**
291
+ * Clears a field's TTL. Returns 1 if cleared, -1 if no TTL, -2 if field missing.
292
+ */
293
+ persistField(key, field) {
294
+ return runInTransaction(db, () => {
295
+ const now = clock();
296
+ expireFieldIfDue(key, field, now);
297
+ const exists = getStmt.get(key, field) != null;
298
+ if (!exists) return -2;
299
+ const row = getFieldTtlStmt.get(key, field);
300
+ if (!row) return -1;
301
+ deleteFieldTtlStmt.run(key, field);
302
+ return 1;
303
+ });
304
+ },
305
+
163
306
  /** Copy all field/value rows from oldKey to newKey. Caller ensures newKey exists in redis_keys. */
164
307
  copyKey(oldKey, newKey) {
165
308
  const rows = getAllStmt.all(oldKey);
@@ -66,6 +66,17 @@ CREATE TABLE IF NOT EXISTS redis_zsets (
66
66
  CREATE INDEX IF NOT EXISTS redis_zsets_key_score_member_idx
67
67
  ON redis_zsets(key, score, member);
68
68
 
69
+ CREATE TABLE IF NOT EXISTS redis_hash_field_ttl (
70
+ key BLOB NOT NULL,
71
+ field BLOB NOT NULL,
72
+ expires_at INTEGER NOT NULL,
73
+ PRIMARY KEY (key, field),
74
+ FOREIGN KEY(key) REFERENCES redis_keys(key) ON DELETE CASCADE
75
+ );
76
+
77
+ CREATE INDEX IF NOT EXISTS redis_hash_field_ttl_expires_at_idx
78
+ ON redis_hash_field_ttl(expires_at);
79
+
69
80
  CREATE TABLE IF NOT EXISTS search_indices (
70
81
  name TEXT PRIMARY KEY,
71
82
  schema_json TEXT NOT NULL,
@@ -65,6 +65,88 @@ describe('Hashes integration', () => {
65
65
  assert.equal(tryParseValue(reply, 0).value, 3);
66
66
  });
67
67
 
68
+ it('HEXPIRE + HTTL + HPERSIST round-trip (node-redis style argv)', async () => {
69
+ await sendCommand(port, argv('HSET', 'LobbyStream', '6GQZW:FBAX7', '1'));
70
+ // Mirror the exact argv seen in the node-redis client log.
71
+ const hexpireReply = await sendCommand(
72
+ port,
73
+ argv('HEXPIRE', 'LobbyStream', '90', 'FIELDS', '1', '6GQZW:FBAX7')
74
+ );
75
+ const hexpireVal = tryParseValue(hexpireReply, 0).value;
76
+ assert.ok(Array.isArray(hexpireVal));
77
+ assert.equal(hexpireVal.length, 1);
78
+ assert.equal(Number(hexpireVal[0]), 1);
79
+
80
+ const httlReply = await sendCommand(
81
+ port,
82
+ argv('HTTL', 'LobbyStream', 'FIELDS', '1', '6GQZW:FBAX7')
83
+ );
84
+ const httlVal = tryParseValue(httlReply, 0).value;
85
+ assert.ok(Array.isArray(httlVal));
86
+ assert.equal(httlVal.length, 1);
87
+ const secs = Number(httlVal[0]);
88
+ assert.ok(secs > 0 && secs <= 90, `expected 0 < ttl <= 90, got ${secs}`);
89
+
90
+ const hpersistReply = await sendCommand(
91
+ port,
92
+ argv('HPERSIST', 'LobbyStream', 'FIELDS', '1', '6GQZW:FBAX7')
93
+ );
94
+ const hpersistVal = tryParseValue(hpersistReply, 0).value;
95
+ assert.equal(Number(hpersistVal[0]), 1);
96
+
97
+ const httlReply2 = await sendCommand(
98
+ port,
99
+ argv('HTTL', 'LobbyStream', 'FIELDS', '1', '6GQZW:FBAX7')
100
+ );
101
+ const ttl2 = Number(tryParseValue(httlReply2, 0).value[0]);
102
+ assert.equal(ttl2, -1);
103
+ });
104
+
105
+ it('HEXPIRE returns -2 for missing key/field', async () => {
106
+ const missingKey = await sendCommand(
107
+ port,
108
+ argv('HEXPIRE', 'hexp:nokey', '5', 'FIELDS', '1', 'f1')
109
+ );
110
+ assert.equal(Number(tryParseValue(missingKey, 0).value[0]), -2);
111
+
112
+ await sendCommand(port, argv('HSET', 'hexp:k', 'f1', 'v1'));
113
+ const missingField = await sendCommand(
114
+ port,
115
+ argv('HEXPIRE', 'hexp:k', '5', 'FIELDS', '1', 'nope')
116
+ );
117
+ assert.equal(Number(tryParseValue(missingField, 0).value[0]), -2);
118
+ });
119
+
120
+ it('HEXPIRE with 0 seconds deletes the field (returns 2)', async () => {
121
+ await sendCommand(port, argv('HSET', 'hexp:zero', 'f1', 'v1', 'f2', 'v2'));
122
+ const reply = await sendCommand(
123
+ port,
124
+ argv('HEXPIRE', 'hexp:zero', '0', 'FIELDS', '1', 'f1')
125
+ );
126
+ assert.equal(Number(tryParseValue(reply, 0).value[0]), 2);
127
+ const getReply = await sendCommand(port, argv('HGET', 'hexp:zero', 'f1'));
128
+ assert.ok(getReply.toString('utf8').startsWith('$-1'));
129
+ });
130
+
131
+ it('HEXPIRE NX condition fails on existing TTL', async () => {
132
+ await sendCommand(port, argv('HSET', 'hexp:nx', 'f1', 'v1'));
133
+ await sendCommand(port, argv('HEXPIRE', 'hexp:nx', '10', 'FIELDS', '1', 'f1'));
134
+ const reply = await sendCommand(
135
+ port,
136
+ argv('HEXPIRE', 'hexp:nx', '20', 'NX', 'FIELDS', '1', 'f1')
137
+ );
138
+ assert.equal(Number(tryParseValue(reply, 0).value[0]), 0);
139
+ });
140
+
141
+ it('HEXPIRE FIELDS count mismatch is a syntax error', async () => {
142
+ await sendCommand(port, argv('HSET', 'hexp:bad', 'f1', 'v1'));
143
+ const reply = await sendCommand(
144
+ port,
145
+ argv('HEXPIRE', 'hexp:bad', '10', 'FIELDS', '2', 'f1')
146
+ );
147
+ assert.ok(reply.toString('utf8').startsWith('-'), 'expected error reply');
148
+ });
149
+
68
150
  it('legacy hash rows with null hash_count hydrate on first HLEN', async () => {
69
151
  const s1 = await createTestServer();
70
152
  await sendCommand(s1.port, argv('HSET', 'legacy:h', 'f1', 'v1', 'f2', 'v2'));
@@ -56,3 +56,165 @@ describe('Engine hashes', () => {
56
56
  assert.throws(() => engine.hlen('hlen:str'), /WRONGTYPE/);
57
57
  });
58
58
  });
59
+
60
+ describe('Engine hash field TTL', () => {
61
+ function makeEngine(nowMs) {
62
+ const dbPath = tmpDbPath();
63
+ const db = openDb(dbPath);
64
+ let t = nowMs;
65
+ const clock = () => t;
66
+ const engine = createEngine({ db, clock });
67
+ return {
68
+ engine,
69
+ advance(ms) { t += ms; },
70
+ clock: () => t,
71
+ };
72
+ }
73
+
74
+ it('HEXPIRE sets TTL; HTTL reports seconds', () => {
75
+ const { engine } = makeEngine(1_000_000);
76
+ engine.hset('h', 'f1', 'v1');
77
+ const res = engine.hexpire('h', engine._clock() + 60_000, [Buffer.from('f1')]);
78
+ assert.deepEqual(res, [1]);
79
+ assert.deepEqual(engine.httl('h', [Buffer.from('f1')]), [60]);
80
+ });
81
+
82
+ it('HTTL returns -1 for field without TTL, -2 for missing field/key', () => {
83
+ const { engine } = makeEngine(1_000_000);
84
+ engine.hset('h', 'f1', 'v1');
85
+ assert.deepEqual(engine.httl('h', [Buffer.from('f1')]), [-1]);
86
+ assert.deepEqual(engine.httl('h', [Buffer.from('missing')]), [-2]);
87
+ assert.deepEqual(engine.httl('nokey', [Buffer.from('f1')]), [-2]);
88
+ });
89
+
90
+ it('HEXPIRE with non-existent field returns -2', () => {
91
+ const { engine } = makeEngine(1_000_000);
92
+ engine.hset('h', 'f1', 'v1');
93
+ const res = engine.hexpire('h', engine._clock() + 1000, [Buffer.from('nope')]);
94
+ assert.deepEqual(res, [-2]);
95
+ });
96
+
97
+ it('HEXPIRE with expiresAt in the past deletes the field (returns 2)', () => {
98
+ const { engine } = makeEngine(1_000_000);
99
+ engine.hset('h', 'f1', 'v1', 'f2', 'v2');
100
+ const res = engine.hexpire('h', engine._clock() - 1, [Buffer.from('f1')]);
101
+ assert.deepEqual(res, [2]);
102
+ assert.equal(engine.hget('h', 'f1'), null);
103
+ assert.equal(engine.hlen('h'), 1);
104
+ });
105
+
106
+ it('HEXPIRE NX/XX condition semantics', () => {
107
+ const { engine } = makeEngine(1_000_000);
108
+ engine.hset('h', 'f1', 'v1');
109
+ assert.deepEqual(
110
+ engine.hexpire('h', engine._clock() + 1000, [Buffer.from('f1')], { condition: 'XX' }),
111
+ [0]
112
+ );
113
+ assert.deepEqual(
114
+ engine.hexpire('h', engine._clock() + 1000, [Buffer.from('f1')], { condition: 'NX' }),
115
+ [1]
116
+ );
117
+ assert.deepEqual(
118
+ engine.hexpire('h', engine._clock() + 2000, [Buffer.from('f1')], { condition: 'NX' }),
119
+ [0]
120
+ );
121
+ assert.deepEqual(
122
+ engine.hexpire('h', engine._clock() + 2000, [Buffer.from('f1')], { condition: 'XX' }),
123
+ [1]
124
+ );
125
+ });
126
+
127
+ it('HEXPIRE GT/LT condition semantics', () => {
128
+ const { engine } = makeEngine(1_000_000);
129
+ engine.hset('h', 'f1', 'v1');
130
+ assert.deepEqual(
131
+ engine.hexpire('h', engine._clock() + 1000, [Buffer.from('f1')], { condition: 'GT' }),
132
+ [0]
133
+ );
134
+ assert.deepEqual(
135
+ engine.hexpire('h', engine._clock() + 1000, [Buffer.from('f1')], { condition: 'LT' }),
136
+ [1]
137
+ );
138
+ assert.deepEqual(
139
+ engine.hexpire('h', engine._clock() + 500, [Buffer.from('f1')], { condition: 'GT' }),
140
+ [0]
141
+ );
142
+ assert.deepEqual(
143
+ engine.hexpire('h', engine._clock() + 2000, [Buffer.from('f1')], { condition: 'GT' }),
144
+ [1]
145
+ );
146
+ });
147
+
148
+ it('lazy expiration: HGET returns null after TTL; HLEN reflects live count', () => {
149
+ const { engine, advance } = makeEngine(1_000_000);
150
+ engine.hset('h', 'f1', 'v1', 'f2', 'v2');
151
+ engine.hexpire('h', engine._clock() + 1000, [Buffer.from('f1')]);
152
+ assert.equal(engine.hget('h', 'f1').toString(), 'v1');
153
+ assert.equal(engine.hlen('h'), 2);
154
+ advance(2000);
155
+ assert.equal(engine.hget('h', 'f1'), null);
156
+ assert.equal(engine.hlen('h'), 1);
157
+ const all = engine.hgetall('h');
158
+ assert.equal(all.length, 2);
159
+ assert.equal(all[0].toString(), 'f2');
160
+ });
161
+
162
+ it('empty hash after lazy expiration removes the key', () => {
163
+ const { engine, advance } = makeEngine(1_000_000);
164
+ engine.hset('h', 'f1', 'v1');
165
+ engine.hexpire('h', engine._clock() + 1000, [Buffer.from('f1')]);
166
+ advance(2000);
167
+ engine.hget('h', 'f1');
168
+ assert.equal(engine.type('h'), 'none');
169
+ });
170
+
171
+ it('HSET clears a field TTL', () => {
172
+ const { engine } = makeEngine(1_000_000);
173
+ engine.hset('h', 'f1', 'v1');
174
+ engine.hexpire('h', engine._clock() + 5000, [Buffer.from('f1')]);
175
+ assert.deepEqual(engine.httl('h', [Buffer.from('f1')]), [5]);
176
+ engine.hset('h', 'f1', 'v2');
177
+ assert.deepEqual(engine.httl('h', [Buffer.from('f1')]), [-1]);
178
+ });
179
+
180
+ it('HINCRBY clears a field TTL', () => {
181
+ const { engine } = makeEngine(1_000_000);
182
+ engine.hset('h', 'n', '1');
183
+ engine.hexpire('h', engine._clock() + 5000, [Buffer.from('n')]);
184
+ engine.hincrby('h', 'n', 2);
185
+ assert.deepEqual(engine.httl('h', [Buffer.from('n')]), [-1]);
186
+ });
187
+
188
+ it('HDEL removes field TTL row too (no leak)', () => {
189
+ const { engine } = makeEngine(1_000_000);
190
+ engine.hset('h', 'f1', 'v1', 'f2', 'v2');
191
+ engine.hexpire('h', engine._clock() + 5000, [Buffer.from('f1')]);
192
+ engine.hdel('h', ['f1']);
193
+ assert.deepEqual(engine.httl('h', [Buffer.from('f1')]), [-2]);
194
+ });
195
+
196
+ it('HPERSIST clears field TTL', () => {
197
+ const { engine } = makeEngine(1_000_000);
198
+ engine.hset('h', 'f1', 'v1');
199
+ engine.hexpire('h', engine._clock() + 5000, [Buffer.from('f1')]);
200
+ assert.deepEqual(engine.hpersist('h', [Buffer.from('f1')]), [1]);
201
+ assert.deepEqual(engine.httl('h', [Buffer.from('f1')]), [-1]);
202
+ assert.deepEqual(engine.hpersist('h', [Buffer.from('f1')]), [-1]);
203
+ assert.deepEqual(engine.hpersist('h', [Buffer.from('nope')]), [-2]);
204
+ });
205
+
206
+ it('HEXPIRE on missing hash key returns -2 for each field', () => {
207
+ const { engine } = makeEngine(1_000_000);
208
+ const res = engine.hexpire('nokey', engine._clock() + 1000, [Buffer.from('a'), Buffer.from('b')]);
209
+ assert.deepEqual(res, [-2, -2]);
210
+ });
211
+
212
+ it('HEXPIRE against a wrong-type key raises WRONGTYPE', () => {
213
+ const { engine } = makeEngine(1_000_000);
214
+ engine.set('str', 'v');
215
+ assert.throws(
216
+ () => engine.hexpire('str', engine._clock() + 1000, [Buffer.from('x')]),
217
+ /WRONGTYPE/
218
+ );
219
+ });
220
+ });
@@ -1,6 +1,7 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { createEngine } from '../../src/engine/engine.js';
4
+ import { createExpirationSweeper } from '../../src/engine/expiration.js';
4
5
  import { openDb } from '../../src/storage/sqlite/db.js';
5
6
  import { tmpDbPath } from '../helpers/tmp.js';
6
7
  import { fixedClock } from '../helpers/clock.js';
@@ -43,3 +44,45 @@ describe('Expiration', () => {
43
44
  assert.equal(engine.ttl('p'), -1);
44
45
  });
45
46
  });
47
+
48
+ describe('Hash field expiration sweeper', () => {
49
+ it('sweeps expired hash fields and removes now-empty hash keys', () => {
50
+ const dbPath = tmpDbPath();
51
+ const db = openDb(dbPath);
52
+ let t = 1_000_000;
53
+ const clock = () => t;
54
+ const engine = createEngine({ db, clock });
55
+ const sweeper = createExpirationSweeper({ db, clock, sweepIntervalMs: 30 });
56
+
57
+ engine.hset('h1', 'f1', 'v1', 'f2', 'v2');
58
+ engine.hset('h2', 'only', 'gone');
59
+ engine.hexpire('h1', t + 500, [Buffer.from('f1')]);
60
+ engine.hexpire('h2', t + 500, [Buffer.from('only')]);
61
+
62
+ t += 1000;
63
+
64
+ // Drive a single sweep via start()/stop() bookends plus a manual flush.
65
+ // Sweeper's sweep() is internal; expose it by creating one tick's worth of behavior.
66
+ const row = db.prepare('SELECT COUNT(*) AS n FROM redis_hash_field_ttl WHERE expires_at <= ?').get(t);
67
+ assert.equal(row.n, 2);
68
+
69
+ // Drain by advancing: reuse setInterval semantics indirectly by stepping logic.
70
+ // Emulate a tick by starting the sweeper with a very short interval and waiting briefly.
71
+ sweeper.start();
72
+ return new Promise((resolve) => {
73
+ setTimeout(() => {
74
+ try {
75
+ sweeper.stop();
76
+ const ttlRows = db.prepare('SELECT COUNT(*) AS n FROM redis_hash_field_ttl').get();
77
+ assert.equal(ttlRows.n, 0, 'TTL rows should be swept');
78
+ assert.equal(engine.hget('h1', 'f1'), null);
79
+ assert.equal(engine.hlen('h1'), 1);
80
+ assert.equal(engine.type('h2'), 'none');
81
+ resolve();
82
+ } catch (e) {
83
+ resolve(Promise.reject(e));
84
+ }
85
+ }, 120);
86
+ });
87
+ });
88
+ });