Client Contracts
One schema, two projections — what reaches the browser and what stays on the server
A syncing app runs two databases from one domain model: the server has the full schema, IndexedDB has a subset. Prisma Next IDB authors that schema once and interprets it twice, rather than maintaining a separate client.prisma and server.prisma by hand.
This page is the reference for that projection. For the setup sequence, start at Sync.
@idb.exclude and @@idb.exclude
Mark a field as server-only with @idb.exclude, or a whole model as server-only with @@idb.exclude:
model User {
id String @id
name String
password String @idb.exclude // dropped from the client contract
boards Board[]
sessions Session[]
}
model Session {
id String @id
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@idb.exclude // whole model dropped from the client contract
}On the browser side, your IDB config strips them via prismaIdbContract(path, { projection: "client" }):
// prisma.config.ts (browser)
import { definePrismaConfig } from "@prisma/cli-engine";
import { defineConfig as ormConfig } from "@prisma-next-idb/family-idb/config-types";
import { prismaIdbContract } from "@prisma-next-idb/family-idb/contract-psl";
export default definePrismaConfig({
orm: ormConfig({
// ...
contract: prismaIdbContract("schema.prisma", { projection: "client" }),
}),
});projection: "client" is the only projection flag you set yourself. Its counterpart, "full" (the interpreter's default when you omit projection), just means "nothing stripped" — it isn't a second helper you hand to a server config. prismaIdbContract always produces an IDB-shaped contract, because it's the IDB family's own PSL helper.
The server side: a different family, not a different projection
If your server also happens to run on the IDB family, pointing it at the same schema without the projection option works. But the realistic case — and the one the sync server setup uses — is a SQL/Postgres server. Its parser doesn't understand @idb.exclude/@@idb.exclude at all and hard-errors on the unrecognized idb namespace, so prismaIdbContract (or any IDB-family helper) isn't in the picture on that side.
Instead, @prisma-next-idb/sync-server/schema exports writeSqlSchemaWithSync, which reads the same schema.prisma, strips the @idb.exclude/@@idb.exclude markers as plain text, appends a SQL-flavored Changelog model, and writes a schema file the SQL family can parse:
// prisma.config.postgres.ts (server)
import { definePrismaConfig } from "@prisma/cli-engine";
import { defineConfig as ormConfig } from "@prisma/orm-postgres/config";
import { writeSqlSchemaWithSync } from "@prisma-next-idb/sync-server/schema";
export default definePrismaConfig({
orm: ormConfig({
contract: writeSqlSchemaWithSync("schema.prisma", "schema.postgres.generated.prisma"),
db: { connection: process.env.DATABASE_URL },
migrations: { dir: "migrations-postgres" }, // a separate lineage from the IDB side's migrations/
}),
});Emit and migrate this side with the same prisma CLI, pointed at this config:
npx prisma contract emit --config prisma.config.postgres.ts
npx prisma db init --config prisma.config.postgres.ts
npx prisma migration new --config prisma.config.postgres.ts
npx prisma db update --config prisma.config.postgres.tsSee Server for the full reasoning behind the generated Changelog model.
Either way, the client and server sides are independent-but-related lifecycles, not two views of one graph — the IDB side has its own contract space and its own migrations/ directory; createAutoMigratingIdbClient only ever sees the client-projected contract and has no knowledge the server schema exists.
Split-package apps
If your frontend and backend are separate packages, put the schema in a shared @myapp/schema package with two
subpath exports — ./client and ./server — so both projections stay versioned atomically. Never have the frontend
depend on the backend's package (or vice versa) just to reach the schema.
A few rules the interpreter enforces on @idb.exclude/@@idb.exclude:
- The
@idkey field cannot be excluded. - An index cannot reference an excluded field.
- An excluded field cannot also be
@unique. - You cannot exclude a relation field directly, or an FK column backing a relation independently — only whole-model
@@idb.excludedrops a relation (see below).
Relations that cross the boundary
When a surviving model has a relation pointing at an excluded model, the relation is dropped, not the surviving model:
model Todo {
id String @id
title String
sessionId String
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
}
model Session {
id String @id
@@idb.exclude
}In the client projection, Todo.session disappears — but Todo.sessionId stays, exactly as declared (nullable or not). It's still a valid domain fact, just no longer enforced or traversable locally. Dropping the scalar field too was rejected: it would silently change record shape for clients that already synced it.
This applies for every cardinality — N:1, 1:N, 1:1 — and to every kind of relation, required or optional. A warning is logged (not an error) whenever it happens:
[prisma-next-idb] Dropped relation "Todo.session" from the client contract: target model "Session" is excluded (ADR 013).Crucially, exclusion never cascades. A model is excluded only because you wrote @@idb.exclude on it — never because something else points at it. There's no fixpoint loop and no transitive surprises: excluding one model can never trigger a second, unrelated exclusion.
Why this matters for sync
The sync ownership DAG (see Server) is built from exactly this client-projected contract — clientModels in the ownership graph is the survivor set after projection. If your rootModel (typically the model sync authorization is scoped by, e.g. User) is itself excluded, or a synced model has no path of N:1 relations back to it, createSyncServer fails fast at startup rather than at request time.