Prisma IDB FaviconPrisma IDB

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.ts

See 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 @id key 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.exclude drops 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.

On this page