Skip to content

Commit cfd27f3

Browse files
committed
fix: event bus type inference on vars
1 parent d4c2e9b commit cfd27f3

4 files changed

Lines changed: 194 additions & 10 deletions

File tree

packages/experimental/src/event.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,4 +211,75 @@ describe("event extension + modules", () => {
211211
await expect(f()).resolves.toEqual({ n: 7 });
212212
expect(seen).toEqual([7]);
213213
});
214+
215+
it("mounted var extensions widen a kind whose payload is that var", async () => {
216+
const account = v.var("evt_var_account", {
217+
schema: v.object({ id: v.string() }),
218+
});
219+
const withTag = v.extend(account, { tag: v.string() });
220+
const bus = v.event("evt_var_payload", {
221+
created: account,
222+
});
223+
224+
const seen: unknown[] = [];
225+
bus.subscribe(async (e, next) => {
226+
if (e.type === "created") seen.push(e.data);
227+
await next();
228+
});
229+
230+
const f = v.fn({ use: [{ bus, account, withTag }] }, async (c) => {
231+
const result = c.bus.publish("created", { id: "1", tag: "vip" });
232+
expectTypeOf(result).toEqualTypeOf<
233+
{ id: string; tag: string } | Promise<{ id: string; tag: string }>
234+
>();
235+
return result;
236+
});
237+
await expect(f()).resolves.toEqual({ id: "1", tag: "vip" });
238+
expect(seen).toEqual([{ id: "1", tag: "vip" }]);
239+
240+
expect(() =>
241+
v.fn({ use: [{ bus, account, withTag }] }, (c) =>
242+
// @ts-expect-error tag required once withTag is mounted
243+
c.bus.publish("created", { id: "1" }),
244+
)(),
245+
).toThrow(/evt_var_account|tag/);
246+
});
247+
248+
it("unmounted, a var-payload kind stays the base shape", async () => {
249+
const account = v.var("evt_var_bare", {
250+
schema: v.object({ id: v.string() }),
251+
});
252+
const bus = v.event("evt_var_bare_bus", { created: account });
253+
const f = v.fn({ use: [{ bus, account }] }, async (c) => {
254+
const result = c.bus.publish("created", { id: "1" });
255+
expectTypeOf(result).toEqualTypeOf<
256+
{ id: string } | Promise<{ id: string }>
257+
>();
258+
return result;
259+
});
260+
await expect(f()).resolves.toEqual({ id: "1" });
261+
});
262+
263+
it("a var field on an event kind widens the same way", async () => {
264+
const account = v.var("evt_var_field_account", {
265+
schema: v.object({ id: v.string() }),
266+
});
267+
const withTag = v.extend(account, { tag: v.string() });
268+
const bus = v.event("evt_var_field", {
269+
created: { account },
270+
});
271+
const f = v.fn({ use: [{ bus, account, withTag }] }, async (c) => {
272+
return c.bus.publish("created", {
273+
account: { id: "2", tag: "gold" },
274+
});
275+
});
276+
await expect(f()).resolves.toEqual({
277+
account: { id: "2", tag: "gold" },
278+
});
279+
expect(() =>
280+
v.fn({ use: [{ bus, account, withTag }] }, (c) =>
281+
c.bus.publish("created", { account: { id: "2" } } as never),
282+
)(),
283+
).toThrow(/tag/);
284+
});
214285
});

packages/experimental/src/event.ts

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ValidationError } from "./error";
2-
import { asType, type InferInput, validate } from "./schema";
2+
import { asType, type InferInput, isVar, validate } from "./schema";
33
import type {
44
LiteralString,
55
Members,
@@ -238,23 +238,89 @@ const runHandlers = (
238238
return thenMaybe(run(0), () => current);
239239
};
240240

241+
/** A var extension's extra fields, applied to event payloads that infer
242+
* from that var when publishing inside a mounted scope. */
243+
export type EventVarExt = { name: string; schema: unknown };
244+
245+
/** Fold mounted `v.extend` / same-name customize schemas onto a payload
246+
* schema that references those vars - whole-var kinds and var fields. */
247+
const applyVarExtsToSchema = (
248+
schema: unknown,
249+
exts: readonly EventVarExt[],
250+
): unknown => {
251+
if (exts.length === 0) return schema;
252+
if (isVar(schema)) {
253+
const name = (schema as { name: string }).name;
254+
const inner = (schema as { schema?: unknown }).schema ?? {};
255+
return mergeVarExtShapes(applyVarExtsToSchema(inner, exts), name, exts);
256+
}
257+
const def = asType(schema);
258+
if (def.name !== "object" || def.shape === undefined) return schema;
259+
const shape = def.shape as Record<string, unknown>;
260+
const next: Record<string, unknown> = {};
261+
let changed = false;
262+
for (const [key, field] of Object.entries(shape)) {
263+
const rewritten = applyVarExtsToSchema(field, exts);
264+
next[key] = rewritten;
265+
if (rewritten !== field) changed = true;
266+
}
267+
return changed ? { ...def, shape: next } : schema;
268+
};
269+
270+
const mergeVarExtShapes = (
271+
schema: unknown,
272+
varName: string,
273+
exts: readonly EventVarExt[],
274+
) => {
275+
const matches = exts.filter((ext) => ext.name === varName);
276+
if (matches.length === 0) return schema;
277+
const def = asType(schema);
278+
const shape: Record<string, unknown> =
279+
def.name === "object" && def.shape !== undefined
280+
? { ...(def.shape as Record<string, unknown>) }
281+
: {};
282+
for (const ext of matches) {
283+
const extra = asType(ext.schema);
284+
if (extra.name === "object" && extra.shape !== undefined) {
285+
Object.assign(shape, extra.shape);
286+
}
287+
}
288+
return {
289+
...(def.name === "object" ? def : { name: "object" as const }),
290+
shape,
291+
};
292+
};
293+
241294
const publishOn = (
242295
name: string,
243296
type: string,
244297
data: unknown,
298+
varExts: readonly EventVarExt[] = [],
245299
): unknown | Promise<unknown> => {
246300
const bus = getBus(name);
247301
const schema = bus.types[type];
248302
const path = `event.${name}.${type}`;
249303
if (schema === undefined) {
250304
throw new Error(`${path}: unknown event kind "${type}"`);
251305
}
306+
const effective = applyVarExtsToSchema(schema, varExts);
252307
const handlers = [...bus.mounted, ...bus.direct];
253-
return thenMaybe(validate(asType(schema), data, path), (parsed) =>
254-
runHandlers(handlers, type, parsed, schema, path),
308+
return thenMaybe(validate(asType(effective), data, path), (parsed) =>
309+
runHandlers(handlers, type, parsed, effective, path),
255310
);
256311
};
257312

313+
/** Publish against a named bus, folding mounted var extensions into
314+
* kinds whose payload infers from those vars. Used by a fn context so
315+
* `c.bus.publish` matches the same `v.extend` / customize the handler
316+
* already sees on `c.input`. */
317+
export const publishEvent = (
318+
name: string,
319+
type: string,
320+
data: unknown,
321+
varExts: readonly EventVarExt[] = [],
322+
): unknown | Promise<unknown> => publishOn(name, type, data, varExts);
323+
258324
/** Register a module-mounted event listener (no-op if already present). */
259325
export const mountEventOn = (entry: EventOnEntry<string>) => {
260326
getBus(entry.name).mounted.add(entry.handler);

packages/experimental/src/fn.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
mountEvent,
1717
mountEventExtension,
1818
mountEventOn,
19+
publishEvent,
1920
} from "./event";
2021
import {
2122
type ApplyOns,
@@ -1169,7 +1170,15 @@ const defineFn = (
11691170
continue;
11701171
}
11711172
if (isEvent(used)) {
1172-
target[name] = used;
1173+
const event = used as {
1174+
name: string;
1175+
types: Record<string, unknown>;
1176+
};
1177+
target[name] = {
1178+
...used,
1179+
publish: (type: string, data: unknown) =>
1180+
publishEvent(event.name, type, data, exts),
1181+
};
11731182
continue;
11741183
}
11751184
// Storage mounts whole - do not walk `$models` as a namespace.

packages/experimental/src/module.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import {
88
onEvent,
99
} from "./event";
1010
import type { FnDefination } from "./fn";
11-
import { type InferArgs, type InferInput, isVar, type vTypes } from "./schema";
11+
import {
12+
type InferArgs,
13+
type InferInput,
14+
isVar,
15+
type TypeDefination,
16+
type vTypes,
17+
} from "./schema";
1218
import type { WidenSchemaFns } from "./scope";
1319
import type {
1420
LiteralString,
@@ -814,7 +820,9 @@ export type ExtendedArgs<PL, K extends string> = UnionToIntersection<
814820
* inputs also pick up `v.extend` / same-name `customize` via
815821
* {@link InputVarExtra}. Storage members get the same scope rewrite as a
816822
* db var's value ({@link WidenSchemaFns}): collections re-resolve
817-
* row/`Where` types against mounted `v.extend` / customize.
823+
* row/`Where` types against mounted `v.extend` / customize. Event kinds
824+
* whose payload is (or contains) a var pick up those same extras on
825+
* `publish` / `subscribe`.
818826
*
819827
* Walks nested module GROUPS and merge-var helper intersections
820828
* (`{ db: { createUser } }` / `VarDef & { createUser }`) so
@@ -840,6 +848,32 @@ type ApplyOnVarHelpers<V, PL> = [Exclude<keyof V, VarSurfaceKey>] extends [
840848
[K in Exclude<keyof V, VarSurfaceKey>]: ApplyOn<V[K], PL>;
841849
};
842850

851+
/**
852+
* Kinds whose payload references a var `PL` mutates. `never` when none -
853+
* {@link ApplyOn} then leaves the event as written.
854+
*/
855+
type EventVarExtraKeys<PL, T> = {
856+
[K in keyof T]: unknown extends InputVarExtraOut<PL, T[K]> ? never : K;
857+
}[keyof T];
858+
859+
/**
860+
* Rewrite one event kind so {@link EventPayloads} sees mounted
861+
* `v.extend` / customize fields. Unchanged kinds keep their schema;
862+
* widened kinds wrap the merged payload as a type def so `InferInput`
863+
* (and Date / class leaves) do not get remapped as object shapes.
864+
*/
865+
type WidenEventKind<S, PL> =
866+
unknown extends InputVarExtraOut<PL, S>
867+
? S
868+
: TypeDefination<
869+
Prettify<InferInput<S> & InputVarExtraOut<PL, S>>,
870+
Prettify<InferInput<S> & InputVarExtraOut<PL, S>>
871+
>;
872+
873+
type WidenEventTypes<T, PL> = {
874+
[K in keyof T]: WidenEventKind<T[K], PL>;
875+
};
876+
843877
export type ApplyOn<F, PL> = F extends StorageLike
844878
? WidenSchemaFns<F, PL>
845879
: F extends FnDefination<
@@ -868,11 +902,15 @@ export type ApplyOn<F, PL> = F extends StorageLike
868902
W,
869903
O
870904
>
871-
: F extends { $var: true }
872-
? ApplyOnVarHelpers<F, PL>
873-
: [GroupMember<F>] extends [never]
905+
: F extends EventDefination<infer N, infer T>
906+
? [EventVarExtraKeys<PL, T>] extends [never]
874907
? F
875-
: { [P in keyof F]: ApplyOn<F[P], PL> };
908+
: EventDefination<N, WidenEventTypes<T, PL>>
909+
: F extends { $var: true }
910+
? ApplyOnVarHelpers<F, PL>
911+
: [GroupMember<F>] extends [never]
912+
? F
913+
: { [P in keyof F]: ApplyOn<F[P], PL> };
876914

877915
export type ApplyOns<Fns, PL> = { [P in keyof Fns]: ApplyOn<Fns[P], PL> };
878916

0 commit comments

Comments
 (0)