resplite 1.2.16 → 1.2.20

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.20",
4
4
  "description": "A RESP2 server with practical Redis compatibility, backed by SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -16,6 +16,7 @@ Supported:
16
16
 
17
17
  - `GET`
18
18
  - `SET`
19
+ - `SETEX`
19
20
  - `MGET`
20
21
  - `MSET`
21
22
  - `DEL`
@@ -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,83 @@
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', 'SETEX', '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
+ } else if (name === 'SETEX') {
54
+ arity = 4;
55
+ }
56
+ return [lower, arity, flags, firstKey, lastKey, step, []];
57
+ }
58
+
59
+ /**
60
+ * @param {object} engine
61
+ * @param {Buffer[]} args - subcommand and optional names for INFO
62
+ * @param {{ getCommandNames?: () => string[] }} context
63
+ */
64
+ export function handleCommand(engine, args, context) {
65
+ const allNames = context?.getCommandNames ? context.getCommandNames() : [];
66
+ const sub = (args && args.length > 0 && Buffer.isBuffer(args[0])) ? args[0].toString('utf8').toUpperCase() : '';
67
+
68
+ if (!sub || sub === '') {
69
+ const reply = allNames.map((n) => docFor(n));
70
+ return reply;
71
+ }
72
+ if (sub === 'COUNT') {
73
+ return allNames.length;
74
+ }
75
+ if (sub === 'INFO') {
76
+ const names = (args.slice(1) || []).map((b) => (Buffer.isBuffer(b) ? b.toString('utf8') : String(b)).toUpperCase());
77
+ const set = new Set(allNames);
78
+ const reply = names.map((n) => set.has(n) ? docFor(n) : null).filter((x) => x != null);
79
+ return reply;
80
+ }
81
+
82
+ return { error: 'ERR unknown subcommand or wrong number of arguments for \'COMMAND\'. Try COMMAND HELP.' };
83
+ }
@@ -8,6 +8,7 @@ import * as echo from './echo.js';
8
8
  import * as quit from './quit.js';
9
9
  import * as get from './get.js';
10
10
  import * as set from './set.js';
11
+ import * as setex from './setex.js';
11
12
  import * as del from './del.js';
12
13
  import * as unlink from './unlink.js';
13
14
  import * as exists from './exists.js';
@@ -72,6 +73,7 @@ import * as ftSugget from './ft-sugget.js';
72
73
  import * as ftSugdel from './ft-sugdel.js';
73
74
  import * as monitor from './monitor.js';
74
75
  import * as client from './client.js';
76
+ import * as command from './command.js';
75
77
 
76
78
  const HANDLERS = new Map([
77
79
  ['PING', (e, a) => ping.handlePing()],
@@ -79,6 +81,7 @@ const HANDLERS = new Map([
79
81
  ['QUIT', (e, a) => quit.handleQuit()],
80
82
  ['GET', (e, a) => get.handleGet(e, a)],
81
83
  ['SET', (e, a) => set.handleSet(e, a)],
84
+ ['SETEX', (e, a) => setex.handleSetex(e, a)],
82
85
  ['DEL', (e, a) => del.handleDel(e, a)],
83
86
  ['UNLINK', (e, a) => unlink.handleUnlink(e, a)],
84
87
  ['EXISTS', (e, a) => exists.handleExists(e, a)],
@@ -143,6 +146,7 @@ const HANDLERS = new Map([
143
146
  ['FT.SUGDEL', (e, a) => ftSugdel.handleFtSugdel(e, a)],
144
147
  ['MONITOR', (e, a, ctx) => monitor.handleMonitor(a, ctx)],
145
148
  ['CLIENT', (e, a, ctx) => client.handleClient(e, a, ctx)],
149
+ ['COMMAND', (e, a, ctx) => command.handleCommand(e, a, ctx)],
146
150
  ]);
147
151
 
148
152
  /**
@@ -159,6 +163,7 @@ export function dispatch(engine, argv, context) {
159
163
  const cmd = (Buffer.isBuffer(argv[0]) ? argv[0].toString('utf8') : String(argv[0])).toUpperCase();
160
164
  const args = argv.slice(1);
161
165
  const argvStrings = argv.map((b) => (Buffer.isBuffer(b) ? b.toString('utf8') : String(b)));
166
+ if (context) context.getCommandNames = () => Array.from(HANDLERS.keys());
162
167
  const handler = HANDLERS.get(cmd);
163
168
  if (!handler) {
164
169
  context?.onUnknownCommand?.({
@@ -0,0 +1,19 @@
1
+ /**
2
+ * SETEX key seconds value - set key to value with expiration in seconds (atomic).
3
+ * Delegates to SET key value EX seconds to avoid duplicating logic.
4
+ */
5
+
6
+ import * as set from './set.js';
7
+
8
+ export function handleSetex(engine, args) {
9
+ if (!args || args.length !== 3) {
10
+ return { error: 'ERR wrong number of arguments for \'SETEX\' command' };
11
+ }
12
+ const key = args[0];
13
+ const sec = parseInt(args[1].toString(), 10);
14
+ if (Number.isNaN(sec) || sec < 1) {
15
+ return { error: 'ERR invalid expire time in \'SETEX\'' };
16
+ }
17
+ const value = args[2];
18
+ return set.handleSet(engine, [key, value, Buffer.from('EX'), Buffer.from(String(sec))]);
19
+ }
@@ -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
+ });
@@ -49,4 +49,24 @@ describe('Strings integration', () => {
49
49
  const r = await sendCommand(port, argv('EXISTS', 'ex1', 'ex2'));
50
50
  assert.equal(r.toString('ascii'), ':1\r\n');
51
51
  });
52
+
53
+ it('SETEX sets key with TTL and returns OK', async () => {
54
+ const setexReply = await sendCommand(port, argv('SETEX', 'setexkey', '60', 'setexval'));
55
+ assert.equal(setexReply.toString('utf8'), '+OK\r\n');
56
+ const getReply = await sendCommand(port, argv('GET', 'setexkey'));
57
+ assert.equal(getReply.toString('utf8'), '$8\r\nsetexval\r\n');
58
+ const ttlReply = await sendCommand(port, argv('TTL', 'setexkey'));
59
+ const t = parseInt(ttlReply.toString('ascii').replace(/\D/g, ''), 10);
60
+ assert.ok(t >= 59 && t <= 60);
61
+ });
62
+
63
+ it('SETEX wrong number of arguments returns error', async () => {
64
+ const reply = await sendCommand(port, argv('SETEX', 'k', '10'));
65
+ assert.ok(reply.toString('utf8').includes('wrong number of arguments'));
66
+ });
67
+
68
+ it('SETEX invalid seconds returns error', async () => {
69
+ const reply = await sendCommand(port, argv('SETEX', 'k', '0', 'v'));
70
+ assert.ok(reply.toString('utf8').includes('invalid expire time'));
71
+ });
52
72
  });