@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 conflict event
  • Partitioning with filters: sync one tenant or region of a large collection
  • Collaborator presence per collection, plus an app-wide online lobby
  • 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); conflict is emitted either way.
  • Local version higher: the remote change is stale and its fields are discarded; conflict is 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).

TopicPurpose
changesDocument 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, updatedBy and updatedAt on 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. Leave userId unset, or make it unique per client instance.

Topics to grant

WhatValue
App SlugWhatever you pass as appName (default sync)
RoomsOne per collection name you pass to joinCollection(). The blueprint defines todos and notes
Topics in each roomchanges
Lobbyonline
PermissionpubSub 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_slug and room_id.
  • Without the allowedLobbies entry the lobby subscribe is refused: the wrapper emits an error (not_authorized) and ready() 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-sdk

Shared 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

OptionTypeDescription
clientNoLagSocketRequired. The injected core NoLag client the app owns and connects.
appNamestringThe App Slug used as the first segment of every topic (default 'sync').
userIdstringUser ID for this client (generated if omitted). Also the updatedBy value on every change you publish; see the limitation above.
usernamestringOptional display name.
metadataRecord<string, unknown>Custom data attached to collaborator presence.
collectionsstring[]Collections to join once the wrapper is ready.
debugbooleanEnable wrapper debug logging (default false).
Method / PropertyReturnsDescription
ready()Promise<void>Resolves once wrapper setup completed. joinCollection() throws 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. Returns the existing instance if already joined (re-pointing its filters when opts.filters is given). The cache starts empty.
leaveCollection(name)voidUnsubscribe from a collection and drop its local cache.
getCollections()SyncRoom[]All currently joined collections.
getCollaborators()SyncCollaborator[]Collaborators currently seen as online.
localCollaboratorSyncCollaborator | nullThis client, available once ready.

Events

EventPayloadDescription
connectednoneWrapper setup completed on a live connection.
disconnectedreason: stringConnection closed.
reconnectingnoneThe core client is attempting to reconnect.
reconnectednoneSetup ran again after a reconnect: lobby and presence re-applied (the topic is not; see Reconnects).
errorerror: ErrorA transport or broker error, for example a refused subscribe.
collaboratorOnlinecollaborator: SyncCollaboratorA collaborator came online.
collaboratorOfflinecollaborator: SyncCollaboratorA collaborator left the lobby.

SyncRoom

Method / PropertyReturnsDescription
createDocument(id, data, opts?)SyncDocumentCreate 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 | nullMerge a partial update into the document and increment its version. null if the document is unknown locally or deleted.
deleteDocument(id)SyncDocument | nullSoft-delete a document (sets deleted: true, increments the version). 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.
filtersFilterValue[]The filter values applied to the changes subscription.
setFilters(values) / addFilters(values) / removeFilters(values)voidChange which tagged changes this collection receives. An empty set restores the unfiltered subscription.

Events

EventPayloadDescription
documentCreatedSyncDocumentA document was created, locally or remotely.
documentUpdatedSyncDocumentA document's fields were updated, locally or remotely.
documentDeletedSyncDocumentA document was soft-deleted, locally or remotely.
localChangeSyncChangeThis client applied and published a change.
conflictSyncConflictA remote change had an equal or lower version than the local copy. resolved is the document after resolution.
syncedSyncDocumentA remote change was applied; fires once per applied change, conflict or not.
collaboratorJoinedSyncCollaboratorA collaborator set presence in this collection.
collaboratorLeftSyncCollaboratorA collaborator's presence left this collection.

Types

TypeMain fields
SyncDocumentid, data, version, updatedBy, updatedAt, createdAt, deleted
SyncChangeid, documentId, type ('create' | 'update' | 'delete'), fields?, version, updatedBy, timestamp, optimistic, filter?
SyncConflictdocumentId, localChange: SyncChange, remoteChange: SyncChange, resolved: SyncDocument
SyncCollaboratoruserId, actorTokenId, username?, metadata?, joinedAt, isLocal