Prisma IDB FaviconPrisma IDB

Server

The ownership DAG, push/pull authorization, and the Changelog schema

@prisma-next-idb/sync-server never touches a database or an HTTP framework. Given a rootModel, it builds an ownership graph from your schema's relations at startup, then turns push events and pull rows into OwnershipCheck descriptions — what a caller needs to verify — leaving the actual query and the actual write to you. This keeps authorization logic out of the browser bundle entirely: it's server-only, by construction.

It's also family-agnostic. The DAG only walks contract.domain — model names and relations — which looks the same whether the server contract is SQL, IDB, or anything else. The one thing that varies by family is where a primary key lives in the contract, which is why createSyncServer takes a getKeyField override.

This page is the reference for the server side. For the setup sequence, start at Sync.

Building the server

contract and clientContract are the contract emit output — import the generated .json and cast it through the generated .d.ts type. NamespaceId (and the contract's other id-like fields) are branded types, not plain strings, so passing the JSON import straight through without the cast fails to type-check:

import { createSyncServer } from "@prisma-next-idb/sync-server";
import { sqlGetKeyField } from "@prisma-next-idb/sync-server-sql";
import type { Contract as ServerContract } from "./prisma/schema.postgres.generated";
import type { Contract as ClientContract } from "./prisma/contract";
import serverContractJson from "./prisma/schema.postgres.generated.json" with { type: "json" };
import clientContractJson from "./prisma/contract.json" with { type: "json" };

const serverContract = serverContractJson as unknown as ServerContract;
const clientContract = clientContractJson as unknown as ClientContract;

export const syncServer = createSyncServer({
  contract: serverContract, // full server contract — includes @idb.exclude'd models
  clientContract, // client-projected contract — defines what's ever synced
  rootModel: "User",
  getKeyField: sqlGetKeyField, // required for a SQL contract — see below
});

This builds the ownership DAG once, at construction — not per request — and throws immediately if the schema is broken: a cycle in the ownership relations, or a client-synced model with no chain of N:1 relations back to rootModel. See Client Contracts for how the client projection is derived.

getKeyField

The default resolver assumes an IDB-shaped contract (model.storage.keyPath). A SQL contract keeps its primary key on the table definition instead, potentially as a compound array — @prisma-next-idb/sync-server-sql exports a ready-made resolver for that shape, sqlGetKeyField, which works against any SQL-family prisma-next contract (Postgres, SQLite, …), not just one engine:

import { domainModelsAtDefaultNamespace } from "@prisma/orm-framework/contract/types";
import type { GetKeyField } from "@prisma-next-idb/sync-server";

export const sqlGetKeyField: GetKeyField = (contract, modelName) => {
  const model = domainModelsAtDefaultNamespace(contract.domain)[modelName]!;
  const { table, namespaceId } = model.storage as { table: string; namespaceId: string };
  const columns = contract.storage.namespaces[namespaceId]!.entries.table[table]!.primaryKey.columns;
  if (columns.length !== 1) throw new Error(`Compound keys aren't supported here (model "${modelName}")`);
  return columns[0]!;
};

That's what sqlGetKeyField actually does — shown here so a different family (Mongo, or anything else) knows the shape of resolver to write; for SQL, just import it instead of redefining it.

Push: validatePush

const checks = syncServer.validatePush(pushEvents, { scopeKey: currentUserId });

Returns one OwnershipCheck per event:

kindMeaning
"unknown-model"Not in clientContract — a real client could never have produced this. Reject outright.
"root"The record is the root model — authorized is already computed (key === scopeKey).
"scoped"Authorized if any one of paths (relation-name chains) resolves to scopeKey.

Authorize and write in the same transaction, checking immediately before the write rather than before the transaction opens — otherwise a record's ownership chain (e.g. a Board.userId) could be reassigned in the gap between the check and the write it was meant to gate. This is the shape — findAlongPath (walk one OwnershipCheck["scoped"].paths chain against your own tables) and applyEvent (the actual insert/update/delete) are not exports of sync-server itself, since it never touches a database. For a SQL contract, though, you don't have to write findAlongPath by hand: @prisma-next-idb/sync-server-sql exports the exact same walk as checkAuthorization, generic over any model/relation shape — see Full endpoint shape below for the complete, importable version:

await db.transaction(async (tx) => {
  let authorized: boolean;
  if (check.kind === "root") {
    authorized = check.authorized;
  } else {
    // ANY ONE path resolving to scopeKey authorizes — use Promise.all + some,
    // not Promise.any: a path that legitimately resolves to `null` shouldn't
    // short-circuit a later path that would have resolved.
    const results = await Promise.all(check.paths.map((path) => findAlongPath(tx, path, check.scopeKey)));
    authorized = results.some(Boolean);
  }
  if (!authorized) throw new Error("SCOPE_VIOLATION"); // rolls back the transaction

  await applyEvent(tx, event);
  await tx.Changelog.create({ data: { model: event.model, scopeKey: currentUserId, outboxEventId: event.id } });
});

Stamp the resolved scopeKey onto the Changelog row right there — reuse the value you already authorized against, don't recompute it for the pull side.

Pull is two steps

buildPullQueries is only the second step. sync-server never fetches changelog rows itself.

1. Cheap pre-filter, on your own changelog storage: WHERE scopeKey = ? AND id > lastChangelogId. Index-friendly, and entirely your own query — sync-server has no opinion on it.

2. Live re-check, via buildPullQueries:

const scoped = syncServer.buildPullQueries(logs, { scopeKey: currentUserId });

This re-derives ownership from the record's current relations, not from what was stamped at push time — because ownership can move after the stamp was taken:

  • Alice creates Todo T1 under Board B1, which she owns. Push authorizes it and stamps scopeKey: "alice" on that changelog row.
  • B1 is later reassigned to Bob.
  • Alice pulls. Step 1's flat filter still returns T1's row — it was true when written. Step 2 re-resolves live: the path now points at Bob, not Alice, so Alice's client correctly treats T1 as no longer accessible instead of materializing stale access.

Known limitation

This only converges correctly for domains where ownership never shifts away from an already-synced client. If a record is reassigned after a client has synced it, and that client has no further changelog rows pending for it, it never receives another update for that record — its stale local copy just sits there.

Schema: the Changelog model

Changelog has to live on the server's real database — never IndexedDB, which is browser-only storage. @prisma-next-idb/sync-server/schema derives it from your existing schema.prisma instead of requiring a hand-typed model:

// prisma.config.postgres.ts
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 },
  }),
});

writeSqlSchemaWithSync reads your schema, strips @idb.exclude/@@idb.exclude (meaningless to a SQL parser — it hard-errors on the unrecognized namespace otherwise), appends a SQL-flavored Changelog model and ChangeOperation enum, and writes the result to the path you give it:

enum ChangeOperation {
  create
  update
  delete
}

model Changelog {
  id            Int             @id @default(autoincrement())
  model         String
  keyPath       String
  operation     ChangeOperation
  scopeKey      String
  outboxEventId String          @unique
  createdAt     DateTime        @default(now())

  @@index([scopeKey, id])
}

prepareSqlSchemaWithSync (pure text transform, no file I/O) and injectChangelogModelSql (just the Changelog append) are also exported individually if you need to compose this into a different schema-loading pipeline.

Set up and evolve this side with the same prisma CLI you already use for the SQL family, pointed at this config with --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

Keep this migration lineage (migrations-postgres/, or whatever migrations.dir you set) separate from the IDB side's own migrations/app/ — they're unrelated graphs (browser IndexedDB vs. the real server), and sharing one directory would make the CLI compute nonsense from hashes across families.

Full endpoint shape

The kanban example's routes are the reference implementation — scopeKey always comes from the authenticated session, resolved server-side, never trusted from the request body. syncServer is the value from Building the server above; getAuthenticatedUserId and db are your own session lookup and database client; sqlSyncAdaptergetKeyField/toSyncPushPayload/applyPushEvent/resolvePullRecord — is createSqlSyncAdapter({ contract: serverContract }) from @prisma-next-idb/sync-server-sql, built once alongside syncServer in the same sync.ts. It's the execution half of the same boundary checkAuthorization is part of, above: everything in it is generic over model/contract (it walks OwnershipChecks and runs CRUD against db.orm.public[model] dynamically), not specific to this app's schema, so it's a real dependency, not reference code to copy-paste.

The request body isn't SyncPushEvent — that's the shape validatePush expects after you've mapped it, not what arrives over the wire (the wire body's entityType/raw payload become SyncPushEvent's model/transformed payload below). It's also not the client's OutboxEvent: sync-server has zero dependency on sync-extension-idb by design (family/transport-agnostic), and the server only cares about 4 of OutboxEvent's dozen-odd fields anyway.

@prisma-next-idb/sync-extension-idb/schemas exports that narrower wire shape as a real zod schema — pushRequestBodySchema — under its own subpath with no other dependency, so importing it server-side doesn't pull in any IDB-only code. .safeParse both validates untrusted network input (a type assertion alone doesn't check anything) and narrows operation to the 3-op union SyncPushEvent expects, so nothing downstream needs a manual cast:

// POST /api/sync/push
import { pushRequestBodySchema } from "@prisma-next-idb/sync-extension-idb/schemas";
import { syncServer, sqlSyncAdapter } from "./sync"; // "Building the server", above
import { getAuthenticatedUserId } from "./auth"; // your own session lookup
import { db } from "./db"; // your own database client

export async function POST(req: Request) {
  const scopeKey = await getAuthenticatedUserId(req);
  const parsed = pushRequestBodySchema.safeParse(await req.json());
  if (!parsed.success) {
    return Response.json({ error: "Malformed request body", details: parsed.error.issues }, { status: 400 });
  }
  const { events } = parsed.data;

  const pushEvents = events.map((e) => ({
    id: e.id,
    model: e.entityType,
    operation: e.operation,
    payload: sqlSyncAdapter.toSyncPushPayload(e.operation, e.payload, sqlSyncAdapter.getKeyField(e.entityType)),
  }));

  const checks = syncServer.validatePush(pushEvents, { scopeKey });

  // Sequential, not Promise.all: a batch can carry data dependencies (a Todo
  // created right after the Board it belongs to) — concurrent checks would
  // race the Board's own not-yet-committed transaction.
  const results = []; // one PushResult per event — the shape pushHandler's
  // response is parsed as, client-side (see Client)
  for (const { eventId, model, check } of checks) {
    const event = events.find((e) => e.id === eventId)!;
    results.push(await sqlSyncAdapter.applyPushEvent(db, event, model, check, scopeKey));
  }
  return Response.json(results);
}
// GET /api/sync/pull?since=<changelogId>
import { syncServer, sqlSyncAdapter } from "./sync"; // "Building the server", above
import { getAuthenticatedUserId } from "./auth"; // your own session lookup
import { db } from "./db"; // your own database client — built from the SQL contract, see Gotchas below

export async function GET(req: Request) {
  const scopeKey = await getAuthenticatedUserId(req);
  const since = new URL(req.url).searchParams.get("since");
  const sinceId = since !== null ? Number(since) : null;
  if (sinceId !== null && !Number.isInteger(sinceId)) {
    return Response.json({ error: "since must be an integer" }, { status: 400 });
  }

  // sync-server has no opinion on this query — any client works. The kanban
  // example runs it through the prisma-next SQL ORM built in step 2, above.
  // Cursor by `id` (monotonic, index-friendly) and cap with `.take()` — see
  // Gotchas below for why an unbounded pull is a real risk, not a theoretical one.
  const ordered = db.orm.public.Changelog.where({ scopeKey })
    .select("id", "model", "keyPath", "operation")
    .orderBy((c) => c.id.asc());
  const rows = await (sinceId !== null ? ordered.cursor({ id: sinceId }) : ordered).take(50).all();

  const checks = syncServer.buildPullQueries(
    rows.map((r) => ({ changelogId: String(r.id), model: r.model, key: r.keyPath })),
    { scopeKey }
  );

  // One LogWithRecord per row (@prisma-next-idb/sync-extension-idb/client)
  // — the shape pullHandler's response is parsed as, client-side (see
  // Client). Unlike push, this cast isn't validating network input — `row`
  // is your own trusted Changelog row, not a caller-supplied value — but
  // `logWithRecordSchema` (from the same /schemas subpath as push's) is
  // there if you'd rather validate the response shape than assert it.
  const logs = await Promise.all(
    checks.map(async ({ changelogId, model, check }) => {
      const row = rows.find((r) => String(r.id) === changelogId)!;
      const operation = row.operation as "create" | "update" | "delete";
      const record = await sqlSyncAdapter.resolvePullRecord(db, model, check, row.keyPath, operation);
      return { changelogId, model, operation, keyPath: row.keyPath, record };
    })
  );
  return Response.json(logs);
}

A record: null in a pull response — an unauthorized or deleted row — is applied by applyPull on the client as a local delete.

Gotchas

Your server's db client has to be built from the generated SQL contract, not the IDB one. One schema produces two very different generated contracts — the browser/IDB projection (contract.json / contract.d.ts) and the real SQL contract (schema.postgres.generated.json / .d.ts). db.orm.public.* and sqlSyncAdapter are only correctly typed against the latter:

// src/lib/server/db.ts
import postgres from "@prisma/orm-postgres/runtime";
import type { Contract } from "./prisma/schema.postgres.generated"; // ✅ the SQL contract
// import type { Contract } from "./prisma/contract";               // ❌ the IDB client contract
import contractJson from "./prisma/schema.postgres.generated.json" with { type: "json" };

export const client = postgres<Contract>({ contractJson });

Pointing postgres<Contract>() at the wrong Contract type doesn't necessarily fail loudly — the model names often overlap enough that db.orm.public.Changelog still resolves — but every query on db silently loses type-checking: no autocomplete, no compiler error on a typo'd field or a wrong argument shape. If db.orm.public.<Model> stops autocompleting, or a field you know exists in schema.prisma isn't recognized, check this import first.

Model names passed into the sync layer are exact, case-sensitive matches. They flow straight from your pushEvents/entityType mapping into db.orm.public[model]sqlSyncAdapter doesn't normalize casing for you. A mismatch (changelog instead of Changelog) throws immediately (Model "changelog" not found on db.orm.public.) rather than silently doing nothing, but it's an easy typo to introduce when hand-writing a query outside the adapter, like the Changelog lookup in the pull endpoint above.

Cap every pull query with .take(). Neither buildPullQueries nor resolvePullRecord impose a limit — that's the caller's own storage query, and sync-server has no opinion on it. The .take(50) in the pull endpoint above is load-bearing, not decorative: without it, a client that's been offline for a long time (or a since=0 from a client that never synced) pulls its entire changelog history in one response. Cursor by id, not by comparing since as a raw filter value — ids are monotonic and index-friendly, timestamps or client-supplied cursors aren't guaranteed to be either.

On this page