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.
package/README.md CHANGED
@@ -62,6 +62,10 @@ const log = new LemonLog('RESPlite');
62
62
  const srv = await createRESPlite({
63
63
  port: 6380,
64
64
  db: './data.db',
65
+ commandPolicy: {
66
+ rename: { KEYS: 'SAFE_KEYS' }, // original KEYS is blocked
67
+ disabled: ['MONITOR'], // blocked as unsupported
68
+ },
65
69
  hooks: {
66
70
  onUnknownCommand({ command, argv, clientAddress }) {
67
71
  log.warn({ command, argv, clientAddress }, 'unsupported command');
@@ -84,6 +88,28 @@ Available hooks:
84
88
 
85
89
  If you want a tiny in-process smoke test that starts RESPLite and connects with the `redis` client in the same script, see [Minimal embedded example](#minimal-embedded-example) below.
86
90
 
91
+ ### Command hardening (rename/disable)
92
+
93
+ You can harden the command surface by renaming sensitive commands and/or disabling them:
94
+
95
+ ```javascript
96
+ const srv = await createRESPlite({
97
+ db: './secure.db',
98
+ commandPolicy: {
99
+ rename: {
100
+ KEYS: 'SAFE_KEYS',
101
+ DEL: 'RMDEL',
102
+ },
103
+ disabled: ['MONITOR', 'CLIENT'],
104
+ },
105
+ });
106
+ ```
107
+
108
+ Behavior:
109
+ - `rename`: only the new alias is accepted; the original command name is blocked.
110
+ - `disabled`: command is blocked and replies with `ERR command not supported yet`.
111
+ - `COMMAND`, `COMMAND COUNT`, and `COMMAND INFO` expose only visible commands (including aliases, excluding blocked names).
112
+
87
113
  ## Migration from Redis
88
114
 
89
115
  RESPLite is a good fit for migrating **non-replicated Redis** instances that have **grown large** (e.g. tens of GB) and where RESPLite's latency is acceptable. The recommended path is to drive the migration from a Node.js script via `resplite/migration`, keeping preflight, dirty tracking, bulk import, cutover, and verification in one place.
@@ -574,7 +600,7 @@ To reproduce the benchmark, run `npm run benchmark -- --template default`. Numbe
574
600
  | **Connection** | PING, ECHO, QUIT |
575
601
  | **Strings** | GET, SET, MGET, MSET, DEL, EXISTS, INCR, DECR, INCRBY, DECRBY, STRLEN |
576
602
  | **TTL** | EXPIRE, PEXPIRE, TTL, PTTL, PERSIST |
577
- | **Hashes** | HSET, HGET, HMGET, HGETALL, HKEYS, HVALS, HDEL, HEXISTS, HINCRBY |
603
+ | **Hashes** | HSET, HGET, HMGET, HGETALL, HKEYS, HVALS, HDEL, HEXISTS, HINCRBY, HEXPIRE, HTTL, HPERSIST |
578
604
  | **Sets** | SADD, SREM, SMEMBERS, SISMEMBER, SCARD, SPOP, SRANDMEMBER |
579
605
  | **Lists** | LPUSH, RPUSH, LLEN, LRANGE, LINDEX, LPOP, RPOP, LSET, LTRIM, BLPOP, BRPOP |
580
606
  | **Sorted sets** | ZADD, ZREM, ZCARD, ZSCORE, ZRANGE, ZREVRANGE, ZRANGEBYSCORE, ZREVRANGEBYSCORE, ZRANK, ZREVRANK, ZCOUNT, ZINCRBY, ZREMRANGEBYRANK, ZREMRANGEBYSCORE |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resplite",
3
- "version": "1.4.20",
3
+ "version": "1.5.2",
4
4
  "description": "A RESP2 server with practical Redis compatibility, backed by SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -98,9 +98,13 @@ function spawnResplite(templateName, port, dbPath) {
98
98
  }
99
99
 
100
100
  function formatNum(n) {
101
+ if (!Number.isFinite(n)) return '—';
101
102
  if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
102
103
  if (n >= 1e3) return (n / 1e3).toFixed(2) + 'K';
103
- return String(n);
104
+ if (n >= 100) return n.toFixed(2);
105
+ if (n >= 10) return n.toFixed(2);
106
+ if (n >= 1) return n.toFixed(2);
107
+ return n.toFixed(3);
104
108
  }
105
109
 
106
110
  function formatMs(ms) {
@@ -603,23 +607,41 @@ async function main() {
603
607
  for (const { client } of respliteClients) await client.quit();
604
608
  for (const { child } of children) child.kill();
605
609
 
606
- const colWidth = 10;
607
610
  const headerCols = ['Suite', 'Redis', ...templateNames];
608
- const sep = headerCols.map((_, i) => (i === 0 ? '-'.repeat(18) : '-'.repeat(colWidth))).join('|');
609
- console.log('');
610
- console.log('--- Summary (ops/sec) ---');
611
- console.log(headerCols.map((h, i) => (i === 0 ? h.padEnd(18) : h.padStart(colWidth))).join(' | '));
612
- console.log(sep);
613
- for (const r of results) {
614
- if (r.error) {
615
- console.log(`${r.suite.padEnd(18)} | ERROR: ${r.error}`);
616
- continue;
617
- }
611
+ const summaryRows = results.map((r) => {
612
+ if (r.error) return { suite: r.suite, values: [`ERROR: ${r.error}`] };
618
613
  const redisVal = r.redis.error ? '—' : formatNum(r.redis.opsPerSec);
619
614
  const templateVals = templateNames.map((t) => (r.templates[t]?.error ? '—' : formatNum(r.templates[t]?.opsPerSec)));
620
- console.log(
621
- [r.suite.padEnd(18), redisVal.padStart(colWidth), ...templateVals.map((v) => v.padStart(colWidth))].join(' | ')
622
- );
615
+ return { suite: r.suite, values: [redisVal, ...templateVals] };
616
+ });
617
+
618
+ const suiteWidth = Math.max(
619
+ headerCols[0].length,
620
+ ...summaryRows.map((r) => r.suite.length)
621
+ );
622
+ const valueWidths = headerCols.slice(1).map((h, idx) =>
623
+ Math.max(
624
+ h.length,
625
+ ...summaryRows.map((r) => (r.values[idx] || '').length)
626
+ )
627
+ );
628
+
629
+ console.log('');
630
+ console.log('--- Summary (ops/sec) ---');
631
+ console.log([
632
+ headerCols[0].padEnd(suiteWidth),
633
+ ...headerCols.slice(1).map((h, idx) => h.padStart(valueWidths[idx])),
634
+ ].join(' | '));
635
+ console.log([
636
+ '-'.repeat(suiteWidth),
637
+ ...valueWidths.map((w) => '-'.repeat(w)),
638
+ ].join('-|-'));
639
+
640
+ for (const row of summaryRows) {
641
+ console.log([
642
+ row.suite.padEnd(suiteWidth),
643
+ ...row.values.map((v, idx) => v.padStart(valueWidths[idx])),
644
+ ].join(' | '));
623
645
  }
624
646
 
625
647
  console.log('');
@@ -15,42 +15,42 @@ const WRITE_COMMANDS = new Set([
15
15
  * @param {string} name - Command name (lowercase for reply).
16
16
  * @returns {Array<string|number|string[]>}
17
17
  */
18
- function docFor(name) {
18
+ function docFor(name, canonicalName = name) {
19
19
  const lower = name.toLowerCase();
20
- const flags = WRITE_COMMANDS.has(name) ? ['write', 'fast'] : ['readonly', 'fast'];
20
+ const flags = WRITE_COMMANDS.has(canonicalName) ? ['write', 'fast'] : ['readonly', 'fast'];
21
21
  let arity = 2;
22
22
  let firstKey = 1;
23
23
  let lastKey = 1;
24
24
  let step = 1;
25
- if (['MGET', 'MSET', 'DEL', 'UNLINK', 'EXISTS', 'KEYS', 'SCAN', 'PING', 'ECHO', 'QUIT', 'TYPE', 'OBJECT', 'SQLITE.INFO', 'CACHE.INFO', 'MEMORY.INFO', 'COMMAND', 'MONITOR', 'CLIENT'].includes(name)) {
26
- if (['PING', 'ECHO', 'QUIT', 'COMMAND', 'MONITOR'].includes(name)) {
25
+ if (['MGET', 'MSET', 'DEL', 'UNLINK', 'EXISTS', 'KEYS', 'SCAN', 'PING', 'ECHO', 'QUIT', 'TYPE', 'OBJECT', 'SQLITE.INFO', 'CACHE.INFO', 'MEMORY.INFO', 'COMMAND', 'MONITOR', 'CLIENT'].includes(canonicalName)) {
26
+ if (['PING', 'ECHO', 'QUIT', 'COMMAND', 'MONITOR'].includes(canonicalName)) {
27
27
  firstKey = 0;
28
28
  lastKey = 0;
29
29
  step = 0;
30
- arity = name === 'COMMAND' ? -1 : (name === 'ECHO' ? 2 : 1);
31
- } else if (['MGET', 'EXISTS', 'KEYS', 'SCAN'].includes(name)) {
30
+ arity = canonicalName === 'COMMAND' ? -1 : (canonicalName === 'ECHO' ? 2 : 1);
31
+ } else if (['MGET', 'EXISTS', 'KEYS', 'SCAN'].includes(canonicalName)) {
32
32
  arity = -2;
33
33
  lastKey = -1;
34
- } else if (name === 'MSET') {
34
+ } else if (canonicalName === 'MSET') {
35
35
  arity = -3;
36
36
  lastKey = -1;
37
37
  step = 2;
38
- } else if (['DEL', 'UNLINK'].includes(name)) {
38
+ } else if (['DEL', 'UNLINK'].includes(canonicalName)) {
39
39
  arity = -2;
40
40
  lastKey = -1;
41
41
  }
42
- } else if (name.startsWith('FT.') || name.startsWith('SQLITE.') || name.startsWith('CACHE.') || name.startsWith('MEMORY.')) {
42
+ } else if (canonicalName.startsWith('FT.') || canonicalName.startsWith('SQLITE.') || canonicalName.startsWith('CACHE.') || canonicalName.startsWith('MEMORY.')) {
43
43
  firstKey = 0;
44
44
  lastKey = 0;
45
45
  step = 0;
46
46
  arity = -2;
47
- } else if (['BLPOP', 'BRPOP'].includes(name)) {
47
+ } else if (['BLPOP', 'BRPOP'].includes(canonicalName)) {
48
48
  arity = -3;
49
49
  lastKey = -1;
50
50
  step = 1;
51
- } else if (['HMGET', 'HGETALL', 'HGET', 'HSET', 'HDEL', 'HEXISTS', 'HINCRBY', 'HLEN'].includes(name)) {
52
- arity = (name === 'HGET' || name === 'HLEN' || name === 'HEXISTS') ? 3 : -3;
53
- } else if (name === 'SETEX') {
51
+ } else if (['HMGET', 'HGETALL', 'HGET', 'HSET', 'HDEL', 'HEXISTS', 'HINCRBY', 'HLEN'].includes(canonicalName)) {
52
+ arity = (canonicalName === 'HGET' || canonicalName === 'HLEN' || canonicalName === 'HEXISTS') ? 3 : -3;
53
+ } else if (canonicalName === 'SETEX') {
54
54
  arity = 4;
55
55
  }
56
56
  return [lower, arity, flags, firstKey, lastKey, step, []];
@@ -63,10 +63,13 @@ function docFor(name) {
63
63
  */
64
64
  export function handleCommand(engine, args, context) {
65
65
  const allNames = context?.getCommandNames ? context.getCommandNames() : [];
66
+ const resolveCanonical = context?.resolveCommandForIntrospection
67
+ ? (name) => context.resolveCommandForIntrospection(name)
68
+ : (name) => name;
66
69
  const sub = (args && args.length > 0 && Buffer.isBuffer(args[0])) ? args[0].toString('utf8').toUpperCase() : '';
67
70
 
68
71
  if (!sub || sub === '') {
69
- const reply = allNames.map((n) => docFor(n));
72
+ const reply = allNames.map((n) => docFor(n, resolveCanonical(n)));
70
73
  return reply;
71
74
  }
72
75
  if (sub === 'COUNT') {
@@ -75,7 +78,7 @@ export function handleCommand(engine, args, context) {
75
78
  if (sub === 'INFO') {
76
79
  const names = (args.slice(1) || []).map((b) => (Buffer.isBuffer(b) ? b.toString('utf8') : String(b)).toUpperCase());
77
80
  const set = new Set(allNames);
78
- const reply = names.map((n) => set.has(n) ? docFor(n) : null).filter((x) => x != null);
81
+ const reply = names.map((n) => set.has(n) ? docFor(n, resolveCanonical(n)) : null).filter((x) => x != null);
79
82
  return reply;
80
83
  }
81
84
 
@@ -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';
@@ -88,6 +91,8 @@ import * as monitor from './monitor.js';
88
91
  import * as client from './client.js';
89
92
  import * as command from './command.js';
90
93
 
94
+ const COMPILED_POLICY_TAG = Symbol('compiled-command-policy');
95
+
91
96
  const HANDLERS = new Map([
92
97
  ['PING', (e, a) => ping.handlePing()],
93
98
  ['ECHO', (e, a) => echo.handleEcho(a)],
@@ -122,6 +127,9 @@ const HANDLERS = new Map([
122
127
  ['HLEN', (e, a) => hlen.handleHlen(e, a)],
123
128
  ['HEXISTS', (e, a) => hexists.handleHexists(e, a)],
124
129
  ['HINCRBY', (e, a) => hincrby.handleHincrby(e, a)],
130
+ ['HEXPIRE', (e, a) => hexpireCmd.handleHexpire(e, a)],
131
+ ['HTTL', (e, a) => httlCmd.handleHttl(e, a)],
132
+ ['HPERSIST', (e, a) => hpersistCmd.handleHpersist(e, a)],
125
133
  ['SADD', (e, a) => sadd.handleSadd(e, a)],
126
134
  ['SREM', (e, a) => srem.handleSrem(e, a)],
127
135
  ['SMEMBERS', (e, a) => smembers.handleSmembers(e, a)],
@@ -175,6 +183,100 @@ const HANDLERS = new Map([
175
183
  ['COMMAND', (e, a, ctx) => command.handleCommand(e, a, ctx)],
176
184
  ]);
177
185
 
186
+ function assertCommandName(value, field) {
187
+ if (typeof value !== 'string' || value.trim() === '') {
188
+ throw new Error(`invalid command policy: ${field} must be a non-empty string`);
189
+ }
190
+ return value.trim().toUpperCase();
191
+ }
192
+
193
+ function isCompiledPolicy(value) {
194
+ return !!(value && typeof value === 'object' && value[COMPILED_POLICY_TAG] === true);
195
+ }
196
+
197
+ export function compileCommandPolicy(policy = {}) {
198
+ if (policy == null) return null;
199
+ if (isCompiledPolicy(policy)) return policy;
200
+ if (typeof policy !== 'object') {
201
+ throw new Error('invalid command policy: expected an object');
202
+ }
203
+
204
+ const rename = policy.rename ?? {};
205
+ const disabled = policy.disabled ?? [];
206
+
207
+ if (rename == null || typeof rename !== 'object' || Array.isArray(rename)) {
208
+ throw new Error('invalid command policy: rename must be an object');
209
+ }
210
+ if (!Array.isArray(disabled)) {
211
+ throw new Error('invalid command policy: disabled must be an array');
212
+ }
213
+
214
+ const knownCommands = new Set(HANDLERS.keys());
215
+ const renamedOriginals = new Set();
216
+ const aliasToOriginal = new Map();
217
+
218
+ for (const [rawOriginal, rawAlias] of Object.entries(rename)) {
219
+ const original = assertCommandName(rawOriginal, 'rename key');
220
+ const alias = assertCommandName(rawAlias, `rename.${rawOriginal}`);
221
+ if (!knownCommands.has(original)) {
222
+ throw new Error(`invalid command policy: cannot rename unknown command "${original}"`);
223
+ }
224
+ if (alias === original) {
225
+ throw new Error(`invalid command policy: alias for "${original}" must be different`);
226
+ }
227
+ if (knownCommands.has(alias)) {
228
+ throw new Error(`invalid command policy: alias "${alias}" conflicts with existing command`);
229
+ }
230
+ if (aliasToOriginal.has(alias)) {
231
+ throw new Error(`invalid command policy: alias "${alias}" is duplicated`);
232
+ }
233
+ renamedOriginals.add(original);
234
+ aliasToOriginal.set(alias, original);
235
+ }
236
+
237
+ const disabledSet = new Set();
238
+ for (const rawName of disabled) {
239
+ const name = assertCommandName(rawName, 'disabled[] item');
240
+ disabledSet.add(name);
241
+ }
242
+
243
+ return {
244
+ [COMPILED_POLICY_TAG]: true,
245
+ disabledSet,
246
+ aliasToOriginal,
247
+ renamedOriginals,
248
+ };
249
+ }
250
+
251
+ function listVisibleCommandNames(policy) {
252
+ const names = [];
253
+ for (const name of HANDLERS.keys()) {
254
+ if (policy?.renamedOriginals?.has(name)) continue;
255
+ if (policy?.disabledSet?.has(name)) continue;
256
+ names.push(name);
257
+ }
258
+ if (policy?.aliasToOriginal) {
259
+ for (const [alias] of policy.aliasToOriginal.entries()) {
260
+ if (policy.disabledSet.has(alias)) continue;
261
+ names.push(alias);
262
+ }
263
+ }
264
+ return names;
265
+ }
266
+
267
+ function resolveIncomingCommand(inputCommand, policy) {
268
+ if (!policy) return { blocked: false, resolvedCommand: inputCommand };
269
+ if (policy.disabledSet.has(inputCommand)) {
270
+ return { blocked: true, resolvedCommand: inputCommand };
271
+ }
272
+ const target = policy.aliasToOriginal.get(inputCommand);
273
+ if (target) return { blocked: false, resolvedCommand: target };
274
+ if (policy.renamedOriginals.has(inputCommand)) {
275
+ return { blocked: true, resolvedCommand: inputCommand };
276
+ }
277
+ return { blocked: false, resolvedCommand: inputCommand };
278
+ }
279
+
178
280
  /**
179
281
  * Dispatch command. Full argv: [commandNameBuf, ...argBuffers].
180
282
  * @param {object} engine
@@ -187,10 +289,22 @@ export function dispatch(engine, argv, context) {
187
289
  return { error: 'ERR wrong number of arguments' };
188
290
  }
189
291
  const cmd = (Buffer.isBuffer(argv[0]) ? argv[0].toString('utf8') : String(argv[0])).toUpperCase();
292
+ const policy = compileCommandPolicy(context?.commandPolicy);
293
+ const commandResolution = resolveIncomingCommand(cmd, policy);
294
+ if (commandResolution.blocked) {
295
+ return { error: unsupported() };
296
+ }
297
+ const resolvedCommand = commandResolution.resolvedCommand;
190
298
  const args = argv.slice(1);
191
299
  const argvStrings = argv.map((b) => (Buffer.isBuffer(b) ? b.toString('utf8') : String(b)));
192
- if (context) context.getCommandNames = () => Array.from(HANDLERS.keys());
193
- const handler = HANDLERS.get(cmd);
300
+ if (context) {
301
+ context.getCommandNames = () => listVisibleCommandNames(policy);
302
+ context.resolveCommandForIntrospection = (name) => {
303
+ if (!policy) return name;
304
+ return policy.aliasToOriginal.get(String(name).toUpperCase()) ?? String(name).toUpperCase();
305
+ };
306
+ }
307
+ const handler = HANDLERS.get(resolvedCommand);
194
308
  if (!handler) {
195
309
  context?.onUnknownCommand?.({
196
310
  command: cmd,
package/src/embed.js CHANGED
@@ -13,6 +13,7 @@ import net from 'node:net';
13
13
  import { handleConnection } from './server/connection.js';
14
14
  import { createEngine } from './engine/engine.js';
15
15
  import { openDb } from './storage/sqlite/db.js';
16
+ import { compileCommandPolicy } from './commands/registry.js';
16
17
 
17
18
  export { handleConnection, createEngine, openDb };
18
19
 
@@ -37,6 +38,7 @@ export { handleConnection, createEngine, openDb };
37
38
  * @param {Record<string, string|number>} [options.pragma] Override specific pragmas only when needed (e.g. { synchronous: 'FULL' }). Applied after the template.
38
39
  * @param {RESPliteHooks} [options.hooks] Optional event hooks for observability (onUnknownCommand, onCommandError, onSocketError).
39
40
  * @param {boolean} [options.gracefulShutdown=true] If true, register SIGTERM/SIGINT to call close(). Set false if you handle shutdown yourself to avoid double handlers.
41
+ * @param {{ rename?: Record<string, string>, disabled?: string[] } | null} [options.commandPolicy] Optional: rename/disable commands for hardening.
40
42
  * @returns {Promise<{ port: number, host: string, close: () => Promise<void> }>}
41
43
  */
42
44
  export async function createRESPlite({
@@ -47,7 +49,9 @@ export async function createRESPlite({
47
49
  pragma,
48
50
  hooks = {},
49
51
  gracefulShutdown = true,
52
+ commandPolicy = null,
50
53
  } = {}) {
54
+ const compiledCommandPolicy = compileCommandPolicy(commandPolicy);
51
55
  const db = openDb(dbPath, { pragmaTemplate, pragma });
52
56
  const engine = createEngine({ db });
53
57
  const connections = new Set();
@@ -55,7 +59,7 @@ export async function createRESPlite({
55
59
  const server = net.createServer((socket) => {
56
60
  connections.add(socket);
57
61
  socket.once('close', () => connections.delete(socket));
58
- handleConnection(socket, engine, hooks);
62
+ handleConnection(socket, engine, hooks, compiledCommandPolicy);
59
63
  });
60
64
  await new Promise((resolve) => server.listen(port, host, resolve));
61
65
 
@@ -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 {
package/src/index.js CHANGED
@@ -26,6 +26,7 @@ const DEFAULT_PORT = 6379;
26
26
  * @param {string} [options.pragmaTemplate]
27
27
  * @param {Record<string, string|number>} [options.pragma] Override specific pragmas when needed (e.g. { synchronous: 'FULL' }). Convention: template is applied by default.
28
28
  * @param {boolean} [options.gracefulShutdown=true] If true, register SIGTERM/SIGINT to close server and DB. Set false if you handle shutdown yourself.
29
+ * @param {{ rename?: Record<string, string>, disabled?: string[] } | null} [options.commandPolicy] Optional: rename/disable commands for hardening.
29
30
  */
30
31
  export function startServer(options = {}) {
31
32
  const dbPath = options.dbPath ?? process.env.RESPLITE_DB ?? DEFAULT_DB_PATH;
@@ -45,7 +46,7 @@ export function startServer(options = {}) {
45
46
  sweeper.start();
46
47
 
47
48
  const connections = new Set();
48
- const server = createServer({ engine, port, connections });
49
+ const server = createServer({ engine, port, connections, commandPolicy: options.commandPolicy ?? null });
49
50
 
50
51
  if (gracefulShutdown) {
51
52
  let shuttingDown = false;