# Architecture overview (https://stratasync.blode.md/architecture)
The core is framework-agnostic. Adapters at the edges handle React, MobX, IndexedDB, and GraphQL. The sync protocol is based on [Linear's published design](https://stratasync.blode.md/architecture/sync-protocol#design-lineage).
## Package dependency graph
Compile-time dependencies between packages:
```mermaid
flowchart TD
SN["@stratasync/next"]
SR["@stratasync/react"]
SC["@stratasync/client"]
SCO["@stratasync/core"]
SY["@stratasync/y-doc"]
SN --> SR
SN --> SC
SN --> SCO
SR --> SC
SR --> SCO
SC --> SCO
SC --> SY
```
Dependencies flow downward. `@stratasync/core` has zero runtime dependencies on any framework, storage engine, or transport: you can test sync logic in pure Node.js without a browser.
Storage, transport, and reactivity adapters implement interfaces from `@stratasync/core` and are injected at runtime via `SyncClientOptions`. Swap adapters without changing the dependency graph.
## Layered architecture
Each layer builds on the one below. See the individual [package docs](https://stratasync.blode.md/packages) for API details.
### Layer 1: Schema and metadata (`@stratasync/core`)
The `ModelRegistry` stores field metadata, relation metadata, load strategies, and a deterministic schema hash for cache-busting and migrations.
### Layer 2: Runtime state (`@stratasync/core`)
An in-memory identity map keyed by model name and primary key, guaranteeing one object per record. A pluggable `ReactivityAdapter` notifies the UI when fields change.
### Layer 3: Local persistence (`@stratasync/storage-idb`)
A durable local replica in IndexedDB storing model rows, sync metadata, the persistent outbox, and partial-index coverage. The `StorageAdapter` interface is transport-agnostic.
### Layer 4: Sync protocol (`@stratasync/client`)
Handles bootstrapping, delta streaming over WebSockets (with HTTP fallback), outbox management with retry and idempotency keys, and field-level LWW rebase on conflicts.
### Layer 5: Transport (`@stratasync/transport-graphql`)
Moves data between client and server using NDJSON streaming for bootstrap, GraphQL mutations with batch support, and WebSocket subscriptions for real-time deltas.
### Layer 6: Reactivity (`@stratasync/mobx`)
Turns model instances into MobX observables. Delta application wraps in a MobX transaction for atomic UI updates.
### Layer 7: Framework integration (`@stratasync/react`, `@stratasync/next`)
React hooks and providers connect the sync client to the component tree. The Next.js package adds server-side bootstrap prefetching for fast first paint. See [React](https://stratasync.blode.md/packages/react) and [Next.js](https://stratasync.blode.md/packages/next) for the full hook API.
## Full system architecture
```mermaid
flowchart LR
subgraph Client
MF["App (Next.js)"]
SN["@stratasync/next"]
SR["@stratasync/react"]
SC["@stratasync/client"]
SCO["@stratasync/core"]
SM["@stratasync/mobx"]
SS["@stratasync/storage-idb"]
ST["@stratasync/transport-graphql"]
IDB[(IndexedDB)]
end
subgraph Server
API["API Server (Fastify)"]
PG[(PostgreSQL)]
REDIS[(Redis pub/sub)]
end
MF --> SN
MF --> SR
SN --> SC
SR --> SC
SC --> SCO
SC -.->|"runtime injection"| SM
SC -.->|"runtime injection"| SS
SS --> IDB
SC -.->|"runtime injection"| ST
ST -->|"HTTP: bootstrap, batch, deltas, mutate"| API
ST -->|"WebSocket: delta stream"| API
API -->|Prisma| PG
API -->|Delta pub/sub| REDIS
```
## Design principles
1. **Deterministic core, adapters at the edges**: The delta applier, rebase algorithm, and transaction serializer produce the same output for the same input. Swap storage, transport, or reactivity without touching the core.
2. **Offline is the default**: Every read comes from the local store; every write goes to the outbox. Network is an optimization, not a requirement.
3. **Server is the authority**: The server assigns global ordering. Clients apply mutations optimistically, but confirmed state only advances through server-issued deltas.
# Data flow (https://stratasync.blode.md/architecture/data-flow)
Two primary data flows: **client writes** (mutations going to the server) and **server pushes** (deltas coming back).
## Client write flow
Call a mutation method on the sync client. The change applies optimistically, persists locally, and sends to the server in the background.
```mermaid
sequenceDiagram
participant UI as React Component
participant Client as Sync Client
participant Store as Identity Map
participant IDB as IndexedDB
participant Outbox as Outbox Manager
participant Transport as Transport Adapter
participant Server as API Server
UI->>Client: client.update("Task", id, changes)
Client->>Client: Create transaction (clientTxId + idempotency key)
Client->>Store: Apply optimistic update
Store-->>UI: MobX reaction (UI re-renders)
Client->>IDB: Persist updated row
Client->>Outbox: Add transaction to outbox
Client->>IDB: Persist outbox entry
Outbox->>Transport: Batch and send mutations
Transport->>Server: POST /sync/mutate
Server->>Server: Validate, assign syncId, persist
Server-->>Transport: Mutation result
Transport-->>Outbox: Acknowledge transaction
Outbox->>IDB: Remove from outbox
```
### Key steps
1. **Optimistic apply**: Creates a `Transaction` with an idempotency key, applies to the identity map, and persists to IndexedDB.
2. **Batch send**: The outbox batches transactions based on `batchDelay` (default 50 ms), reducing network overhead.
3. **Server processing**: Validates the mutation, assigns a `syncId`, persists, and broadcasts a delta to all clients.
4. **Acknowledgment**: The server responds with the assigned `syncId`. The outbox removes acknowledged transactions.
## Server push flow
Deltas arrive through the WebSocket subscription when another client writes a change or the server processes your own mutation.
```mermaid
sequenceDiagram
participant Server as API Server
participant WS as WebSocket
participant Transport as Transport Adapter
participant Client as Sync Client
participant Rebase as Rebase Engine
participant Store as Identity Map
participant IDB as IndexedDB
participant UI as React Component
Server->>WS: Delta packet (syncActions + lastSyncId)
WS->>Transport: Receive delta
Transport->>Client: onDelta(packet)
Client->>IDB: Write batch (apply all actions)
Client->>Store: Update identity map
Client->>Rebase: Rebase pending transactions
Rebase-->>Store: Re-apply local intent
Client->>IDB: Update lastSyncId
Store-->>UI: MobX reaction (UI re-renders)
```
### Key steps
1. **Batch write**: Applies all sync actions from the `DeltaPacket` to IndexedDB in a single atomic write.
2. **Identity map update**: Inserts create instances, updates modify them, deletes remove them.
3. **Rebase**: If affected models have pending local transactions, the rebase engine reconciles server state with local intent.
4. **Watermark advance**: Advances `lastSyncId` to the packet's watermark, so the next delta fetch starts from the right place.
## Conflict resolution (rebase)
Field-level LWW rebase resolves conflicts between server deltas and pending local transactions. Each pending transaction stores a `patch` and an `original` snapshot. Non-overlapping changes merge cleanly; overlapping fields use the configured `rebaseStrategy` (`"server-wins"`, `"client-wins"`, or `"merge"`).
See [Conflict resolution](https://stratasync.blode.md/guides/conflict-resolution) for strategies and examples.
## Offline and reconnect flow
Read and write while disconnected: the outbox queues mutations in IndexedDB until the connection returns.
```mermaid
sequenceDiagram
participant UI as React Component
participant Client as Sync Client
participant IDB as IndexedDB
participant Outbox as Outbox Manager
participant Transport as Transport Adapter
Note over Transport: Connection lost
UI->>Client: client.update("Task", id, changes)
Client->>Client: Create transaction
Client->>IDB: Persist optimistic update
Client->>Outbox: Queue transaction
Client->>IDB: Persist to outbox
Note over UI: UI shows changes immediately
Note over Transport: Connection restored
Transport->>Client: Reconnected
Client->>Transport: Fetch deltas after lastSyncId
Transport-->>Client: Catch-up delta packet
Client->>Client: Apply deltas + rebase
Outbox->>Transport: Replay pending transactions
Transport-->>Outbox: Acknowledge
```
On reconnection, the client fetches missed deltas, then replays the outbox. Idempotency keys make retry safe, even if a mutation was sent but not acknowledged before the disconnect.
See [Offline-first guide](https://stratasync.blode.md/guides/offline-first) for configuration and best practices.
## Transaction lifecycle
Each outbox transaction moves through these states:
```mermaid
stateDiagram-v2
[*] --> Queued: created
Queued --> Sent: transport sends
Sent --> AwaitingSync: server acknowledges
AwaitingSync --> Confirmed: delta with syncId arrives
Sent --> Queued: network error (retry)
Sent --> Failed: server rejects
Confirmed --> [*]: removed from outbox
Failed --> [*]: removed from outbox
```
**Queued** (waiting to send), **Sent** (awaiting response), **AwaitingSync** (acknowledged, delta pending), **Confirmed** (done, removed from outbox), **Failed** (rejected, rolled back). Network errors move back to Queued for retry.
# Sync protocol (https://stratasync.blode.md/architecture/sync-protocol)
The server assigns a monotonically increasing `syncId` to every committed change, creating a single global ordering that all clients follow.
### Design lineage
Based on the server-sequenced sync architecture Linear described but never open-sourced. Extended with Yjs CRDT integration, undo/redo, and pluggable adapters.
## The sync log
A monotonic append-only log of **sync actions**. Each action represents a single change to a single model row.
| Field | Description |
| ----------- | ---------------------------------------------------------------------------------------------------- |
| `id` | The `syncId`: a monotonically increasing integer assigned by the server. |
| `modelName` | Which model changed (for example, `"Task"` or `"User"`). |
| `modelId` | The primary key of the affected row. |
| `action` | The change type: `"I"` (insert), `"U"` (update), `"D"` (delete), `"A"` (archive), `"V"` (unarchive). |
| `data` | The changed fields (for inserts and updates) or `null` (for deletes). |
| `groups` | Optional sync group memberships for access control. |
A **delta packet** bundles sync actions with a `lastSyncId` watermark:
```ts
interface DeltaPacket {
lastSyncId: number;
actions: SyncAction[];
}
```
The client tracks its own `lastSyncId` and advances it after applying each delta packet. This is the only way confirmed state advances on the client.
## Client state machine
Five states: `"disconnected"`, `"connecting"`, `"bootstrapping"`, `"syncing"`, and `"error"`.
```mermaid
stateDiagram-v2
[*] --> disconnected
disconnected --> connecting: start()
connecting --> bootstrapping: storage opened
bootstrapping --> syncing: bootstrap complete
syncing --> syncing: delta received
syncing --> connecting: connection lost
connecting --> syncing: reconnected + caught up
syncing --> error: fatal error
error --> disconnected: stop()
syncing --> disconnected: stop()
```
**Connecting** opens IndexedDB, reads `schemaHash` and `lastSyncId`, and decides on full or local bootstrap. **Bootstrapping** loads the initial dataset (see [Bootstrap modes](#bootstrap-modes)). **Syncing** applies delta packets, persists to IndexedDB, rebases pending transactions, and advances `lastSyncId`. **Error** captures fatal failures; call `stop()` to return to `disconnected`.
### Reconnecting and catch-up
On disconnect, the client retries with exponential backoff. After reconnecting, it fetches deltas after `lastSyncId` via the HTTP catch-up endpoint, transitions to `syncing`, and retries pending outbox transactions.
## Bootstrap modes
Set via `bootstrapMode` on `SyncClientOptions`.
| Mode | Behavior |
| --------- | ----------------------------------------------------------------------------------------------------------- |
| `"auto"` | Full bootstrap if no local data exists; local bootstrap with delta catch-up otherwise. This is the default. |
| `"full"` | Always performs a full bootstrap from the server, ignoring any local data. |
| `"local"` | Bootstraps from local data only. Useful offline or when displaying cached data before connecting. |
**Full bootstrap** streams NDJSON model rows from the server, writes to IndexedDB, hydrates the identity map, and sets `lastSyncId`. **Local bootstrap** reads from IndexedDB into the identity map, then fetches deltas from the stored `lastSyncId`.
## Model load strategies
Each model declares a load strategy that controls when it syncs.
| Strategy | Description |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `"instant"` | Included in the initial bootstrap. The full dataset syncs eagerly. Best for small, frequently accessed models (users, teams, labels). |
| `"lazy"` | Not included in bootstrap. Loads from the server on first access via `ensureModel()` or `useModel()`. Cached locally after first load. |
| `"partial"` | Loads by index values (for example, all comments for a specific task). The client tracks partial index coverage. |
| `"explicitlyRequested"` | Never loaded automatically. Fetched only when you explicitly request it. |
| `"local"` | Client-only data that never syncs to the server. Useful for UI state or drafts. |
## Idempotency
Every mutation carries an idempotency key (`clientId + clientTxId`). The server deduplicates, so resending after a crash or network drop is safe.
```ts
{
clientId: "c_abc123", // Unique per browser/device, persisted in IndexedDB
clientTxId: "tx_def456", // Unique per transaction
}
```
## Schema hash and migrations
`computeSchemaHash()` produces a deterministic 8-character hex hash from all model definitions. If the stored hash doesn't match on startup, the client triggers a full re-bootstrap.
## Wire primitives
Three wire formats:
**Model row** (bootstrap and batch load):
```json
{ "__class": "Task", "id": "abc", "title": "Bug fix", "status": "open" }
```
**Sync action** (delta stream):
```json
{
"id": 42,
"modelName": "Task",
"modelId": "abc",
"action": "U",
"data": { "status": "closed", "updatedAt": "2025-01-15T00:00:00Z" }
}
```
**Mutation request** (outbox to server):
```json
{
"transactions": [
{
"clientTxId": "tx_def456",
"clientId": "c_abc123",
"modelName": "Task",
"modelId": "abc",
"action": "update",
"data": { "status": "closed" },
"original": { "status": "open" }
}
]
}
```
## Sync groups and partial replication
Sync groups control which data each client sees. Model rows belong to one or more groups (workspace IDs, team IDs, user IDs), and the server filters responses by the client's subscribed groups. See the [load strategies guide](https://stratasync.blode.md/guides/load-strategies).
# Collaborative editing with Yjs (https://stratasync.blode.md/guides/collaborative-editing)
Regular fields use server-sequenced LWW sync. Collaborative fields use Yjs CRDTs for character-level merge: no central server needed for conflict resolution.
## Architecture overview
Each client binds to a local Yjs document that syncs through the shared WebSocket.
```mermaid
flowchart TB
subgraph Client A
EA[Editor] --> YA[Y.Doc]
YA --> WA[WebSocket]
end
subgraph Client B
EB[Editor] --> YB[Y.Doc]
YB --> WB[WebSocket]
end
subgraph Server
WA --> SS[Sync Server]
WB --> SS
SS --> Redis[(Redis)]
SS --> PubSub[PubSub]
PubSub --> SS
SS --> DB[(Database)]
end
```
- **Demand-driven sessions**: Live editing starts only when 2+ users view a document and one is editing, cutting server usage by ~96%.
- **Shared WebSocket**: Yjs updates piggyback on the existing sync connection.
- **Standard sync fallback**: If live editing goes down, no data is lost.
## Setting up YjsDocumentManager
`YjsDocumentManager` manages Yjs document instances and their server connection.
```ts
import { YjsDocumentManager } from "@stratasync/y-doc";
// The transport adapter provides the WebSocket connection
const yjsTransport = transport.getYjsTransport();
const documentManager = new YjsDocumentManager({
transport: yjsTransport,
});
```
### Getting a document
Get a `Y.Doc` for a model instance. Documents are created, cached, and connected automatically.
```ts
import type { DocumentKey } from "@stratasync/y-doc";
const docKey: DocumentKey = {
entityType: "Task",
entityId: taskId,
fieldName: "description",
};
// Get or create a Y.Doc for a specific task's description
const doc = documentManager.getDocument(docKey);
// Access the shared text type
const yText = doc.getText("content");
```
`"content"` is the shared Y.Text type name. Multiple clients with the same document ID share state.
### Connection management
```ts
// Connect to start receiving/sending updates
documentManager.connect(docKey);
// Check connection state
const state = documentManager.getConnectionState(docKey);
// "disconnected" | "connecting" | "syncing" | "connected"
// Disconnect when done
documentManager.disconnect(docKey);
```
## Using the useYjsDocument hook
`useYjsDocument` provides a reactive interface to Yjs documents in React.
```tsx
"use client";
import { useYjsDocument } from "@stratasync/react";
export function CollaborativeEditor({ taskId }: { taskId: string }) {
const { doc, connectionState, content } = useYjsDocument({
entityType: "Task",
entityId: taskId,
fieldName: "description",
});
if (connectionState === "connecting") {
return
Connecting to editing session...
;
}
if (!doc) {
return
Loading document...
;
}
// `content` is a reactive string derived from doc.getText("content")
// `doc` is the raw Y.Doc for editor binding
return (
);
}
```
### Managing presence state
```ts
const { startViewing, stopViewing, focus, blur, isViewing, isEditing } =
useYjsPresence({
entityType: "Task",
entityId: taskId,
fieldName: "description",
});
// Start viewing when you navigate to the document
startViewing();
// Signal editing when you focus an input
focus(); // Automatically calls startViewing() if not already viewing
// Signal blur when you leave the input
blur();
// Stop viewing when you navigate away
stopViewing();
```
Use `trackFocus` and `trackVisibility` to handle these automatically via a ref callback.
## Session lifecycle
Sessions start on demand and tear down after a grace period.
```mermaid
stateDiagram-v2
[*] --> NoSession: Document opened
NoSession --> LiveSession: >= 2 viewers AND >= 1 editor
LiveSession --> GracePeriod: < 2 viewers OR 0 editors
GracePeriod --> NoSession: Grace period expires (15-30s)
GracePeriod --> LiveSession: Conditions met again
LiveSession --> [*]: All users leave
```
Without a live session, clients operate independently via standard sync. Edits are CRDT deltas, so all clients merge seamlessly when a session starts.
## Integration with rich-text editors
### Tiptap
[Tiptap](https://tiptap.dev) has first-class Yjs support. Disable its built-in history since Yjs handles undo/redo.
```tsx
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
import { useYjsDocument } from "@stratasync/react";
export function TiptapEditor({ taskId }: { taskId: string }) {
const { doc, connectionState, participants } = useYjsDocument({
entityType: "Task",
entityId: taskId,
fieldName: "description",
});
const editor = useEditor(
{
extensions: [
StarterKit.configure({ history: false }),
Collaboration.configure({ document: doc ?? undefined }),
CollaborationCursor.configure({
provider: null, // Strata Sync handles transport
user: { name: "Current User", color: "#3b82f6" },
}),
],
},
[doc]
);
if (connectionState === "connecting" || !doc) {
return
);
}
```
### ProseMirror (direct)
If you use ProseMirror directly, bind through `y-prosemirror`.
```ts
import { ySyncPlugin, yCursorPlugin, yUndoPlugin } from "y-prosemirror";
const plugins = [ySyncPlugin(yText), yCursorPlugin(awareness), yUndoPlugin()];
```
Get `yText` and `awareness` from the Yjs document and presence manager. See [@stratasync/y-doc](https://stratasync.blode.md/packages/y-doc) for details.
## Offline collaboration
Edit while offline: changes apply to the local `Y.Doc` and buffer until connectivity returns. CRDT deltas sync to the server, and concurrent edits merge automatically at the character level.
# Conflict resolution (https://stratasync.blode.md/guides/conflict-resolution)
When multiple clients modify the same data, the rebase algorithm resolves conflicts automatically. This guide covers detection, strategies, and manual intervention.
## Server-sequenced model
The server assigns global order. See [Sync protocol](https://stratasync.blode.md/architecture/sync-protocol) for details.
## How rebase works
When a server delta arrives for a model with pending local transactions, the client replays local changes on top of the new server state.
```mermaid
flowchart TD
A[Server delta arrives] --> B[Index pending txs by model+id]
B --> C{For each server action}
C --> D{Is this our own tx?}
D -->|Yes| E[Mark as confirmed]
D -->|No| F{Conflicts with pending tx?}
F -->|No| G[Keep pending tx]
F -->|Yes| H[Detect conflict type]
H --> I[Apply resolution strategy]
I --> J[Update in-memory model]
C --> K[Remaining txs stay pending]
```
Three outcomes per server action:
1. **Own transaction**: `clientTxId` matches: confirmed and removed from outbox.
2. **No conflict**: Different model/ID or different fields. Pending transaction stays.
3. **Conflict**: Overlapping fields. Conflict type detected and strategy applied.
Each pending update stores `{ modelId, payload, original }` where `original[field]` is the value when editing started:
```
Client original state: { title: "Draft", status: "open" }
Client pending update: { title: "Final" } <- user changed title
Server delta arrives: { title: "Draft", status: "closed" } <- another user changed status
```
With field-level conflict detection enabled (the default), `title` has a pending client change but no server change, so no conflict. `status` has a server change but no pending client change, so no conflict. Both changes merge to `{ title: "Final", status: "closed" }`.
## Conflict types
Four conflict types:
| Type | Local action | Server action | Example |
| --------------- | ------------ | ------------- | -------------------------------- |
| `update-update` | Update | Update | Both changed the same field |
| `update-delete` | Update | Delete | Client edited, server deleted |
| `delete-update` | Delete | Update | Client deleted, server edited |
| `insert-insert` | Insert | Insert | Both inserted the same ID (rare) |
Archive (`A`) and Unarchive (`V`) actions are normalized to Update (`U`) for conflict detection.
## Resolution strategies
Three strategies:
### server-wins (default)
Accepts the server's version and discards the pending transaction.
```ts
const client = createSyncClient({
// ...adapters
rebaseStrategy: "server-wins",
});
```
**When to use**: Most applications. Works well for form fields, status changes, and assignments.
### client-wins
Re-applies the local change on top of server state. Pending transaction stays and retransmits.
```ts
const client = createSyncClient({
// ...adapters
rebaseStrategy: "client-wins",
});
```
**When to use**: Rare. Useful when the local user's intent should always take priority, such as a force-update action.
### merge
Combines server and client changes at the field level. Non-overlapping fields merge; overlapping fields use the client's value.
```ts
const client = createSyncClient({
// ...adapters
rebaseStrategy: "merge",
fieldLevelConflicts: true,
});
```
**When to use**: When different users typically edit different fields of the same record.
## Choosing a strategy
| Scenario | Recommended strategy |
| --------------------------------- | --------------------------------------------------------------------------------- |
| General purpose (forms, settings) | `rebaseStrategy: "merge"` + `fieldLevelConflicts: true` (default) |
| Simple apps, few concurrent users | `rebaseStrategy: "server-wins"` (default) |
| Collaborative text editing | Use [Yjs CRDTs](https://stratasync.blode.md/guides/collaborative-editing) instead |
| Critical data (finance, legal) | `rebaseStrategy: "server-wins"` with `rebaseConflict` event handler for custom UI |
| Force-update operations | `rebaseStrategy: "client-wins"` |
## Field-level conflict detection
Enabled by default. Only changes to the _same fields_ trigger a conflict. Disable to treat any concurrent edit to the same model+ID as a conflict:
```ts
const client = createSyncClient({
// ...adapters
fieldLevelConflicts: false, // any overlapping model+ID is a conflict
});
```
- **Overlapping fields**: Both client and server changed the same field (true conflict).
- **Non-overlapping fields**: Client and server changed different fields (no conflict, changes coexist).
Most concurrent edits affect different fields, so field-level detection greatly reduces conflicts.
## Handling conflicts manually
For critical data requiring user intervention, use the `rebaseConflict` event. `insert-insert` conflicts always resolve to `manual` since they typically indicate a bug in ID generation.
### Subscribing to the rebaseConflict event
Subscribe for logging, UI notifications, or custom resolution:
```ts
import { createSyncClient } from "@stratasync/client";
const client = createSyncClient({
// ...adapters
});
client.onEvent((event) => {
if (event.type !== "rebaseConflict") {
return;
}
// event.modelName, event.modelId, event.conflictType, event.resolution
switch (event.conflictType) {
case "update-update":
// Two users edited the same field on the same model
break;
case "update-delete":
// Local user edited a record that the server deleted
break;
case "delete-update":
// Local user deleted a record that the server updated
break;
case "insert-insert":
// Both clients created a record with the same ID
break;
}
});
```
### Surfacing conflicts in the UI
Let users choose how to resolve conflicts:
```tsx
"use client";
import { useState, useEffect } from "react";
import { useSyncClient } from "@stratasync/react";
interface ConflictRecord {
modelName: string;
modelId: string;
conflictType: string;
resolution: string;
}
export function ConflictResolver() {
const { client } = useSyncClient();
const [conflicts, setConflicts] = useState([]);
useEffect(() => {
const unsubscribe = client.onEvent((event) => {
if (event.type !== "rebaseConflict") {
return;
}
setConflicts((prev) => [
...prev,
{
modelName: event.modelName,
modelId: event.modelId,
conflictType: event.conflictType,
resolution: event.resolution,
},
]);
});
return unsubscribe;
}, [client]);
if (conflicts.length === 0) {
return null;
}
function dismiss(conflict: ConflictRecord) {
setConflicts((prev) => prev.filter((c) => c !== conflict));
}
async function retryWithClient(conflict: ConflictRecord) {
await client.update(conflict.modelName, conflict.modelId, {});
setConflicts((prev) => prev.filter((c) => c !== conflict));
}
return (
{conflict.conflictType} on {conflict.modelName}:{conflict.modelId}
))}
);
}
```
## Concurrent edit examples
Two users editing the same task, starting from `{ title: "Draft", status: "open", priority: "low" }`.
### Field-level conflicts + merge (recommended)
User A changes `title` to `"Final Report"`. User B changes `status` to `"in-review"`. The changes affect different fields, so no conflict fires. Both users converge on `{ title: "Final Report", status: "in-review" }`.
```ts
const client = createSyncClient({
// ...adapters
rebaseStrategy: "merge",
fieldLevelConflicts: true,
});
```
### Without field-level conflicts (server-wins)
With `fieldLevelConflicts: false`, any concurrent edit to the same model+ID is a conflict. User A's pending title change and User B's pending status change both get discarded in favor of the server version. One user's change is lost.
```ts
const client = createSyncClient({
// ...adapters
fieldLevelConflicts: false,
});
```
This is why `fieldLevelConflicts: true` (the default) with `rebaseStrategy: "merge"` is the recommended configuration.
## Next steps
- [Offline-First Patterns](https://stratasync.blode.md/guides/offline-first): How mutations flow through the outbox.
- [Collaborative Editing](https://stratasync.blode.md/guides/collaborative-editing): CRDT-based editing that avoids field-level conflicts.
- [sync-core Transactions API](https://stratasync.blode.md/packages/core/transactions): Low-level transaction creation and management.
# Load strategies (https://stratasync.blode.md/guides/load-strategies)
Set per model via `@ClientModel`. Controls bootstrap timing, transfer volume, and offline availability.
## Decision tree
```mermaid
flowchart TD
A[Does this model need to sync with the server?]
A -->|No| B["local"]
A -->|Yes| C[Is the data needed on every page load?]
C -->|Yes| D[Is the total dataset small?]
D -->|Yes| E["instant"]
D -->|No| F["partial"]
C -->|No| G[Should it load automatically when accessed?]
G -->|Yes| H["lazy"]
G -->|No| I["explicitlyRequested"]
```
## Strategy reference
| Strategy | Behavior | Best for | Trade-off |
| --------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `instant` | Bootstrap includes all instances; data available from first render | Small, always-needed models (users, teams, labels) | Adds to bootstrap payload size |
| `lazy` | Not included in bootstrap; fetched on first access via hooks or relationship traversal | Models needed only on some pages (attachments, activity feeds) | First access has network latency; only accessed instances available offline |
| `partial` | Loads a relevant subset at bootstrap; fetches remaining instances on demand | High-volume models where only a slice matters (comments, notifications) | Requires partial index tracking in IndexedDB |
| `explicitlyRequested` | Never auto-loaded; fetched only via `client.get()` or `client.query()` | Large or sensitive models loaded on specific pages (audit logs, analytics) | No data present until you request it |
| `local` | Never synced; stored only in IndexedDB and the in-memory identity map | Client-only state (drafts, UI preferences, unsent messages) | No server backup or cross-device access |
## Code examples
Set the strategy in the `@ClientModel` decorator.
### instant
```ts
@ClientModel("User", { loadStrategy: "instant" })
export class User extends Model {
/* ... */
}
```
### lazy
```ts
@ClientModel("Attachment", { loadStrategy: "lazy" })
export class Attachment extends Model {
/* ... */
}
```
### partial
```ts
@ClientModel("Comment", {
loadStrategy: "partial",
partialLoadMode: "regular",
})
export class Comment extends Model {
/* ... */
}
```
Partial load modes control bootstrap priority:
| Mode | Behavior |
| ------------- | ------------------------------------------------------------ |
| `full` | Load the complete subset during bootstrap (highest priority) |
| `regular` | Load the subset at normal priority |
| `lowPriority` | Load the subset after higher-priority models |
### explicitlyRequested
```ts
@ClientModel("AuditLog", { loadStrategy: "explicitlyRequested" })
export class AuditLog extends Model {
/* ... */
}
```
### local
```ts
@ClientModel("DraftMessage", { loadStrategy: "local" })
export class DraftMessage extends Model {
/* ... */
}
```
## Quick reference table
| Model type | Example | Strategy | Reasoning |
| ------------------ | ------------------- | ---------------------- | ---------------------------- |
| Core entities | User, Team, Project | `instant` | Always needed, small dataset |
| Primary work items | Task | `instant` or `partial` | Depends on volume |
| Secondary content | Comment, Attachment | `partial` or `lazy` | Only needed in context |
| Large datasets | AuditLog, Analytics | `explicitlyRequested` | Loaded on specific pages |
| Sensitive data | AdminSettings | `explicitlyRequested` | Access-controlled |
| Client-only state | Draft, UIPreference | `local` | Never synced |
## Performance implications
- **Bootstrap payload size**: Includes all `instant` models and `partial` subsets. If slow, move large models to `partial`/`lazy`, limit via `prefetchBootstrap`, or enable compression.
- **Identity map memory**: Every loaded instance stays in memory. Use `partial` for high-volume models.
- **Delta stream filtering**: Deltas arrive for all accessible models but apply only for loaded instances. `local` models never receive deltas.
## Complete example
All five strategies in a task management app:
```ts
// Always loaded -- small dataset
@ClientModel("User", { loadStrategy: "instant" })
export class User extends Model {
/* ... */
}
// Always loaded -- core work items
@ClientModel("Task", { loadStrategy: "instant" })
export class Task extends Model {
/* ... */
}
// Partially loaded -- fetch comments for viewed tasks
@ClientModel("Comment", {
loadStrategy: "partial",
partialLoadMode: "regular",
usedForPartialIndexes: true,
})
export class Comment extends Model {
/* ... */
}
// Only loaded when explicitly requested
@ClientModel("AuditLog", { loadStrategy: "explicitlyRequested" })
export class AuditLog extends Model {
/* ... */
}
// Local-only draft state
@ClientModel("CommentDraft", { loadStrategy: "local" })
export class CommentDraft extends Model {
/* ... */
}
```
### Bootstrap flow for mixed strategies
The server streams `instant` models first, then `partial` subsets, skipping `explicitlyRequested` and `local`. The client then opens a delta stream and fetches more on demand.
```mermaid
sequenceDiagram
participant Client
participant Server
Client->>Server: Bootstrap request
Note over Server: Stream instant models
Server-->>Client: All User rows
Server-->>Client: All Task rows
Note over Server: Stream partial models
Server-->>Client: Comment rows (for user's sync groups)
Note over Server: Skip explicitlyRequested and local
Server-->>Client: Metadata (lastSyncId, groups)
Note over Client: Seed IndexedDB + identity map
Client->>Server: Open delta stream from lastSyncId
Note over Client: Later, user views a task
Client->>Server: Batch load: Comments where taskId = X
Server-->>Client: Comment rows for task X
Note over Client: Update partial index coverage
```
# Model relationships (https://stratasync.blode.md/guides/model-relationships)
Define foreign keys, inverse collections, and ordered lists with six relationship decorators.
## Relationship decorators at a glance
| Decorator | Direction | Cardinality | Creates foreign key? |
| ---------------------- | -------------------- | ------------ | ------------------------- |
| `@Reference` | Model A -> Model B | One-to-one | Yes (on A) |
| `@ManyToOne` | Model A -> Model B | Many-to-one | Yes (on A) |
| `@OneToMany` | Model B -> Model A[] | One-to-many | No (uses A's foreign key) |
| `@BackReference` | Computed inverse | Varies | No |
| `@ReferenceArray` | Model A -> Model B[] | Ordered list | Yes (array field on A) |
| `@ReferenceCollection` | Alias for @OneToMany | One-to-many | No |
## @Reference: belongs to
One-to-one relationship where the current model holds the foreign key.
```ts
import { Model, ClientModel, Property, Reference } from "@stratasync/core";
@ClientModel("Task", { loadStrategy: "instant" })
export class Task extends Model {
@Property()
declare id: string;
@Property()
declare title: string;
// Creates an `assigneeId` foreign key property automatically
@Reference(() => User, "assignedTasks")
declare assignee: User | null;
// The foreign key is available as a regular property
@Property()
declare assigneeId: string | null;
}
```
You can override the default foreign key name with the `foreignKey` option:
```ts
@Reference(() => User, "createdTasks", { foreignKey: "creatorId" })
declare creator: User | null;
@Property()
declare creatorId: string | null;
```
### Options
| Option | Type | Default | Description |
| ------------ | --------- | ----------- | --------------------------------- |
| `foreignKey` | `string` | `${name}Id` | Name of the foreign key property |
| `nullable` | `boolean` | - | Whether the reference can be null |
| `indexed` | `boolean` | auto | Whether to index the foreign key |
| `lazy` | `boolean` | - | Whether to lazily hydrate |
See [Decorators](https://stratasync.blode.md/packages/core/decorators) for the full API reference.
## @ManyToOne: explicit many-to-one
Like `@Reference` but accepts a model name string.
```ts
import { Model, ClientModel, Property, ManyToOne } from "@stratasync/core";
@ClientModel("Comment", { loadStrategy: "lazy" })
export class Comment extends Model {
@Property()
declare id: string;
@Property()
declare body: string;
// String-based model name (no factory function needed)
@ManyToOne("Task", "comments", { foreignKey: "taskId" })
declare task: unknown;
@Property()
declare taskId: string;
// Also supports factory functions
@ManyToOne(() => User, "comments", { foreignKey: "authorId" })
declare author: unknown;
@Property()
declare authorId: string;
}
```
Use `@ManyToOne` for string-based references, `@Reference` for factory functions (helps with circular imports). Both produce identical metadata.
See [Decorators](https://stratasync.blode.md/packages/core/decorators) for the full API reference.
## @OneToMany: inverse collection
The inverse side of a many-to-one. Creates a `LazyCollection` of models pointing to the current instance via foreign key.
```ts
import { Model, ClientModel, Property, OneToMany } from "@stratasync/core";
@ClientModel("Project", { loadStrategy: "instant" })
export class Project extends Model {
@Property()
declare id: string;
@Property()
declare name: string;
// All tasks where task.projectId === this.id
@OneToMany({ foreignKey: "projectId" })
declare tasks: unknown[];
}
```
### Options
| Option | Type | Default | Description |
| ------------ | --------- | ------- | ---------------------------------- |
| `foreignKey` | `string` | - | Foreign key on the child model |
| `indexed` | `boolean` | - | Whether to use index for lookups |
| `nullable` | `boolean` | - | Whether the collection can be null |
See [Decorators](https://stratasync.blode.md/packages/core/decorators) for the full API reference.
## @BackReference: computed inverse
A computed inverse of a reference. Unlike `@OneToMany`, doesn't create a collection: it represents the inverse lookup.
```ts
import { Model, ClientModel, Property, BackReference } from "@stratasync/core";
@ClientModel("User", { loadStrategy: "instant" })
export class User extends Model {
@Property()
declare id: string;
@Property()
declare name: string;
// Computed inverse of Task.assignee
@BackReference({ foreignKey: "assigneeId" })
declare assignedTasks: unknown[];
// Computed inverse of Comment.author
@BackReference({ foreignKey: "authorId" })
declare comments: unknown[];
}
```
### Options
| Option | Type | Default | Description |
| ------------ | -------- | ------- | ------------------------------------ |
| `foreignKey` | `string` | - | Foreign key on the referencing model |
See [Decorators](https://stratasync.blode.md/packages/core/decorators) for the full API reference.
## @ReferenceArray: ordered list of references
An ordered list of references. Preserves insertion order.
```ts
import { Model, ClientModel, Property, ReferenceArray } from "@stratasync/core";
@ClientModel("Board", { loadStrategy: "instant" })
export class Board extends Model {
@Property()
declare id: string;
@Property()
declare name: string;
// Ordered list of column IDs
@ReferenceArray()
declare columns: unknown[];
@Property()
declare columnIds: string[];
}
```
For many-to-many relationships, use the `through` option to specify a join model:
```ts
@ReferenceArray({ through: "ProjectMember" })
declare members: unknown[];
```
### Options
| Option | Type | Default | Description |
| --------- | -------- | ------- | -------------------------------- |
| `through` | `string` | - | Join model name for many-to-many |
See [Decorators](https://stratasync.blode.md/packages/core/decorators) for the full API reference.
## @ReferenceCollection: unordered set
Alias for `@OneToMany`. Use when the collection is unordered.
```ts
import {
Model,
ClientModel,
Property,
ReferenceCollection,
} from "@stratasync/core";
@ClientModel("Team", { loadStrategy: "instant" })
export class Team extends Model {
@Property()
declare id: string;
@Property()
declare name: string;
// Unordered set of team members
@ReferenceCollection({ foreignKey: "teamId" })
declare members: unknown[];
}
```
See [Decorators](https://stratasync.blode.md/packages/core/decorators) for the full API reference.
## Lazy loading vs. eager loading
By default, references resolve eagerly from the identity map. Use `lazy` to defer hydration.
```ts
// Eager (default): resolved from identity map on access
@Reference(() => User, "assignedTasks")
declare assignee: User | null;
// Lazy: not hydrated until explicitly accessed
@Reference(() => User, "assignedTasks", { lazy: true })
declare assignee: User | null;
```
Lazy loading helps with large reference chains, partial-load models (where the referenced data may not be available yet), and performance-sensitive views where you load models but don't display all their references. See [Load Strategies](https://stratasync.blode.md/guides/load-strategies) for more on controlling when relationship targets load.
## Circular references
The identity map and the factory function pattern handle circular references naturally. You can model self-referencing trees (such as subtask hierarchies) without extra configuration.
```ts
@ClientModel("Task", { loadStrategy: "instant" })
export class Task extends Model {
@Property()
declare id: string;
@Reference(() => Task, undefined, { foreignKey: "parentId" })
declare parent: Task | null;
@Property()
declare parentId: string | null;
@OneToMany({ foreignKey: "parentId" })
declare children: unknown[];
}
```
The factory function `() => Task` works because TypeScript hoists class declarations, so the class is available when the factory runs. The identity map guarantees that `task.parent.children[0]` resolves to the same object reference as `task`. When rendering recursive trees in React, use depth limits or lazy loading to avoid infinite loops.
## Navigating the relationship graph
Once you define relationships, you can traverse them through the identity map without extra queries.
```ts
// Get a project and traverse relationships
const project = client.getCached("Project", projectId);
// Navigate to team
const team = project.team; // Resolves via identity map
// Get all tasks in the project
const tasks = project.tasks; // LazyCollection
// Get the assignee of a task
const task = tasks[0];
const assignee = task.assignee; // Resolves via identity map
// Navigate back to the user's team
const userTeam = assignee.team; // Same team object (referential equality)
```
See [Decorators](https://stratasync.blode.md/packages/core/decorators) for a complete domain model example.
# Offline-first patterns (https://stratasync.blode.md/guides/offline-first)
Strata Sync works offline by default. Every mutation applies locally, persists in an outbox, and syncs when connectivity returns.
## How the outbox works
Every mutation (create, update, delete, archive) follows the same path through the outbox.
```mermaid
sequenceDiagram
participant UI
participant Client
participant Outbox
participant Server
UI->>Client: client.update("Task", id, data)
Client->>Client: Apply optimistically to identity map
Client->>Outbox: Persist transaction (state: "queued")
Client-->>UI: Component re-renders with new data
Outbox->>Server: Send batch (state: "sent")
Server-->>Outbox: Confirm with syncId (state: "awaitingSync")
Server-->>Client: Delta arrives via WebSocket
Client->>Outbox: Remove confirmed transaction (state: "completed")
```
The storage adapter stores the outbox in IndexedDB, so pending mutations survive page reloads, browser crashes, and device restarts. When the client starts, it replays any `queued` or `sent` transactions. For the full outbox lifecycle and state definitions, see [Data flow](https://stratasync.blode.md/architecture/data-flow).
## Optimistic updates
The client applies every change to the in-memory identity map before any network request, so the UI reflects your intent without waiting for the server.
```tsx
"use client";
import { observer } from "mobx-react-lite";
import { useSyncClient } from "@stratasync/react";
const TaskStatus = observer(function TaskStatus({
taskId,
}: {
taskId: string;
}) {
const { client } = useSyncClient();
async function markDone() {
await client.update("Task", taskId, {
status: "done",
updatedAt: new Date().toISOString(),
});
}
return ;
});
```
If the server rejects the mutation, the client rolls back the optimistic update and emits a `rebaseConflict` event. See [Conflict resolution](https://stratasync.blode.md/guides/conflict-resolution) for details.
## Monitoring connection state
Use `useConnectionState`, `useIsOffline`, and `usePendingCount` to track sync status and display appropriate UI.
```tsx
"use client";
import {
useConnectionState,
useIsOffline,
usePendingCount,
} from "@stratasync/react";
export function SyncIndicator() {
const { status, error } = useConnectionState();
const isOffline = useIsOffline();
const { count, hasPending } = usePendingCount();
if (error) {
return
);
}
```
See [Hooks](https://stratasync.blode.md/packages/react/hooks) for the full state reference.
## Pending mutation tracking
`usePendingCount` gives you real-time visibility into unsynced mutations.
```tsx
"use client";
import { usePendingCount } from "@stratasync/react";
export function SaveIndicator() {
const { count, hasPending } = usePendingCount();
if (!hasPending) {
return All changes saved;
}
return (
Saving {count} {count === 1 ? "change" : "changes"}...
);
}
```
### Preventing data loss on tab close
Combine `usePendingCount` with the `beforeunload` event to warn users before they navigate away with unsynced changes.
```tsx
"use client";
import { useEffect } from "react";
import { usePendingCount } from "@stratasync/react";
export function PendingGuard() {
const { hasPending } = usePendingCount();
useEffect(() => {
if (!hasPending) {
return;
}
function handleBeforeUnload(event: BeforeUnloadEvent) {
event.preventDefault();
}
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [hasPending]);
return null;
}
```
## Reconnection flow
When the connection drops, Strata Sync recovers automatically through exponential backoff and delta catch-up.
```mermaid
flowchart TD
A[Connection Lost] --> B[Exponential Backoff Retry]
B -->|Connected| C[Send lastSyncId to Server]
C --> D{Server Has Deltas?}
D -->|Yes| E[Stream Missed Deltas]
D -->|No / Gap Too Large| F[Full Re-bootstrap]
E --> G[Apply Deltas + Rebase Pending]
F --> G
G --> H[Resume Live Delta Stream]
H --> I[Flush Outbox]
```
Key behaviors:
- **Exponential backoff**: Retries at 1s, 2s, 4s, up to 30s, with 20% jitter to avoid thundering herd.
- **Delta catch-up**: The client sends its `lastSyncId` and receives only missed deltas. No full re-download in most cases.
- **Outbox replay**: The client retransmits any queued or previously-sent mutations. Each mutation carries an idempotency key, so duplicate delivery is safe.
- **Rebase**: If server deltas affect the same models as pending mutations, the client rebases automatically. See [Conflict resolution](https://stratasync.blode.md/guides/conflict-resolution).
## Best practices
Follow these guidelines to build a polished offline experience.
- Show sync status in your app shell so users know when they're offline and how many changes are pending.
- Assume every mutation succeeds and show the result immediately: handle failures as exceptions, not expected outcomes.
- Structure mutations to be idempotent so replaying them after reconnection produces the same result.
- Register a listener for `rebaseConflict` events to handle cases where an optimistic update conflicts with a server change. See [Conflict resolution](https://stratasync.blode.md/guides/conflict-resolution).
- Use the `PendingGuard` pattern (shown above) to prevent data loss when the user closes the tab with unsynced mutations.
## Complete example: offline-capable task manager
This component ties together all the patterns into a single offline-capable view.
```tsx
"use client";
import { observer } from "mobx-react-lite";
import { Suspense } from "react";
import {
useQuery,
useSyncClient,
useIsOffline,
usePendingCount,
} from "@stratasync/react";
function OfflineBanner() {
const isOffline = useIsOffline();
const { count, hasPending } = usePendingCount();
if (!isOffline) return null;
return (
You are offline.
{hasPending && (
{" "}
{count} {count === 1 ? "change" : "changes"} will sync when you
reconnect.
)}
);
}
const TaskList = observer(function TaskList() {
const { client } = useSyncClient();
const { data: tasks } = useQuery("Task", {
where: (task) => (task as Record).status !== "archived",
orderBy: (a, b) =>
(b as Record).createdAt.localeCompare(
(a as Record).createdAt
),
});
async function addTask() {
await client.create("Task", {
title: "New task",
status: "todo",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
async function toggleDone(taskId: string, currentStatus: string) {
await client.update("Task", taskId, {
status: currentStatus === "done" ? "todo" : "done",
updatedAt: new Date().toISOString(),
});
}
return (
{tasks.map((task) => {
const t = task as Record;
return (
toggleDone(t.id, t.status)}
/>
{t.title}
);
})}
);
});
export default function TaskManager() {
return (
Loading...}>
);
}
```
# Server-side rendering and bootstrap (https://stratasync.blode.md/guides/ssr-bootstrap)
Prefetch data on the server, embed it in the HTML, and seed client storage. The client starts its delta stream from where the server left off: no loading spinners.
## Why bootstrap matters
Without SSR bootstrap, the browser shows a spinner while fetching from the sync API. With bootstrap, data is embedded in the HTML response: users see content immediately.
### Without SSR bootstrap
```mermaid
sequenceDiagram
participant Browser
participant Server
participant SyncAPI
Browser->>Server: Request page
Server-->>Browser: HTML (empty shell)
Browser->>Browser: Mount React, show loading spinner
Browser->>SyncAPI: Bootstrap request
SyncAPI-->>Browser: NDJSON stream of model data
Browser->>Browser: Populate stores, re-render with data
```
### With SSR bootstrap
```mermaid
sequenceDiagram
participant Browser
participant Server
participant SyncAPI
Server->>SyncAPI: prefetchBootstrap (at request time)
SyncAPI-->>Server: NDJSON stream
Server->>Server: Serialize snapshot into page props
Server-->>Browser: HTML with data embedded
Browser->>Browser: Seed storage from snapshot
Browser->>Browser: Create client and start sync
Browser->>SyncAPI: Delta stream (catch up from lastSyncId)
```
## Prefetching on the server
Call `prefetchBootstrap` in a Server Component or `generateMetadata`:
```ts
import {
prefetchBootstrap,
serializeBootstrapSnapshot,
} from "@stratasync/next/server";
const snapshot = await prefetchBootstrap({
endpoint: "`api.example.com/sync`",
authorization: `Bearer ${userToken}`,
models: ["Task", "User", "Team"],
timeout: 10_000,
});
```
See [Server utilities](https://stratasync.blode.md/packages/next/server) for all `prefetchBootstrap` options.
### Snapshot shape
Contains all accessible model instances. `lastSyncId` tells the client where to start its delta stream.
```ts
interface BootstrapSnapshot {
version: 1;
schemaHash: string;
lastSyncId: number;
firstSyncId?: number;
groups: string[];
rows: ModelRow[];
fetchedAt: number;
rowCount?: number;
}
```
## Serializing and passing to the client
Encode the snapshot for transfer across the Server/Client Component boundary. With `compress: true`, data is gzip-compressed and base64-encoded.
```ts
const payload = await serializeBootstrapSnapshot(snapshot, {
compress: true,
});
```
## Seeding storage from bootstrap
Populate IndexedDB before starting the sync client so queries return data on first render.
```ts
import { seedStorageFromBootstrap } from "@stratasync/next";
import { createIndexedDbStorage } from "@stratasync/storage-idb";
const storage = createIndexedDbStorage({ name: "my-app" });
const result = await seedStorageFromBootstrap({
storage,
snapshot: payload,
dbName: "my-app",
clearExisting: true,
closeAfter: true,
});
if (!result.applied) {
// Schema changed -- need a full re-bootstrap
}
```
See [Client utilities](https://stratasync.blode.md/packages/next/client) for the full `seedStorageFromBootstrap` API.
## Stale checking
Check if a snapshot is too old before seeding:
```ts
import { isBootstrapSnapshotStale } from "@stratasync/next/server";
const snapshot = await prefetchBootstrap({ endpoint });
if (isBootstrapSnapshotStale(snapshot, 30_000)) {
// Snapshot is older than 30 seconds -- re-fetch or let the delta stream catch up
}
```
Staleness rarely matters: the delta stream catches up immediately after hydration.
## Complete Next.js App Router example
Server layout fetches data, client provider seeds storage, page consumes synced data.
### Server layout
```tsx
// app/layout.tsx
import {
prefetchBootstrap,
serializeBootstrapSnapshot,
} from "@stratasync/next/server";
import { cookies } from "next/headers";
import { Providers } from "./providers";
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const cookieStore = await cookies();
const token = cookieStore.get("session")?.value;
let bootstrap = null;
if (token) {
try {
const snapshot = await prefetchBootstrap({
endpoint: process.env.SYNC_API_URL!,
authorization: `Bearer ${token}`,
});
bootstrap = await serializeBootstrapSnapshot(snapshot, {
compress: true,
});
} catch {
// Graceful degradation -- client will bootstrap normally
}
}
return (
{children}
);
}
```
### Client provider
```tsx
// app/providers.tsx
"use client";
import { useRef, useCallback } from "react";
import { NextSyncProvider, seedStorageFromBootstrap } from "@stratasync/next";
import type { BootstrapSnapshotPayload } from "@stratasync/next/server";
import { createSyncClient } from "@stratasync/client";
import { createIndexedDbStorage } from "@stratasync/storage-idb";
import { createGraphQLTransport } from "@stratasync/transport-graphql";
import { createMobXReactivity } from "@stratasync/mobx";
import { schema } from "../lib/schema";
export function Providers({
children,
bootstrap,
}: {
children: React.ReactNode;
bootstrap: BootstrapSnapshotPayload | null;
}) {
const seeded = useRef(false);
const clientFactory = useCallback(() => {
const client = createSyncClient({
schema,
storage: createIndexedDbStorage({ name: "my-app" }),
transport: createGraphQLTransport({
endpoint: "/api/graphql",
syncEndpoint: "/api/sync",
wsEndpoint: "wss://api.example.com/sync/ws",
auth: { getAccessToken: async () => "token" },
}),
reactivity: createMobXReactivity(),
});
if (bootstrap && !seeded.current) {
seeded.current = true;
seedStorageFromBootstrap({
storage: createIndexedDbStorage({ name: "my-app" }),
snapshot: bootstrap,
dbName: "my-app",
clearExisting: true,
closeAfter: true,
});
}
return client;
}, [bootstrap]);
return (
Loading...}>
{children}
);
}
```
### Page using synced data
```tsx
// app/tasks/task-list.tsx
"use client";
import { observer } from "mobx-react-lite";
import { useQuery } from "@stratasync/react";
export const TaskList = observer(function TaskList() {
const { data: tasks, isLoading } = useQuery("Task", {
orderBy: (a, b) =>
(b as Record).createdAt.localeCompare(
(a as Record).createdAt
),
});
if (isLoading) return
Loading...
;
return (
{tasks.map((task) => {
const t = task as Record;
return (
{t.title} {t.status}
);
})}
);
});
```
The server prefetches and serializes, the client seeds IndexedDB before start, and `NextSyncProvider` calls `client.start()`. Because storage is pre-populated, hooks return data on the first render.
## Incremental hydration
After seeding, the client opens a delta stream from `lastSyncId`. Changes between server render and client hydration arrive automatically, making the transition seamless.
# Introduction (https://stratasync.blode.md/)
A local-first sync engine for TypeScript, React, and Next.js. Reads come from a local store, so the UI stays fast. Writes queue offline and sync when you reconnect. A server-sequenced log ensures every client converges.
Built on the sync architecture [Linear](https://linear.app) described but never open-sourced, extended with Yjs CRDT collaboration, undo/redo, and pluggable adapters.
## Key features
- **Local-first reads**: render from IndexedDB, no network round-trips
- **Server-sequenced consistency**: a monotonic `syncId` orders all changes globally
- **MobX reactivity**: only re-renders components that read changed fields
- **Offline writes**: a persistent outbox replays mutations on reconnect
- **Rich-text collaboration**: Yjs CRDT for multi-user editing
- **Undo/redo**: transaction-based history tracking
- **Field-level conflict resolution**: LWW rebase with configurable strategies
- **Partial replication**: load strategies control eager vs. on-demand sync
- **Type-safe schema**: decorators define models, typed hooks bind them to React
## What you can build
Strata Sync fits products that need local-first responsiveness with predictable sync:
- Collaborative editors with offline draft support
- Task and project apps that must work on unreliable networks
- Customer-facing dashboards backed by a shared canonical state
- Mobile workflows that queue writes and reconcile after reconnecting
- Internal tools that need strong consistency without giving up responsiveness
## How it works
1. Define models with decorators (`@ClientModel`, `@Property`, `@ManyToOne`, `@OneToMany`).
2. Create a sync client with storage, transport, and reactivity adapters.
3. Wrap your React tree with `SyncProvider`.
4. Read data with hooks (`useModel`, `useQuery`) that auto-update.
5. Write data with `create`, `update`, and `delete`: applied optimistically, synced in the background.
## How Strata Sync compares
| Feature | Strata Sync | ElectricSQL | Zero | InstantDB | PowerSync |
| ------------------- | ---------------------- | -------------- | -------------------- | -------------------- | -------------------- |
| Local storage | Built-in (IndexedDB) | Bring your own | Built-in (IndexedDB) | Built-in (IndexedDB) | Built-in (SQLite) |
| Conflict resolution | Automatic, field-level | Bring your own | Server decides | Server decides | Customisable |
| Real-time editing | Rich-text with Yjs | Not included | Not included | Not included | Not included |
| Offline writes | Full offline support | Bring your own | Not supported | Basic support | Full offline support |
| Undo / redo | Built-in | Not included | Not included | Not included | Not included |
## Packages
Install only the packages you need.
| Package | Description |
| ------------------------------- | ---------------------------------------------------- |
| `@stratasync/core` | Model runtime, schema decorators, transactions |
| `@stratasync/client` | Client orchestrator, CRUD, query engine, outbox |
| `@stratasync/react` | React hooks and `SyncProvider` binding |
| `@stratasync/next` | Next.js App Router SSR bootstrap and serialization |
| `@stratasync/y-doc` | Yjs CRDT document and presence management |
| `@stratasync/mobx` | MobX reactivity adapter for observable models |
| `@stratasync/storage-idb` | IndexedDB storage for data, outbox, and metadata |
| `@stratasync/transport-graphql` | GraphQL + WebSocket transport for sync and mutations |
# Installation (https://stratasync.blode.md/installation)
Install the packages for your stack, or scaffold a complete app with the Claude Code skill.
## Scaffold a full-stack app
Scaffold a complete Next.js + Fastify app:
```bash
npx skills add stratasync/stratasync
```
This installs a Claude Code skill that generates a working client and server with models, sync, IndexedDB, WebSocket, and PostgreSQL.
## Requirements
- **Node.js** >= 18
- **TypeScript** 5.9+ with `experimentalDecorators` enabled
- **React** 18 or 19
- **Next.js** 14 or 15 (if using `@stratasync/next`)
## Install
```bash
# React app
npm install @stratasync/core @stratasync/client @stratasync/react @stratasync/mobx @stratasync/storage-idb @stratasync/transport-graphql
# Next.js app
npm install @stratasync/core @stratasync/client @stratasync/react @stratasync/next @stratasync/mobx @stratasync/storage-idb @stratasync/transport-graphql
# Add collaborative editing
npm install @stratasync/y-doc
```
## Peer dependencies
```bash
npm install react mobx yjs
```
| Package | Required versions |
| ------------------- | ----------------------------------------- |
| `@stratasync/react` | `react ^18 \|\| ^19`, `yjs ^13.6` |
| `@stratasync/next` | `next ^14 \|\| ^15`, `react ^18 \|\| ^19` |
| `@stratasync/mobx` | `mobx ^6.0` |
## TypeScript configuration
Enable experimental decorators for model schema definitions.
```json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": false,
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler"
}
}
```
`emitDecoratorMetadata` is not needed: decorators register with the `ModelRegistry` at runtime.
# Packages (https://stratasync.blode.md/packages)
Strata Sync is split into focused packages so you can adopt only the layers you
need.
## Core runtime
- [`@stratasync/core`](https://stratasync.blode.md/packages/core) - Model runtime, schema, decorators, and
transactions.
- [`@stratasync/client`](https://stratasync.blode.md/packages/client) - Client orchestration, queries,
events, and the offline outbox.
## UI and framework integrations
- [`@stratasync/react`](https://stratasync.blode.md/packages/react) - React hooks and providers.
- [`@stratasync/next`](https://stratasync.blode.md/packages/next) - Next.js App Router bootstrap and
hydration utilities.
- [`@stratasync/mobx`](https://stratasync.blode.md/packages/mobx) - MobX reactivity bindings for models.
## Storage and transport adapters
- [`@stratasync/storage-idb`](https://stratasync.blode.md/packages/storage-idb) - IndexedDB persistence
for local-first apps.
- [`@stratasync/transport-graphql`](https://stratasync.blode.md/packages/transport-graphql) - GraphQL and
WebSocket sync transport.
## Collaboration
- [`@stratasync/y-doc`](https://stratasync.blode.md/packages/y-doc) - Yjs-backed collaborative editing and
presence.
Use the package pages above for installation, imports, and API details.
# @stratasync/client (https://stratasync.blode.md/packages/client)
## Installation
```bash
npm install @stratasync/client
```
`@stratasync/client` depends on `@stratasync/core` and `@stratasync/y-doc` as workspace dependencies.
## Quick start
Wire up storage, transport, and reactivity, then call `start()`.
```ts
import { createSyncClient } from "@stratasync/client";
import { createIndexedDbStorage } from "@stratasync/storage-idb";
import { createGraphQLTransport } from "@stratasync/transport-graphql";
import { createMobXReactivity } from "@stratasync/mobx";
const client = createSyncClient({
storage: createIndexedDbStorage(),
transport: createGraphQLTransport({
endpoint: "`api.example.com/graphql`",
syncEndpoint: "`api.example.com/sync`",
wsEndpoint: "wss://api.example.com/sync/ws",
auth: { getAccessToken: async () => "your-token" },
}),
reactivity: createMobXReactivity(),
optimistic: true,
batchMutations: true,
});
await client.start();
const task = await client.create("Task", {
title: "My task",
status: "todo",
});
await client.stop();
```
## Architecture role
Sits between core primitives and framework bindings. Framework-agnostic: no React, Next.js, or browser API dependencies beyond injected adapters.
```
sync-core, sync-y-doc
^-- sync-client
^-- sync-react
^-- sync-next
```
The React hooks in `@stratasync/react` are thin wrappers around `SyncClient`. See [React](https://stratasync.blode.md/packages/react) for details.
## SyncClient interface
`createSyncClient` returns a `SyncClient` with five categories of methods:
### Lifecycle
Control the sync engine and connection state.
| Property / Method | Type | Description |
| ------------------- | ----------------- | ---------------------------------- |
| `state` | `SyncClientState` | Current sync state. |
| `connectionState` | `ConnectionState` | Current connection state. |
| `lastSyncId` | `number` | Last sync ID received from server. |
| `lastError` | `Error \| null` | Last error (if any). |
| `clientId` | `string` | Client instance identifier. |
| `start()` | `Promise` | Start the sync lifecycle. |
| `stop()` | `Promise` | Stop the sync lifecycle. |
| `syncNow()` | `Promise` | Force an immediate sync. |
| `clearAll()` | `Promise` | Clear all local data. |
| `getPendingCount()` | `Promise` | Pending transaction count. |
### Reading
Fetch models from local storage or the identity map.
| Method | Type | Description |
| ------------------------------- | ------------------------- | ------------------------- |
| `get(modelName, id)` | `Promise` | Get a model by ID. |
| `getCached(modelName, id)` | `T \| null` | Get from identity map. |
| `ensureModel(modelName, id)` | `Promise` | Load if not cached. |
| `getAll(modelName, options?)` | `Promise` | Get all models of a type. |
| `query(modelName, options?)` | `Promise>` | Query with filters. |
| `getIdentityMap(modelName)` | `Map` | Raw identity map access. |
| `isModelMissing(modelName, id)` | `boolean` | Check if model not found. |
### Writing
All writes go through the outbox for offline-first persistence.
| Method | Type | Description |
| -------------------------------- | --------------- | -------------------- |
| `create(modelName, data)` | `Promise` | Create a new model. |
| `update(modelName, id, changes)` | `Promise` | Update a model. |
| `delete(modelName, id)` | `Promise` | Delete a model. |
| `archive(modelName, id)` | `Promise` | Soft-delete a model. |
| `unarchive(modelName, id)` | `Promise` | Restore a model. |
### Undo/Redo
See [Undo and redo](https://stratasync.blode.md/packages/client/undo-redo) for details.
| Method | Type | Description |
| ----------- | --------------- | --------------------------- |
| `canUndo()` | `boolean` | Whether undo is available. |
| `canRedo()` | `boolean` | Whether redo is available. |
| `undo()` | `Promise` | Undo last operation. |
| `redo()` | `Promise` | Redo last undone operation. |
### Events
See [Event system](https://stratasync.blode.md/packages/client/events) for the full reference.
| Method | Type | Description |
| ----------------------------------- | ------------ | ----------------------------------------- |
| `onEvent(callback)` | `() => void` | Subscribe to events. Returns unsubscribe. |
| `onStateChange(callback)` | `() => void` | Subscribe to state changes. |
| `onConnectionStateChange(callback)` | `() => void` | Subscribe to connection changes. |
The `yjs` property (`{ documentManager, presenceManager } | undefined`) provides access to Yjs managers when configured.
## Advanced exports
For custom orchestration, testing, or direct identity map manipulation. Most apps don't need these.
| Export | Description |
| --------------------- | ------------------------------------------------------------------------ |
| `IdentityMap` | In-memory cache that deduplicates model instances by ID. |
| `IdentityMapRegistry` | Manages multiple `IdentityMap` instances, one per model type. |
| `OutboxManager` | Manages the pending transaction outbox for offline-first persistence. |
| `SyncOrchestrator` | Coordinates the full sync lifecycle (bootstrap, deltas, reconnection). |
| `executeQuery` | Runs a query against the identity map with filter operators and sorting. |
---
Content truncated to keep this response under crawler size limits.
Use https://stratasync.blode.md/llms.txt for the full page index and fetch individual .md pages for uncapped content.