resplite 1.2.16 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resplite",
3
- "version": "1.2.16",
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
  /**
@@ -159,6 +161,7 @@ export function dispatch(engine, argv, context) {
159
161
  const cmd = (Buffer.isBuffer(argv[0]) ? argv[0].toString('utf8') : String(argv[0])).toUpperCase();
160
162
  const args = argv.slice(1);
161
163
  const argvStrings = argv.map((b) => (Buffer.isBuffer(b) ? b.toString('utf8') : String(b)));
164
+ if (context) context.getCommandNames = () => Array.from(HANDLERS.keys());
162
165
  const handler = HANDLERS.get(cmd);
163
166
  if (!handler) {
164
167
  context?.onUnknownCommand?.({
@@ -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
+ });