> ## Documentation Index
> Fetch the complete documentation index at: https://claritylabs.inc/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# CL Sync Core Concepts: Store, Scope, and Outbox

> Understand how CL Sync scopes data per user, manages collections, runs optimistic mutations, and survives page reloads with a durable outbox.

## Scope

Every store is tied to a **scope** — a set of identifiers that uniquely describe the current session context:

```typescript theme={"system"}
interface SyncScope {
  appId: string;        // required — your application's unique identifier
  environment?: string; // e.g. "production" or import.meta.env.MODE
  userId?: string;      // the authenticated user's ID
  orgId?: string;       // an organization or workspace ID
}
```

CL Sync calls `createScopeKey(scope)` to produce a composite string key in the form `"appId:env:userId:orgId"`. Records are scoped by app, environment, user, and organization within a database named by app and environment. Switching scope does not delete previously stored data.

```typescript theme={"system"}
import { createScopeKey } from "@claritylabs/cl-sync";

createScopeKey({ appId: "my-app", environment: "prod", userId: "u-1", orgId: "o-1" });
// → "my-app:prod:u-1:o-1"
```

> [!NOTE]
>
> If `environment` is omitted, its segment defaults to `"default"`. If `userId` is omitted it defaults to `"anonymous"`, and if `orgId` is omitted it defaults to `"none"`. For single-user apps without auth, omitting both is fine.

## Collections

A **collection** is a named set of records with configurable persistence and query behavior. You define collections with `defineCollection`:

```typescript theme={"system"}
interface CollectionDefinition<TRecord, TArgs = unknown> {
  name: string;
  getId?: (record: TRecord) => SyncId;
  persist?: boolean;            // default: true
  staleMs?: number;
  redactBeforePersist?: (record: TRecord) => TRecord | null;
  sort?: (a: TRecord, b: TRecord) => number;
  deriveKey?: (args: TArgs) => string;
}
```

Key behaviors:

* **`persist`** — set to `false` to keep records in memory only (useful for transient UI state).
* **`deriveKey`** — turns query arguments into a stable cache key. Records fetched for `{ orgId: "org-1" }` are stored independently from records for `{ orgId: "org-2" }`.
* **`redactBeforePersist`** — called before writing to IndexedDB. Return a sanitized copy to strip sensitive fields, or `null` to skip persistence for that record entirely.
* **`staleMs`** — marks a collection state as stale after this many milliseconds, signalling that a fresh server fetch is needed.

## SyncStore

The `SyncStore` is the central object. You create it once with `createSyncStore` and share it across your component tree via `SyncProvider`. Its main responsibilities are:

| Responsibility | Methods |
| - | - |
| Lifecycle | `hydrate()`, `clearScope()` |
| Reading | `getCollection()`, `getRecord()`, `getCollectionState()`, `getStatus()` |
| Writing | `upsertCollection()`, `patchRecord()` |
| Mutations | `enqueueMutation()`, `registerMutation()`, `flushPendingMutations()` |
| Subscriptions | `subscribe()`, `emit()` |
| Schema | `getSchema()`, `getMeta()` |

Calling `store.hydrate()` loads all persisted records from IndexedDB, runs any pending migrations, and sets `status.hydrated = true`. All hook subscriptions re-render at this point.

## Outbox

The **outbox** is CL Sync's durability mechanism. Every time you call `store.enqueueMutation()`, the following happens in order:

1. The mutation's `reducer` runs immediately, applying an optimistic local state change.
2. The mutation arguments are written to the outbox table in IndexedDB.
3. The mutation's `flush` function is called to send the change to your server.

Because step 2 happens before step 3, a mutation that fails (network error, browser close) survives and can be retried. Each outbox entry tracks its state:

```typescript theme={"system"}
interface OutboxItem<TArgs> {
  id: string;
  mutation: string;           // mutation name, used to look up the definition on replay
  args: TArgs;
  scopeKey: string;
  status: "pending" | "flushing" | "failed";
  createdAt: number;
  updatedAt: number;
  attempts: number;
  lastError?: string;
}
```

On the next page load, `store.hydrate()` restores pending outbox items. After registering your mutation definitions, call `store.flushPendingMutations()` to retry them.

> [!WARNING]
>
> Registering mutations before calling `flushPendingMutations` is essential. Any outbox item whose `mutation` name doesn't match a registered definition is skipped with `reason: "missing_definition"`.

## SyncStatus

`store.getStatus()` (and the `useSyncStatus()` hook) returns a live snapshot of sync state:

```typescript theme={"system"}
interface SyncStatus {
  hydrated: boolean;       // true once hydrate() resolves
  hydrating: boolean;      // true while hydrate() is in progress
  online: boolean;         // reflects navigator.onLine
  pendingMutations: number; // count of pending + flushing outbox items
  lastSyncAt?: number;     // timestamp of last successful upsertCollection call
  lastError?: string;      // most recent error from a failed flush
}
```

Use `hydrated` to gate your UI — until it's `true`, records are not yet available from IndexedDB.

## Optimistic updates

When you define a `reducer` on a mutation, it runs **synchronously and immediately** when `enqueueMutation` is called. Your UI updates before any network request is made. If the `flush` call fails, you implement rollback logic in `onReject`:

```typescript theme={"system"}
defineMutation({
  name: "todos.complete",
  reducer: (store, args) => {
    store.patchRecord("todos", args.id, { completed: true });
  },
  flush: async (args) => { /* ... */ },
  onReject: (store, args) => {
    // Undo the optimistic patch
    store.patchRecord("todos", args.id, { completed: false });
  },
});
```

CL Sync does not automatically roll back optimistic changes — you control the rollback in `onReject`. This keeps the library simple and predictable while giving you full flexibility over conflict resolution.

## Persistence modes

By default, CL Sync writes all persistent collections to IndexedDB. You can opt out at two levels:

| Level | How |
| - | - |
| Per-collection | Set `persist: false` on the `CollectionDefinition` |
| Whole store | Pass `persistence: "memory"` to `createSyncStore` |

Memory mode is useful for server-side rendering environments (where IndexedDB isn't available) and for unit tests where you don't want filesystem side effects.

```typescript theme={"system"}
const store = createSyncStore({
  scope: { appId: "my-app" },
  persistence: "memory", // no IndexedDB writes
});
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.