Prisma IDB FaviconPrisma IDB

Outbox, Push & Pull

Understanding the sync mechanism and client-side outbox management

The sync system uses an Outbox Pattern on the client to queue mutations reliably and Changelog Materialization on the server to track changes.

Outbox Pattern

When mutations happen locally, they're persisted to IndexedDB immediately, then queued in the outbox for syncing:

This ensures:

  • Durability: Changes survive app restarts
  • Reliability: Automatic retry until server ACKs
  • Ordering: Changes processed in creation order

Outbox Event Record

The outbox stores events with this structure (defined in idb-interface.ts):

export interface OutboxEventRecord {
  id: string; // UUID generated by client
  entityType: string; // Model name (Board, Todo, User, etc)
  operation: "create" | "update" | "delete";
  payload: unknown; // The full record data
  createdAt: Date;
  tries: number; // Retry attempt count
  lastError: string | null; // Error message if failed
  synced: boolean; // Whether server has ACK'd
  syncedAt: Date | null; // When marked synced
  lastAttemptedAt: Date | null; // When last sync attempt was made
  retryable: boolean; // Whether to keep retrying
}

Accessing the Outbox

The client exposes the outbox via client.$outbox:

import { getClient } from "$lib/client";

const client = getClient();

// Get next batch of unsynced events (default limit: 20)
const events = await client.$outbox.getNextBatch({ limit: 50 });
console.log(`Found ${events.length} unsynced events`);

// Check if any retryable unsynced events exist
const hasEvents = await client.$outbox.hasAnyRetryableUnsynced();
if (hasEvents) {
  console.log("Ready to sync");
}

// Get statistics
const stats = await client.$outbox.stats();
console.log(`Unsynced: ${stats.unsynced}, Failed: ${stats.failed}`);

These methods are called automatically by the sync worker. See Step 5: Sync Worker to configure sync behavior.

Sync Worker Results

After push and pull operations complete, the sync worker processes results. The sync worker is configured with handlers for push and pull responses:

export class TodosState {
  syncWorker = getClient().createSyncWorker({
    push: {
      handler: async (events) => {
        // Send events to server, get back array of PushResult
        return await fetch("/api/sync/push", {
          method: "POST",
          body: JSON.stringify({ events }),
        }).then((r) => r.json());
      },
    },
    pull: {
      handler: async (cursor) => {
        // Fetch changes since cursor
        return await fetch("/api/sync/pull", {
          method: "POST",
          body: JSON.stringify({ lastChangelogId: cursor }),
        }).then((r) => r.json());
      },
    },
  });
}

Push Results

The server returns a PushResult[] for each event. See applyPush for the actual result structure:

export interface PushResult {
  id: string; // Event ID from outbox
  appliedChangelogId: string | null; // Changelog entry ID if successful
  error: null | {
    type: keyof typeof PushErrorTypes;
    message: string;
    retryable: boolean;
  };
}

The sync worker automatically:

  • Marks successful events synced with client.$outbox.markSynced(appliedLogs)
  • Updates failed events with client.$outbox.markFailed(eventId, error)
  • Retries events marked retryable: true
  • Logs non-retryable errors for user action

Pull Results

Pull returns changelog entries with materialized records:

interface PullResponse {
  cursor: string; // Last changelog ID for next pull
  logsWithRecords: Array<{
    id: string; // Changelog entry ID
    model: string; // Model name
    operation: "create" | "update" | "delete";
    keyPath: Array<string | number>;
    record?: any; // The actual data (null for deletes)
    changelogId: string;
  }>;
}

The sync worker applies these changes to IndexedDB automatically via client.applyPull(logsWithRecords).

Outbox Methods

The generated outbox class has these methods:

create()

Create an outbox event (called automatically by mutations when outbox sync is enabled):

const event = await client.$outbox.create(
  {
    data: {
      entityType: "Board",
      operation: "create",
      payload: { id: "board-1", name: "New Board", userId: "user-1" },
    },
  },
  { tx: transaction, silent: true }
);

getNextBatch()

Fetch unsynced, retryable events in creation order:

const batch = await client.$outbox.getNextBatch({ limit: 50 });

Returns up to limit events, sorted by createdAt.

hasAnyRetryableUnsynced()

Check if sync work is needed (gates the pull phase):

if (await client.$outbox.hasAnyRetryableUnsynced()) {
  console.log("Events waiting to sync");
}

markSynced()

Mark events as successfully synced. Called by sync worker with results from applyPush:

await client.$outbox.markSynced([
  { id: "evt-1", lastAppliedChangeId: "ch-123" },
  { id: "evt-2", lastAppliedChangeId: "ch-124" },
]);

markFailed()

Mark event as failed. Called by sync worker when applyPush returns an error:

await client.$outbox.markFailed("evt-1", {
  type: "SCOPE_VIOLATION",
  message: "User is not authorized to modify this board",
  retryable: false,
});

Non-retryable errors require manual intervention (user must fix permissions, etc). Retryable errors are automatically attempted again.

stats()

Get outbox statistics:

const { unsynced, failed, lastError } = await client.$outbox.stats();

Returns counts of unsynced and failed events, plus the most recent error message.

clearSynced()

Remove old synced events to keep storage clean:

// Delete synced events older than 7 days
const deleted = await client.$outbox.clearSynced({ olderThanDays: 7 });
console.log(`Cleaned up ${deleted} old events`);

Real Example

From pidb-kanban-example/test/demo.test.ts:

test("syncs_create_update_delete_across_devices", async ({ pages }) => {
  const [pageA, pageB] = pages;

  // Device A: Create and update a board
  await pageA.getByTestId("create-board-button").click();
  await pageA.getByTestId("rename-board-input").fill("Project Alpha");
  await pageA.getByTestId("rename-board-submit").click();

  // Device A: Trigger sync
  await Promise.all([
    pageA.getByTestId("sync-now-button").click(),
    pageA.waitForResponse((resp) => resp.url().includes("/sync/pull")),
  ]);

  // Device B: Sync and verify board appears
  await pageB.getByTestId("sync-now-button").click();
  await expect(pageB.getByText("Project Alpha")).toBeVisible();

  // Device A: Delete the board
  await pageA.getByTestId("delete-board-button").click();

  // Device A: Sync deletion
  await pageA.getByTestId("sync-now-button").click();

  // Device B: Pull deletion
  await pageB.getByTestId("sync-now-button").click();
  await expect(pageB.getByText("Project Alpha")).not.toBeVisible();
});

Error Handling

Non-retryable errors indicate problems that won't go away automatically:

export const PushErrorTypes = {
  INVALID_MODEL: "INVALID_MODEL", // Unknown model name
  RECORD_VALIDATION_FAILURE: "RECORD_VALIDATION_FAILURE", // Data doesn't match schema
  MISSING_PARENT: "MISSING_PARENT", // Foreign key target missing
  SCOPE_VIOLATION: "SCOPE_VIOLATION", // User lacks permission
  UNKNOWN_OPERATION: "UNKNOWN_OPERATION", // Not create/update/delete
  UNKNOWN_ERROR: "UNKNOWN_ERROR", // Server error
  MAX_RETRIES: "MAX_RETRIES", // Exceeded retry limit
  CUSTOM_VALIDATION_FAILED: "CUSTOM_VALIDATION_FAILED", // Custom validation rejected
};

The sync worker emits events you can listen to:

client.syncWorker.on("pushcompleted", (result) => {
  console.log("Push complete:", result);
});

client.syncWorker.on("pullcompleted", (result) => {
  console.log("Pull complete:", result);
});

client.syncWorker.on("statuschange", (status) => {
  console.log("Sync status:", status);
});

See Step 5: Sync Worker for full configuration options.

On this page