Skip to content

Commit 9d2ed05

Browse files
committed
feat(experimental): input & output variable schemas
1 parent 69399df commit 9d2ed05

11 files changed

Lines changed: 397 additions & 180 deletions

File tree

packages/experimental/src/attrs.test.ts

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
import { describe, expect, it } from "vitest";
1+
import { describe, expect, expectTypeOf, it } from "vitest";
22
import { ValidationError } from "./error";
33
import { v } from "./index";
44
import {
55
asType,
66
attrsOf,
7+
isNoInput,
8+
isNoOutput,
79
omitFields,
810
parseFields,
911
rejectFields,
12+
type SchemaInputOf,
13+
type SchemaOutputOf,
1014
validate,
1115
withAttrs,
1216
} from "./schema";
@@ -16,10 +20,10 @@ describe("schema $attrs", () => {
1620
const base = v.string();
1721
const once = withAttrs(base, "db", { unique: true });
1822
const twice = withAttrs(once, "db", { index: true });
19-
const both = withAttrs(twice, "http", { serverOnly: true });
23+
const both = withAttrs(twice, "http", { tag: true });
2024

2125
expect(attrsOf(twice, "db")).toEqual({ unique: true, index: true });
22-
expect(attrsOf(both, "http")).toEqual({ serverOnly: true });
26+
expect(attrsOf(both, "http")).toEqual({ tag: true });
2327
expect(attrsOf(both)?.db).toEqual({ unique: true, index: true });
2428
// Original untouched.
2529
expect(attrsOf(base)).toBeUndefined();
@@ -44,11 +48,11 @@ describe("schema $attrs", () => {
4448
default: null,
4549
schema: v.object({ id: v.string() }),
4650
});
47-
const marked = withAttrs(user, "http", { serverOnly: true });
51+
const marked = withAttrs(user, "http", { tag: true });
4852
expect(marked.$var).toBe(true);
4953
expect(marked.name).toBe("attr_user");
5054
expect(typeof marked.customize).toBe("function");
51-
expect(attrsOf(marked, "http")).toEqual({ serverOnly: true });
55+
expect(attrsOf(marked, "http")).toEqual({ tag: true });
5256
// Field schema on the var is untouched.
5357
expect(attrsOf(marked.schema)).toBeUndefined();
5458
});
@@ -58,27 +62,85 @@ describe("schema $attrs", () => {
5862
default: null,
5963
schema: v.object({ id: v.string() }),
6064
});
61-
const marked = withAttrs(user, "http", { serverOnly: true });
65+
const marked = withAttrs(user, "http", { tag: true });
6266
const widened = marked.customize({
6367
schema: (c) => c.add({ role: c.string() }),
6468
});
6569
expect(widened.$var).toBe(true);
66-
expect(attrsOf(widened, "http")).toEqual({ serverOnly: true });
70+
expect(attrsOf(widened, "http")).toEqual({ tag: true });
6771
expect(
6872
(widened.schema as { shape: Record<string, unknown> }).shape.role,
6973
).toBeDefined();
7074
});
7175
});
7276

77+
describe("v.noInput / v.noOutput and schema views", () => {
78+
const user = v.var("attr_views_user", {
79+
schema: v.object({
80+
id: v.noInput(v.string()),
81+
email: v.string(),
82+
passwordHash: v.noOutput(v.string()),
83+
}),
84+
});
85+
86+
it("marks $attrs.v", () => {
87+
expect(attrsOf(v.noInput(v.string()), "v")).toEqual({ noInput: true });
88+
expect(attrsOf(v.noOutput(v.string()), "v")).toEqual({ noOutput: true });
89+
expect(isNoInput(v.noInput(v.string()))).toBe(true);
90+
expect(isNoOutput(v.noOutput(v.string()))).toBe(true);
91+
});
92+
93+
it("var.input and var.output project nested fields", () => {
94+
const inputShape = asType(user.input.schema).shape as Record<
95+
string,
96+
unknown
97+
>;
98+
const outputShape = asType(user.output.schema).shape as Record<
99+
string,
100+
unknown
101+
>;
102+
expect(Object.keys(inputShape).sort()).toEqual(["email", "passwordHash"]);
103+
expect(Object.keys(outputShape).sort()).toEqual(["email", "id"]);
104+
});
105+
106+
it("type .input / .output match omitFields", () => {
107+
const schema = v.object({
108+
id: v.noInput(v.string()),
109+
email: v.string(),
110+
secret: v.noOutput(v.string()),
111+
});
112+
expect(Object.keys(asType(schema.input).shape as object).sort()).toEqual([
113+
"email",
114+
"secret",
115+
]);
116+
expect(Object.keys(asType(schema.output).shape as object).sort()).toEqual([
117+
"email",
118+
"id",
119+
]);
120+
});
121+
122+
it("SchemaInputOf / SchemaOutputOf drop gated keys", () => {
123+
type Row = {
124+
id: ReturnType<typeof v.noInput<ReturnType<typeof v.string>>>;
125+
email: ReturnType<typeof v.string>;
126+
passwordHash: ReturnType<typeof v.noOutput<ReturnType<typeof v.string>>>;
127+
};
128+
expectTypeOf<keyof SchemaInputOf<Row>>().toEqualTypeOf<
129+
"email" | "passwordHash"
130+
>();
131+
expectTypeOf<keyof SchemaOutputOf<Row>>().toEqualTypeOf<"id" | "email">();
132+
});
133+
});
134+
73135
describe("omitFields / rejectFields / parseFields", () => {
74136
const dropMarked = (schema: unknown) =>
75-
attrsOf(schema, "http")?.readonly === true;
137+
attrsOf(schema, "v")?.noInput === true;
76138
const shape = v.object({
77139
id: v.string(),
78-
role: withAttrs(v.string(), "http", { readonly: true }),
140+
role: v.noInput(v.string()),
79141
meta: v.object({
80142
note: v.string(),
81-
secret: withAttrs(v.string(), "http", { readonly: true }),
143+
secret: v.noInput(v.string()),
82144
}),
83145
});
84146

packages/experimental/src/capability.test.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -505,16 +505,15 @@ describe("serve", () => {
505505
createAgent(transport, { attestation: forged }),
506506
).rejects.toThrow(/attestation not signed by this server/);
507507
});
508-
it("wire rejects http.serverOnly fields; in-process still accepts them", async () => {
509-
const { serverOnly } = await import("./plugins/http/attrs");
508+
it("wire and in-process both reject v.noInput fields", async () => {
510509
const { ValidationError } = await import("./error");
511510

512511
const createUser = v.fn(
513512
"user.create",
514513
{
515514
input: {
516515
email: v.string(),
517-
role: serverOnly(v.string({ optional: true })),
516+
role: v.noInput(v.string({ optional: true })),
518517
},
519518
use: [{ capability }],
520519
},
@@ -538,12 +537,15 @@ describe("serve", () => {
538537

539538
await expect(
540539
agent.call("user.create", { email: "a@b.c" }),
541-
).resolves.toEqual({ email: "a@b.c", role: undefined });
540+
).resolves.toEqual({ email: "a@b.c" });
542541

543-
// Direct in-process call may pass server-only fields.
544-
await expect(
545-
createUser({ email: "x@y.z", role: "admin" }),
546-
).resolves.toEqual({ email: "x@y.z", role: "admin" });
542+
// In-process uses the same .input view (sync throw at the door).
543+
expect(() => createUser({ email: "x@y.z", role: "admin" })).toThrow(
544+
ValidationError,
545+
);
546+
await expect(createUser({ email: "x@y.z" })).resolves.toEqual({
547+
email: "x@y.z",
548+
});
547549
});
548550
});
549551

packages/experimental/src/capability.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { collectFns, type FnDefination, type Module, v } from "./index";
2-
import { rejectServerOnly } from "./plugins/http/attrs";
2+
import { isNoInput, rejectFields } from "./schema";
33

44
const subtle = globalThis.crypto.subtle;
55

@@ -565,12 +565,17 @@ export const serve = async (
565565
const held = await spend(token);
566566
c.capability = { ...held, entry: c.input.call };
567567
}
568-
// Wire edge: reject http.readonly / serverOnly fields before the
569-
// target validates (object validate would otherwise strip them
570-
// quietly).
568+
// Wire edge: reject v.noInput fields before the target validates
569+
// (object validate would otherwise strip them quietly).
571570
const inputSchema = (target as FnDefination<any, any>).$schema?.input;
572571
if (inputSchema !== undefined) {
573-
await rejectServerOnly(inputSchema, c.input.input, c.input.call);
572+
await rejectFields(
573+
inputSchema,
574+
c.input.input,
575+
isNoInput,
576+
c.input.call,
577+
"noInput field is not allowed over the wire",
578+
);
574579
}
575580
return (target as (i: unknown, p: unknown) => unknown)(c.input.input, c);
576581
},

packages/experimental/src/fn.test.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,20 +73,34 @@ describe("v.fn call forms", () => {
7373
expect(() => make({ ok: "nope" })()).toThrow(/fnt\.split\.output/);
7474
});
7575

76-
it("http.returned fields are stripped from output validation", async () => {
77-
const { returned } = await import("./plugins/http/attrs");
78-
const user = v.var("fnt_returned_user", {
76+
it("v.noOutput fields are stripped from output validation", async () => {
77+
const user = v.var("fnt_no_output_user", {
7978
schema: v.object({
8079
id: v.string(),
81-
password: returned(v.string()),
80+
password: v.noOutput(v.string()),
8281
}),
8382
});
84-
const f = v.fn("fnt.returned", { output: user }, () => ({
83+
const f = v.fn("fnt.noOutput", { output: user }, () => ({
8584
id: "1",
8685
password: "secret",
8786
}));
8887
expect(f()).toEqual({ id: "1" });
8988
});
89+
90+
it("v.noInput fields are rejected on in-process fn input", () => {
91+
const f = v.fn(
92+
"fnt.noInput",
93+
{
94+
input: {
95+
email: v.string(),
96+
role: v.noInput(v.string({ optional: true })),
97+
},
98+
},
99+
(c) => c.input,
100+
);
101+
expect(() => f({ email: "a@b.c", role: "admin" })).toThrow(/noInput field/);
102+
expect(f({ email: "a@b.c" })).toEqual({ email: "a@b.c" });
103+
});
90104
});
91105

92106
describe("omittable input", () => {

packages/experimental/src/fn.ts

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,18 @@ import {
4242
} from "./module";
4343
import {
4444
asType,
45-
attrsOf,
4645
type InferArgs,
4746
type InferInput,
47+
isNoInput,
4848
isVar,
4949
type OutputSchemaOf,
50-
omitFields,
5150
outputContract,
51+
rejectFields,
52+
type SchemaInputOf,
53+
type SchemaOutputOf,
5254
type TypeDefination,
55+
toInputSchema,
56+
toOutputSchema,
5357
validate,
5458
vTypes,
5559
} from "./schema";
@@ -304,10 +308,10 @@ export interface FnDefination<
304308
}
305309

306310
export type ArgsOf<I> = I extends readonly unknown[]
307-
? { -readonly [K in keyof I]: InferArgs<I[K]> }
311+
? { -readonly [K in keyof I]: InferArgs<SchemaInputOf<I[K]>> }
308312
: unknown extends I
309313
? void
310-
: InferArgs<I>;
314+
: InferArgs<SchemaInputOf<I>>;
311315

312316
/**
313317
* Call args of a declaring fn: the declared input, plus whatever mounted
@@ -450,7 +454,9 @@ type VarSurfaceKeys =
450454
| "$source"
451455
| "$attrs"
452456
| "$merge"
453-
| "customize";
457+
| "customize"
458+
| "input"
459+
| "output";
454460

455461
type MergeHelpersOnVar<V> = Omit<V, VarSurfaceKeys>;
456462

@@ -549,8 +555,8 @@ export type Context<
549555
ExtPL = unknown,
550556
> = {
551557
input: unknown extends InputVarExtraOut<ExtPL, I>
552-
? InferInput<I>
553-
: Prettify<InferInput<I> & InputVarExtraOut<ExtPL, I>>;
558+
? InferInput<SchemaInputOf<I>>
559+
: Prettify<InferInput<SchemaInputOf<I>> & InputVarExtraOut<ExtPL, I>>;
554560
/**
555561
* Mint a DECLARED error - tag-checked, payload validated at creation:
556562
* `throw c.error("invalid_credentials", { attempts: 3 })`. Only tags
@@ -581,7 +587,7 @@ export type Context<
581587

582588
export type InferReturn<O> = unknown extends O
583589
? unknown
584-
: InferInput<OutputSchemaOf<O>>;
590+
: InferInput<SchemaOutputOf<OutputSchemaOf<O>>>;
585591

586592
export interface Fn<
587593
Base = unknown,
@@ -862,17 +868,13 @@ const defineFn = (
862868

863869
// Only the VALIDATION half of the output contract is checked on exit -
864870
// a `{ def }`-only output is a documented promise, never a check.
865-
// Fields marked `http.returned` are projected out so they never leave
866-
// the process through this fn's result (direct in-process data on the
867-
// handler's locals is unaffected).
871+
// Fields marked `v.noOutput` are projected out via `.output` so they
872+
// never leave through this fn's result.
868873
const rawOutputValidation = outputContract(options.output).validation;
869874
const outputValidation =
870875
rawOutputValidation === undefined
871876
? undefined
872-
: omitFields(
873-
rawOutputValidation,
874-
(field) => attrsOf(field, "http")?.returned === true,
875-
);
877+
: toOutputSchema(rawOutputValidation);
876878
const errorTypes = declaredErrors
877879
? Object.fromEntries(
878880
Object.entries(declaredErrors).map(([tag, schema]) => [
@@ -994,11 +996,20 @@ const defineFn = (
994996
if (tupleInput) {
995997
const attempts = tupleInput.map((def, index) => {
996998
try {
997-
const result = validate(
998-
asType(def),
999-
(input as unknown[])[index],
1000-
`${key}.input[${index}]`,
999+
const path = `${key}.input[${index}]`;
1000+
const raw = (input as unknown[])[index];
1001+
const gate = rejectFields(
1002+
def,
1003+
raw,
1004+
isNoInput,
1005+
path,
1006+
"noInput field is not allowed",
10011007
);
1008+
const runValidate = () =>
1009+
validate(asType(toInputSchema(def)), raw, path);
1010+
const result = isThenable(gate)
1011+
? gate.then(runValidate)
1012+
: runValidate();
10021013
if (isThenable(result)) {
10031014
return result.then(
10041015
(value) => ({ ok: true as const, value }),
@@ -1047,7 +1058,21 @@ const defineFn = (
10471058
}
10481059
return options.input === undefined
10491060
? input
1050-
: validate(asType(options.input), input, `${key}.input`);
1061+
: thenMaybe(
1062+
rejectFields(
1063+
options.input,
1064+
input,
1065+
isNoInput,
1066+
`${key}.input`,
1067+
"noInput field is not allowed",
1068+
),
1069+
() =>
1070+
validate(
1071+
asType(toInputSchema(options.input)),
1072+
input,
1073+
`${key}.input`,
1074+
),
1075+
);
10511076
};
10521077

10531078
const applyInputExtensions = (parsed: unknown) => {
@@ -1268,7 +1293,7 @@ const defineFn = (
12681293
);
12691294

12701295
// Exit contracts run after the body, whether or not it was async.
1271-
// Output validation both checks AND projects (so `http.returned`
1296+
// Output validation both checks AND projects (so `v.noOutput`
12721297
// fields / undeclared keys leave through the validated shape).
12731298
const finish = (result: unknown) => {
12741299
const afterOutput = (out: unknown) => {

0 commit comments

Comments
 (0)