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,162 @@
1
+ /**
2
+ * PubSubHub — single-process in-memory pub/sub (§6.2).
3
+ *
4
+ * Maps channels and glob patterns to the set of subscribed connections. PUBLISH
5
+ * pushes RESP3 push frames (`>`) directly to each matching connection's socket.
6
+ * Multi-process broadcast is explicitly a non-goal.
7
+ */
8
+
9
+ import { R, type Reply } from "../resp/types";
10
+ import type { Connection } from "../connection";
11
+
12
+ export class PubSubHub {
13
+ #channels = new Map<string, Set<Connection>>();
14
+ #patterns = new Map<string, Set<Connection>>();
15
+
16
+ subscribe(conn: Connection, channel: string): number {
17
+ addTo(this.#channels, channel, conn);
18
+ conn.channels.add(channel);
19
+ return conn.subscriptionCount();
20
+ }
21
+
22
+ unsubscribe(conn: Connection, channel: string): number {
23
+ removeFrom(this.#channels, channel, conn);
24
+ conn.channels.delete(channel);
25
+ return conn.subscriptionCount();
26
+ }
27
+
28
+ psubscribe(conn: Connection, pattern: string): number {
29
+ addTo(this.#patterns, pattern, conn);
30
+ conn.patterns.add(pattern);
31
+ return conn.subscriptionCount();
32
+ }
33
+
34
+ punsubscribe(conn: Connection, pattern: string): number {
35
+ removeFrom(this.#patterns, pattern, conn);
36
+ conn.patterns.delete(pattern);
37
+ return conn.subscriptionCount();
38
+ }
39
+
40
+ /** Drop a connection from all channels/patterns (on disconnect). */
41
+ drop(conn: Connection): void {
42
+ for (const ch of conn.channels) removeFrom(this.#channels, ch, conn);
43
+ for (const p of conn.patterns) removeFrom(this.#patterns, p, conn);
44
+ conn.channels.clear();
45
+ conn.patterns.clear();
46
+ }
47
+
48
+ /** Deliver a message; returns the number of clients that received it. */
49
+ publish(channel: string, message: Uint8Array): number {
50
+ let n = 0;
51
+ const direct = this.#channels.get(channel);
52
+ if (direct) {
53
+ const frame = R.push([R.bulk("message"), R.bulk(channel), R.bulk(message)]);
54
+ for (const conn of direct) {
55
+ conn.send(frame);
56
+ n++;
57
+ }
58
+ }
59
+ for (const [pattern, conns] of this.#patterns) {
60
+ if (!matchPattern(pattern, channel)) continue;
61
+ const frame = R.push([
62
+ R.bulk("pmessage"),
63
+ R.bulk(pattern),
64
+ R.bulk(channel),
65
+ R.bulk(message),
66
+ ]);
67
+ for (const conn of conns) {
68
+ conn.send(frame);
69
+ n++;
70
+ }
71
+ }
72
+ return n;
73
+ }
74
+
75
+ channelNames(): string[] {
76
+ return [...this.#channels.entries()].filter(([, s]) => s.size > 0).map(([c]) => c);
77
+ }
78
+
79
+ numSub(channel: string): number {
80
+ return this.#channels.get(channel)?.size ?? 0;
81
+ }
82
+ }
83
+
84
+ function addTo(map: Map<string, Set<Connection>>, key: string, conn: Connection): void {
85
+ let s = map.get(key);
86
+ if (!s) {
87
+ s = new Set();
88
+ map.set(key, s);
89
+ }
90
+ s.add(conn);
91
+ }
92
+
93
+ function removeFrom(
94
+ map: Map<string, Set<Connection>>,
95
+ key: string,
96
+ conn: Connection,
97
+ ): void {
98
+ const s = map.get(key);
99
+ if (!s) return;
100
+ s.delete(conn);
101
+ if (s.size === 0) map.delete(key);
102
+ }
103
+
104
+ /** Redis-style glob match: `*` `?` `[...]` with `\` escapes. */
105
+ export function matchPattern(pattern: string, str: string): boolean {
106
+ return globMatch(pattern, 0, str, 0);
107
+ }
108
+
109
+ function globMatch(p: string, pi: number, s: string, si: number): boolean {
110
+ while (pi < p.length) {
111
+ const pc = p[pi]!;
112
+ if (pc === "*") {
113
+ while (pi + 1 < p.length && p[pi + 1] === "*") pi++;
114
+ if (pi + 1 === p.length) return true;
115
+ for (let k = si; k <= s.length; k++) {
116
+ if (globMatch(p, pi + 1, s, k)) return true;
117
+ }
118
+ return false;
119
+ }
120
+ if (si >= s.length) return false;
121
+ if (pc === "?") {
122
+ pi++;
123
+ si++;
124
+ continue;
125
+ }
126
+ if (pc === "[") {
127
+ const close = p.indexOf("]", pi + 1);
128
+ if (close === -1) {
129
+ if (p[pi] !== s[si]) return false; // literal '['
130
+ pi++;
131
+ si++;
132
+ continue;
133
+ }
134
+ let negate = false;
135
+ let start = pi + 1;
136
+ if (p[start] === "^") {
137
+ negate = true;
138
+ start++;
139
+ }
140
+ let matched = false;
141
+ for (let k = start; k < close; k++) {
142
+ if (p[k + 1] === "-" && k + 2 < close) {
143
+ if (s[si]! >= p[k]! && s[si]! <= p[k + 2]!) matched = true;
144
+ k += 2;
145
+ } else if (p[k] === s[si]) {
146
+ matched = true;
147
+ }
148
+ }
149
+ if (matched === negate) return false;
150
+ pi = close + 1;
151
+ si++;
152
+ continue;
153
+ }
154
+ if (pc === "\\" && pi + 1 < p.length) pi++;
155
+ if (p[pi] !== s[si]) return false;
156
+ pi++;
157
+ si++;
158
+ }
159
+ return si === s.length;
160
+ }
161
+
162
+ export type { Reply };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * ExpiryReaper — active TTL sweep (§5.3.2).
3
+ *
4
+ * Lazy expiry (on read) keeps expired keys from leaking into replies, but does
5
+ * not reclaim keys that are never touched. This periodic sweep deletes expired
6
+ * rows in bulk so DBSIZE and on-disk size stay consistent.
7
+ */
8
+
9
+ import type { StorageEngine } from "../storage/types";
10
+
11
+ export class ExpiryReaper {
12
+ #timer: ReturnType<typeof setInterval> | null = null;
13
+
14
+ constructor(
15
+ private readonly storage: StorageEngine,
16
+ private readonly intervalMs: number,
17
+ ) {}
18
+
19
+ start(): void {
20
+ if (this.#timer !== null) return;
21
+ this.#timer = setInterval(() => {
22
+ this.storage.sweepExpired(Date.now());
23
+ }, this.intervalMs);
24
+ // Don't keep the process alive solely for the reaper.
25
+ this.#timer.unref?.();
26
+ }
27
+
28
+ stop(): void {
29
+ if (this.#timer !== null) {
30
+ clearInterval(this.#timer);
31
+ this.#timer = null;
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * WatchRegistry — optimistic-lock version tracking for MULTI/WATCH/EXEC.
3
+ *
4
+ * Under the single-writer assumption (§5.3), correctness only requires detecting
5
+ * whether any watched key was modified between WATCH and EXEC. We keep an
6
+ * in-process monotonic version per key, bumped on every write. WATCH snapshots
7
+ * the current versions; EXEC compares. A missing key has version 0.
8
+ */
9
+
10
+ export class WatchRegistry {
11
+ #versions = new Map<string, number>();
12
+
13
+ /** Bump a key's version. Called by storage on every mutation. */
14
+ bump = (key: Uint8Array): void => {
15
+ const k = hashKey(key);
16
+ this.#versions.set(k, (this.#versions.get(k) ?? 0) + 1);
17
+ };
18
+
19
+ /** Current version of a key (0 if never written). */
20
+ version(key: Uint8Array): number {
21
+ return this.#versions.get(hashKey(key)) ?? 0;
22
+ }
23
+ }
24
+
25
+ /** A connection's WATCH snapshot: key (as hash) → version observed at WATCH. */
26
+ export type WatchSnapshot = Map<string, { key: Uint8Array; version: number }>;
27
+
28
+ /** Stable map-key for a byte array (latin1 round-trips every byte). */
29
+ export function hashKey(key: Uint8Array): string {
30
+ let s = "";
31
+ for (let i = 0; i < key.length; i++) s += String.fromCharCode(key[i]!);
32
+ return s;
33
+ }