Skip to content

Commit 32699a2

Browse files
committed
feat(expermental): infer .input & .output of vars in events
1 parent 32a9c2f commit 32699a2

4 files changed

Lines changed: 115 additions & 16 deletions

File tree

packages/experimental/src/event.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,4 +309,47 @@ describe("event extension + modules", () => {
309309
)(),
310310
).toThrow(/tag/);
311311
});
312+
313+
it("publish expects .input; complete resolves .output", async () => {
314+
const user = v.var("evt_io_user", {
315+
schema: v.object({
316+
id: v.noInput(v.string({ default: "minted" })),
317+
email: v.string(),
318+
passwordHash: v.noOutput(v.string()),
319+
}),
320+
});
321+
const bus = v.event("evt_io", { created: user });
322+
323+
bus.subscribe(async (e, next) => {
324+
if (e.type === "created") {
325+
await next({
326+
id: "srv-1",
327+
passwordHash: "hashed",
328+
});
329+
return;
330+
}
331+
await next();
332+
});
333+
334+
expect(() =>
335+
bus.publish("created", {
336+
email: "a@b.c",
337+
id: "smuggle",
338+
} as never),
339+
).toThrow(/noInput field/);
340+
341+
const [result, complete] = await bus.publish("created", {
342+
email: "a@b.c",
343+
passwordHash: "plain",
344+
});
345+
expectTypeOf(result).toEqualTypeOf<{
346+
email: string;
347+
passwordHash: string;
348+
}>();
349+
expect(result).toEqual({ email: "a@b.c", passwordHash: "plain" });
350+
351+
const out = await complete();
352+
expectTypeOf(out).toEqualTypeOf<{ id: string; email: string }>();
353+
expect(out).toEqual({ id: "srv-1", email: "a@b.c" });
354+
});
312355
});

packages/experimental/src/event.ts

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,45 @@
11
import { ValidationError } from "./error";
2-
import { asType, type InferInput, isVar, validate } from "./schema";
2+
import {
3+
asType,
4+
type InferArgs,
5+
type InferInput,
6+
isNoInput,
7+
isNoOutput,
8+
isVar,
9+
projectValue,
10+
rejectFields,
11+
type SchemaInputOf,
12+
type SchemaOutputOf,
13+
toInputSchema,
14+
validate,
15+
} from "./schema";
316
import type {
417
LiteralString,
518
Members,
619
Prettify,
720
UnionToIntersection,
821
} from "./types";
922

10-
/** Payload for each kind in an event-type map. */
23+
/** Handler-visible payload (full schema, including noInput / noOutput). */
1124
export type EventPayloads<T> = {
1225
[K in keyof T]: InferInput<T[K]>;
1326
};
1427

28+
/** What {@link EventDefination.publish} accepts - the `.input` view. */
29+
export type EventPublishArgs<T> = {
30+
[K in keyof T]: InferArgs<SchemaInputOf<T[K]>>;
31+
};
32+
33+
/** Validated publish-time payload - parsed `.input` view. */
34+
export type EventPublishResult<T> = {
35+
[K in keyof T]: InferInput<SchemaInputOf<T[K]>>;
36+
};
37+
38+
/** What `complete()` resolves to - the `.output` view. */
39+
export type EventCompleteResult<T> = {
40+
[K in keyof T]: InferInput<SchemaOutputOf<T[K]>>;
41+
};
42+
1543
/** Discriminated message handed to subscribers. */
1644
export type EventMessage<T> = Prettify<
1745
{
@@ -47,18 +75,21 @@ export interface EventDefination<
4775
*/
4876
subscribe: (handler: EventHandler<T>) => () => void;
4977
/**
50-
* Validate `data` against the kind's schema, run subscribers (direct +
51-
* any mounted via `v.on` / modules), merge `next` mutations, and return:
52-
* - `result`: the validated payload at publish-time
53-
* - `complete`: a promise function resolving to the final payload after
54-
* the full subscriber chain finishes
78+
* Validate `data` against the kind's `.input` view, run subscribers
79+
* (direct + any mounted via `v.on` / modules), merge `next` mutations,
80+
* and return:
81+
* - `result`: the validated `.input` payload at publish-time
82+
* - `complete`: a promise function resolving to the `.output` payload
83+
* after the full subscriber chain finishes
5584
*/
5685
publish: <K extends keyof T & string>(
5786
type: K,
58-
data: EventPayloads<T>[K],
87+
data: EventPublishArgs<T>[K],
5988
) =>
60-
| [EventPayloads<T>[K], () => Promise<EventPayloads<T>[K]>]
61-
| Promise<[EventPayloads<T>[K], () => Promise<EventPayloads<T>[K]>]>;
89+
| [EventPublishResult<T>[K], () => Promise<EventCompleteResult<T>[K]>]
90+
| Promise<
91+
[EventPublishResult<T>[K], () => Promise<EventCompleteResult<T>[K]>]
92+
>;
6293
/**
6394
* Mint a NEW event def under the same name with more kinds - the
6495
* re-export pattern (`customize` for vars). Shared bus; widened types.
@@ -307,12 +338,30 @@ const publishOn = (
307338
}
308339
const effective = applyVarExtsToSchema(schema, varExts);
309340
const handlers = [...bus.mounted, ...bus.direct];
310-
return thenMaybe(validate(asType(effective), data, path), (parsed) => {
341+
// Publish door matches v.fn input: reject smuggled noInput keys, then
342+
// validate the `.input` view. Handlers still patch against the full
343+
// schema so they can fill noInput fields via `next`.
344+
const parseInput = () =>
345+
thenMaybe(
346+
rejectFields(
347+
effective,
348+
data,
349+
isNoInput,
350+
path,
351+
"noInput field is not allowed",
352+
),
353+
() => validate(asType(toInputSchema(effective)), data, path),
354+
);
355+
return thenMaybe(parseInput(), (parsed) => {
311356
const done = runHandlers(handlers, type, parsed, effective, path);
312357
return [
313358
parsed,
314359
() =>
315-
isThenable(done) ? (done as Promise<unknown>) : Promise.resolve(done),
360+
Promise.resolve(
361+
thenMaybe(done, (final) =>
362+
projectValue(effective, final, isNoOutput),
363+
),
364+
),
316365
] as [unknown, () => Promise<unknown>];
317366
});
318367
};

packages/experimental/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,13 +148,16 @@ export {
148148
ValidationError,
149149
} from "./error";
150150
export type {
151+
EventCompleteResult,
151152
EventDefination,
152153
EventExtension,
153154
EventHandler,
154155
EventMessage,
155156
EventNext,
156157
EventOnEntry,
157158
EventPayloads,
159+
EventPublishArgs,
160+
EventPublishResult,
158161
EventsFrom,
159162
ModuleEvents,
160163
} from "./event";

packages/experimental/src/schema.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -691,9 +691,10 @@ export const omitFields = <S>(schema: S, drop: FieldPred): S => {
691691
/**
692692
* Strip keys from an already-validated value where `drop` matches the
693693
* field schema. Used after union arm selection so defaults/transforms
694-
* from the probe validate are not run a second time.
694+
* from the probe validate are not run a second time, and by event
695+
* `complete()` to project the `.output` view without re-transforming.
695696
*/
696-
const omitValue = (
697+
export const projectValue = (
697698
schema: unknown,
698699
value: unknown,
699700
drop: FieldPred,
@@ -711,16 +712,19 @@ const omitValue = (
711712
def.shape as Record<string, unknown>,
712713
)) {
713714
if (drop(child) || !Object.hasOwn(record, key)) continue;
714-
out[key] = omitValue(child, record[key], drop);
715+
out[key] = projectValue(child, record[key], drop);
715716
}
716717
return out;
717718
}
718719
if (def.name === "array" && def.shape !== undefined && Array.isArray(value)) {
719-
return value.map((item) => omitValue(def.shape, item, drop));
720+
return value.map((item) => projectValue(def.shape, item, drop));
720721
}
721722
return value;
722723
};
723724

725+
/** @deprecated Use {@link projectValue}. */
726+
const omitValue = projectValue;
727+
724728
/**
725729
* Throw if any field matching `match` is present on `value` (own key).
726730
* Object validation otherwise strips unknown keys, so this is what stops

0 commit comments

Comments
 (0)