Skip to content

Commit 0f8006c

Browse files
committed
feat(experimental): add modular event categories with extend and v.on
1 parent f59c210 commit 0f8006c

5 files changed

Lines changed: 656 additions & 20 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { describe, expect, expectTypeOf, it } from "vitest";
2+
import { type ModuleEvents, v } from "./index";
3+
4+
describe("v.event", () => {
5+
it("publishes through direct subscribers and merges next() patches", async () => {
6+
const signUp = v.event("evt_signup", {
7+
email: v.object({
8+
id: v.string(),
9+
name: v.string(),
10+
email: v.string(),
11+
}),
12+
emailOpt: v.object({ id: v.string(), email: v.string() }),
13+
});
14+
15+
const seen: string[] = [];
16+
const unsubscribe = signUp.subscribe(async (e, next) => {
17+
seen.push(String(e.type));
18+
if (e.type === "emailOpt") {
19+
await next({ email: "patched@example.com" });
20+
return;
21+
}
22+
await next();
23+
});
24+
25+
const result = await signUp.publish("emailOpt", {
26+
id: "1",
27+
email: "ada@example.com",
28+
});
29+
expect(result).toEqual({ id: "1", email: "patched@example.com" });
30+
expect(seen).toEqual(["emailOpt"]);
31+
32+
unsubscribe();
33+
await signUp.publish("email", {
34+
id: "2",
35+
name: "Ada",
36+
email: "ada@example.com",
37+
});
38+
expect(seen).toEqual(["emailOpt"]);
39+
});
40+
41+
it("validates payloads and rejects unknown kinds", async () => {
42+
const bus = v.event("evt_validate", {
43+
ping: v.object({ n: v.number() }),
44+
});
45+
expect(() => bus.publish("ping", { n: "x" } as never)).toThrow(
46+
/expected number/,
47+
);
48+
expect(() => (bus as any).publish("pong", { n: 1 })).toThrow(
49+
/unknown event kind/,
50+
);
51+
expect(bus.publish("ping", { n: 1 })).toEqual({ n: 1 });
52+
});
53+
54+
it("skips the chain when next is not called (veto)", async () => {
55+
const bus = v.event("evt_veto", {
56+
x: v.object({ v: v.number() }),
57+
});
58+
const order: string[] = [];
59+
bus.subscribe(async (_e, next) => {
60+
order.push("outer");
61+
await next();
62+
});
63+
bus.subscribe(async () => {
64+
order.push("veto");
65+
});
66+
bus.subscribe(async (_e, next) => {
67+
order.push("inner");
68+
await next();
69+
});
70+
const result = await bus.publish("x", { v: 1 });
71+
expect(result).toEqual({ v: 1 });
72+
expect(order).toEqual(["outer", "veto"]);
73+
});
74+
});
75+
76+
describe("event extension + modules", () => {
77+
it("v.extend / .extend widen kinds; modules mount listeners", async () => {
78+
const signUp = v.event("evt_mod_signup", {
79+
email: v.object({ id: v.string(), email: v.string() }),
80+
});
81+
const withOauth = signUp.extend({
82+
oauth: v.object({ provider: v.string(), id: v.string() }),
83+
});
84+
const oauthExt = v.extend(signUp, {
85+
oauth: v.object({ provider: v.string(), id: v.string() }),
86+
});
87+
88+
expectTypeOf<keyof typeof withOauth.types>().toEqualTypeOf<
89+
"email" | "oauth"
90+
>();
91+
92+
const log: string[] = [];
93+
const onSignUp = v.on(signUp, async (e, next) => {
94+
log.push(`on:${String(e.type)}`);
95+
await next();
96+
});
97+
const onKey = v.on("event.evt_mod_signup", async (e, next) => {
98+
log.push(`key:${String(e.type)}`);
99+
await next();
100+
});
101+
102+
const core = { signUp, oauthExt };
103+
const hooks = { onSignUp, onKey };
104+
105+
// Mounting a fn that uses the module registers event listeners.
106+
v.fn("evt_mod.app", { use: [core, hooks] }, () => "ok");
107+
108+
const fromEmail = await signUp.publish("email", {
109+
id: "1",
110+
email: "a@b.co",
111+
});
112+
expect(fromEmail).toEqual({ id: "1", email: "a@b.co" });
113+
114+
const fromOauth = await withOauth.publish("oauth", {
115+
provider: "github",
116+
id: "42",
117+
});
118+
expect(fromOauth).toEqual({ provider: "github", id: "42" });
119+
expect(log).toEqual(["on:email", "key:email", "on:oauth", "key:oauth"]);
120+
});
121+
122+
it("ModuleEvents merges kind maps by declared name across modules", () => {
123+
const a = {
124+
signUp: v.event("evt_infer", {
125+
email: v.object({ id: v.string() }),
126+
}),
127+
};
128+
const b = {
129+
more: v.extend(a.signUp, {
130+
oauth: v.object({ provider: v.string() }),
131+
}),
132+
};
133+
type Merged = ModuleEvents<[typeof a, typeof b]>;
134+
type KindMap = Merged extends { evt_infer: infer K } ? K : never;
135+
// Structural assignability both ways = equal payload maps.
136+
const _forward = null as unknown as KindMap;
137+
const _expected = null as unknown as {
138+
email: { id: string };
139+
oauth: { provider: string };
140+
};
141+
const _a: typeof _expected = _forward;
142+
const _b: KindMap = _expected;
143+
void _a;
144+
void _b;
145+
});
146+
147+
it("lands on context when mounted via use", async () => {
148+
const bus = v.event("evt_ctx", {
149+
ping: v.object({ n: v.number() }),
150+
});
151+
const seen: number[] = [];
152+
bus.subscribe(async (e, next) => {
153+
if (e.type === "ping") seen.push(e.data.n);
154+
await next();
155+
});
156+
const f = v.fn({ use: [{ bus }] }, async (c) => {
157+
return c.bus.publish("ping", { n: 7 });
158+
});
159+
await expect(f()).resolves.toEqual({ n: 7 });
160+
expect(seen).toEqual([7]);
161+
});
162+
});

0 commit comments

Comments
 (0)