resplite 1.2.14 → 1.2.18

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
@@ -63,14 +63,14 @@ const srv = await createRESPlite({
63
63
  port: 6380,
64
64
  db: './data.db',
65
65
  hooks: {
66
- onUnknownCommand({ command, argsCount, clientAddress }) {
67
- log.warn({ command, argsCount, clientAddress }, 'unsupported command');
66
+ onUnknownCommand({ command, argv, clientAddress }) {
67
+ log.warn({ command, argv, clientAddress }, 'unsupported command');
68
68
  },
69
- onCommandError({ command, error, clientAddress }) {
70
- log.warn({ command, error, clientAddress }, 'command error');
69
+ onCommandError({ command, argv, error, clientAddress }) {
70
+ log.warn({ command, argv, error, clientAddress }, 'command error');
71
71
  },
72
72
  onSocketError({ error, clientAddress }) {
73
- log.error({ err: error, clientAddress }, 'connection error');
73
+ log.error({ error, clientAddress }, 'connection error');
74
74
  },
75
75
  },
76
76
  });
@@ -78,8 +78,8 @@ const srv = await createRESPlite({
78
78
 
79
79
  Available hooks:
80
80
 
81
- - `onUnknownCommand`: client sent a command not implemented by RESPLite, such as `SUBSCRIBE` or `PUBLISH`.
82
- - `onCommandError`: a command failed because of wrong type, invalid args, or a handler error.
81
+ - `onUnknownCommand`: client sent a command not implemented by RESPLite, such as `SUBSCRIBE` or `PUBLISH`. Payload includes `argv` (full command line as strings, e.g. `['CLIENT','LIST']`) so you can log exactly what was sent.
82
+ - `onCommandError`: a command failed because of wrong type, invalid args, or a handler error. Payload includes `argv` for the full command line.
83
83
  - `onSocketError`: the connection socket emitted an error, for example `ECONNRESET`.
84
84
 
85
85
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resplite",
3
- "version": "1.2.14",
3
+ "version": "1.2.18",
4
4
  "description": "A RESP2 server with practical Redis compatibility, backed by SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -1,5 +1,5 @@
1
1
  /**
2
- * CLIENT - connection introspection (SETNAME, GETNAME, ID).
2
+ * CLIENT - connection introspection (SETNAME, GETNAME, ID) and SETINFO (no-op for client lib version).
3
3
  * Requires connection context.
4
4
  */
5
5
 
@@ -31,5 +31,11 @@ export function handleClient(engine, args, context) {
31
31
  }
32
32
  return context.connectionId;
33
33
  }
34
+ if (sub === 'SETINFO') {
35
+ if (args.length !== 3) {
36
+ return { error: 'ERR wrong number of arguments for \'CLIENT SETINFO\' command' };
37
+ }
38
+ return { simple: 'OK' };
39
+ }
34
40
  return { error: 'ERR unknown subcommand or wrong number of arguments for \'CLIENT\'. Try CLIENT HELP.' };
35
41
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * COMMAND - introspection: list commands, count, or info for specific commands.
3
+ * Reply format compatible with Redis COMMAND (array of [name, arity, flags, firstKey, lastKey, step, acl_categories]).
4
+ */
5
+
6
+ /** @type {Set<string>} Commands that modify data (write). */
7
+ const WRITE_COMMANDS = new Set([
8
+ 'SET', 'MSET', 'DEL', 'UNLINK', 'EXPIRE', 'PEXPIRE', 'PERSIST', 'INCR', 'DECR', 'INCRBY', 'DECRBY',
9
+ 'HSET', 'HDEL', 'HINCRBY', 'SADD', 'SREM', 'LPUSH', 'RPUSH', 'LPOP', 'RPOP', 'LREM', 'ZADD', 'ZREM',
10
+ 'FT.CREATE', 'FT.ADD', 'FT.DEL', 'FT.SUGADD', 'FT.SUGDEL', 'CLIENT',
11
+ ]);
12
+
13
+ /**
14
+ * Build Redis-style command doc: [name, arity, flags, firstKey, lastKey, step, acl_categories].
15
+ * @param {string} name - Command name (lowercase for reply).
16
+ * @returns {Array<string|number|string[]>}
17
+ */
18
+ function docFor(name) {
19
+ const lower = name.toLowerCase();
20
+ const flags = WRITE_COMMANDS.has(name) ? ['write', 'fast'] : ['readonly', 'fast'];
21
+ let arity = 2;
22
+ let firstKey = 1;
23
+ let lastKey = 1;
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)) {
27
+ firstKey = 0;
28
+ lastKey = 0;
29
+ step = 0;
30
+ arity = name === 'COMMAND' ? -1 : (name === 'ECHO' ? 2 : 1);
31
+ } else if (['MGET', 'EXISTS', 'KEYS', 'SCAN'].includes(name)) {
32
+ arity = -2;
33
+ lastKey = -1;
34
+ } else if (name === 'MSET') {
35
+ arity = -3;
36
+ lastKey = -1;
37
+ step = 2;
38
+ } else if (['DEL', 'UNLINK'].includes(name)) {
39
+ arity = -2;
40
+ lastKey = -1;
41
+ }
42
+ } else if (name.startsWith('FT.') || name.startsWith('SQLITE.') || name.startsWith('CACHE.') || name.startsWith('MEMORY.')) {
43
+ firstKey = 0;
44
+ lastKey = 0;
45
+ step = 0;
46
+ arity = -2;
47
+ } else if (['BLPOP', 'BRPOP'].includes(name)) {
48
+ arity = -3;
49
+ lastKey = -1;
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
+ }
54
+ return [lower, arity, flags, firstKey, lastKey, step, []];
55
+ }
56
+
57
+ /**
58
+ * @param {object} engine
59
+ * @param {Buffer[]} args - subcommand and optional names for INFO
60
+ * @param {{ getCommandNames?: () => string[] }} context
61
+ */
62
+ export function handleCommand(engine, args, context) {
63
+ const allNames = context?.getCommandNames ? context.getCommandNames() : [];
64
+ const sub = (args && args.length > 0 && Buffer.isBuffer(args[0])) ? args[0].toString('utf8').toUpperCase() : '';
65
+
66
+ if (!sub || sub === '') {
67
+ const reply = allNames.map((n) => docFor(n));
68
+ return reply;
69
+ }
70
+ if (sub === 'COUNT') {
71
+ return allNames.length;
72
+ }
73
+ if (sub === 'INFO') {
74
+ const names = (args.slice(1) || []).map((b) => (Buffer.isBuffer(b) ? b.toString('utf8') : String(b)).toUpperCase());
75
+ const set = new Set(allNames);
76
+ const reply = names.map((n) => set.has(n) ? docFor(n) : null).filter((x) => x != null);
77
+ return reply;
78
+ }
79
+
80
+ return { error: 'ERR unknown subcommand or wrong number of arguments for \'COMMAND\'. Try COMMAND HELP.' };
81
+ }
@@ -72,6 +72,7 @@ import * as ftSugget from './ft-sugget.js';
72
72
  import * as ftSugdel from './ft-sugdel.js';
73
73
  import * as monitor from './monitor.js';
74
74
  import * as client from './client.js';
75
+ import * as command from './command.js';
75
76
 
76
77
  const HANDLERS = new Map([
77
78
  ['PING', (e, a) => ping.handlePing()],
@@ -143,6 +144,7 @@ const HANDLERS = new Map([
143
144
  ['FT.SUGDEL', (e, a) => ftSugdel.handleFtSugdel(e, a)],
144
145
  ['MONITOR', (e, a, ctx) => monitor.handleMonitor(a, ctx)],
145
146
  ['CLIENT', (e, a, ctx) => client.handleClient(e, a, ctx)],
147
+ ['COMMAND', (e, a, ctx) => command.handleCommand(e, a, ctx)],
146
148
  ]);
147
149
 
148
150
  /**
@@ -158,11 +160,14 @@ export function dispatch(engine, argv, context) {
158
160
  }
159
161
  const cmd = (Buffer.isBuffer(argv[0]) ? argv[0].toString('utf8') : String(argv[0])).toUpperCase();
160
162
  const args = argv.slice(1);
163
+ const argvStrings = argv.map((b) => (Buffer.isBuffer(b) ? b.toString('utf8') : String(b)));
164
+ if (context) context.getCommandNames = () => Array.from(HANDLERS.keys());
161
165
  const handler = HANDLERS.get(cmd);
162
166
  if (!handler) {
163
167
  context?.onUnknownCommand?.({
164
168
  command: cmd,
165
169
  argsCount: args.length,
170
+ argv: argvStrings ?? [cmd],
166
171
  clientAddress: context.clientAddress ?? '',
167
172
  connectionId: context.connectionId ?? 0,
168
173
  });
@@ -175,6 +180,7 @@ export function dispatch(engine, argv, context) {
175
180
  context?.onCommandError?.({
176
181
  command: cmd,
177
182
  error: result.error,
183
+ argv: argvStrings ?? [cmd],
178
184
  clientAddress: context.clientAddress ?? '',
179
185
  connectionId: context.connectionId ?? 0,
180
186
  });
@@ -188,6 +194,7 @@ export function dispatch(engine, argv, context) {
188
194
  context?.onCommandError?.({
189
195
  command: cmd,
190
196
  error: errorMsg,
197
+ argv: argvStrings ?? [cmd],
191
198
  clientAddress: context.clientAddress ?? '',
192
199
  connectionId: context.connectionId ?? 0,
193
200
  });
package/src/embed.js CHANGED
@@ -21,8 +21,8 @@ export { handleConnection, createEngine, openDb };
21
21
  * All hooks are optional. Called with plain objects; do not mutate.
22
22
  *
23
23
  * @typedef {object} RESPliteHooks
24
- * @property {(payload: { command: string, argsCount: number, clientAddress: string, connectionId: number }) => void} [onUnknownCommand] Invoked when the client sends a command not implemented by RESPLite.
25
- * @property {(payload: { command: string, error: string, clientAddress: string, connectionId: number }) => void} [onCommandError] Invoked when a command handler throws or returns an error (e.g. WRONGTYPE, invalid args).
24
+ * @property {(payload: { command: string, argsCount: number, argv: string[], clientAddress: string, connectionId: number }) => void} [onUnknownCommand] Invoked when the client sends a command not implemented by RESPLite. `argv` is the full command line as strings (e.g. `['CLIENT','LIST']`) for logging.
25
+ * @property {(payload: { command: string, error: string, argv: string[], clientAddress: string, connectionId: number }) => void} [onCommandError] Invoked when a command handler throws or returns an error (e.g. WRONGTYPE, invalid args). `argv` is the full command line as strings for logging.
26
26
  * @property {(payload: { error: Error, clientAddress: string, connectionId: number }) => void} [onSocketError] Invoked when a connection socket emits an error (e.g. ECONNRESET).
27
27
  */
28
28
 
@@ -87,6 +87,13 @@ describe('CLIENT integration', () => {
87
87
  assert.equal(getResult.length, 0);
88
88
  });
89
89
 
90
+ it('CLIENT SETINFO LIB-VER / LIB-NAME returns OK (no-op)', async () => {
91
+ const reply = await sendCommand(port, argv('CLIENT', 'SETINFO', 'LIB-VER', '1.5.17'));
92
+ assert.equal(tryParseValue(reply, 0).value, 'OK');
93
+ const reply2 = await sendCommand(port, argv('CLIENT', 'SETINFO', 'LIB-NAME', 'myclient'));
94
+ assert.equal(tryParseValue(reply2, 0).value, 'OK');
95
+ });
96
+
90
97
  it('CLIENT wrong subcommand returns error', async () => {
91
98
  const reply = await sendCommand(port, argv('CLIENT', 'LIST'));
92
99
  const v = tryParseValue(reply, 0).value;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Integration tests for COMMAND (introspection).
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
+ describe('COMMAND integration', () => {
12
+ let s;
13
+ let port;
14
+
15
+ before(async () => {
16
+ s = await createTestServer();
17
+ port = s.port;
18
+ });
19
+
20
+ after(async () => {
21
+ await s.closeAsync();
22
+ });
23
+
24
+ it('COMMAND (no args) returns array of command docs', async () => {
25
+ const reply = await sendCommand(port, argv('COMMAND'));
26
+ const v = tryParseValue(reply, 0).value;
27
+ assert.ok(Array.isArray(v), 'expected array');
28
+ assert.ok(v.length > 0, 'expected at least one command');
29
+ const pingDoc = v.find((doc) => {
30
+ const n = Buffer.isBuffer(doc[0]) ? doc[0].toString('utf8') : doc[0];
31
+ return n === 'ping';
32
+ });
33
+ assert.ok(pingDoc, 'expected ping in command list');
34
+ const first = pingDoc;
35
+ assert.ok(Array.isArray(first), 'each doc is array');
36
+ const name = Buffer.isBuffer(first[0]) ? first[0].toString('utf8') : first[0];
37
+ assert.strictEqual(name, 'ping', 'first element is name (lowercase)');
38
+ assert.strictEqual(typeof first[1], 'number', 'arity');
39
+ assert.ok(Array.isArray(first[2]), 'flags');
40
+ const flags = first[2].map((f) => (Buffer.isBuffer(f) ? f.toString('utf8') : f));
41
+ assert.ok(flags.includes('readonly') || flags.includes('write'));
42
+ });
43
+
44
+ it('COMMAND COUNT returns integer', async () => {
45
+ const reply = await sendCommand(port, argv('COMMAND', 'COUNT'));
46
+ const v = tryParseValue(reply, 0).value;
47
+ assert.strictEqual(typeof v, 'number');
48
+ assert.ok(Number.isInteger(v) && v > 0);
49
+ });
50
+
51
+ it('COMMAND INFO name returns doc for that command', async () => {
52
+ const reply = await sendCommand(port, argv('COMMAND', 'INFO', 'GET'));
53
+ const v = tryParseValue(reply, 0).value;
54
+ assert.ok(Array.isArray(v));
55
+ assert.ok(v.length >= 1);
56
+ const getDoc = v[0];
57
+ const name = Buffer.isBuffer(getDoc[0]) ? getDoc[0].toString('utf8') : getDoc[0];
58
+ assert.strictEqual(name, 'get');
59
+ assert.strictEqual(getDoc[1], 2, 'GET arity 2');
60
+ });
61
+
62
+ it('COMMAND INFO unknown returns empty array', async () => {
63
+ const reply = await sendCommand(port, argv('COMMAND', 'INFO', 'NOSUCHCOMMAND'));
64
+ const v = tryParseValue(reply, 0).value;
65
+ assert.ok(Array.isArray(v));
66
+ assert.strictEqual(v.length, 0);
67
+ });
68
+
69
+ it('COMMAND unknown subcommand returns error', async () => {
70
+ const reply = await sendCommand(port, argv('COMMAND', 'NOSUCH'));
71
+ const v = tryParseValue(reply, 0).value;
72
+ assert.ok(v && v.error);
73
+ assert.match(v.error, /unknown subcommand|COMMAND/);
74
+ });
75
+ });