Skip to content

Commit ad0be30

Browse files
committed
fix+feat(realtime): connId-keyed subs + event-typed topics
Two fixes in v0.10.2: 1. Subscriptions keyed by stable connId in ws.data, not ws object identity. Bun/Elysia hands different wrapper objects to open vs message handlers — subscribe stored wrapper-A, unsubscribe looked up wrapper-B, Set.delete returned false, broadcasts kept firing. Reproduced from a Postman log: subscribe acked, unsubscribe replied topics:[] on the same connection. 2. Event-typed topic syntax. posts.create / posts.update / posts.delete filter to that event type. *.create etc. catches globally. Previously stored as literal keys nothing broadcasted to. Also: {type:"list-subs"} debug message returns the active topic set for this connection; subscribe/unsubscribe acks now carry the canonical list; SSE adapters auto-attach data.connId at register time. Topic grammar after this: | Topic | Receives | |----------------------------------------|-------------------------------------| | <col> | every event for collection | | <col>/<id> | events for one record | | <col>.create / .update / .delete | only that event type per collection | | * | every event everywhere | | *.create / .update / .delete | that event type globally | | <col>.* / <col>/* | normalised to <col> | Tests: +13 (event-typed 5, wrapper-identity 1, normalise+list-subs+ack 7). Full suite: 831 pass / 2 pre-existing GIF flakes.
1 parent b9da7a6 commit ad0be30

7 files changed

Lines changed: 214 additions & 46 deletions

File tree

admin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "vaultbase-admin",
33
"private": true,
4-
"version": "0.10.1",
4+
"version": "0.10.2",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "vaultbase",
3-
"version": "0.10.1",
3+
"version": "0.10.2",
44
"type": "module",
55
"scripts": {
66
"dev": "bun --watch src/index.ts",

src/__tests__/realtime-rules.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,16 @@ import { subscribe, broadcast, setWSAuth, _reset } from "../realtime/manager.ts"
44
interface MockWS {
55
sent: string[];
66
send(data: string): void;
7+
data: { connId: string };
78
}
89

10+
let _mockId = 0;
911
function mockWs(): MockWS {
10-
return { sent: [], send(data) { this.sent.push(data); } };
12+
return {
13+
sent: [],
14+
send(data) { this.sent.push(data); },
15+
data: { connId: `rules-${++_mockId}` },
16+
};
1117
}
1218

1319
function rec(extra: Record<string, unknown> = {}): { record: Parameters<typeof broadcast>[1] extends infer E ? (E extends { record: infer R } ? R : never) : never; raw: Record<string, unknown> } {

src/__tests__/realtime.test.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,16 @@ import { subscribe, unsubscribe, disconnectAll, broadcast, normalizeTopic, _rese
44
interface MockWS {
55
sent: string[];
66
send(data: string): void;
7+
data: { connId: string };
78
}
89

10+
let _mockId = 0;
911
function mockWs(): MockWS {
10-
const ws: MockWS = { sent: [], send(data) { this.sent.push(data); } };
12+
const ws: MockWS = {
13+
sent: [],
14+
send(data) { this.sent.push(data); },
15+
data: { connId: `mock-${++_mockId}` },
16+
};
1117
return ws;
1218
}
1319

@@ -51,6 +57,7 @@ describe("RealtimeManager", () => {
5157
const dead: MockWS = {
5258
sent: [],
5359
send() { throw new Error("WebSocket is closed"); },
60+
data: { connId: `dead-${++_mockId}` },
5461
};
5562
subscribe(dead, ["posts"]);
5663
expect(() =>
@@ -170,4 +177,85 @@ describe("RealtimeManager", () => {
170177
const removed = unsubscribe(ws, ["posts.*", "users.*"]);
171178
expect(removed).toEqual(["posts"]);
172179
});
180+
181+
// ── Event-typed topics ────────────────────────────────────────────────
182+
183+
it("posts.create only fires on create events, not update/delete", () => {
184+
const ws = mockWs();
185+
subscribe(ws, ["posts.create"]);
186+
broadcast("posts", { type: "create", collection: "posts", record: { id: "1", collectionId: "c", collectionName: "posts", created: 0, updated: 0 } });
187+
broadcast("posts", { type: "update", collection: "posts", record: { id: "1", collectionId: "c", collectionName: "posts", created: 0, updated: 0 } });
188+
broadcast("posts", { type: "delete", collection: "posts", id: "1" });
189+
expect(ws.sent).toHaveLength(1);
190+
expect(JSON.parse(ws.sent[0]!).type).toBe("create");
191+
});
192+
193+
it("posts.update + posts.delete topics filter independently", () => {
194+
const upd = mockWs();
195+
const del = mockWs();
196+
subscribe(upd, ["posts.update"]);
197+
subscribe(del, ["posts.delete"]);
198+
broadcast("posts", { type: "create", collection: "posts", record: { id: "1", collectionId: "c", collectionName: "posts", created: 0, updated: 0 } });
199+
broadcast("posts", { type: "update", collection: "posts", record: { id: "1", collectionId: "c", collectionName: "posts", created: 0, updated: 0 } });
200+
broadcast("posts", { type: "delete", collection: "posts", id: "1" });
201+
expect(upd.sent).toHaveLength(1);
202+
expect(del.sent).toHaveLength(1);
203+
expect(JSON.parse(upd.sent[0]!).type).toBe("update");
204+
expect(JSON.parse(del.sent[0]!).type).toBe("delete");
205+
});
206+
207+
it("*.create catches creates from every collection", () => {
208+
const ws = mockWs();
209+
subscribe(ws, ["*.create"]);
210+
broadcast("posts", { type: "create", collection: "posts", record: { id: "1", collectionId: "c", collectionName: "posts", created: 0, updated: 0 } });
211+
broadcast("orders", { type: "create", collection: "orders", record: { id: "2", collectionId: "c", collectionName: "orders", created: 0, updated: 0 } });
212+
broadcast("posts", { type: "delete", collection: "posts", id: "3" });
213+
expect(ws.sent).toHaveLength(2);
214+
});
215+
216+
it("collection-level + event-typed sub on same connection still receives once per event", () => {
217+
const ws = mockWs();
218+
subscribe(ws, ["posts", "posts.create"]);
219+
broadcast("posts", { type: "create", collection: "posts", record: { id: "1", collectionId: "c", collectionName: "posts", created: 0, updated: 0 } });
220+
expect(ws.sent).toHaveLength(1);
221+
});
222+
223+
it("normalizeTopic preserves event-typed canonical forms", () => {
224+
expect(normalizeTopic("posts.create")).toBe("posts.create");
225+
expect(normalizeTopic("posts.update")).toBe("posts.update");
226+
expect(normalizeTopic("posts.delete")).toBe("posts.delete");
227+
expect(normalizeTopic("*.create")).toBe("*.create");
228+
// Unknown event suffix kept verbatim — won't match any broadcast,
229+
// but doesn't surprise legacy callers either.
230+
expect(normalizeTopic("posts.foobar")).toBe("posts.foobar");
231+
});
232+
233+
// ── Bun/Elysia wrapper-identity quirk ──────────────────────────────────
234+
// Bun hands `open(ws)` and `message(ws, ...)` different wrapper objects
235+
// backing the same socket. The manager keys by `ws.data.connId`, so
236+
// wrapper identity doesn't matter — both wrappers carry the same connId
237+
// via Bun's persistent `data` slot. Reproduce here with two distinct
238+
// mock objects that share a connId, the way Elysia + Bun appear to
239+
// present them to handlers.
240+
it("subscribe + unsubscribe work across distinct wrappers sharing connId", () => {
241+
const sharedId = "conn-A";
242+
const wsAtOpen: MockWS = {
243+
sent: [], send(d) { this.sent.push(d); },
244+
data: { connId: sharedId },
245+
};
246+
const wsAtMessage: MockWS = {
247+
sent: [], send(d) { this.sent.push(d); },
248+
data: { connId: sharedId },
249+
};
250+
251+
subscribe(wsAtOpen, ["posts"]);
252+
// Unsubscribe via the second wrapper — must still find + delete the sub.
253+
const removed = unsubscribe(wsAtMessage, ["posts"]);
254+
expect(removed).toEqual(["posts"]);
255+
256+
// After unsubscribe, broadcasts must not deliver to either wrapper.
257+
broadcast("posts", { type: "delete", collection: "posts", id: "x" });
258+
expect(wsAtOpen.sent).toHaveLength(0);
259+
expect(wsAtMessage.sent).toHaveLength(0);
260+
});
173261
});

src/core/version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
*
55
* Bump in lockstep with `package.json` version + the git tag.
66
*/
7-
export const VAULTBASE_VERSION = "0.10.1";
7+
export const VAULTBASE_VERSION = "0.10.2";

src/realtime/manager.ts

Lines changed: 95 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -43,73 +43,116 @@ const WILDCARD = "*";
4343
* - "<collection>" → all events for the collection
4444
* - "<collection>/<id>" → events for one specific record
4545
* - "*" → every event everywhere
46+
*
47+
* Storage is keyed by **connection id** (string), not by `WSLike` object
48+
* identity. Bun/Elysia can hand you a different wrapper per handler call
49+
* (one for `open`, another for `message`); using `===` for membership
50+
* misbehaves — subscribe stored wrapper A, unsubscribe looked up wrapper B,
51+
* cross-call mutation silently dropped. The id is minted at connect time
52+
* and stashed in Bun's persistent `ws.data` slot.
53+
*
54+
* The inner Map maps connId → adapter so broadcast can still call .send()
55+
* via the wrapper that's currently live. Whichever wrapper subscribed last
56+
* "wins" — the most recent send target is what fires.
4657
*/
47-
const subs = new Map<string, Set<WSLike>>();
48-
const wsAuth = new WeakMap<WSLike, WSAuth>();
58+
const subs = new Map<string, Map<string, WSLike>>();
59+
const wsAuth = new Map<string, WSAuth>();
60+
61+
/** Pull the persistent connection id off `ws.data` (set by the WS open handler). */
62+
function connId(ws: WSLike): string {
63+
const id = (ws as unknown as { data?: { connId?: string } }).data?.connId;
64+
if (typeof id !== "string") throw new Error("realtime: ws.data.connId missing — open handler must mint one");
65+
return id;
66+
}
4967

5068
export function setWSAuth(ws: WSLike, auth: WSAuth | null): void {
51-
if (auth) wsAuth.set(ws, auth);
52-
else wsAuth.delete(ws);
69+
const id = connId(ws);
70+
if (auth) wsAuth.set(id, auth);
71+
else wsAuth.delete(id);
5372
}
5473

5574
export function getWSAuth(ws: WSLike): WSAuth | undefined {
56-
return wsAuth.get(ws);
75+
return wsAuth.get(connId(ws));
5776
}
5877

5978
/**
6079
* Canonicalise a topic string. The internal store keys are:
6180
*
62-
* <collection> collection-level
63-
* <collection>/<id> single-record
64-
* * global wildcard
81+
* <collection> every event for the collection
82+
* <collection>/<id> events for one specific record
83+
* <collection>.<event-type> only that event-type (create / update / delete)
84+
* * every event everywhere
85+
* *.<event-type> that event-type globally
6586
*
66-
* For ergonomic + PB-compat reasons we accept these synonyms:
87+
* Ergonomic synonyms we collapse:
6788
*
68-
* <collection>.* → <collection> (dotted-wildcard)
69-
* <collection>/* → <collection> (slashed-wildcard)
70-
* <collection> → <collection> (no-op)
89+
* <collection>.* → <collection> (dotted-wildcard)
90+
* <collection>/* → <collection> (slashed-wildcard)
7191
*
72-
* Returns the canonical form, or `null` if the topic is empty/malformed.
73-
* Symmetric — used by both subscribe + unsubscribe so the two halves
74-
* always agree on the storage key.
92+
* Symmetric — applied by both subscribe + unsubscribe so the two halves
93+
* always agree on the storage key. Returns `null` on empty input.
7594
*/
95+
const EVENT_KINDS = new Set(["create", "update", "delete"]);
96+
7697
export function normalizeTopic(raw: string): string | null {
7798
if (typeof raw !== "string") return null;
7899
const t = raw.trim();
79100
if (!t) return null;
80101
if (t === "*") return "*";
81102
if (t.endsWith(".*")) return t.slice(0, -2) || null;
82103
if (t.endsWith("/*")) return t.slice(0, -2) || null;
104+
// `<base>.<event-type>` — keep verbatim only when the suffix is a
105+
// known event kind. Anything else stays as-is for legacy callers.
106+
const dot = t.lastIndexOf(".");
107+
if (dot > 0) {
108+
const suffix = t.slice(dot + 1);
109+
if (EVENT_KINDS.has(suffix)) return t; // canonical event-typed form
110+
}
83111
return t;
84112
}
85113

86114
export function subscribe(ws: WSLike, topics: string[]): string[] {
115+
const id = connId(ws);
87116
const accepted: string[] = [];
88117
for (const raw of topics) {
89118
const t = normalizeTopic(raw);
90119
if (!t) continue;
91-
if (!subs.has(t)) subs.set(t, new Set());
92-
subs.get(t)!.add(ws);
120+
let inner = subs.get(t);
121+
if (!inner) { inner = new Map(); subs.set(t, inner); }
122+
inner.set(id, ws);
93123
accepted.push(t);
94124
}
95125
return accepted;
96126
}
97127

98128
export function unsubscribe(ws: WSLike, topics: string[]): string[] {
129+
const id = connId(ws);
99130
const removed: string[] = [];
100131
for (const raw of topics) {
101132
const t = normalizeTopic(raw);
102133
if (!t) continue;
103-
if (subs.get(t)?.delete(ws)) removed.push(t);
134+
if (subs.get(t)?.delete(id)) removed.push(t);
104135
}
105136
return removed;
106137
}
107138

139+
/** Every topic this WS is currently subscribed to. Cheap introspection for debugging. */
140+
export function listSubsFor(ws: WSLike): string[] {
141+
const id = connId(ws);
142+
const out: string[] = [];
143+
for (const [topic, inner] of subs.entries()) {
144+
if (inner.has(id)) out.push(topic);
145+
}
146+
out.sort();
147+
return out;
148+
}
149+
108150
export function disconnectAll(ws: WSLike): void {
109-
for (const set of subs.values()) {
110-
set.delete(ws);
151+
const id = connId(ws);
152+
for (const inner of subs.values()) {
153+
inner.delete(id);
111154
}
112-
wsAuth.delete(ws);
155+
wsAuth.delete(id);
113156
}
114157

115158
/**
@@ -118,9 +161,9 @@ export function disconnectAll(ws: WSLike): void {
118161
* supplied, everyone passes (back-compat). When supplied, behavior matches
119162
* the records HTTP `view_rule` semantics.
120163
*/
121-
function shouldSendTo(ws: WSLike, opts?: BroadcastOpts): boolean {
164+
function shouldSendTo(id: string, opts?: BroadcastOpts): boolean {
122165
if (!opts || opts.viewRule === undefined) return true;
123-
const auth = wsAuth.get(ws);
166+
const auth = wsAuth.get(id);
124167
if (auth?.type === "admin") return true;
125168
const rule = opts.viewRule;
126169
if (rule === null) return true; // public
@@ -133,34 +176,39 @@ function shouldSendTo(ws: WSLike, opts?: BroadcastOpts): boolean {
133176

134177
/**
135178
* Send to subscribers of `<collection>`, `<collection>/<id>` (when the event has
136-
* a record id), and the wildcard `*` topic — fans out with per-ws dedup. When
179+
* a record id), and the wildcard `*` topic — fans out with per-id dedup. When
137180
* the caller passes `opts.viewRule` (and `opts.record` for the eval target),
138181
* each subscriber's auth is checked against the rule and non-matching connections
139182
* are skipped silently.
140183
*/
141184
export function broadcast(collection: string, event: RealtimeEvent, opts?: BroadcastOpts): void {
142-
const targets: (string | undefined)[] = [collection, WILDCARD];
185+
const targets: (string | undefined)[] = [
186+
collection, // collection-level
187+
WILDCARD, // global
188+
`${collection}.${event.type}`, // event-typed per collection
189+
`${WILDCARD}.${event.type}`, // event-typed global
190+
];
143191
if (event.type === "create" || event.type === "update") {
144192
targets.push(`${collection}/${event.record.id}`);
145193
} else if (event.type === "delete") {
146194
targets.push(`${collection}/${event.id}`);
147195
}
148196
const payload = JSON.stringify(event);
149-
// Dedup: a ws subscribed to both "posts" and "*" should still receive the
150-
// event once. WeakSet doesn't support iteration, so use a regular Set.
151-
const sent = new Set<WSLike>();
197+
// Dedup: a connection subscribed to both "posts" and "*" should still receive
198+
// the event once.
199+
const sent = new Set<string>();
152200
for (const topic of targets) {
153201
if (!topic) continue;
154-
const set = subs.get(topic);
155-
if (!set) continue;
156-
for (const ws of set) {
157-
if (sent.has(ws)) continue;
158-
sent.add(ws);
159-
if (!shouldSendTo(ws, opts)) continue;
202+
const inner = subs.get(topic);
203+
if (!inner) continue;
204+
for (const [id, ws] of inner) {
205+
if (sent.has(id)) continue;
206+
sent.add(id);
207+
if (!shouldSendTo(id, opts)) continue;
160208
try {
161209
ws.send(payload);
162210
} catch {
163-
set.delete(ws);
211+
inner.delete(id);
164212
}
165213
}
166214
}
@@ -174,12 +222,12 @@ export function broadcast(collection: string, event: RealtimeEvent, opts?: Broad
174222
* collide with a user-defined collection.
175223
*/
176224
export function broadcastSystem(topic: string, message: object): void {
177-
const set = subs.get(topic);
178-
if (!set) return;
225+
const inner = subs.get(topic);
226+
if (!inner) return;
179227
const payload = JSON.stringify(message);
180-
for (const ws of set) {
228+
for (const [id, ws] of inner) {
181229
try { ws.send(payload); }
182-
catch { set.delete(ws); }
230+
catch { inner.delete(id); }
183231
}
184232
}
185233

@@ -193,6 +241,12 @@ export function broadcastSystem(topic: string, message: object): void {
193241
const sseClients = new Map<string, WSLike>();
194242

195243
export function registerSSEClient(clientId: string, adapter: WSLike): void {
244+
// Mirror the WS contract: every adapter must carry a stable `data.connId`
245+
// so subscribe / unsubscribe / disconnectAll have a real key. SSE adapters
246+
// typically don't carry `data`, so we attach it here.
247+
const a = adapter as unknown as { data?: { connId?: string } };
248+
if (!a.data || typeof a.data !== "object") a.data = { connId: clientId };
249+
else if (typeof a.data.connId !== "string") a.data.connId = clientId;
196250
sseClients.set(clientId, adapter);
197251
}
198252

@@ -212,8 +266,9 @@ export function unregisterSSEClient(clientId: string): void {
212266
export function setSSESubscriptions(clientId: string, topics: string[]): boolean {
213267
const adapter = sseClients.get(clientId);
214268
if (!adapter) return false;
269+
const id = connId(adapter);
215270
// Remove from every topic, then re-add the new set.
216-
for (const set of subs.values()) set.delete(adapter);
271+
for (const inner of subs.values()) inner.delete(id);
217272
subscribe(adapter, topics);
218273
return true;
219274
}

0 commit comments

Comments
 (0)