@nolag/sync
Realtime data sync with document CRUD, version tracking, and conflict handling.
Overview
Sync structured data across connected clients in real time. Documents within a collection carry version numbers. Updates carry only the fields that changed and are merged into each client's 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 step in. Collections group related documents like lightweight tables. Your app owns one core NoLag client and injects it into NoLagSync; the wrapper attaches its behaviour to that connection.
@nolag/sync moves changes between clients that are connected at the same time. It is not a database: there is no server-side copy of the documents, so keep the source of truth in your own store.
Key Features
- Document create, update and delete broadcast to every subscriber of the collection
- Field-level updates: an update carries only the changed fields and merges into the local copy
- Version-based conflict detection with a
conflictevent - Partitioning with filters: sync one tenant or region of a large collection
- Collaborator presence per collection, plus an app-wide
onlinelobby - Local cache: every read and write method is synchronous
How It Works
NoLagSync attaches to an injected @nolag/js-sdk client. Calling joinCollection(name) returns a SyncRoom for the room name, subscribes to its changes topic and sets the collaborator's presence there. Every create, update and delete is applied to the local cache first, then published as a SyncChange with echo: false, so every other subscriber applies it too. The wrapper emits the local events itself, and ignores any incoming change whose updatedBy equals its own userId.
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 (the remote one wins a tie);
conflictis emitted either way. - Local version higher: the remote change is stale and its fields are discarded;
conflictis emitted.
The cache starts empty when you join. kraken stores nothing for this topic, so a client only learns about documents created after it joined, and a remote update or delete for a document it has never seen is dropped. To start from the current state, load it from your own store and create the documents locally before collaborating, or give late joiners a snapshot through a hydration webhook (see Webhooks).
| Topic | Purpose |
|---|---|
changes | Document create, update, and delete operations with version metadata |
Known limitations in 1.2.0
- When the local copy wins a conflict, the wrapper keeps its own data but records the remote change's
version,updatedByandupdatedAton the document, so the local version number can go down. - A stale remote delete still marks the local document deleted, even though the conflict is reported as resolved in favour of the local copy.
- Two clients that pass the same
userId(for example one user in two tabs) ignore each other's changes. LeaveuserIdunset, or make it unique per client instance.
Topics to grant
| What | Value |
|---|---|
| App Slug | Whatever you pass as appName (default sync) |
| Rooms | One per collection name you pass to joinCollection(). The blueprint defines todos and notes |
| Topics in each room | changes |
| Lobby | online |
| Permission | pubSub for clients that write; subscribe for a read-only view |
Grant these either in kraken's static token file or in a @nolag/core project document. See Static auth and Project document for the full formats.
{
"tokens": {
"alice-sync-token": {
"actorTokenId": "alice",
"projectId": "demo-project",
"organizationId": "demo-org",
"actorType": "user",
"allowedTopics": [
{
"pattern": "sync/todos/changes",
"permission": "pubSub",
"app_id": "sync-app",
"room_id": "room-todos",
"room_slug": "todos"
},
{
"pattern": "sync/notes/changes",
"permission": "pubSub",
"app_id": "sync-app",
"room_id": "room-notes",
"room_slug": "notes"
}
],
"allowedLobbies": [{ "lobby_slug": "online", "lobby_id": "sync-online" }]
}
}
}Things that matter with the static file:
- Every client that syncs the same collection needs the same
app_id. - Collaborator presence only works in rooms that have a rule carrying both
room_slugandroom_id. - Without the
allowedLobbiesentry the lobby subscribe is refused: the wrapper emits anerror(not_authorized) andready()only resolves after the core SDK's 10 second lobby timeout.
With @nolag/core, a collection per customer or per document fits well as a room with grants, so only the people allowed to edit it can reach it. See Rooms and Access Control.
Installation
npm install @nolag/sync @nolag/js-sdkShared connection. One core NoLag client can back several wrapper SDKs at once, 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(). One connection holds presence in one room at a time, so a client that joins a second collection (or a room in another wrapper on the same client) leaves the first collection's collaborator list.
Quick Start
import { NoLag } from '@nolag/js-sdk'
import { NoLagSync } from '@nolag/sync'
const client = NoLag('alice-sync-token', { url: 'ws://localhost:8080/ws' })
const sync = new NoLagSync({
client,
appName: 'sync', // the App Slug you granted
username: 'Alice',
})
await client.connect() // the app owns the connection
await sync.ready() // wrapper setup complete
const collection = sync.joinCollection('todos')
// 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)
})
// Equal versions from two writers, or a stale remote change
collection.on('conflict', ({ documentId, remoteChange, resolved }) => {
console.warn(
`Conflict on ${documentId}: remote v${remoteChange.version} by ${remoteChange.updatedBy};` +
` kept data from v${resolved.version}`,
)
})
// 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 (version starts at 1)
const created = collection.createDocument('task-001', {
title: 'Design new homepage',
status: 'todo',
assignee: 'alice',
})
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'
console.log(`${collection.getAllDocuments().length} documents`)
// Soft delete: excluded from getAllDocuments()
collection.deleteDocument('task-001')
// Teardown: the wrapper releases its handlers; the app closes the socket.
sync.detach()
client.disconnect()Syncing one partition
// Receive only changes tagged tenant-a
const tenantTodos = sync.joinCollection('todos', { filters: ['tenant-a'] })
// Tag a new document; its later updates and deletes reuse the tag
tenantTodos.createDocument('task-002', { title: 'Billing run' }, { filter: 'tenant-a' })A client that joins without filters receives every change, including tagged ones. Filters are routing, not access control: keep tenants apart with rooms or access scopes when they must not see each other. See Filters.
Reconnects
When the core client reconnects, the wrapper re-subscribes the online lobby, re-sets its presence in every joined collection and then emits reconnected. It does not re-subscribe the changes topic: it was written for a broker that restores subscriptions on its own. Do not rely on that with kraken (see Production); re-issue the subscription yourself:
sync.on('reconnected', () => {
for (const room of sync.getCollections()) {
const filters = room.filters.length > 0 ? { filters: room.filters } : {}
client.setApp('sync').setRoom(room.name).subscribe('changes', filters)
}
})The wrapper's message handler stays registered on the client and the local cache is kept, so subscribing again is enough. Changes other clients made while this one was away are not delivered later; reload from your own store if that matters.
The online lobby. collaboratorOnline, collaboratorOffline and getCollaborators() depend on the app's online lobby. On kraken v0.9.0 a lobby covers the rooms in the subscribing token's own grants, and that room list is cached on each kraken node for 5 minutes after the last lobby subscribe there; after that, lobby events stop until something subscribes to the lobby again. collaboratorJoined and collaboratorLeft come from room presence and are not affected. See Lobbies.
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 first segment of every topic (default 'sync'). |
userId | string | User ID for this client (generated if omitted). Also the updatedBy value on every change you publish; see the limitation above. |
username | string | Optional display name. |
metadata | Record<string, unknown> | Custom data attached to collaborator presence. |
collections | string[] | Collections to join once the wrapper is ready. |
debug | boolean | Enable wrapper debug logging (default false). |
| Method / Property | Returns | Description |
|---|---|---|
ready() | Promise<void> | Resolves once wrapper setup completed. joinCollection() throws 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. Returns the existing instance if already joined (re-pointing its filters when opts.filters is given). The cache starts empty. |
leaveCollection(name) | void | Unsubscribe from a collection and drop its local cache. |
getCollections() | SyncRoom[] | All currently joined collections. |
getCollaborators() | SyncCollaborator[] | Collaborators currently seen as online. |
localCollaborator | SyncCollaborator | null | This client, available once ready. |
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 | Setup ran again after a reconnect: lobby and presence re-applied (the topic is not; see Reconnects). |
error | error: Error | A transport or broker error, for example a refused subscribe. |
collaboratorOnline | collaborator: SyncCollaborator | A collaborator came online. |
collaboratorOffline | collaborator: SyncCollaborator | A collaborator left the lobby. |
SyncRoom
| Method / Property | Returns | Description |
|---|---|---|
createDocument(id, data, opts?) | SyncDocument | Create a document with an explicit ID and initial data. Version starts at 1. opts.filter (or opts.filters) tags this document and its later changes. |
updateDocument(id, fields) | SyncDocument | null | Merge a partial update into the document and increment its version. null if the document is unknown locally or deleted. |
deleteDocument(id) | SyncDocument | null | Soft-delete a document (sets deleted: true, increments the version). 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. |
filters | FilterValue[] | The filter values applied to the changes subscription. |
setFilters(values) / addFilters(values) / removeFilters(values) | void | Change which tagged changes this collection receives. An empty set restores the unfiltered subscription. |
Events
| Event | Payload | Description |
|---|---|---|
documentCreated | SyncDocument | A document was created, locally or remotely. |
documentUpdated | SyncDocument | A document's fields were updated, locally or remotely. |
documentDeleted | SyncDocument | A document was soft-deleted, locally or remotely. |
localChange | SyncChange | This 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 after resolution. |
synced | SyncDocument | A remote change was applied; fires once per applied change, conflict or not. |
collaboratorJoined | SyncCollaborator | A collaborator set presence in this collection. |
collaboratorLeft | SyncCollaborator | A collaborator's presence left this collection. |
Types
| Type | Main fields |
|---|---|
SyncDocument | id, data, version, updatedBy, updatedAt, createdAt, deleted |
SyncChange | id, documentId, type ('create' | 'update' | 'delete'), fields?, version, updatedBy, timestamp, optimistic, filter? |
SyncConflict | documentId, localChange: SyncChange, remoteChange: SyncChange, resolved: SyncDocument |
SyncCollaborator | userId, actorTokenId, username?, metadata?, joinedAt, isLocal |