Skip to content

Commit fab4d9c

Browse files
committed
fix(experimental): enhance declaration emit safety with opaque seed types
Updated the declaration emit tests to ensure that the emitted types remain under the TypeScript serialization limit (TS7056) by using opaque seed types. Adjusted the `WithSeed` types to prevent inlining of the use graph and added tests for `fn.with` and `Instance.with` to verify correct behavior with the new opaque seeds. Also included updates to the `auth-fns` to reflect these changes.
1 parent 32699a2 commit fab4d9c

7 files changed

Lines changed: 258 additions & 45 deletions

File tree

packages/experimental/src/declaration-emit.test.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,37 @@ describe("declaration emit (TS7056)", () => {
4141
expect(dts).not.toMatch(/\$models/);
4242
expect(dts).toMatch(/export declare const signUpEmail:/);
4343
expect(dts).toMatch(/export declare const signInEmail:/);
44-
// Compact `W` (var leaves) plus bound creates on intersected `.with`.
45-
expect(dts).toMatch(/createUser\?:/);
46-
expect(dts).toMatch(/user\?:/);
44+
// Opaque W - no intersected `.with(WithSeed<RV,U>)` and no use-graph.
45+
expect(dts).not.toMatch(/with\(context:/);
46+
expect(dts).not.toMatch(/createUser\?:/);
47+
} finally {
48+
rmSync(outDir, { recursive: true, force: true });
49+
}
50+
});
51+
52+
it("exports leaf createAccount / core bags / signUpEmail with db use", () => {
53+
const outDir = mkdtempSync(join(tmpdir(), "bc-decl-leaf-"));
54+
try {
55+
execFileSync(
56+
process.execPath,
57+
[tsc, "-p", join(fixtureDir, "tsconfig.json"), "--outDir", outDir],
58+
{ cwd: root, stdio: "pipe" },
59+
);
60+
61+
const dts = readFileSync(
62+
join(outDir, "test/declaration-emit/leaf-exports.d.ts"),
63+
"utf8",
64+
);
65+
expect(dts.length).toBeLessThan(MAX_DTS_BYTES);
66+
expect(dts).not.toMatch(/ScopeOf|ResolvedVars/);
67+
expect(dts).not.toMatch(/\$models/);
68+
expect(dts).toMatch(/export declare const createAccount:/);
69+
expect(dts).toMatch(/export declare const createUser:/);
70+
expect(dts).toMatch(/export declare const signUpEmail:/);
71+
expect(dts).toMatch(/export declare const coreUser:/);
72+
expect(dts).toMatch(/export declare const emailPassword:/);
73+
expect(dts).not.toMatch(/with\(context:/);
74+
expect(dts).not.toMatch(/createUser\?:/);
4775
} finally {
4876
rmSync(outDir, { recursive: true, force: true });
4977
}

packages/experimental/src/fn.ts

Lines changed: 57 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -192,53 +192,55 @@ type WithFnsSeed<U> = {
192192
: WithFnsSeed<U[K]>;
193193
};
194194

195-
/** Var seeds only - the half of {@link WithSeed} that stays small in
196-
* declaration emit when the `use` fn override map is wide. */
195+
/** Var seeds only - the half of {@link WithSeed} that stays small when
196+
* the `use` fn override map is wide. */
197197
type WithVarsSeed<RV> = { [K in keyof RV]?: RV[K] };
198198

199199
/**
200-
* Flat `.with` seed map stored on exported fns. Evaluating ScopeOf /
201-
* ModuleFns here (instead of embedding those wrappers as type arguments)
202-
* keeps declaration emit small: `.d.ts` shows leaf var shapes and bound
203-
* call signatures, not `ScopeOf<ResolvedVars<entire module graph>>`.
204-
*
205-
* Declaration emit stores {@link WithSeedStored} on the fn (var seeds
206-
* only); terminating `.fn()` intersects a full {@link WithSeed} `.with`.
200+
* Flat `.with` seed map: var values plus bound call overrides for `use`
201+
* fns. Prefer this on {@link Instance.with} (builder-scoped) - putting
202+
* `ScopeOf` / `ModuleFns` onto an exported terminating fn's inferred type
203+
* blows past declaration serialize limits (TS7056).
207204
*/
208205
export type WithSeed<RV, U> = Prettify<WithVarsSeed<RV> & WithFnsSeed<U>>;
209206

210-
/** What declaration emit stores for `W` - var seeds only so TS does not
211-
* inline `BoundFnCall` per used fn from the builder `use` graph. */
207+
/**
208+
* @deprecated Var-seed half of {@link WithSeed}. Terminating exports no
209+
* longer store this on `W` - use {@link WithSeedOpaque} / {@link Instance.with}.
210+
*/
212211
export type WithSeedStored<RV, _U = unknown> = WithVarsSeed<RV>;
213212

214-
/** What `v.fn` / `e.fn` returns: contract params plus a stored seed map
215-
* for `.with`, never the raw ScopeOf / ModuleFns graph. */
213+
/**
214+
* Emit-safe `.with` seed on terminating / exported fns. A plain object
215+
* bag - declaration emit never inlines the builder's `ScopeOf` /
216+
* `ModuleFns` graph. Precise seed checking lives on {@link Instance.with}.
217+
*/
218+
export type WithSeedOpaque = object;
219+
220+
/** What `v.fn` / `e.fn` returns: contract params plus an opaque `.with`
221+
* seed slot - never `ScopeOf` / `ModuleFns` / {@link WithSeed}. */
216222
export type PublicFn<
217223
A,
218224
R,
219225
K extends string,
220226
I,
221227
P extends readonly string[],
222228
Er,
223-
W = unknown,
229+
W = WithSeedOpaque,
224230
O = unknown,
225231
> = FnDefination<A, R, K, I, P, Er, W, O>;
226232

227-
/** Terminating `.fn()` product: compact {@link WithSeedStored} on the
228-
* fn for declaration emit, full {@link WithSeed} on `.with` while typing. */
233+
/** Terminating `.fn()` product: opaque `W` so `export const foo = b.fn(...)`
234+
* stays under TS7056. Precise seeds: `builder.with(foo, seed)`. */
229235
type TerminatingFn<
230236
A,
231237
R,
232238
K extends string,
233239
I,
234240
P extends readonly string[],
235241
Er,
236-
RV,
237-
U,
238242
O = unknown,
239-
> = PublicFn<A, R, K, I, P, Er, WithSeedStored<RV, U>, O> & {
240-
with(context: WithSeed<RV, U>): BoundCall<A, R, I, Er>;
241-
};
243+
> = PublicFn<A, R, K, I, P, Er, WithSeedOpaque, O>;
242244

243245
/** What `.with` returns: the same callable, context baked in.
244246
* Re-exported from the package entry so exporting `.with(...)` results
@@ -255,9 +257,10 @@ export interface FnDefination<
255257
I = unknown,
256258
P extends readonly string[] = readonly string[],
257259
Er = NoErrors,
258-
/** `.with` seed map ({@link WithSeed}). Defaults keep structural
259-
* `extends FnDefination<any, ...>` checks passing. */
260-
W = unknown,
260+
/** `.with` seed map. Terminating exports use {@link WithSeedOpaque};
261+
* defaults keep structural `extends FnDefination<any, ...>` checks
262+
* passing. */
263+
W = WithSeedOpaque,
261264
O = unknown,
262265
> {
263266
(...args: CallArgs<A, I>): R;
@@ -271,10 +274,10 @@ export interface FnDefination<
271274
/**
272275
* Call with a HAND-BUILT context. Keys naming a var SEED that var in a
273276
* fresh scope; keys naming a `use` fn OVERRIDE that binding for the
274-
* whole subtree below. Both are typed from the fn's chain - what the
275-
* BUILDER mounted counts, so `signOut.with({ user })` type-checks even
276-
* though `signOut` itself never says `use: [user]`. A parent passed to
277-
* the bound call is FORKED: its vars are copied in, never written back.
277+
* whole subtree below. On terminating / exported fns `W` is
278+
* {@link WithSeedOpaque} (emit-safe); for precise seed checking use
279+
* {@link Instance.with}. A parent passed to the bound call is FORKED:
280+
* its vars are copied in, never written back.
278281
*/
279282
with(context: W): BoundCall<A, R, I, Er>;
280283
/** Brand, so a plugin module can be scanned for its fns. */
@@ -651,9 +654,7 @@ export interface Fn<
651654
Prefix extends "" ? string : Prefix,
652655
unknown,
653656
readonly string[],
654-
NoErrors,
655-
ScopeOf<[], Base, BasePL>,
656-
BaseFns
657+
NoErrors
657658
>;
658659
<K extends LiteralString, R>(
659660
key: K,
@@ -672,9 +673,7 @@ export interface Fn<
672673
`${Prefix}${K}`,
673674
unknown,
674675
readonly string[],
675-
NoErrors,
676-
ScopeOf<[], Base, BasePL>,
677-
BaseFns
676+
NoErrors
678677
>;
679678

680679
<
@@ -712,8 +711,6 @@ export interface Fn<
712711
I,
713712
P,
714713
Er,
715-
ScopeOf<PL, Base, ChainPL<BasePL, PL>>,
716-
UsableInScope<BaseFns, PL, BasePL>,
717714
O
718715
>;
719716
<
@@ -753,8 +750,6 @@ export interface Fn<
753750
I,
754751
P,
755752
Er,
756-
ScopeOf<PL, Base, ChainPL<BasePL, PL>>,
757-
UsableInScope<BaseFns, PL, BasePL>,
758753
O
759754
>;
760755

@@ -1656,6 +1651,18 @@ export interface InstanceOn<Base, BaseFns, Prefix extends string> {
16561651
): OnEntry<`${Prefix}${N}`, Ext>;
16571652
}
16581653

1654+
/** Bound call from a terminating fn - used by {@link Instance.with}. */
1655+
type BoundCallFrom<F> = F extends FnDefination<
1656+
infer A,
1657+
infer R,
1658+
string,
1659+
infer I,
1660+
any,
1661+
infer Er
1662+
>
1663+
? BoundCall<A, R, I, Er>
1664+
: never;
1665+
16591666
export type Instance<
16601667
Base,
16611668
BaseFns,
@@ -1676,6 +1683,16 @@ export type Instance<
16761683
/** Same as `v.on`, with the prefix on string targets and the handler
16771684
* typed against the matched target fn. */
16781685
on: InstanceOn<Base, BaseFns, Prefix>;
1686+
/**
1687+
* Precise `.with` for fns built on this builder. Seeds are typed from
1688+
* the builder's mounted vars / `use` fns - without putting that graph
1689+
* onto the fn's exported declaration type (see {@link WithSeedOpaque}).
1690+
* Runtime is `fn.with(context)`.
1691+
*/
1692+
with<F extends FnDefination<any, any, string, any, any, any>>(
1693+
fn: F,
1694+
context: WithSeed<ScopeOf<[], Base, PL>, BaseFns>,
1695+
): BoundCallFrom<F>;
16791696
/**
16801697
* The context a handler on this builder receives - a TYPE carrier for
16811698
* `typeof f.ctx` (helper signatures, plugin contracts). Every handler
@@ -1748,6 +1765,8 @@ const builderFn = (baseKey: string, base: Record<string, any>) => {
17481765
a,
17491766
b,
17501767
),
1768+
with: (fn: { with: (context: unknown) => unknown }, context: unknown) =>
1769+
fn.with(context),
17511770
};
17521771
}
17531772
return defineFn(key || "anonymous", options, handler);

packages/experimental/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ export type {
183183
WidenedArgs,
184184
WithContext,
185185
WithSeed,
186+
WithSeedOpaque,
187+
WithSeedStored,
186188
} from "./fn";
187189
export {
188190
type ApplyOn,

packages/experimental/src/with.test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,22 @@ describe("fn.with", () => {
7878
).toBe("mock");
7979
});
8080

81+
it("fn.with stays callable with an opaque seed (emit-safe)", () => {
82+
expect(label.with({ user: { id: "ada" } })()).toBe("user:ada");
83+
expect(label.with({ nope: 1 } as object)()).toBe("anonymous");
84+
});
85+
});
86+
87+
describe("Instance.with", () => {
88+
it("seeds with precise builder-scoped checking", () => {
89+
expect(s.with(label, { user: { id: "ada" } })()).toBe("user:ada");
90+
expect(s.with(flow, { send: () => "mock" })()).toBe("mock");
91+
});
92+
8193
it("only the chain's vars and fns type-check", () => {
82-
// @ts-expect-error - `nope` is neither a var nor a use fn of `label`
83-
label.with({ nope: 1 });
94+
// @ts-expect-error - `nope` is neither a var nor a use fn of the builder
95+
s.with(label, { nope: 1 });
8496
// @ts-expect-error - value must match the var's type
85-
label.with({ user: { id: 42 } });
97+
s.with(label, { user: { id: 42 } });
8698
});
8799
});

packages/experimental/test/declaration-emit/auth-fns.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ export const signInEmail = e.fn(
173173
signUpEmail({ email: "a@b.c", password: "x" });
174174
signUpEmail.try({ email: "a@b.c", password: "x" });
175175
signUpEmail.with({ user: null });
176+
e.with(signUpEmail, { user: null, createUser: async () => null as never });
176177
signInEmail.key satisfies "auth.sign_in.email";
177178
signUpEmail.provides satisfies readonly ["user", "session"];
178179

0 commit comments

Comments
 (0)