Prisma IDB FaviconPrisma IDB

Sync server

Reference for ownership checks, the Changelog model and the SQL adapter

Reference for @prisma-idb/sync-server and @prisma-idb/sync-server-sql. For complete push and pull endpoints, follow the Sync tutorial.

How authorization works

Every synced record belongs to one user, or to whatever your root model is. The server works out the owner by following many-to-one relations from the record up to the root model. With Todo → Board → User, a todo belongs to the user who owns its board.

sync-server builds this ownership graph from your schema once, at startup. For each pushed event or pulled changelog row, it returns a description of what to check: which relation paths lead from the record to the root, and which key they must reach. It never runs queries itself, so it works with any database and any HTTP framework. sync-server-sql runs those checks against a Prisma 8 SQL client.

The checks run on the server only, so the browser bundle never contains authorization logic.

createSyncServer(options)

import { createSyncServer } from "@prisma-idb/sync-server";
import { sqlGetKeyField } from "@prisma-idb/sync-server-sql";

const syncServer = createSyncServer({ contract, clientContract, rootModel: "User", getKeyField: sqlGetKeyField });
OptionDescription
contractThe server's full contract, including models excluded from the browser.
clientContractThe browser's contract. Only its models are synced.
rootModelThe model that owns everything, usually User. The signed-in user's id is compared with its primary key.
getKeyFieldFinds a model's primary-key field. The default reads an IndexedDB contract. Pass sqlGetKeyField for a SQL contract.

Import each contract's .json and cast it to its generated Contract type. Id fields in the contract have branded types, so the raw JSON doesn't type-check without the cast.

createSyncServer throws at startup if the ownership relations form a cycle, or a synced model has no path of many-to-one relations to rootModel.

Synced models need a single-field primary key. Compound keys aren't supported by sync yet, and getKeyField throws for them.

Writing a getKeyField for another family

A resolver takes the contract and a model name and returns the name of the model's primary-key field. For SQL contracts, sqlGetKeyField uses the following metadata:

The resolver reads model.storage.table and model.storage.namespaceId, then finds the table's primaryKey.columns in contract.storage.namespaces. It rejects missing table metadata and any primary key that does not have exactly one column. Return that column's name.

Use the exported GetKeyField type from @prisma-idb/sync-server to type a custom resolver. See the SQL resolver implementation for the contract shape and checks.

validatePush(events, { scopeKey })

Takes the pushed events, each { id, model, operation, payload, wireKey }, and the signed-in user's id. Returns one { eventId, model, check } per event, where check is one of:

check.kindMeaning
"validation-failure"The payload or key is malformed. check.error identifies the validation failure.
"unknown-model"The model isn't in the browser contract. A real client can't send this; reject it.
"root"The record is the root model itself. check.authorized is already computed.
"scoped"Authorized if any one of check.paths leads from the record to scopeKey.

payload holds native application values (Date, bigint, Uint8Array), which validatePush validates against your contracts. wireKey is the record's primary key in the JSON form the client sent, such as an ISO date string or a decimal string for a bigint. The ownership check compares and returns that form, because changelog rows store keys in it. wireKey is required, so TypeScript rejects an event without it. For a string key, wireKey equals payload[keyField].

Run the check inside the same transaction as the write, just before it. Checking before the transaction opens would let another request change the record's owner in between. sqlSyncAdapter.applyPushEvent does this for you.

The request body has a different shape from these events: the client sends entityType and a raw payload. Validate it with pushRequestBodySchema from @prisma-idb/sync-extension-idb/schemas, then pass its events to sqlSyncAdapter.applyPush, as the tutorial shows. The schemas entrypoint has no browser-only code, so it is safe to import on the server. applyPush decodes wire values and maps them to validation inputs.

buildPullQueries(logs, { scopeKey })

Takes changelog rows, each { changelogId, model, key }, and returns one { changelogId, model, check } per row, with "unknown-model", "root" or "scoped" ownership checks. The SQL adapter validates each key when it resolves the record.

Why pull checks ownership twice

Pull is two steps:

  1. Your query reads the user's changelog rows: WHERE scopeKey = ? AND id > lastChangelogId. scopeKey is the owner recorded when the change was pushed. This step is cheap and uses an index.
  2. buildPullQueries checks each record's owner again, against the current data.

The second step matters because ownership can change after a push. Say Alice creates a todo on her board, then the board is given to Bob. The changelog row still says Alice, but the live check now leads to Bob, so Alice's client receives record: null and deletes its copy.

Known limitation

A client only learns about a change of owner when a changelog row for that record comes up in its pull. If a record moves to another user and no later change to it is pulled, the previous owner's client keeps a stale copy.

SQL adapter

Returned pull and rejected-push records contain only top-level fields from the client contract. Authorization uses the full server row before projection, so server-only ownership foreign keys still work. Projection happens before wire encoding.

createSqlSyncAdapter({ contract, syncServer, getKeyField?, contractCheck? }) from @prisma-idb/sync-server-sql binds the server contract and ownership graph once at startup. getKeyField defaults to sqlGetKeyField. applyPush, pull and resolvePullRecord require syncServer. The other methods work without it. The package also exports sqlGetKeyField, DEFAULT_MAX_PUSH_BATCH_SIZE (1000) and DEFAULT_PULL_LIMIT (50), plus the adapter's option, input and result types.

MemberResult
applyPush(db, { events, scopeKey, clientStorageHash?, maxBatchSize? })Validates and applies events in order. Returns { ok: true, results } through the first retryable failure. Rejects oversized batches and duplicate event ids before any writes. The default maximum is 1000 events.
pull(db, { scopeKey, clientStorageHash?, lastChangelogId?, limit? })Reads the next page, checks current ownership and resolves each record. Returns { ok: true, logs }. The default page size is 50. The cursor is exclusive.
getKeyField(model)The model's primary-key field.
toSyncPushPayload(operation, payload, keyField)Maps a decoded payload to the shape validatePush expects.
applyPushEvent(db, event, model, check, scopeKey)Checks ownership, writes the record and inserts its Changelog row in one transaction. Repeated event ids do not apply twice.
resolvePullRecord(db, model, check, keyPath, operation)Returns the current record with only client-contract fields if the user still owns it, or null. Needs syncServer.

db is your Prisma 8 SQL client. It must have .transaction(fn) and .orm.public.<Model>. Resolve scopeKey from the authenticated session, never the request body or query string.

Route outcomes

Handle each known failure reason explicitly. The reason unions can grow in future releases; an exhaustive TypeScript check helps detect new reasons when upgrading.

HelperFailure reasonMeaning
applyPush"batch-too-large"No writes. Includes maxBatchSize and received.
applyPush"duplicate-event-id"No writes. Includes the repeated eventId.
both"contract-mismatch"Nothing read or written. The client's storage hash is missing or differs from the server's. Includes expected. Answer HTTP 409. See Contract check.
pull"invalid-cursor"No pull query. The cursor must be a UUID v7 changelog id. null and undefined start at the beginning. An empty string is invalid; convert an empty query parameter to null, as in the tutorial.

pull throws RangeError for a limit that is not a positive, finite integer. Database and ORM errors reject the pull promise.

Contract check

A client on an older contract can receive a row it cannot decode. It would then drop the row and move its cursor past it, losing the row for good. To prevent this, the server compares storage hashes before it reads or writes anything.

syncServer.clientStorageHash is storage.storageHash from the server's copy of the emitted client contract. IDB hashes cover stores, indexes, and stored record shapes, including fields, value objects, and enum values. Load the same client contract.json on both sides, including any extension stores it contains. Do not use the SQL server contract's hash. The worker passes its contract's hash as context.storageHash (see Sync worker). Forward it to pull and applyPush as clientStorageHash.

By default, a missing or different storage hash returns "contract-mismatch". Answer it with HTTP 409. The worker keeps the client's unsent edits and its pull cursor unchanged, reports contractmismatch, and retries with backoff. After the user reloads onto the new contract, the same edits push and the same cursor pulls.

Pass contractCheck: "off" to createSqlSyncAdapter only if your deploys already keep clients and server on one contract. Deploy a new server contract and its client together, or accept that older clients wait until they update.

Per-event validation and retries

applyPush revives dates, bigint and bytes from their JSON representations. validatePush validates keys against the server contract and records and patches against clientContract. This lets browser events omit fields excluded from the browser contract. Malformed records and patches return RECORD_VALIDATION_FAILURE; malformed keys return KEYPATH_VALIDATION_FAILURE. These failures are non-retryable and open no write transaction. Updates cannot change the primary key.

Push results include the first retryable failure and omit all later events. The worker keeps omitted events pending without counting a try against them. This ordering lets a Todo wait until its Board succeeds on retry.

When a database write fails, applyPushEvent logs the real error on the server and returns a generic failure. Database details such as constraint names do not reach the browser. The failure is non-retryable when the database reports SQLSTATE class 22 (data exception) or class 23 (integrity constraint violation), such as a unique violation. Retrying the same write cannot succeed. Every other failure stays retryable, including lost connections, timeouts, deadlocks and errors with no SQLSTATE.

If a retry hits a unique key of its own first request, which timed out but still committed, applyPushEvent returns success: true.

Rejected events carry the server's row

applyPush adds a record field to every non-retryable failure. It holds the row's current state for scopeKey, as pull would return it: the row, or null if it is deleted or belongs to another user. The server runs the same ownership check as pull, so record never exposes a row the caller could not already pull. The client replaces its rejected local write with record. Serialize it the same way you serialize pulled records.

record is absent when the server cannot read the row: the key cannot be decoded or the model is unknown. The lower-level applyPushEvent never sets it.

For required Postgres Json fields (pg/json@1 and pg/jsonb@1), wire null is written as JSON null. Nullable JSON fields retain the ORM's SQL NULL behavior.

Pull validation

Ordinary pull logs include record, with null signaling a delete or revoked ownership. When key decoding throws KEYPATH_VALIDATION_FAILURE, pull returns validationError: "KEYPATH_VALIDATION_FAILURE" and omits record. applyPull skips those entries, counts them as validation failures and advances the cursor without changing local records or version metadata. This marker does not cover every malformed key: a root-model ownership rejection can return record: null before decoding, so the client deletes its local copy.

The Changelog model

The server records every synced change in a Changelog table, which pull reads from. You don't declare it: the Postgres config adds it to your schema, along with this enum:

enum ChangeOperation {
  create
  update
  delete
}

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

  @@index([scopeKey, id])
}

outboxEventId is unique, so pushing the same event twice writes it once. The [scopeKey, id] index serves the pull query. The id is a UUID v7, so it sorts correctly as a plain string and the client can treat it as an opaque cursor.

Keep the server's migrations in their own folder, such as migrations-postgres/, separate from the browser's migrations/. They're separate histories for separate databases.

Other databases

defineConfig from @prisma-idb/sync-server/postgres wraps sqlContractWithSync from @prisma-idb/sync-server/schema, which works with any SQL target. Use it directly for another target, or when you need options the wrapper doesn't expose. It needs the framework's own defineConfig, with every descriptor set by hand:

import { definePrismaConfig } from "@prisma/cli-engine";
import { defineConfig } from "@prisma/orm-framework/config/config-types";
import postgresAdapter from "@prisma/orm-postgres/adapter/control";
import postgresDriver from "@prisma/orm-postgres/driver/control";
import sql from "@prisma/orm-postgres/family/control";
import { PG_INT_CODEC_ID, PG_TEXT_CODEC_ID } from "@prisma/orm-postgres/target/codec-ids";
import postgres from "@prisma/orm-postgres/target/control";
import postgresPackRef from "@prisma/orm-postgres/target/pack";
import { postgresCreateNamespace } from "@prisma/orm-postgres/target/types";
import { sqlContractWithSync } from "@prisma-idb/sync-server/schema";

export default definePrismaConfig({
  orm: defineConfig({
    family: sql,
    target: postgres,
    adapter: postgresAdapter,
    driver: postgresDriver,
    contract: sqlContractWithSync("src/prisma/schema.prisma", {
      target: postgresPackRef,
      createNamespace: postgresCreateNamespace,
      enumInferenceCodecs: { text: PG_TEXT_CODEC_ID, int: PG_INT_CODEC_ID },
      output: "src/prisma/schema.postgres.generated.json",
    }),
    db: { connection: process.env.DATABASE_URL },
  }),
});

For your own schema pipeline, prepareSqlSchemaWithSync takes schema text and returns it with the idb attributes removed and the Changelog added. injectChangelogModelSql only adds the Changelog.

Common mistakes

  • Building the server's database client from the browser's contract. db.orm.public.* and the SQL adapter need the SQL contract (schema.postgres.generated). With the browser's contract type, model names often still resolve, but queries lose their types: no autocomplete, and no error for a misspelled field.
  • Wrong capitalization in model names. Names pass straight through to db.orm.public[model], so changelog instead of Changelog fails with Model "changelog" not found on db.orm.public.
  • An unlimited custom pull query. sqlSyncAdapter.pull defaults to 50 rows and uses an exclusive changelog cursor. If you use the lower-level methods, limit and page your own changelog query too.

On this page