---
title: Sync SDK
description: Real-time data sync with document CRUD, version tracking, and conflict resolution across clients.
---

# @nolag/sync

Real-time data sync with document CRUD, version tracking, and conflict resolution.

## Overview

Sync structured data across clients in real time. Documents within a collection carry monotonically increasing version numbers. Updates carry only the fields that changed and are merged into the local copy, and when two clients diverge the SDK resolves the document by version, then by timestamp (last writer wins), and emits a `conflict` event so your application can apply its own resolution if the default is not appropriate. Collections group related documents and behave like lightweight database tables. Your app owns one core NoLag client and injects it into `NoLagSync`; the wrapper attaches its behaviour to that connection.

### Key Features

- Real-time document create, update, and delete broadcast to all collection subscribers
- Field-level updates: an update carries only the changed fields and merges into the local copy
- Version-based conflict detection with a `conflict` event for custom resolution
- Ephemeral change channel with no server-side retention
- Collaborator online/offline presence via lobby
- Local cache: every read and write method is synchronous

## How It Works

`NoLagSync` attaches to an injected `@nolag/js-sdk` client and manages a lobby that tracks which collaborators are online. Calling `joinCollection(name)` returns a `SyncRoom` that subscribes to the `changes` topic. Every create, update, and delete is applied to the local cache first, then published as a `SyncChange` so every other subscriber applies it too. Publishers never receive their own messages; the wrapper emits the local events itself.

Conflict resolution is per document, keyed on the version counter. When a remote change arrives for a document you already hold:

- Remote version higher than local: the change is applied, no conflict.
- Versions equal: the change with the later timestamp wins (remote wins a tie); `conflict` is emitted either way.
- Local version higher: the remote change is stale and discarded in full, even if it touched different fields; `conflict` is emitted.

The cache starts empty when you join. The `changes` topic is not logged and nothing is replayed, so a client only sees documents created after it joined, and a remote update or delete for a document it has never seen is dropped. If you need the current state on join, keep it in your own store and load it before joining. The app owns the socket lifecycle; the wrapper never opens or closes it.

| Topic | Purpose |
| --- | --- |
| `changes` | Document create, update, and delete operations with version metadata |

A fresh subscribe or an ordinary reconnect never replays history, and this SDK never triggers replay; see [Replay](/docs/concepts/replay) for the one case where the broker replays messages.

**Before you start.** Create an app from the `nolag-sync-sdk` blueprint. That seeds the rooms this SDK expects (`todos`, `notes`) and the `online` lobby, and returns an app slug with a random suffix. That slug is the `appName` you pass to the wrapper. You can also do this in the portal: Apps, New App, pick the blueprint.

```typescript [Setup (server side, once)]
import { NoLagApi } from "@nolag/js-sdk";

const api = new NoLagApi(process.env.NOLAG_API_KEY); // nlg_live_...
const app = await api.apps.create({ name: "My App", blueprintId: "nolag-sync-sdk" });
console.log(app.slug); // e.g. "my-app-a3f9": this is your appName

const actor = await api.actors.create({ name: "web-client", actorType: "user" });
console.log(actor.accessToken); // shown once; keep it
```

## Installation

```bash [Terminal]
npm install @nolag/sync @nolag/js-sdk
```

**Shared connection.** One core NoLag client can back several wrapper SDKs at once, for example data sync, chat, and notify on a single socket, as long as each wrapper uses a distinct `appName`. Each wrapper attaches its handlers on construction and releases them with `detach()`, and never touches the socket itself. Your app owns `connect()` and `disconnect()`.

## Quick Start

```typescript [TypeScript]
import { NoLag } from '@nolag/js-sdk'
import { NoLagSync } from '@nolag/sync'

// The app owns one core client. In a browser, pass a token provider so the
// SDK can mint fresh short-lived client tokens from your backend.
const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token)

// Inject the client into the sync wrapper
const sync = new NoLagSync({
  client,
  appName: APP_SLUG, // the slug returned when you created the app
  username: 'Alice',
})

await client.connect()   // the app owns the connection
await sync.ready()       // wrapper setup complete

// Join a seeded collection (analogous to a database table)
const collection = sync.joinCollection('todos')

// React to changes. These fire for your own writes and for remote ones.
collection.on('documentCreated', (doc) => {
  console.log('New document:', doc.id, doc.data)
})

collection.on('documentUpdated', (doc) => {
  console.log('Updated:', doc.id, 'v' + doc.version, 'by', doc.updatedBy)
})

collection.on('documentDeleted', (doc) => {
  console.log('Deleted:', doc.id)
})

// Conflicts: equal versions from two writers, or a stale remote change
collection.on('conflict', ({ documentId, localChange, remoteChange, resolved }) => {
  console.warn(
    `Conflict on ${documentId}: local v${localChange.version} vs remote v${remoteChange.version};` +
      ` kept v${resolved.version} by ${resolved.updatedBy}`,
  )
})

// Fires after every remote change is applied, with the resulting document
collection.on('synced', (doc) => {
  console.log('Applied remote change to', doc.id)
})

// Create a document (collaborators see it instantly; version starts at 1)
const created = collection.createDocument('task-001', {
  title: 'Design new homepage',
  status: 'todo',
  assignee: 'alice',
  priority: 1,
})
console.log(created.version) // 1

// Update specific fields (merged into data; version becomes 2)
const updated = collection.updateDocument('task-001', {
  status: 'in-progress',
  assignee: 'bob',
})
console.log(updated?.version) // 2

// Read from the local cache (no network call)
const task = collection.getDocument('task-001')
console.log(task?.data.status) // 'in-progress'

const all = collection.getAllDocuments()
console.log(`${all.length} documents in collection`)

// Delete a document (soft delete: excluded from getAllDocuments)
collection.deleteDocument('task-001')

// Teardown: the wrapper releases its handlers; the app closes the socket.
sync.detach()
client.disconnect()
```

## API Reference

### NoLagSync

#### Constructor Options

| Option | Type | Description |
| --- | --- | --- |
| `client` | `NoLagSocket` | **Required.** The injected core NoLag client the app owns and connects. |
| `appName` | `string` | The app slug used as the topic prefix. Pass the suffixed slug returned when you created the app; the default `'sync'` will not match a hosted app. |
| `userId` | `string` | Stable user ID (auto-generated if omitted). Also the `updatedBy` value on every change you publish. |
| `username` | `string` | Optional human-readable display name. |
| `metadata` | `Record<string, unknown>` | Optional custom data attached to collaborator presence. |
| `collections` | `string[]` | Collections to pre-join once the wrapper is ready. |
| `debug` | `boolean` | Enable wrapper debug logging (default `false`). |

| Method | Returns | Description |
| --- | --- | --- |
| `ready()` | `Promise<void>` | Resolves once wrapper setup completed. Join methods throw before this resolves. |
| `detach()` | `void` | Release this wrapper's handlers and topics; terminal, never closes the socket. |
| `joinCollection(name, opts?)` | `SyncRoom` | Subscribe to a collection and return it. Synchronous; returns the existing instance if already joined. The cache starts empty. `opts.filters` limits the subscription to changes published with those filter values. |
| `leaveCollection(name)` | `void` | Unsubscribe from a collection and release its local cache. |
| `getCollections()` | `SyncRoom[]` | All currently joined collections. |
| `getCollaborators()` | `SyncCollaborator[]` | Collaborators currently present in the `online` lobby. |

### NoLagSync Events

| Event | Payload | Description |
| --- | --- | --- |
| `connected` | none | Wrapper setup completed on a live connection. |
| `disconnected` | `reason: string` | Connection closed. |
| `reconnecting` | none | The core client is attempting to reconnect. |
| `reconnected` | none | Connection restored; collection membership and presence are restored automatically. |
| `error` | `error: Error` | A transport or protocol error occurred. |
| `collaboratorOnline` | `collaborator: SyncCollaborator` | A collaborator joined the lobby. |
| `collaboratorOffline` | `collaborator: SyncCollaborator` | A collaborator left the lobby. |

### SyncRoom

| Method | Returns | Description |
| --- | --- | --- |
| `createDocument(id, data, opts?)` | `SyncDocument` | Create a document with an explicit ID and initial data. Version starts at 1. `opts.filter` routes this document and its later changes to peers filtering on that value. |
| `updateDocument(id, fields)` | `SyncDocument \| null` | Merge a partial field update into the document and increment its version. Returns `null` if the document does not exist locally or is deleted. |
| `deleteDocument(id)` | `SyncDocument \| null` | Soft-delete a document (sets `deleted: true`, increments the version). Returns `null` if unknown locally. |
| `getDocument(id)` | `SyncDocument \| undefined` | Read a document from the local cache, including soft-deleted ones. |
| `getAllDocuments()` | `SyncDocument[]` | All non-deleted documents in the local cache. |
| `setFilters(values)` / `addFilters(values)` / `removeFilters(values)` | `void` | Change which filter values this collection's `changes` subscription receives. An empty set restores the wildcard subscription. |

### SyncRoom Events

| Event | Payload | Description |
| --- | --- | --- |
| `documentCreated` | `SyncDocument` | A document was created, locally or by a remote collaborator. |
| `documentUpdated` | `SyncDocument` | A document's fields were updated, locally or remotely. |
| `documentDeleted` | `SyncDocument` | A document was soft-deleted, locally or remotely. |
| `localChange` | `SyncChange` | The local client applied and published a change. |
| `conflict` | `SyncConflict` | A remote change had an equal or lower version than the local copy. `resolved` is the document state that was kept. |
| `synced` | `SyncDocument` | A remote change was applied; carries the resulting document. Fires once per applied change, conflict or not. |
| `collaboratorJoined` | `collaborator: SyncCollaborator` | A collaborator joined this collection. |
| `collaboratorLeft` | `collaborator: SyncCollaborator` | A collaborator left this collection. |

### Types

| Type | Shape |
| --- | --- |
| `SyncDocument` | `{ id, data, version, updatedBy, updatedAt, createdAt, deleted }` |
| `SyncChange` | `{ id, documentId, type: 'create' \| 'update' \| 'delete', fields?, version, updatedBy, timestamp, optimistic, filter?, isReplay }` |
| `SyncConflict` | `{ documentId, localChange: SyncChange, remoteChange: SyncChange, resolved: SyncDocument }` |
| `SyncCollaborator` | `{ userId, actorTokenId, username?, metadata?, joinedAt, isLocal }` |
