resplite 1.4.20 → 1.5.2

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.
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { RESPReader } from '../resp/parser.js';
6
- import { dispatch } from '../commands/registry.js';
6
+ import { compileCommandPolicy, dispatch } from '../commands/registry.js';
7
7
  import { encode, encodeSimpleString, encodeError } from '../resp/encoder.js';
8
8
  import { registerMonitorClient, unregisterMonitorClient, broadcastMonitorCommand } from './monitor.js';
9
9
 
@@ -13,11 +13,13 @@ let nextConnectionId = 0;
13
13
  * @param {import('net').Socket} socket
14
14
  * @param {object} engine
15
15
  * @param {object} [hooks] Optional: onUnknownCommand, onCommandError, onSocketError
16
+ * @param {object|null} [commandPolicy] Optional: command rename/disable policy.
16
17
  */
17
- export function handleConnection(socket, engine, hooks = {}) {
18
+ export function handleConnection(socket, engine, hooks = {}, commandPolicy = null) {
18
19
  const reader = new RESPReader();
19
20
  const connectionId = ++nextConnectionId;
20
21
  const clientAddress = `${socket.remoteAddress ?? 'unknown'}:${socket.remotePort ?? 0}`;
22
+ const compiledCommandPolicy = compileCommandPolicy(commandPolicy);
21
23
  const context = {
22
24
  connectionId,
23
25
  clientAddress,
@@ -29,6 +31,7 @@ export function handleConnection(socket, engine, hooks = {}) {
29
31
  onUnknownCommand: hooks.onUnknownCommand,
30
32
  onCommandError: hooks.onCommandError,
31
33
  onSocketError: hooks.onSocketError,
34
+ commandPolicy: compiledCommandPolicy,
32
35
  };
33
36
 
34
37
  function writeResult(out) {
@@ -4,6 +4,7 @@
4
4
 
5
5
  import net from 'node:net';
6
6
  import { handleConnection } from './connection.js';
7
+ import { compileCommandPolicy } from '../commands/registry.js';
7
8
 
8
9
  /**
9
10
  * @param {object} options
@@ -11,15 +12,17 @@ import { handleConnection } from './connection.js';
11
12
  * @param {number} [options.port=6379]
12
13
  * @param {string} [options.host='0.0.0.0']
13
14
  * @param {Set<import('node:net').Socket>} [options.connections] If provided, each accepted socket is added here (for graceful shutdown).
15
+ * @param {object|null} [options.commandPolicy] Optional: command rename/disable policy.
14
16
  * @returns {import('node:net').Server}
15
17
  */
16
- export function createServer({ engine, port = 6379, host = '0.0.0.0', connections = null }) {
18
+ export function createServer({ engine, port = 6379, host = '0.0.0.0', connections = null, commandPolicy = null }) {
19
+ const compiledCommandPolicy = compileCommandPolicy(commandPolicy);
17
20
  const server = net.createServer((socket) => {
18
21
  if (connections) {
19
22
  connections.add(socket);
20
23
  socket.once('close', () => connections.delete(socket));
21
24
  }
22
- handleConnection(socket, engine);
25
+ handleConnection(socket, engine, {}, compiledCommandPolicy);
23
26
  });
24
27
  return server;
25
28
  }
@@ -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,
@@ -7,17 +7,20 @@ import net from 'node:net';
7
7
  import { handleConnection } from '../../src/server/connection.js';
8
8
  import { createEngine } from '../../src/engine/engine.js';
9
9
  import { openDb } from '../../src/storage/sqlite/db.js';
10
+ import { compileCommandPolicy } from '../../src/commands/registry.js';
10
11
  import { tmpDbPath } from './tmp.js';
11
12
 
12
13
  export function createTestServer(options = {}) {
13
14
  const dbPath = options.dbPath || tmpDbPath();
14
15
  const db = openDb(dbPath);
15
16
  const engine = createEngine({ db });
17
+ const hooks = options.hooks || {};
18
+ const commandPolicy = compileCommandPolicy(options.commandPolicy ?? null);
16
19
  const connections = new Set();
17
20
  const server = net.createServer((socket) => {
18
21
  connections.add(socket);
19
22
  socket.once('close', () => connections.delete(socket));
20
- handleConnection(socket, engine);
23
+ handleConnection(socket, engine, hooks, commandPolicy);
21
24
  });
22
25
  return new Promise((resolve, reject) => {
23
26
  server.listen(0, '127.0.0.1', () => {
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Integration tests for command hardening policy (rename/disable).
3
+ */
4
+
5
+ import { describe, it, before, after } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { createTestServer } from '../helpers/server.js';
8
+ import { sendCommand, argv } from '../helpers/client.js';
9
+ import { tryParseValue } from '../../src/resp/parser.js';
10
+
11
+ function parseResp(buffer) {
12
+ const parsed = tryParseValue(buffer, 0);
13
+ return parsed ? parsed.value : null;
14
+ }
15
+
16
+ function asUtf8(value) {
17
+ return Buffer.isBuffer(value) ? value.toString('utf8') : String(value);
18
+ }
19
+
20
+ describe('command hardening policy', () => {
21
+ let s;
22
+ let port;
23
+
24
+ before(async () => {
25
+ s = await createTestServer({
26
+ commandPolicy: {
27
+ rename: {
28
+ KEYS: 'SAFE_KEYS',
29
+ DEL: 'RMDEL',
30
+ },
31
+ disabled: ['MONITOR'],
32
+ },
33
+ });
34
+ port = s.port;
35
+ });
36
+
37
+ after(async () => {
38
+ await s.closeAsync();
39
+ });
40
+
41
+ it('renamed command is available only through alias', async () => {
42
+ const setReply = parseResp(await sendCommand(port, argv('SET', 'policy:k1', 'v1')));
43
+ assert.equal(asUtf8(setReply), 'OK');
44
+
45
+ const aliasReply = parseResp(await sendCommand(port, argv('SAFE_KEYS', 'policy:*')));
46
+ assert.ok(Array.isArray(aliasReply));
47
+ assert.ok(aliasReply.map(asUtf8).includes('policy:k1'));
48
+
49
+ const oldNameReply = parseResp(await sendCommand(port, argv('KEYS', 'policy:*')));
50
+ assert.ok(oldNameReply && oldNameReply.error);
51
+ assert.equal(asUtf8(oldNameReply.error), 'ERR command not supported yet');
52
+ });
53
+
54
+ it('explicitly disabled command returns unsupported', async () => {
55
+ const reply = parseResp(await sendCommand(port, argv('MONITOR')));
56
+ assert.ok(reply && reply.error);
57
+ assert.equal(asUtf8(reply.error), 'ERR command not supported yet');
58
+ });
59
+
60
+ it('COMMAND only exposes visible names and keeps canonical metadata', async () => {
61
+ const listReply = parseResp(await sendCommand(port, argv('COMMAND')));
62
+ assert.ok(Array.isArray(listReply));
63
+ const names = listReply.map((doc) => asUtf8(doc[0]).toUpperCase());
64
+ assert.ok(names.includes('SAFE_KEYS'));
65
+ assert.ok(names.includes('RMDEL'));
66
+ assert.ok(!names.includes('KEYS'));
67
+ assert.ok(!names.includes('DEL'));
68
+ assert.ok(!names.includes('MONITOR'));
69
+
70
+ const infoReply = parseResp(await sendCommand(port, argv('COMMAND', 'INFO', 'RMDEL')));
71
+ assert.ok(Array.isArray(infoReply));
72
+ assert.equal(infoReply.length, 1);
73
+ const rmDelDoc = infoReply[0];
74
+ assert.equal(asUtf8(rmDelDoc[0]), 'rmdel');
75
+ assert.equal(rmDelDoc[1], -2);
76
+ const flags = rmDelDoc[2].map(asUtf8);
77
+ assert.ok(flags.includes('write'));
78
+ });
79
+
80
+ it('renamed write command executes through alias and blocks original', async () => {
81
+ await sendCommand(port, argv('SET', 'policy:del', 'to-delete'));
82
+
83
+ const aliasDeleteReply = parseResp(await sendCommand(port, argv('RMDEL', 'policy:del')));
84
+ assert.equal(aliasDeleteReply, 1);
85
+
86
+ const originalDeleteReply = parseResp(await sendCommand(port, argv('DEL', 'policy:del')));
87
+ assert.ok(originalDeleteReply && originalDeleteReply.error);
88
+ assert.equal(asUtf8(originalDeleteReply.error), 'ERR command not supported yet');
89
+ });
90
+ });
@@ -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'));
@@ -27,7 +27,13 @@ const PREFIX = `__resplite_tracker_test_${process.pid}__`;
27
27
 
28
28
  /** Connect to local Redis; return null if unavailable. */
29
29
  async function tryConnectRedis() {
30
- const client = createClient({ url: REDIS_URL });
30
+ const client = createClient({
31
+ url: REDIS_URL,
32
+ socket: {
33
+ connectTimeout: 1500,
34
+ reconnectStrategy: () => new Error('no reconnect in tests'),
35
+ },
36
+ });
31
37
  try {
32
38
  await client.connect();
33
39
  await client.ping();