@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.

TopicPurpose
changesDocument 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 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.

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

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

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

OptionTypeDescription
clientNoLagSocketRequired. The injected core NoLag client the app owns and connects.
appNamestringThe 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.
userIdstringStable user ID (auto-generated if omitted). Also the updatedBy value on every change you publish.
usernamestringOptional human-readable display name.
metadataRecord<string, unknown>Optional custom data attached to collaborator presence.
collectionsstring[]Collections to pre-join once the wrapper is ready.
debugbooleanEnable wrapper debug logging (default false).
MethodReturnsDescription
ready()Promise<void>Resolves once wrapper setup completed. Join methods throw before this resolves.
detach()voidRelease this wrapper's handlers and topics; terminal, never closes the socket.
joinCollection(name, opts?)SyncRoomSubscribe 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)voidUnsubscribe 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

EventPayloadDescription
connectednoneWrapper setup completed on a live connection.
disconnectedreason: stringConnection closed.
reconnectingnoneThe core client is attempting to reconnect.
reconnectednoneConnection restored; collection membership and presence are restored automatically.
errorerror: ErrorA transport or protocol error occurred.
collaboratorOnlinecollaborator: SyncCollaboratorA collaborator joined the lobby.
collaboratorOfflinecollaborator: SyncCollaboratorA collaborator left the lobby.

SyncRoom

MethodReturnsDescription
createDocument(id, data, opts?)SyncDocumentCreate 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 | nullMerge 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 | nullSoft-delete a document (sets deleted: true, increments the version). Returns null if unknown locally.
getDocument(id)SyncDocument | undefinedRead 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)voidChange which filter values this collection's changes subscription receives. An empty set restores the wildcard subscription.

SyncRoom Events

EventPayloadDescription
documentCreatedSyncDocumentA document was created, locally or by a remote collaborator.
documentUpdatedSyncDocumentA document's fields were updated, locally or remotely.
documentDeletedSyncDocumentA document was soft-deleted, locally or remotely.
localChangeSyncChangeThe local client applied and published a change.
conflictSyncConflictA remote change had an equal or lower version than the local copy. resolved is the document state that was kept.
syncedSyncDocumentA remote change was applied; carries the resulting document. Fires once per applied change, conflict or not.
collaboratorJoinedcollaborator: SyncCollaboratorA collaborator joined this collection.
collaboratorLeftcollaborator: SyncCollaboratorA collaborator left this collection.

Types

TypeShape
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 }