# 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 (

Status: {connectionState}

); } ``` ## Presence tracking with useYjsPresence Shows who's viewing or editing a document. ```tsx "use client"; import { useYjsPresence } from "@stratasync/react"; export function PresenceBar({ taskId }: { taskId: string }) { const { startViewing, stopViewing, isViewing, isEditing } = useYjsPresence({ entityType: "Task", entityId: taskId, fieldName: "description", }); return (
Viewing: {isViewing ? "Yes" : "No"}
Editing: {isEditing ? "Yes" : "No"}
); } ``` ### 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

Connecting...

; } return (
{participants.map((p) => ( {p.userId} {p.isEditing && "(editing)"} ))}
); } ``` ### 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 (

{conflicts.length} conflict{conflicts.length > 1 ? "s" : ""} detected

{conflicts.map((conflict, i) => (

{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
Sync error: {error.message}
; } if (isOffline) { return (
Offline {hasPending && -- {count} pending changes}
); } return (
Connected ({status}) {hasPending && -- syncing {count} changes}
); } ``` 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.