bundis 0.1.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.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Transaction commands (Phase 2): MULTI, EXEC, DISCARD, WATCH, UNWATCH, RESET.
3
+ *
4
+ * MULTI starts queueing on the connection (handled in the dispatcher, which
5
+ * replies +QUEUED). EXEC runs the queued commands inside one SQLite transaction
6
+ * and returns the result array; if any WATCHed key changed since WATCH, EXEC
7
+ * returns nil (optimistic lock, §6.3). Under the single-writer assumption a
8
+ * simple version comparison via {@link WatchRegistry} is sufficient.
9
+ */
10
+
11
+ import { R, type Reply } from "../resp/types";
12
+ import { Errors } from "../engine/errors";
13
+ import { executeCore } from "../dispatcher";
14
+ import type { CommandContext } from "../engine/context";
15
+ import { hashKey, type WatchSnapshot } from "../sidecar/watch";
16
+
17
+ export function multi(ctx: CommandContext): Reply {
18
+ if (ctx.conn.txn) return R.error("ERR", "MULTI calls can not be nested");
19
+ ctx.conn.txn = { queued: [], error: false };
20
+ return R.ok();
21
+ }
22
+
23
+ export function discard(ctx: CommandContext): Reply {
24
+ if (!ctx.conn.txn) throw Errors.discardWithoutMulti();
25
+ ctx.conn.txn = null;
26
+ ctx.conn.watch = null;
27
+ return R.ok();
28
+ }
29
+
30
+ export function exec(ctx: CommandContext): Reply {
31
+ const conn = ctx.conn;
32
+ const txn = conn.txn;
33
+ if (!txn) throw Errors.execWithoutMulti();
34
+ conn.txn = null;
35
+
36
+ if (txn.error) {
37
+ conn.watch = null;
38
+ throw Errors.execAbort();
39
+ }
40
+ if (conn.watch && isWatchDirty(conn.watch, ctx)) {
41
+ conn.watch = null;
42
+ return R.nullReply(); // a watched key changed → abort
43
+ }
44
+ conn.watch = null;
45
+
46
+ const results = ctx.storage.withTransaction(() =>
47
+ txn.queued.map((cmd) => {
48
+ const reply = executeCore(conn, cmd, ctx.server, ctx.nowMs);
49
+ return reply ?? R.nullReply();
50
+ }),
51
+ );
52
+ return R.array(results);
53
+ }
54
+
55
+ export function watch(ctx: CommandContext): Reply {
56
+ ctx.requireArgc(1);
57
+ if (ctx.conn.txn) return R.error("ERR", "WATCH inside MULTI is not allowed");
58
+ const snap: WatchSnapshot = ctx.conn.watch ?? new Map();
59
+ for (let i = 0; i < ctx.argc; i++) {
60
+ const key = ctx.arg(i);
61
+ snap.set(hashKey(key), { key, version: ctx.server.watch.version(key) });
62
+ }
63
+ ctx.conn.watch = snap;
64
+ return R.ok();
65
+ }
66
+
67
+ export function unwatch(ctx: CommandContext): Reply {
68
+ ctx.conn.watch = null;
69
+ return R.ok();
70
+ }
71
+
72
+ export function reset(ctx: CommandContext): Reply {
73
+ ctx.conn.txn = null;
74
+ ctx.conn.watch = null;
75
+ ctx.server.hub.drop(ctx.conn);
76
+ ctx.conn.state = "READY";
77
+ return R.simple("RESET");
78
+ }
79
+
80
+ function isWatchDirty(snap: WatchSnapshot, ctx: CommandContext): boolean {
81
+ for (const { key, version } of snap.values()) {
82
+ if (ctx.server.watch.version(key) !== version) return true;
83
+ }
84
+ return false;
85
+ }
package/src/config.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Server configuration, resolved from CLI flags and environment.
3
+ *
4
+ * Precedence: CLI flag > env var > default. Kept tiny and dependency-free.
5
+ * --host / REDIS_HOST (default 0.0.0.0)
6
+ * --port / REDIS_PORT (default 6379; 0 = ephemeral, used by tests)
7
+ * --db / REDIS_DB_PATH (default ./data.db; ":memory:" for in-memory)
8
+ * --password / REDIS_PASSWORD (default none → AUTH always succeeds)
9
+ */
10
+
11
+ export interface ServerConfig {
12
+ host: string;
13
+ port: number;
14
+ dbPath: string;
15
+ password: string | null;
16
+ /** Active-expiry sweep interval in ms. */
17
+ reaperIntervalMs: number;
18
+ }
19
+
20
+ export function loadConfig(argv: string[] = Bun.argv.slice(2)): ServerConfig {
21
+ const flags = parseFlags(argv);
22
+ const env = Bun.env;
23
+ return {
24
+ host: flags.host ?? env.REDIS_HOST ?? "0.0.0.0",
25
+ port: int(flags.port ?? env.REDIS_PORT, 6379),
26
+ dbPath: flags.db ?? env.REDIS_DB_PATH ?? "./data.db",
27
+ password: flags.password ?? env.REDIS_PASSWORD ?? null,
28
+ reaperIntervalMs: int(flags.reaper ?? env.REDIS_REAPER_MS, 100),
29
+ };
30
+ }
31
+
32
+ function parseFlags(argv: string[]): Record<string, string> {
33
+ const out: Record<string, string> = {};
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const a = argv[i]!;
36
+ if (!a.startsWith("--")) continue;
37
+ const eq = a.indexOf("=");
38
+ if (eq !== -1) {
39
+ out[a.slice(2, eq)] = a.slice(eq + 1);
40
+ } else {
41
+ const next = argv[i + 1];
42
+ if (next !== undefined && !next.startsWith("--")) {
43
+ out[a.slice(2)] = next;
44
+ i++;
45
+ } else {
46
+ out[a.slice(2)] = "true";
47
+ }
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+
53
+ function int(v: string | undefined, fallback: number): number {
54
+ if (v === undefined) return fallback;
55
+ const n = parseInt(v, 10);
56
+ return Number.isNaN(n) ? fallback : n;
57
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * L3 Connection — per-socket state machine and write path.
3
+ *
4
+ * Owns everything isolated to one client connection (§4.1.3): handshake/auth
5
+ * state, selected DB, the streaming parser buffer, pub/sub subscriptions, the
6
+ * MULTI transaction queue, and the WATCH snapshot. Also implements the
7
+ * backpressure-aware {@link send} used by both the dispatcher and the PubSubHub.
8
+ */
9
+
10
+ import type { Socket } from "bun";
11
+ import { serialize } from "./resp/serializer";
12
+ import { RespParser } from "./resp/parser";
13
+ import type { Command, Reply } from "./resp/types";
14
+ import type { WatchSnapshot } from "./sidecar/watch";
15
+
16
+ export type ConnState = "HANDSHAKE" | "READY" | "SUBSCRIBED";
17
+
18
+ /** A buffered MULTI transaction. */
19
+ export interface TxnState {
20
+ queued: Command[];
21
+ /** Set if a command failed to queue (bad arity / unknown) → EXEC aborts. */
22
+ error: boolean;
23
+ }
24
+
25
+ let nextId = 1;
26
+
27
+ export class Connection {
28
+ readonly id = nextId++;
29
+ state: ConnState = "HANDSHAKE";
30
+ db = 0;
31
+ authed = false;
32
+ proto = 2; // upgraded to 3 on HELLO 3
33
+ name = "";
34
+
35
+ readonly parser = new RespParser();
36
+
37
+ // pub/sub
38
+ readonly channels = new Set<string>();
39
+ readonly patterns = new Set<string>();
40
+
41
+ // transactions
42
+ txn: TxnState | null = null;
43
+ watch: WatchSnapshot | null = null;
44
+
45
+ /** Bytes awaiting a `drain` event when the socket buffer was full. */
46
+ #outbox: Uint8Array[] = [];
47
+ #closed = false;
48
+
49
+ constructor(readonly socket: Socket<Connection>) {}
50
+
51
+ /** Total active (P)SUBSCRIBE count, used in reply frames. */
52
+ subscriptionCount(): number {
53
+ return this.channels.size + this.patterns.size;
54
+ }
55
+
56
+ /** True once the client has at least one active subscription. */
57
+ inSubscribeMode(): boolean {
58
+ return this.subscriptionCount() > 0;
59
+ }
60
+
61
+ /** Serialize and write a reply, honoring backpressure. */
62
+ send(reply: Reply): void {
63
+ this.write(serialize(reply));
64
+ }
65
+
66
+ /** Write raw bytes, buffering any unflushed remainder for `drain`. */
67
+ write(bytes: Uint8Array): void {
68
+ if (this.#closed) return;
69
+ if (this.#outbox.length > 0) {
70
+ // Preserve ordering: once we're behind, everything queues.
71
+ this.#outbox.push(bytes);
72
+ return;
73
+ }
74
+ const written = this.socket.write(bytes);
75
+ if (written < bytes.length) {
76
+ this.#outbox.push(bytes.subarray(written));
77
+ }
78
+ }
79
+
80
+ /** Called from the socket `drain` handler: flush what we can, in order. */
81
+ flush(): void {
82
+ while (this.#outbox.length > 0 && !this.#closed) {
83
+ const chunk = this.#outbox[0]!;
84
+ const written = this.socket.write(chunk);
85
+ if (written < chunk.length) {
86
+ this.#outbox[0] = chunk.subarray(written);
87
+ return; // still backpressured
88
+ }
89
+ this.#outbox.shift();
90
+ }
91
+ }
92
+
93
+ markClosed(): void {
94
+ this.#closed = true;
95
+ this.#outbox.length = 0;
96
+ }
97
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * L4 Dispatcher — command routing table + execution policy.
3
+ *
4
+ * Compatibility extends by adding cases here (§3.3). `dispatch` applies the
5
+ * connection-level policy (auth gate, SUBSCRIBED-mode restriction, MULTI
6
+ * queueing) then delegates to `executeCore`, which looks up the handler, builds
7
+ * the context, and turns thrown errors into RESP error replies. EXEC reuses
8
+ * `executeCore` directly to run queued commands.
9
+ */
10
+
11
+ import { R, type Reply } from "./resp/types";
12
+ import type { Command } from "./resp/types";
13
+ import { CommandContext, type ServerContext } from "./engine/context";
14
+ import { Errors, toRespError } from "./engine/errors";
15
+ import type { Connection } from "./connection";
16
+
17
+ import * as handshake from "./commands/handshake";
18
+ import * as string from "./commands/string";
19
+ import * as expire from "./commands/expire";
20
+ import * as multikey from "./commands/multikey";
21
+ import * as hash from "./commands/hash";
22
+ import * as set from "./commands/set";
23
+ import * as pubsub from "./commands/pubsub";
24
+ import * as txn from "./commands/transaction";
25
+
26
+ /** A command handler. Returns the reply, or null if it wrote its own output. */
27
+ export type Handler = (ctx: CommandContext) => Reply | null;
28
+
29
+ const TABLE: Record<string, Handler> = {
30
+ // handshake / connection
31
+ HELLO: handshake.hello,
32
+ AUTH: handshake.auth,
33
+ PING: handshake.ping,
34
+ SELECT: handshake.select,
35
+ ECHO: handshake.echo,
36
+ QUIT: handshake.quit,
37
+ INFO: handshake.info,
38
+ CLIENT: handshake.client,
39
+
40
+ // string / kv
41
+ SET: string.set,
42
+ GET: string.get,
43
+ GETSET: string.getset,
44
+ GETDEL: string.getdel,
45
+ APPEND: string.append,
46
+ STRLEN: string.strlen,
47
+ DEL: string.del,
48
+ UNLINK: string.del,
49
+ EXISTS: string.exists,
50
+ INCR: string.incr,
51
+ DECR: string.decr,
52
+ INCRBY: string.incrby,
53
+ DECRBY: string.decrby,
54
+ INCRBYFLOAT: string.incrbyfloat,
55
+
56
+ // multi-key
57
+ MGET: multikey.mget,
58
+ MSET: multikey.mset,
59
+ MSETNX: multikey.msetnx,
60
+ SETEX: multikey.setex,
61
+ PSETEX: multikey.psetex,
62
+ SETNX: multikey.setnx,
63
+
64
+ // expiry
65
+ EXPIRE: expire.expire,
66
+ PEXPIRE: expire.pexpire,
67
+ EXPIREAT: expire.expireat,
68
+ PEXPIREAT: expire.pexpireat,
69
+ TTL: expire.ttl,
70
+ PTTL: expire.pttl,
71
+ PERSIST: expire.persist,
72
+
73
+ // hash
74
+ HSET: hash.hset,
75
+ HMSET: hash.hmset,
76
+ HSETNX: hash.hsetnx,
77
+ HGET: hash.hget,
78
+ HMGET: hash.hmget,
79
+ HGETALL: hash.hgetall,
80
+ HDEL: hash.hdel,
81
+ HEXISTS: hash.hexists,
82
+ HKEYS: hash.hkeys,
83
+ HVALS: hash.hvals,
84
+ HLEN: hash.hlen,
85
+ HINCRBY: hash.hincrby,
86
+ HINCRBYFLOAT: hash.hincrbyfloat,
87
+
88
+ // set
89
+ SADD: set.sadd,
90
+ SREM: set.srem,
91
+ SISMEMBER: set.sismember,
92
+ SMEMBERS: set.smembers,
93
+ SCARD: set.scard,
94
+ SRANDMEMBER: set.srandmember,
95
+ SPOP: set.spop,
96
+
97
+ // pub/sub
98
+ SUBSCRIBE: pubsub.subscribe,
99
+ UNSUBSCRIBE: pubsub.unsubscribe,
100
+ PSUBSCRIBE: pubsub.psubscribe,
101
+ PUNSUBSCRIBE: pubsub.punsubscribe,
102
+ PUBLISH: pubsub.publish,
103
+ PUBSUB: pubsub.pubsub,
104
+
105
+ // transactions
106
+ MULTI: txn.multi,
107
+ EXEC: txn.exec,
108
+ DISCARD: txn.discard,
109
+ WATCH: txn.watch,
110
+ UNWATCH: txn.unwatch,
111
+ RESET: txn.reset,
112
+ };
113
+
114
+ /** Commands permitted while in SUBSCRIBED mode (§6.1). */
115
+ const SUBSCRIBE_ALLOWED = new Set([
116
+ "SUBSCRIBE",
117
+ "UNSUBSCRIBE",
118
+ "PSUBSCRIBE",
119
+ "PUNSUBSCRIBE",
120
+ "PING",
121
+ "QUIT",
122
+ "RESET",
123
+ ]);
124
+
125
+ /** Commands allowed before auth when a password is configured. */
126
+ const PREAUTH_ALLOWED = new Set(["HELLO", "AUTH", "QUIT", "RESET"]);
127
+
128
+ /** Commands that are not queued during MULTI (they drive the transaction). */
129
+ const TXN_CONTROL = new Set(["EXEC", "DISCARD", "MULTI", "WATCH", "RESET"]);
130
+
131
+ /**
132
+ * Top-level entry for one inbound command. Applies connection policy and writes
133
+ * the reply (handlers that manage their own output return null).
134
+ */
135
+ export function dispatch(
136
+ conn: Connection,
137
+ command: Command,
138
+ server: ServerContext,
139
+ nowMs: number,
140
+ ): void {
141
+ if (command.name === "") return; // empty multibulk / blank inline line
142
+
143
+ // Auth gate.
144
+ if (
145
+ server.config.password !== null &&
146
+ !conn.authed &&
147
+ !PREAUTH_ALLOWED.has(command.name)
148
+ ) {
149
+ conn.send(errReply(Errors.noAuth()));
150
+ return;
151
+ }
152
+
153
+ // SUBSCRIBED-mode restriction.
154
+ if (conn.inSubscribeMode() && !SUBSCRIBE_ALLOWED.has(command.name)) {
155
+ conn.send(errReply(Errors.unsupportedInSubscribe(command.name)));
156
+ return;
157
+ }
158
+
159
+ // MULTI queueing.
160
+ if (conn.txn && !TXN_CONTROL.has(command.name)) {
161
+ if (!TABLE[command.name]) {
162
+ conn.txn.error = true;
163
+ conn.send(errReply(Errors.unknownCmd(command.name, argStrings(command))));
164
+ return;
165
+ }
166
+ conn.txn.queued.push(command);
167
+ conn.send(R.simple("QUEUED"));
168
+ return;
169
+ }
170
+
171
+ const reply = executeCore(conn, command, server, nowMs);
172
+ if (reply !== null) conn.send(reply);
173
+ }
174
+
175
+ /**
176
+ * Look up and run a single command, returning its reply (or null if the handler
177
+ * wrote its own output). Never throws: thrown errors become RESP error replies.
178
+ */
179
+ export function executeCore(
180
+ conn: Connection,
181
+ command: Command,
182
+ server: ServerContext,
183
+ nowMs: number,
184
+ ): Reply | null {
185
+ const handler = TABLE[command.name];
186
+ if (!handler) {
187
+ return errReply(Errors.unknownCmd(command.name, argStrings(command)));
188
+ }
189
+ const ctx = new CommandContext(conn, server, command.args, nowMs);
190
+ try {
191
+ return handler(ctx);
192
+ } catch (err) {
193
+ return errReply(toRespError(err));
194
+ }
195
+ }
196
+
197
+ function errReply(e: { code: string; message: string }): Reply {
198
+ return R.error(e.code, e.message);
199
+ }
200
+
201
+ function argStrings(command: Command): string[] {
202
+ const dec = new TextDecoder();
203
+ return command.args.slice(1).map((a) => dec.decode(a));
204
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * CommandContext — everything a command handler needs, plus arg helpers.
3
+ *
4
+ * Handlers receive a single context: the connection, the storage engine, the
5
+ * pub/sub hub, the server registry, and the parsed argument bytes. The helpers
6
+ * centralize the (binary-safe) byte↔string/number conversions and arity checks
7
+ * so each handler stays focused on semantics.
8
+ */
9
+
10
+ import type { Connection } from "../connection";
11
+ import type { StorageEngine } from "../storage/types";
12
+ import type { PubSubHub } from "../sidecar/pubsub";
13
+ import type { WatchRegistry } from "../sidecar/watch";
14
+ import type { ServerConfig } from "../config";
15
+ import { Errors, NotIntegerError } from "./errors";
16
+
17
+ export interface ServerContext {
18
+ readonly storage: StorageEngine;
19
+ readonly hub: PubSubHub;
20
+ readonly watch: WatchRegistry;
21
+ readonly config: ServerConfig;
22
+ }
23
+
24
+ const decoder = new TextDecoder();
25
+
26
+ export class CommandContext {
27
+ constructor(
28
+ readonly conn: Connection,
29
+ readonly server: ServerContext,
30
+ /** All tokens including the command name (args[0]). */
31
+ readonly args: Uint8Array[],
32
+ /** Captured once per command so all sub-operations agree on "now". */
33
+ readonly nowMs: number,
34
+ ) {}
35
+
36
+ get storage(): StorageEngine {
37
+ return this.server.storage;
38
+ }
39
+
40
+ get cmd(): string {
41
+ return decoder.decode(this.args[0]).toUpperCase();
42
+ }
43
+
44
+ /** Number of arguments excluding the command name. */
45
+ get argc(): number {
46
+ return this.args.length - 1;
47
+ }
48
+
49
+ /** Raw bytes of argument `i` (0-based, excluding command name). */
50
+ arg(i: number): Uint8Array {
51
+ const v = this.args[i + 1];
52
+ if (v === undefined) throw Errors.wrongArgs(this.cmd);
53
+ return v;
54
+ }
55
+
56
+ /** Optional raw bytes of argument `i`, or undefined if absent. */
57
+ argOpt(i: number): Uint8Array | undefined {
58
+ return this.args[i + 1];
59
+ }
60
+
61
+ /** UTF-8 string of argument `i`. */
62
+ str(i: number): string {
63
+ return decoder.decode(this.arg(i));
64
+ }
65
+
66
+ /** Upper-cased ASCII of argument `i` (for option keywords). */
67
+ upper(i: number): string {
68
+ return this.str(i).toUpperCase();
69
+ }
70
+
71
+ /** Base-10 bigint of argument `i`; throws ERR not-integer on bad input. */
72
+ int(i: number): bigint {
73
+ const s = this.str(i).trim();
74
+ if (!/^[+-]?\d+$/.test(s)) throw new NotIntegerError();
75
+ return BigInt(s);
76
+ }
77
+
78
+ /** Finite float of argument `i`; throws ERR not-float on bad input. */
79
+ float(i: number): number {
80
+ const s = this.str(i).trim();
81
+ const n = Number(s);
82
+ if (s.length === 0 || Number.isNaN(n)) throw Errors.notFloat();
83
+ return n;
84
+ }
85
+
86
+ /** Require at least `n` arguments (excluding command name). */
87
+ requireArgc(n: number): void {
88
+ if (this.argc < n) throw Errors.wrongArgs(this.cmd);
89
+ }
90
+
91
+ /** Require exactly `n` arguments (excluding command name). */
92
+ requireExactArgc(n: number): void {
93
+ if (this.argc !== n) throw Errors.wrongArgs(this.cmd);
94
+ }
95
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Domain + RESP error model.
3
+ *
4
+ * Command handlers throw {@link RespError}; the dispatcher catches it and emits a
5
+ * RESP error reply (`-CODE msg`). The storage layer must stay free of Redis
6
+ * concepts (§3.2), so it throws the generic {@link TypeMismatchError}, which the
7
+ * command layer translates into a `WRONGTYPE` RespError at the boundary.
8
+ */
9
+
10
+ import type { RedisType } from "../storage/types";
11
+
12
+ /** An error that maps directly to a RESP error reply. `code` is the prefix. */
13
+ export class RespError extends Error {
14
+ constructor(
15
+ readonly code: string,
16
+ override readonly message: string,
17
+ ) {
18
+ super(message);
19
+ this.name = "RespError";
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Thrown by the storage layer when an operation targets a key whose stored type
25
+ * differs from what the operation expects. Carries no RESP vocabulary.
26
+ */
27
+ export class TypeMismatchError extends Error {
28
+ constructor(
29
+ readonly actual: RedisType,
30
+ readonly expected: RedisType,
31
+ ) {
32
+ super(`type mismatch: have ${actual}, want ${expected}`);
33
+ this.name = "TypeMismatchError";
34
+ }
35
+ }
36
+
37
+ /** Stored value is not a base-10 integer (INCR/DECR/HINCRBY on bad data). */
38
+ export class NotIntegerError extends Error {
39
+ constructor() {
40
+ super("value is not an integer or out of range");
41
+ this.name = "NotIntegerError";
42
+ }
43
+ }
44
+
45
+ /** Stored value is not a valid float (INCRBYFLOAT/HINCRBYFLOAT on bad data). */
46
+ export class NotFloatError extends Error {
47
+ constructor() {
48
+ super("value is not a valid float");
49
+ this.name = "NotFloatError";
50
+ }
51
+ }
52
+
53
+ const WRONGTYPE_MSG =
54
+ "Operation against a key holding the wrong kind of value";
55
+
56
+ export const Errors = {
57
+ wrongType: () => new RespError("WRONGTYPE", WRONGTYPE_MSG),
58
+ wrongPass: () =>
59
+ new RespError("WRONGPASS", "invalid username-password pair or user is disabled."),
60
+ noAuth: () => new RespError("NOAUTH", "Authentication required."),
61
+ syntax: () => new RespError("ERR", "syntax error"),
62
+ notInt: () => new RespError("ERR", "value is not an integer or out of range"),
63
+ notFloat: () => new RespError("ERR", "value is not a valid float"),
64
+ wrongArgs: (cmd: string) =>
65
+ new RespError("ERR", `wrong number of arguments for '${cmd.toLowerCase()}' command`),
66
+ unknownCmd: (cmd: string, args: string[]) =>
67
+ new RespError(
68
+ "ERR",
69
+ `unknown command '${cmd}', with args beginning with: ${args
70
+ .slice(0, 1)
71
+ .map((a) => `'${a}'`)
72
+ .join(", ")}`,
73
+ ),
74
+ unsupportedInSubscribe: (cmd: string) =>
75
+ new RespError(
76
+ "ERR",
77
+ `Can't execute '${cmd.toLowerCase()}': only (P)SUBSCRIBE / (P)UNSUBSCRIBE / PING / QUIT / RESET are allowed in this context`,
78
+ ),
79
+ execWithoutMulti: () => new RespError("ERR", "EXEC without MULTI"),
80
+ discardWithoutMulti: () => new RespError("ERR", "DISCARD without MULTI"),
81
+ execAbort: () =>
82
+ new RespError("EXECABORT", "Transaction discarded because of previous errors."),
83
+ } as const;
84
+
85
+ /** Translate a thrown error into a RESP error, mapping storage errors. */
86
+ export function toRespError(err: unknown): RespError {
87
+ if (err instanceof RespError) return err;
88
+ if (err instanceof TypeMismatchError) return Errors.wrongType();
89
+ if (err instanceof NotIntegerError) return Errors.notInt();
90
+ if (err instanceof NotFloatError) return Errors.notFloat();
91
+ const msg = err instanceof Error ? err.message : String(err);
92
+ return new RespError("ERR", msg);
93
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Public API of bundis.
3
+ *
4
+ * - embedServer(): run the server in the current process (main-process mode).
5
+ * - spawnServer(): run the server as a separate Bun process (sidecar mode).
6
+ * - startServer(): lower-level handle taking a full ServerConfig.
7
+ *
8
+ * For a standalone daemon, use the CLI: `bun run src/cli.ts` / `bunx bundis`.
9
+ */
10
+
11
+ export { startServer, type RunningServer } from "./server";
12
+ export { loadConfig, type ServerConfig } from "./config";
13
+ export {
14
+ embedServer,
15
+ spawnServer,
16
+ READY_EVENT,
17
+ type LaunchOptions,
18
+ type EmbeddedServer,
19
+ type SpawnedServer,
20
+ type SpawnServerOptions,
21
+ } from "./launch";