Skip to content

Commit 0fa64ac

Browse files
TML-2955: A client-safe static surface — @prisma-next/<target>/static (#888)
## At a glance You can now use a contract's enums, query builder, and generated types in a `'use client'` component — with **no database driver in the browser bundle**: ```tsx 'use client'; import mongoStatic from '@prisma-next/mongo/static'; import contractJson from './contract.json' with { type: 'json' }; import type { Contract } from './contract'; const { enums } = mongoStatic<Contract>({ contractJson }); export function OrderTypePicker() { // fully typed from the contract — enums.OrderType.members.Delivery, .Pickup, … return <RadioGroup options={Object.values(enums.OrderType.members)} />; } ``` The same call shape exists on every target (`postgresStatic`, `sqliteStatic`), and the same object is exposed on a connected client as `db.context` / `db.contract`. ## The decision **Expose the `ExecutionContext` as a first-class, client-safe surface — a new `@prisma-next/<target>/static` entrypoint — symmetrically on all three target facades.** The `ExecutionContext` is the driver-free core every query is built against: the contract, its codecs, the query operations, and the derived types. Enums, the query builder, and `raw` are all just views over it. It needs no connection and no driver to construct — so in principle it's usable anywhere, including the browser. This PR makes that real and uniform. ## Why it wasn't already usable The context existed but you couldn't get at it cleanly from client code: - **Postgres** built it upfront and exposed it as `db.context` — but only on a fully-wired, driver-carrying client. - **Mongo** built the equivalent lazily *inside the connect path*, never exposed it, and typed its contract as `unknown`. - There was **no driver-free entrypoint** on any target, so importing the facade to reach enums also imported the DB driver. So when an app needed typed enums in a `'use client'` form, it fell back to an interim: hand-calling the low-level `buildNamespacedEnums(...)` and casting the untyped result in application code. That worked but was a narrow, per-consumer workaround, not the abstraction. ## What this PR does - Adds **`@prisma-next/<target>/static`** with `<target>Static({ contractJson })`, returning the `ExecutionContext` plus the surface derived from it (`enums`, query builder, `raw`, `contract`). It's driver-free by construction, and a test walks the entrypoint's import graph to keep it that way. - Builds the Mongo context **upfront**, types it `MongoExecutionContext<TContract>`, and exposes `db.context` / `db.contract` — matching Postgres, so the shape is the same across targets. - Routes each facade *and* its `/static` twin through **one shared builder**, so the enum typing lives in framework code once instead of a cast in every app. - Migrates `examples/retail-store` off the interim: `src/enums.ts` now derives from `mongoStatic(...)`, consumed by the `'use client'` checkout form — which doubles as the `next build` "no driver in the client bundle" acceptance test. ## One cross-cutting change worth a look Making the static context truly driver-free reached one layer deeper than the facades. Building the context aggregates the target's codecs, and the Mongo codec imported `ObjectId` from `mongodb` (a value import), which drags the whole driver into a client bundle. `ObjectId` is really a `bson` class that `mongodb` re-exports, so the codec now imports it from the standalone `bson` package. It's a one-line change but it lives in `packages/3-mongo-target/2-mongo-adapter`, and it's what lets `next build` succeed with zero driver code client-side. Extension packs (postgis, pgvector, …) are unaffected: the *facade* still builds its context from the full stack including `options.extensions`; only the client-safe `/static` path is target+adapter-only. ## Testing Full local gate green (build, typecheck, lint incl. `lint:deps`/`lint:casts`, `check:upgrade-coverage`, `fixtures:check`, `test:packages`/`integration`/`e2e`) and CI green. `retail-store` `next build` produces no `mongodb` in the client bundle. Upgrade instructions recorded as additive/incidental for 0.14→0.15. ## Alternatives considered - **Keep the app-code interim** (`buildNamespacedEnums` + a cast). Rejected: untyped without a per-call cast, duplicated per consumer, and it leaks a framework detail into every app. - **An enums-only helper** — the `mongoEnums` / `@prisma-next/mongo/enums` point-solution prototyped in #880 and pulled back out. Rejected: it solved one symptom (enums) rather than exposing the abstraction they derive from. Exposing the whole `ExecutionContext` gives enums, query building, `raw`, and types from one surface, symmetrically. - **Make the existing `/runtime` entrypoint tree-shakeable** instead of adding `/static`. Rejected: the driver is imported at module scope, so bundlers can't reliably drop it. A separate driver-free module is a hard boundary — and the client-safety test enforces it. Spec: [`projects/expose-static-execution-context/spec.md`](projects/expose-static-execution-context/spec.md). Closes TML-2955. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added contract “static” entrypoints for Mongo, Postgres, and SQLite (e.g., `/<target>Static({ contractJson })`) that expose `context`, `contract`, enums, query/`raw`, and related helpers. * Added `./static` subpath exports for Mongo/Postgres/SQLite. * Mongo facade now exposes `db.context` and `db.contract`. * **Bug Fixes** * Improved type preservation for `contract` (prevents widening to `unknown`) and aligned runtime/static surfaces. * Strengthened validation and errors for required extension packs. * **Documentation** * Updated 0.14 → 0.15 upgrade notes for the new static APIs. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
1 parent 4d8ab61 commit 0fa64ac

37 files changed

Lines changed: 1014 additions & 133 deletions

File tree

examples/retail-store/src/enums.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
1-
import { buildNamespacedEnums, type NamespacedEnums } from '@prisma-next/contract/enum-accessor';
2-
import { blindCast } from '@prisma-next/utils/casts';
1+
import mongoStatic from '@prisma-next/mongo/static';
32
import type { Contract } from './contract';
43
import contractJson from './contract.json' with { type: 'json' };
5-
// Interim: build the enum accessors from the static contract with a blindCast.
6-
// TML-2955 will expose @prisma-next/mongo/static so the typing comes from the
7-
// framework and this whole module can be deleted.
8-
export const enums = blindCast<
9-
NamespacedEnums<Contract>['__unbound__'],
10-
'buildNamespacedEnums returns untyped EnumAccessor; cast to the typed shape from Contract'
11-
>(buildNamespacedEnums(contractJson.domain)['__unbound__']);
4+
5+
export const enums = mongoStatic<Contract>({ contractJson }).enums;

packages/1-framework/0-foundation/contract/src/enum-accessor.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { blindCast } from '@prisma-next/utils/casts';
12
import type { Contract } from './contract-types';
23
import type { ContractEnum } from './domain-types';
34
import type { JsonValue } from './types';
@@ -63,16 +64,17 @@ export function buildEnumsMapForNamespace(
6364
return result;
6465
}
6566

66-
export function buildNamespacedEnums(domain: {
67-
readonly namespaces: Readonly<
68-
Record<string, { readonly enum?: Readonly<Record<string, ContractEnum>> }>
69-
>;
70-
}): Record<string, Record<string, EnumAccessor>> {
67+
export function buildNamespacedEnums<TContract extends Contract>(
68+
domain: TContract['domain'],
69+
): NamespacedEnums<TContract> {
7170
const result: Record<string, Record<string, EnumAccessor>> = {};
7271
for (const namespaceId of Object.keys(domain.namespaces)) {
7372
result[namespaceId] = buildEnumsMapForNamespace(domain, namespaceId);
7473
}
75-
return result;
74+
return blindCast<
75+
NamespacedEnums<TContract>,
76+
'built dynamically from domain.namespaces; the mapped-type shape cannot be proven statically'
77+
>(result);
7678
}
7779

7880
type Present<T> = Exclude<T, undefined>;

packages/1-framework/0-foundation/contract/test/enum-accessor.test.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { describe, expect, expectTypeOf, it } from 'vitest';
2+
import type { Contract } from '../src/contract-types';
23
import type { ContractEnumAccessor, EnumMemberNames, EnumValues } from '../src/enum-accessor';
34
import {
45
buildEnumsMapForNamespace,
56
buildNamespacedEnums,
67
createEnumAccessor,
78
} from '../src/enum-accessor';
89

10+
type ContractWithDomain<TDomain> = Contract & { readonly domain: TDomain };
11+
912
const roleEnum = {
1013
codecId: 'pg/text@1',
1114
members: [
@@ -187,11 +190,11 @@ describe('buildNamespacedEnums()', () => {
187190
it('keys the accessor map by namespace then enum name', () => {
188191
const domain = {
189192
namespaces: {
190-
public: { enum: { Role: roleEnum, Status: statusEnum } },
193+
public: { models: {}, enum: { Role: roleEnum, Status: statusEnum } },
191194
},
192195
};
193196

194-
const enums = buildNamespacedEnums(domain);
197+
const enums = buildNamespacedEnums<ContractWithDomain<typeof domain>>(domain);
195198
expect(Object.keys(enums).sort()).toEqual(['public']);
196199
expect(enums['public']?.['Role']?.values).toEqual(['user', 'admin']);
197200
expect(enums['public']?.['Status']?.values).toEqual(['active', 'inactive', 'pending']);
@@ -200,22 +203,23 @@ describe('buildNamespacedEnums()', () => {
200203
it('resolves same-named enums in different namespaces independently', () => {
201204
const domain = {
202205
namespaces: {
203-
public: { enum: { Role: roleEnum } },
204-
audit: { enum: { Role: statusEnum } },
206+
public: { models: {}, enum: { Role: roleEnum } },
207+
audit: { models: {}, enum: { Role: statusEnum } },
205208
},
206209
};
207210

208-
const enums = buildNamespacedEnums(domain);
211+
const enums = buildNamespacedEnums<ContractWithDomain<typeof domain>>(domain);
209212
expect(enums['public']?.['Role']?.values).toEqual(['user', 'admin']);
210213
expect(enums['audit']?.['Role']?.values).toEqual(['active', 'inactive', 'pending']);
211214
});
212215

213216
it('exposes member accessors and helpers per namespace', () => {
214217
const domain = {
215-
namespaces: { public: { enum: { Role: roleEnum } } },
218+
namespaces: { public: { models: {}, enum: { Role: roleEnum } } },
216219
};
217220

218-
const role = buildNamespacedEnums(domain)['public']?.['Role'];
221+
const role =
222+
buildNamespacedEnums<ContractWithDomain<typeof domain>>(domain)['public']?.['Role'];
219223
expect(role?.members['User']).toBe('user');
220224
expect(role?.has('admin')).toBe(true);
221225
expect(role?.nameOf('user')).toBe('User');
@@ -224,10 +228,12 @@ describe('buildNamespacedEnums()', () => {
224228

225229
it('yields an empty enum map for a namespace without enums', () => {
226230
const domain = {
227-
namespaces: { public: {} },
231+
namespaces: { public: { models: {} } },
228232
};
229233

230-
expect(buildNamespacedEnums(domain)).toEqual({ public: {} });
234+
expect(buildNamespacedEnums<ContractWithDomain<typeof domain>>(domain)).toEqual({
235+
public: {},
236+
});
231237
});
232238
});
233239

packages/2-mongo-family/7-runtime/src/mongo-execution-stack.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,16 +111,19 @@ export interface MongoCodecLookup {
111111
*
112112
* Mirrors SQL's `ExecutionContext` in role; Mongo's flavour is leaner because there are no parameterised codecs, JSON-schema validators, or mutation-default generators in scope yet.
113113
*/
114-
export interface MongoExecutionContext<TTargetId extends string = 'mongo'> {
115-
readonly contract: unknown;
114+
export interface MongoExecutionContext<TContract = unknown, TTargetId extends string = 'mongo'> {
115+
readonly contract: TContract;
116116
readonly codecs: MongoCodecLookup;
117117
readonly stack: MongoExecutionStack<TTargetId>;
118118
}
119119

120-
export function createMongoExecutionContext<TTargetId extends string = 'mongo'>(options: {
121-
readonly contract: unknown;
120+
export function createMongoExecutionContext<
121+
TContract = unknown,
122+
TTargetId extends string = 'mongo',
123+
>(options: {
124+
readonly contract: TContract;
122125
readonly stack: MongoExecutionStack<TTargetId>;
123-
}): MongoExecutionContext<TTargetId> {
126+
}): MongoExecutionContext<TContract, TTargetId> {
124127
const registry = newMongoCodecRegistry();
125128
const owners = new Map<string, string>();
126129

packages/2-mongo-family/7-runtime/test/mongo-execution-stack.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import mongoRuntimeAdapter from '@prisma-next/adapter-mongo/runtime';
22
import { isRuntimeError } from '@prisma-next/framework-components/runtime';
33
import { mongoCodec, newMongoCodecRegistry } from '@prisma-next/mongo-codec';
44
import mongoRuntimeTarget from '@prisma-next/target-mongo/runtime';
5-
import { describe, expect, it } from 'vitest';
5+
import { describe, expect, expectTypeOf, it } from 'vitest';
66
import {
77
createMongoExecutionContext,
88
createMongoExecutionStack,
9+
type MongoExecutionContext,
910
type MongoRuntimeExtensionDescriptor,
1011
} from '../src/mongo-execution-stack';
1112

@@ -146,6 +147,17 @@ describe('createMongoExecutionContext', () => {
146147
expect(context.contract).toBe(contract);
147148
expect(context.stack).toBe(stack);
148149
});
150+
151+
it('preserves the TContract type on context.contract (no unknown widening)', () => {
152+
const stack = createMongoExecutionStack({
153+
target: mongoRuntimeTarget,
154+
adapter: mongoRuntimeAdapter,
155+
});
156+
const contract = { target: 'mongo' as const, storageHash: 'sha256:test' };
157+
const context = createMongoExecutionContext({ contract, stack });
158+
expectTypeOf(context).toMatchTypeOf<MongoExecutionContext<typeof contract>>();
159+
expectTypeOf(context.contract).toEqualTypeOf<typeof contract>();
160+
});
149161
});
150162

151163
describe('runtime adapter descriptor', () => {

packages/3-extensions/mongo/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"./control": "./dist/control.mjs",
6666
"./family": "./dist/family.mjs",
6767
"./runtime": "./dist/runtime.mjs",
68+
"./static": "./dist/static.mjs",
6869
"./target": "./dist/target.mjs",
6970
"./package.json": "./package.json"
7071
},
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export type { MongoStaticContext } from '../static/mongo-static';
2+
export { default } from '../static/mongo-static';

packages/3-extensions/mongo/src/runtime/mongo.ts

Lines changed: 9 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,38 @@
1-
import mongoRuntimeAdapter from '@prisma-next/adapter-mongo/runtime';
2-
import { buildNamespacedEnums, type NamespacedEnums } from '@prisma-next/contract/enum-accessor';
31
import { MongoDriverImpl } from '@prisma-next/driver-mongo';
42
import { MongoContractSerializer } from '@prisma-next/family-mongo/ir';
5-
import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
63
import { AsyncIterableResult } from '@prisma-next/framework-components/runtime';
74
import type {
85
AnyMongoTypeMaps,
96
MongoContract,
107
MongoContractWithTypeMaps,
118
} from '@prisma-next/mongo-contract';
129
import type { MongoOrmClient, MongoQueryPlan, MongoRawClient } from '@prisma-next/mongo-orm';
13-
import { mongoOrm, mongoRaw } from '@prisma-next/mongo-orm';
14-
import { mongoQuery } from '@prisma-next/mongo-query-builder';
10+
import { mongoOrm } from '@prisma-next/mongo-orm';
1511
import type { MongoMiddleware, MongoRuntime } from '@prisma-next/mongo-runtime';
16-
import {
17-
createMongoExecutionContext,
18-
createMongoExecutionStack,
19-
createMongoRuntime,
20-
} from '@prisma-next/mongo-runtime';
21-
import mongoRuntimeTarget from '@prisma-next/target-mongo/runtime';
22-
import { blindCast } from '@prisma-next/utils/casts';
12+
import { createMongoRuntime, type MongoExecutionContext } from '@prisma-next/mongo-runtime';
2313
import { ifDefined } from '@prisma-next/utils/defined';
14+
import { buildMongoStaticContext, type MongoStaticContext } from '../static/mongo-static';
2415
import {
2516
type MongoBinding,
2617
type MongoBindingInput,
2718
resolveMongoBinding,
2819
resolveOptionalMongoBinding,
2920
} from './binding';
3021

31-
export type MongoTargetId = typeof mongoRuntimeTarget.targetId;
22+
export type MongoTargetId = 'mongo';
3223

3324
type UnboundEnums<TContract extends MongoContractWithTypeMaps<MongoContract, AnyMongoTypeMaps>> =
34-
NamespacedEnums<TContract>[typeof UNBOUND_NAMESPACE_ID];
35-
36-
function unboundNamespace<T>(builderOutput: { readonly [UNBOUND_NAMESPACE_ID]?: unknown }): T {
37-
return blindCast<T, 'the unbound namespace always exists on a mongo builder output'>(
38-
builderOutput[UNBOUND_NAMESPACE_ID],
39-
);
40-
}
25+
MongoStaticContext<TContract>['enums'];
4126

4227
export interface MongoClient<
4328
TContract extends MongoContractWithTypeMaps<MongoContract, AnyMongoTypeMaps>,
4429
> {
4530
readonly orm: MongoOrmClient<TContract>;
46-
readonly query: ReturnType<typeof mongoQuery<TContract>>;
31+
readonly query: MongoStaticContext<TContract>['query'];
4732
readonly raw: MongoRawClient<TContract>;
4833
readonly contract: TContract;
4934
readonly enums: UnboundEnums<TContract>;
35+
readonly context: MongoExecutionContext<TContract>;
5036
execute<Row>(plan: MongoQueryPlan<Row>): AsyncIterableResult<Row>;
5137
connect(bindingInput?: MongoBindingInput): Promise<MongoRuntime>;
5238
runtime(): Promise<MongoRuntime>;
@@ -133,9 +119,7 @@ export default function mongo<
133119
const contract = resolveContract(options);
134120
let binding = resolveOptionalMongoBinding(options);
135121

136-
// `mongoQuery` calls its parameter `contractJson`, but accepts the validated
137-
// contract value here (it normalises both internally).
138-
const query = mongoQuery<TContract>({ contractJson: contract });
122+
const { context, query, enums, raw } = buildMongoStaticContext<TContract>(contract);
139123

140124
// Single source of truth for the lifecycle. `runtimePromise` is the in-flight
141125
// or settled build; `closed` is the terminal state set by `close()`. A failed
@@ -145,11 +129,6 @@ export default function mongo<
145129
let ownedDispose: (() => Promise<void>) | undefined;
146130

147131
const buildRuntime = async (resolvedBinding: MongoBinding): Promise<MongoRuntime> => {
148-
const stack = createMongoExecutionStack({
149-
target: mongoRuntimeTarget,
150-
adapter: mongoRuntimeAdapter,
151-
});
152-
const context = createMongoExecutionContext({ contract, stack });
153132
const driver =
154133
resolvedBinding.kind === 'url'
155134
? await MongoDriverImpl.fromConnection(resolvedBinding.url, resolvedBinding.dbName)
@@ -201,18 +180,13 @@ export default function mongo<
201180
executor: { execute },
202181
});
203182

204-
const raw = mongoRaw<TContract>({ contract });
205-
206-
const enums: UnboundEnums<TContract> = unboundNamespace(
207-
Object.freeze(buildNamespacedEnums(contract.domain)),
208-
);
209-
210183
return {
211184
orm,
212185
query,
213186
raw,
214187
contract,
215188
enums,
189+
context,
216190
execute,
217191

218192
async connect(bindingInput?: MongoBindingInput): Promise<MongoRuntime> {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import mongoRuntimeAdapter from '@prisma-next/adapter-mongo/runtime';
2+
import { buildNamespacedEnums, type NamespacedEnums } from '@prisma-next/contract/enum-accessor';
3+
import { MongoContractSerializer } from '@prisma-next/family-mongo/ir';
4+
import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
5+
import type {
6+
AnyMongoTypeMaps,
7+
MongoContract,
8+
MongoContractWithTypeMaps,
9+
} from '@prisma-next/mongo-contract';
10+
import type { MongoRawClient } from '@prisma-next/mongo-orm';
11+
import { mongoRaw } from '@prisma-next/mongo-orm';
12+
import { mongoQuery } from '@prisma-next/mongo-query-builder';
13+
import {
14+
createMongoExecutionContext,
15+
createMongoExecutionStack,
16+
type MongoExecutionContext,
17+
} from '@prisma-next/mongo-runtime';
18+
import mongoRuntimeTarget from '@prisma-next/target-mongo/runtime';
19+
import { assertDefined } from '@prisma-next/utils/assertions';
20+
import { blindCast } from '@prisma-next/utils/casts';
21+
22+
type UnboundEnums<TContract extends MongoContractWithTypeMaps<MongoContract, AnyMongoTypeMaps>> =
23+
NamespacedEnums<TContract>[typeof UNBOUND_NAMESPACE_ID];
24+
25+
function extractUnboundEnums<
26+
TContract extends MongoContractWithTypeMaps<MongoContract, AnyMongoTypeMaps>,
27+
>(contract: TContract): UnboundEnums<TContract> {
28+
const enums = buildNamespacedEnums<TContract>(contract.domain)[UNBOUND_NAMESPACE_ID];
29+
assertDefined(enums, 'the unbound namespace always exists on a mongo builder output');
30+
return enums;
31+
}
32+
33+
export interface MongoStaticContext<
34+
TContract extends MongoContractWithTypeMaps<MongoContract, AnyMongoTypeMaps>,
35+
> {
36+
readonly context: MongoExecutionContext<TContract>;
37+
readonly contract: TContract;
38+
readonly enums: UnboundEnums<TContract>;
39+
readonly query: ReturnType<typeof mongoQuery<TContract>>;
40+
readonly raw: MongoRawClient<TContract>;
41+
}
42+
43+
export function buildMongoStaticContext<
44+
TContract extends MongoContractWithTypeMaps<MongoContract, AnyMongoTypeMaps>,
45+
>(contract: TContract): MongoStaticContext<TContract> {
46+
const stack = createMongoExecutionStack({
47+
target: mongoRuntimeTarget,
48+
adapter: mongoRuntimeAdapter,
49+
});
50+
const context = createMongoExecutionContext<TContract>({ contract, stack });
51+
const enums = extractUnboundEnums(contract);
52+
const query = mongoQuery<TContract>({ contractJson: contract });
53+
const raw = mongoRaw<TContract>({ contract });
54+
return { context, contract, enums, query, raw };
55+
}
56+
57+
export default function mongoStatic<
58+
TContract extends MongoContractWithTypeMaps<MongoContract, AnyMongoTypeMaps>,
59+
>(options: { readonly contractJson: unknown }): MongoStaticContext<TContract> {
60+
const contract = blindCast<
61+
TContract,
62+
'MongoContractSerializer validates and returns a typed contract'
63+
>(new MongoContractSerializer().deserializeContract(options.contractJson));
64+
return buildMongoStaticContext(contract);
65+
}

0 commit comments

Comments
 (0)