bundis 0.1.0 → 0.2.0

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
@@ -60,17 +60,34 @@ await server.stop(); // kills the child and waits for exit
60
60
 
61
61
  Options for both: `host` (default `127.0.0.1`), `port` (default `6379`, `0` =
62
62
  ephemeral), `dbPath` (default `./data.db`, `":memory:"` for non-persistent),
63
- `password`, `reaperIntervalMs`. `spawnServer` additionally takes `bunPath` and
63
+ `password`, `reaperIntervalMs`, `maxMemoryMb` (default `256` — overall memory
64
+ budget: 50% SQLite page cache, 25% hot cache), `cacheMb` (hot-cache ceiling,
65
+ default `maxMemoryMb/4`, `0` disables), `cacheIdleSec` (hot-cache base
66
+ time-to-idle, default `300`). `spawnServer` additionally takes `bunPath` and
64
67
  `readyTimeoutMs`. The returned `url` already embeds the password when set.
65
68
 
69
+ ### Hot cache
70
+
71
+ Every `SET` is written through to SQLite **and** kept in an in-memory hot cache;
72
+ `GET`s served from memory skip SQLite entirely (~2.8x read throughput on hot
73
+ working sets). Entries fall out after an idle period that grows with hit count
74
+ (adaptive TTI, capped at 8x), under an LRU byte ceiling. The cache is a pure
75
+ read accelerator — SQLite remains the source of truth, so durability and
76
+ restart behavior are unchanged. Stats appear under `# Cache` in `INFO`.
77
+
66
78
  ### Standalone daemon — CLI
67
79
 
68
80
  ```bash
69
81
  bunx bundis --port 6379 --db ./data.db
70
82
  # (in this repo: bun run src/cli.ts)
71
- # flags (or env): --host/REDIS_HOST --port/REDIS_PORT
83
+ # flags (or env): --host/REDIS_HOST (default 127.0.0.1; use 0.0.0.0 to expose)
84
+ # --port/REDIS_PORT
72
85
  # --db/REDIS_DB_PATH (":memory:" for in-memory)
73
86
  # --password/REDIS_PASSWORD
87
+ # --max-memory-mb/REDIS_MAX_MEMORY_MB (default 256)
88
+ # --cache-mb/REDIS_CACHE_MB (default maxMemory/4; 0 = off)
89
+ # --cache-idle/REDIS_CACHE_IDLE_SEC (default 300)
90
+ # --max-clients/REDIS_MAX_CLIENTS (default 10000)
74
91
  ```
75
92
 
76
93
  stdout prints one JSON ready line (`{"event":"bundis:ready",...}`);
@@ -89,12 +106,18 @@ await client.get("k"); // "v"
89
106
  - **String / numeric:** SET (EX/PX/EXAT/PXAT/NX/XX/KEEPTTL/GET), GET, GETSET,
90
107
  GETDEL, APPEND, STRLEN, DEL/UNLINK, EXISTS, INCR/DECR/INCRBY/DECRBY/INCRBYFLOAT
91
108
  - **Multi-key:** MGET, MSET, MSETNX, SETEX, PSETEX, SETNX
92
- - **Expiry:** EXPIRE, PEXPIRE, EXPIREAT, PEXPIREAT, TTL, PTTL, PERSIST
109
+ - **Expiry:** EXPIRE/PEXPIRE/EXPIREAT/PEXPIREAT (with NX/XX/GT/LT), TTL, PTTL, PERSIST
93
110
  - **Hash:** HSET, HMSET, HSETNX, HGET, HMGET, HGETALL, HDEL, HEXISTS, HKEYS,
94
111
  HVALS, HLEN, HINCRBY, HINCRBYFLOAT
95
112
  - **Set:** SADD, SREM, SISMEMBER, SMEMBERS, SCARD, SRANDMEMBER, SPOP
96
113
  - **Pub/Sub:** SUBSCRIBE, UNSUBSCRIBE, PSUBSCRIBE, PUNSUBSCRIBE, PUBLISH, PUBSUB
97
114
  - **Transactions:** MULTI, EXEC, DISCARD, WATCH, UNWATCH
115
+ - **Server/admin:** TYPE, DBSIZE, FLUSHDB, FLUSHALL, CONFIG GET/SET, COMMAND
116
+
117
+ The only supported client is the stock `Bun.RedisClient`, which always speaks
118
+ RESP3 — that is the wire-compatibility contract, and the server is RESP3-only by
119
+ design. Default bind is `127.0.0.1`; set `--host 0.0.0.0` (ideally with
120
+ `--password`) to expose the server beyond loopback.
98
121
 
99
122
  ## Test
100
123
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bundis",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "RESP3-compatible server backed by SQLite — works with the stock Bun.RedisClient",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -36,3 +36,17 @@ function shutdown(): void {
36
36
 
37
37
  process.on("SIGINT", shutdown);
38
38
  process.on("SIGTERM", shutdown);
39
+
40
+ // Last-resort backstop: log, try to close storage cleanly, exit non-zero.
41
+ process.on("uncaughtException", (err) => {
42
+ console.error("bundis: uncaught exception:", err);
43
+ try {
44
+ running.stop();
45
+ } catch {
46
+ // already torn down
47
+ }
48
+ process.exit(1);
49
+ });
50
+ process.on("unhandledRejection", (err) => {
51
+ console.error("bundis: unhandled rejection:", err);
52
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Server/admin commands: TYPE, DBSIZE, FLUSHDB, FLUSHALL, CONFIG, COMMAND.
3
+ *
4
+ * These exist because real tooling sends them before doing anything useful:
5
+ * integration tests flush between cases, redis-cli probes TYPE/COMMAND on
6
+ * connect, and client libraries (BullMQ, connect-redis, health checks) read
7
+ * CONFIG GET at init. CONFIG SET is a lenient stub — it never honors
8
+ * security-sensitive parameters (dir, save, …); values are accepted and
9
+ * dropped so init-time probes succeed.
10
+ */
11
+
12
+ import { R, type Reply } from "../resp/types";
13
+ import { Errors } from "../engine/errors";
14
+ import { commandCount } from "../dispatcher";
15
+ import type { CommandContext } from "../engine/context";
16
+
17
+ export function type(ctx: CommandContext): Reply {
18
+ ctx.requireExactArgc(1);
19
+ return R.simple(ctx.storage.typeOf(ctx.arg(0), ctx.nowMs) ?? "none");
20
+ }
21
+
22
+ export function dbsize(ctx: CommandContext): Reply {
23
+ ctx.requireExactArgc(0);
24
+ return R.int(ctx.storage.dbsize(ctx.nowMs));
25
+ }
26
+
27
+ /** FLUSHDB/FLUSHALL [ASYNC|SYNC] — single DB, so both clear everything. */
28
+ export function flushall(ctx: CommandContext): Reply {
29
+ if (ctx.argc > 1) return R.error("ERR", "syntax error");
30
+ if (ctx.argc === 1) {
31
+ const mode = ctx.upper(0);
32
+ if (mode !== "ASYNC" && mode !== "SYNC") return R.error("ERR", "syntax error");
33
+ }
34
+ ctx.storage.flushAll();
35
+ return R.ok();
36
+ }
37
+
38
+ /** Values reported by CONFIG GET (kept truthful to the actual configuration). */
39
+ function configValues(ctx: CommandContext): Record<string, string> {
40
+ return {
41
+ maxmemory: String(ctx.server.config.maxMemoryBytes),
42
+ "maxmemory-policy": "noeviction",
43
+ appendonly: "no",
44
+ save: "",
45
+ databases: "1",
46
+ "proto-max-bulk-len": String(512 * 1024 * 1024),
47
+ };
48
+ }
49
+
50
+ export function config(ctx: CommandContext): Reply {
51
+ ctx.requireArgc(1);
52
+ const sub = ctx.upper(0);
53
+ switch (sub) {
54
+ case "GET": {
55
+ ctx.requireArgc(2);
56
+ const values = configValues(ctx);
57
+ // Dedupe across overlapping patterns: each parameter appears at most once.
58
+ const matched = new Map<string, string>();
59
+ for (let i = 1; i < ctx.argc; i++) {
60
+ const pattern = ctx.str(i).toLowerCase();
61
+ for (const [name, value] of Object.entries(values)) {
62
+ if (globMatch(pattern, name)) matched.set(name, value);
63
+ }
64
+ }
65
+ return R.map([...matched].map(([name, value]) => [R.bulk(name), R.bulk(value)]));
66
+ }
67
+ case "SET":
68
+ // Lenient stub: accepted, never applied. Still validate arity like Redis
69
+ // (at least one name/value pair → an even number of trailing tokens).
70
+ if (ctx.argc < 3 || ctx.argc % 2 === 0) throw Errors.wrongArgs("config|set");
71
+ return R.ok();
72
+ case "RESETSTAT":
73
+ case "REWRITE":
74
+ return R.ok();
75
+ default:
76
+ return R.error("ERR", `Unknown CONFIG subcommand or wrong number of arguments for '${ctx.str(0)}'`);
77
+ }
78
+ }
79
+
80
+ export function command(ctx: CommandContext): Reply {
81
+ if (ctx.argc === 0) return R.array([]); // full introspection unsupported; empty is accepted by clients
82
+ switch (ctx.upper(0)) {
83
+ case "COUNT":
84
+ return R.int(commandCount());
85
+ case "DOCS":
86
+ return R.map([]);
87
+ case "INFO":
88
+ // One reply element per requested command name (nil = no details), so
89
+ // positional consumers that map request→reply slots stay aligned.
90
+ return R.array(Array.from({ length: Math.max(0, ctx.argc - 1) }, () => R.nullReply()));
91
+ case "GETKEYS":
92
+ case "GETKEYSANDFLAGS":
93
+ return R.error("ERR", "The command has no key arguments");
94
+ default:
95
+ return R.error(
96
+ "ERR",
97
+ `Unknown subcommand or wrong number of arguments for '${ctx.str(0)}'. Try COMMAND HELP.`,
98
+ );
99
+ }
100
+ }
101
+
102
+ /** Minimal glob: `*`, `?` (enough for CONFIG GET patterns). */
103
+ function globMatch(pattern: string, s: string): boolean {
104
+ const re = new RegExp(
105
+ "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$",
106
+ );
107
+ return re.test(s);
108
+ }
@@ -1,32 +1,61 @@
1
1
  /**
2
2
  * Expiry commands (Phase 0): EXPIRE, PEXPIRE, EXPIREAT, PEXPIREAT, TTL, PTTL,
3
3
  * PERSIST. TTL/PTTL return -2 (missing), -1 (no expiry), or remaining time.
4
+ *
5
+ * EXPIRE-family commands accept one optional NX|XX|GT|LT flag (Redis ≥ 7.0):
6
+ * NX = only when no TTL exists, XX = only when one exists, GT/LT = only when
7
+ * the new expiry is later/earlier than the current one (a key without TTL
8
+ * counts as infinitely late, so GT never applies and LT always does).
4
9
  */
5
10
 
6
11
  import { R, type Reply } from "../resp/types";
12
+ import { Errors } from "../engine/errors";
7
13
  import type { CommandContext } from "../engine/context";
8
14
 
9
15
  export function expire(ctx: CommandContext): Reply {
10
16
  ctx.requireArgc(2);
11
- const atMs = ctx.nowMs + Number(ctx.int(1)) * 1000;
12
- return R.int(ctx.storage.expireSet(ctx.arg(0), atMs, ctx.nowMs) ? 1 : 0);
17
+ return expireGeneric(ctx, ctx.nowMs + Number(ctx.int(1)) * 1000);
13
18
  }
14
19
 
15
20
  export function pexpire(ctx: CommandContext): Reply {
16
21
  ctx.requireArgc(2);
17
- const atMs = ctx.nowMs + Number(ctx.int(1));
18
- return R.int(ctx.storage.expireSet(ctx.arg(0), atMs, ctx.nowMs) ? 1 : 0);
22
+ return expireGeneric(ctx, ctx.nowMs + Number(ctx.int(1)));
19
23
  }
20
24
 
21
25
  export function expireat(ctx: CommandContext): Reply {
22
26
  ctx.requireArgc(2);
23
- const atMs = Number(ctx.int(1)) * 1000;
24
- return R.int(ctx.storage.expireSet(ctx.arg(0), atMs, ctx.nowMs) ? 1 : 0);
27
+ return expireGeneric(ctx, Number(ctx.int(1)) * 1000);
25
28
  }
26
29
 
27
30
  export function pexpireat(ctx: CommandContext): Reply {
28
31
  ctx.requireArgc(2);
29
- const atMs = Number(ctx.int(1));
32
+ return expireGeneric(ctx, Number(ctx.int(1)));
33
+ }
34
+
35
+ function expireGeneric(ctx: CommandContext, atMs: number): Reply {
36
+ const flags: string[] = [];
37
+ for (let i = 2; i < ctx.argc; i++) {
38
+ const f = ctx.upper(i);
39
+ if (f !== "NX" && f !== "XX" && f !== "GT" && f !== "LT") throw Errors.syntax();
40
+ flags.push(f);
41
+ }
42
+ if (flags.includes("NX") && flags.length > 1) {
43
+ return R.error("ERR", "NX and XX, GT or LT options at the same time are not compatible");
44
+ }
45
+ if (flags.includes("GT") && flags.includes("LT")) {
46
+ return R.error("ERR", "GT and LT options at the same time are not compatible");
47
+ }
48
+ const flag = (flags[0] ?? null) as "NX" | "XX" | "GT" | "LT" | null;
49
+ if (flag !== null) {
50
+ const cur = ctx.storage.pttl(ctx.arg(0), ctx.nowMs);
51
+ if (cur === -2) return R.int(0); // missing key: no flag can apply
52
+ const hasTtl = cur >= 0;
53
+ const curAtMs = hasTtl ? ctx.nowMs + cur : Infinity; // no TTL = infinitely late
54
+ if (flag === "NX" && hasTtl) return R.int(0);
55
+ if (flag === "XX" && !hasTtl) return R.int(0);
56
+ if (flag === "GT" && atMs <= curAtMs) return R.int(0);
57
+ if (flag === "LT" && atMs >= curAtMs) return R.int(0);
58
+ }
30
59
  return R.int(ctx.storage.expireSet(ctx.arg(0), atMs, ctx.nowMs) ? 1 : 0);
31
60
  }
32
61
 
@@ -59,8 +59,12 @@ export function ping(ctx: CommandContext): Reply {
59
59
  return R.simple("PONG");
60
60
  }
61
61
 
62
- export function select(_ctx: CommandContext): Reply {
63
- // Single logical DB; accept any index for client compatibility.
62
+ export function select(ctx: CommandContext): Reply {
63
+ // Single logical DB (CLAUDE.md scope): index 0 is accepted, anything else is
64
+ // an honest error — silently mapping /1 onto /0's data would clobber it.
65
+ ctx.requireExactArgc(1);
66
+ const idx = Number(ctx.int(0));
67
+ if (idx !== 0) return R.error("ERR", "DB index is out of range");
64
68
  return R.ok();
65
69
  }
66
70
 
@@ -86,10 +90,29 @@ export function info(ctx: CommandContext): Reply {
86
90
  "# Clients",
87
91
  "connected_clients:1",
88
92
  "",
93
+ "# Memory",
94
+ `used_memory:${process.memoryUsage().rss}`,
95
+ `maxmemory:${ctx.server.config.maxMemoryBytes}`,
96
+ "maxmemory_policy:noeviction", // data lives in SQLite; only caches evict
97
+ "",
89
98
  "# Keyspace",
90
99
  `db0:keys=${ctx.storage.dbsize(ctx.nowMs)},expires=0,avg_ttl=0`,
91
100
  "",
92
101
  ];
102
+ const stats = (ctx.storage as { stats?: () => Record<string, number> }).stats?.();
103
+ if (stats) {
104
+ lines.push(
105
+ "# Cache",
106
+ `cache_entries:${stats.entries}`,
107
+ `cache_bytes:${stats.bytes}`,
108
+ `cache_max_bytes:${stats.maxBytes}`,
109
+ `cache_hits:${stats.hits}`,
110
+ `cache_misses:${stats.misses}`,
111
+ `cache_evicted_idle:${stats.evictedIdle}`,
112
+ `cache_evicted_lru:${stats.evictedLru}`,
113
+ "",
114
+ );
115
+ }
93
116
  return R.verbatim("txt", lines.join("\r\n"));
94
117
  }
95
118
 
@@ -44,15 +44,19 @@ export function msetnx(ctx: CommandContext): Reply {
44
44
 
45
45
  export function setex(ctx: CommandContext): Reply {
46
46
  ctx.requireExactArgc(3);
47
- const atMs = ctx.nowMs + Number(ctx.int(1)) * 1000;
48
- ctx.storage.kvSet(ctx.arg(0), ctx.arg(2), ctx.nowMs, { expireAtMs: atMs });
47
+ const seconds = Number(ctx.int(1));
48
+ if (seconds <= 0) throw Errors.invalidExpire("setex"); // Redis semantics
49
+ ctx.storage.kvSet(ctx.arg(0), ctx.arg(2), ctx.nowMs, {
50
+ expireAtMs: ctx.nowMs + seconds * 1000,
51
+ });
49
52
  return R.ok();
50
53
  }
51
54
 
52
55
  export function psetex(ctx: CommandContext): Reply {
53
56
  ctx.requireExactArgc(3);
54
- const atMs = ctx.nowMs + Number(ctx.int(1));
55
- ctx.storage.kvSet(ctx.arg(0), ctx.arg(2), ctx.nowMs, { expireAtMs: atMs });
57
+ const ms = Number(ctx.int(1));
58
+ if (ms <= 0) throw Errors.invalidExpire("psetex"); // Redis semantics
59
+ ctx.storage.kvSet(ctx.arg(0), ctx.arg(2), ctx.nowMs, { expireAtMs: ctx.nowMs + ms });
56
60
  return R.ok();
57
61
  }
58
62
 
@@ -80,8 +80,12 @@ export function pubsub(ctx: CommandContext): Reply {
80
80
  ctx.requireArgc(1);
81
81
  const sub = ctx.upper(0);
82
82
  switch (sub) {
83
- case "CHANNELS":
84
- return R.array(ctx.server.hub.channelNames().map((c) => R.bulk(c)));
83
+ case "CHANNELS": {
84
+ const names = ctx.server.hub.channelNames();
85
+ const filtered =
86
+ ctx.argc >= 2 ? names.filter((c) => globMatch(ctx.str(1), c)) : names;
87
+ return R.array(filtered.map((c) => R.bulk(c)));
88
+ }
85
89
  case "NUMSUB": {
86
90
  const out: Reply[] = [];
87
91
  for (let i = 1; i < ctx.argc; i++) {
@@ -90,11 +94,30 @@ export function pubsub(ctx: CommandContext): Reply {
90
94
  }
91
95
  return R.array(out);
92
96
  }
97
+ case "NUMPAT":
98
+ if (ctx.argc !== 1) {
99
+ return R.error(
100
+ "ERR",
101
+ `Unknown PUBSUB subcommand or wrong number of arguments for '${ctx.str(0)}'`,
102
+ );
103
+ }
104
+ return R.int(ctx.server.hub.numPat());
93
105
  default:
94
- return R.array([]);
106
+ return R.error(
107
+ "ERR",
108
+ `Unknown PUBSUB subcommand or wrong number of arguments for '${ctx.str(0)}'`,
109
+ );
95
110
  }
96
111
  }
97
112
 
113
+ /** Minimal glob matcher (`*`, `?`) for PUBSUB CHANNELS [pattern]. */
114
+ function globMatch(pattern: string, s: string): boolean {
115
+ const re = new RegExp(
116
+ "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".") + "$",
117
+ );
118
+ return re.test(s);
119
+ }
120
+
98
121
  function rangeArgs(ctx: CommandContext, from: number): string[] {
99
122
  const out: string[] = [];
100
123
  for (let i = from; i < ctx.argc; i++) out.push(ctx.str(i));
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { R, type Reply } from "../resp/types";
10
+ import { Errors } from "../engine/errors";
10
11
  import type { CommandContext } from "../engine/context";
11
12
 
12
13
  export function sadd(ctx: CommandContext): Reply {
@@ -36,6 +37,7 @@ export function scard(ctx: CommandContext): Reply {
36
37
 
37
38
  export function srandmember(ctx: CommandContext): Reply {
38
39
  ctx.requireArgc(1);
40
+ if (ctx.argc > 2) throw Errors.syntax();
39
41
  const hasCount = ctx.argOpt(1) !== undefined;
40
42
  const count = hasCount ? Number(ctx.int(1)) : null;
41
43
  const res = ctx.storage.sRandMember(ctx.arg(0), count, ctx.nowMs);
@@ -48,11 +50,15 @@ export function srandmember(ctx: CommandContext): Reply {
48
50
 
49
51
  export function spop(ctx: CommandContext): Reply {
50
52
  ctx.requireArgc(1);
53
+ if (ctx.argc > 2) throw Errors.syntax();
51
54
  const hasCount = ctx.argOpt(1) !== undefined;
52
55
  const count = hasCount ? Number(ctx.int(1)) : null;
56
+ if (count !== null && count < 0) {
57
+ return R.error("ERR", "value is out of range, must be positive");
58
+ }
53
59
  const res = ctx.storage.sPop(ctx.arg(0), count, ctx.nowMs);
54
60
  if (count === null) {
55
61
  return res === null ? R.nullReply() : R.bulk(res as Uint8Array);
56
62
  }
57
- return R.set((res as Uint8Array[]).map((m) => R.bulk(m)));
63
+ return R.array((res as Uint8Array[]).map((m) => R.bulk(m)));
58
64
  }
@@ -24,14 +24,20 @@ export function set(ctx: CommandContext): Reply {
24
24
  while (ctx.argOpt(i) !== undefined) {
25
25
  const o = ctx.upper(i);
26
26
  switch (o) {
27
- case "EX":
28
- opts.expireAtMs = ctx.nowMs + Number(ctx.int(i + 1)) * 1000;
27
+ case "EX": {
28
+ const sec = Number(ctx.int(i + 1));
29
+ if (sec <= 0) throw Errors.invalidExpire("set"); // Redis semantics
30
+ opts.expireAtMs = ctx.nowMs + sec * 1000;
29
31
  i += 2;
30
32
  break;
31
- case "PX":
32
- opts.expireAtMs = ctx.nowMs + Number(ctx.int(i + 1));
33
+ }
34
+ case "PX": {
35
+ const ms = Number(ctx.int(i + 1));
36
+ if (ms <= 0) throw Errors.invalidExpire("set");
37
+ opts.expireAtMs = ctx.nowMs + ms;
33
38
  i += 2;
34
39
  break;
40
+ }
35
41
  case "EXAT":
36
42
  opts.expireAtMs = Number(ctx.int(i + 1)) * 1000;
37
43
  i += 2;
@@ -61,13 +67,20 @@ export function set(ctx: CommandContext): Reply {
61
67
  }
62
68
  }
63
69
 
64
- let old: Uint8Array | null = null;
65
- const result = ctx.storage.withTransaction(() => {
66
- if (wantGet) old = ctx.storage.kvGet(key, ctx.nowMs);
67
- return ctx.storage.kvSet(key, value, ctx.nowMs, opts);
68
- });
69
-
70
- if (wantGet) return R.bulk(old);
70
+ if (wantGet) {
71
+ // GET option returns the old value whether or not the write happened; it
72
+ // needs read-then-write atomicity, so the cache fill is suppressed inside
73
+ // this transaction (correct a rollback must not cache a phantom).
74
+ let old: Uint8Array | null = null;
75
+ ctx.storage.withTransaction(() => {
76
+ old = ctx.storage.kvGet(key, ctx.nowMs);
77
+ ctx.storage.kvSet(key, value, ctx.nowMs, opts);
78
+ });
79
+ return R.bulk(old);
80
+ }
81
+ // Plain SET: kvSet is already internally transactional, so we DON'T wrap it —
82
+ // wrapping would set #inTxn and suppress the hot-cache write-through fill.
83
+ const result = ctx.storage.kvSet(key, value, ctx.nowMs, opts);
71
84
  return result === "set" ? R.ok() : R.nullReply();
72
85
  }
73
86
 
@@ -12,7 +12,7 @@ import { R, type Reply } from "../resp/types";
12
12
  import { Errors } from "../engine/errors";
13
13
  import { executeCore } from "../dispatcher";
14
14
  import type { CommandContext } from "../engine/context";
15
- import { hashKey, type WatchSnapshot } from "../sidecar/watch";
15
+ import { hashKey, releaseSnapshot, type WatchSnapshot } from "../sidecar/watch";
16
16
 
17
17
  export function multi(ctx: CommandContext): Reply {
18
18
  if (ctx.conn.txn) return R.error("ERR", "MULTI calls can not be nested");
@@ -23,7 +23,7 @@ export function multi(ctx: CommandContext): Reply {
23
23
  export function discard(ctx: CommandContext): Reply {
24
24
  if (!ctx.conn.txn) throw Errors.discardWithoutMulti();
25
25
  ctx.conn.txn = null;
26
- ctx.conn.watch = null;
26
+ clearWatch(ctx);
27
27
  return R.ok();
28
28
  }
29
29
 
@@ -34,14 +34,14 @@ export function exec(ctx: CommandContext): Reply {
34
34
  conn.txn = null;
35
35
 
36
36
  if (txn.error) {
37
- conn.watch = null;
37
+ clearWatch(ctx);
38
38
  throw Errors.execAbort();
39
39
  }
40
40
  if (conn.watch && isWatchDirty(conn.watch, ctx)) {
41
- conn.watch = null;
41
+ clearWatch(ctx);
42
42
  return R.nullReply(); // a watched key changed → abort
43
43
  }
44
- conn.watch = null;
44
+ clearWatch(ctx);
45
45
 
46
46
  const results = ctx.storage.withTransaction(() =>
47
47
  txn.queued.map((cmd) => {
@@ -58,28 +58,38 @@ export function watch(ctx: CommandContext): Reply {
58
58
  const snap: WatchSnapshot = ctx.conn.watch ?? new Map();
59
59
  for (let i = 0; i < ctx.argc; i++) {
60
60
  const key = ctx.arg(i);
61
- snap.set(hashKey(key), { key, version: ctx.server.watch.version(key) });
61
+ const k = hashKey(key);
62
+ if (snap.has(k)) continue; // already watched: keep the earlier snapshot
63
+ snap.set(k, { key, version: ctx.server.watch.acquire(key) });
62
64
  }
63
65
  ctx.conn.watch = snap;
64
66
  return R.ok();
65
67
  }
66
68
 
67
69
  export function unwatch(ctx: CommandContext): Reply {
68
- ctx.conn.watch = null;
70
+ clearWatch(ctx);
69
71
  return R.ok();
70
72
  }
71
73
 
72
74
  export function reset(ctx: CommandContext): Reply {
73
75
  ctx.conn.txn = null;
74
- ctx.conn.watch = null;
76
+ clearWatch(ctx);
75
77
  ctx.server.hub.drop(ctx.conn);
76
78
  ctx.conn.state = "READY";
77
79
  return R.simple("RESET");
78
80
  }
79
81
 
82
+ /** Drop the connection's WATCH snapshot, releasing registry interest. */
83
+ function clearWatch(ctx: CommandContext): void {
84
+ if (ctx.conn.watch) {
85
+ releaseSnapshot(ctx.server.watch, ctx.conn.watch);
86
+ ctx.conn.watch = null;
87
+ }
88
+ }
89
+
80
90
  function isWatchDirty(snap: WatchSnapshot, ctx: CommandContext): boolean {
81
91
  for (const { key, version } of snap.values()) {
82
- if (ctx.server.watch.version(key) !== version) return true;
92
+ if (ctx.server.watch.peek(key) !== version) return true;
83
93
  }
84
94
  return false;
85
95
  }
package/src/config.ts CHANGED
@@ -2,10 +2,14 @@
2
2
  * Server configuration, resolved from CLI flags and environment.
3
3
  *
4
4
  * Precedence: CLI flag > env var > default. Kept tiny and dependency-free.
5
- * --host / REDIS_HOST (default 0.0.0.0)
5
+ * --host / REDIS_HOST (default 127.0.0.1; use 0.0.0.0 to expose)
6
6
  * --port / REDIS_PORT (default 6379; 0 = ephemeral, used by tests)
7
7
  * --db / REDIS_DB_PATH (default ./data.db; ":memory:" for in-memory)
8
8
  * --password / REDIS_PASSWORD (default none → AUTH always succeeds)
9
+ * --max-memory-mb / REDIS_MAX_MEMORY_MB (default 256 — overall budget:
10
+ * 50% SQLite page cache, 25% hot cache unless --cache-mb given)
11
+ * --cache-mb / REDIS_CACHE_MB (default: maxMemory/4; 0 disables hot cache)
12
+ * --cache-idle / REDIS_CACHE_IDLE_SEC (default 300 — hot-cache base TTI)
9
13
  */
10
14
 
11
15
  export interface ServerConfig {
@@ -15,17 +19,40 @@ export interface ServerConfig {
15
19
  password: string | null;
16
20
  /** Active-expiry sweep interval in ms. */
17
21
  reaperIntervalMs: number;
22
+ /** Max simultaneous client connections. */
23
+ maxClients: number;
24
+ /**
25
+ * Overall memory budget for the server in bytes. A budget, not a hard OS
26
+ * limit: it sizes the SQLite page cache (50%) and, when --cache-mb is not
27
+ * given, the hot cache (25%); the rest is headroom for connections/JS heap.
28
+ */
29
+ maxMemoryBytes: number;
30
+ /** Hot-cache byte ceiling; 0 disables the in-memory cache. */
31
+ cacheMaxBytes: number;
32
+ /** Hot-cache base time-to-idle in ms. */
33
+ cacheIdleMs: number;
18
34
  }
19
35
 
20
36
  export function loadConfig(argv: string[] = Bun.argv.slice(2)): ServerConfig {
21
37
  const flags = parseFlags(argv);
22
38
  const env = Bun.env;
39
+ const MB = 1024 * 1024;
40
+ const maxMemoryMb = int(flags["max-memory-mb"] ?? env.REDIS_MAX_MEMORY_MB, 256);
41
+ // Unless set explicitly, the hot cache takes 25% of the overall budget.
42
+ const cacheMbRaw = flags["cache-mb"] ?? env.REDIS_CACHE_MB;
43
+ const cacheMb = cacheMbRaw !== undefined ? int(cacheMbRaw, 64) : Math.floor(maxMemoryMb / 4);
23
44
  return {
24
- host: flags.host ?? env.REDIS_HOST ?? "0.0.0.0",
45
+ // Loopback by default: exposing a (possibly password-less) server to the
46
+ // LAN must be an explicit decision (--host 0.0.0.0).
47
+ host: flags.host ?? env.REDIS_HOST ?? "127.0.0.1",
25
48
  port: int(flags.port ?? env.REDIS_PORT, 6379),
26
49
  dbPath: flags.db ?? env.REDIS_DB_PATH ?? "./data.db",
27
50
  password: flags.password ?? env.REDIS_PASSWORD ?? null,
28
51
  reaperIntervalMs: int(flags.reaper ?? env.REDIS_REAPER_MS, 100),
52
+ maxClients: int(flags["max-clients"] ?? env.REDIS_MAX_CLIENTS, 10_000),
53
+ maxMemoryBytes: maxMemoryMb * MB,
54
+ cacheMaxBytes: cacheMb * MB,
55
+ cacheIdleMs: int(flags["cache-idle"] ?? env.REDIS_CACHE_IDLE_SEC, 300) * 1000,
29
56
  };
30
57
  }
31
58