> ## Documentation Index
> Fetch the complete documentation index at: https://docs.nodalmerge.com/llms.txt
> Use this file to discover all available pages before exploring further.

# JavaScript SDK

> Build local-first collaborative applications with the NodalMerge JavaScript SDK, from room connection through sync, presence, and production hardening.

# JavaScript SDK

The JavaScript SDK gives you a practical API for room connection, synchronized state updates, replay-aware reads, and collaboration signals.

Use this page as your implementation baseline for browser or JS runtime clients.

## Install

```bash theme={null}
npm install nodalmerge-sdk-js
```

## Minimal setup

```ts theme={null}
import { createNodalMergeSdk } from "nodalmerge-sdk-js";

const sdk = await createNodalMergeSdk({
  wsUrl: "ws://127.0.0.1:7878/ws/runtime",
  roomId: "demo-room",
  transport: { mode: "auto" },
  reconnect: {
    enabled: true,
    initialDelayMs: 500,
    maxDelayMs: 8000
  }
});

await sdk.room.connect();
```

## Read and write state

```ts theme={null}
sdk.sync.set("profile/name", "alice");
sdk.sync.set("profile/role", "engineer");
const sent = sdk.sync.push();

const name = sdk.sync.get("profile/name");
const canonicalName = sdk.sync.getCanonical("profile/name");
```

Call `sdk.sync.push()` after local mutations to flush updates. `push()` returns a
`boolean` — `true` if a pack was actually sent, `false` if there was nothing pending.
It sends only the delta accumulated since the last successful push (tracked
internally), not a full state export.

`sdk.sync.get()` returns the speculative/intent-lane view (including your own
unconfirmed writes); `sdk.sync.getCanonical()` returns the authoritative
canonical-lane view — the same intent-vs-canonical distinction described for text
reads below.

**Push acknowledgement and retry.** The server responds to each `push()` with a
`pack-ack` message reporting accepted/rejected counts. The SDK handles this
automatically: if any nodes are rejected, it reverts the corresponding local
optimistic state and resends. You don't need to handle `pack-ack` directly unless
you're building custom sync-status UI — see `protocol/websocket-messages` and
`api-reference/websocket-commands#pack-ack-server-client`.

**Pack size limit.** A single pushed pack is capped at 60 KiB of base64 (61,440
characters). Pushes exceeding this are rejected client-side — the SDK reverts the
pending marks and emits an `"error"` event rather than sending an oversized frame.
Batch large sets of writes into multiple smaller `push()` calls if you hit this.

## Text operations

Use positional helpers for collaborative text edits:

```ts theme={null}
sdk.sync.insertTextAt("doc/title", 0, "Hello");
sdk.sync.insertTextAt("doc/title", 5, " world");
sdk.sync.deleteTextAt("doc/title", 5, 1);
sdk.sync.push();
```

### Text range anchors

When your editor needs stable positions under concurrent edits, use anchor-based helpers. Anchors identify *where* to insert or delete relative to the document structure rather than a raw integer offset that may shift under concurrent changes.

**Insert anchors** (`TextInsertAnchor`):

| Shape                                | Meaning                          |
| ------------------------------------ | -------------------------------- |
| `{ kind: "offset", pos: number }`    | Insert at character offset `pos` |
| `{ kind: "start" }`                  | Insert at the very beginning     |
| `{ kind: "end" }`                    | Append to the end                |
| `{ kind: "after", lamport, author }` | Insert after a specific RGA node |

**Delete anchors** (`TextDeleteAnchor`):

| Shape                                | Meaning                                   |
| ------------------------------------ | ----------------------------------------- |
| `{ kind: "offset", pos: number }`    | Delete starting at character offset `pos` |
| `{ kind: "start" }`                  | Delete from the beginning                 |
| `{ kind: "after", lamport, author }` | Delete starting after a specific RGA node |

```ts theme={null}
// Append to end
sdk.sync.insertTextRange("doc/title", { kind: "end" }, " world");

// Prepend
sdk.sync.insertTextRange("doc/title", { kind: "start" }, "Hello ");

// Delete 1 char at offset 5
sdk.sync.deleteTextRange("doc/title", { kind: "offset", pos: 5 }, 1);

// Insert after a known RGA node (useful in rich-text editors that track op IDs)
sdk.sync.insertTextRange("doc/title", {
  kind: "after",
  lamport: 42,
  author: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}, "!");

sdk.sync.push();
```

Prefer anchor-based APIs in editors that maintain cursor/selection as logical positions rather than integer offsets; they survive concurrent insertions at the same position without extra rebasing logic.

### Text reads and attribution

For attribution UIs (per-glyph author), read the visible RGA sequence:

```ts theme={null}
const glyphs = sdk.sync.getTextSequence("notes/demo/body");
// [{ lamport, author, ch }, ...] — tombstones omitted

const atLamport = sdk.sync.getTextAtLamport("notes/demo/body", 42);
const glyphsAt = sdk.sync.getTextSequenceAtLamport("notes/demo/body", 42);
```

`getTextAtLamport` / `getTextSequenceAtLamport` replay only DAG nodes with `transaction.lamport <= maxLamport` (tombstones included). The speculative read (`getText`) returns the intent-lane view; `getTextCanonical` returns the canonical lane view.

Try the interactive surface: [Collab text playground](/developer-experience/apps#collab-text) (`http://127.0.0.1:4177`, default room `collab-text`).

## Presence and ephemeral collaboration

Presence is for live ephemeral state, not durable business data:

```ts theme={null}
sdk.presence.set({ cursor: { x: 120, y: 220 } }, { ttlMs: 5_000 });
```

Use presence for cursor/typing/view state. Persist durable facts via sync state instead.

## Blob and file storage patterns

For file-like payloads, use the SDK CAS APIs and keep sync state as references:

```ts theme={null}
const bytes = new Uint8Array([1, 2, 3, 4]);
const hash = sdk.cas.setBlob("files/avatar.png", bytes);
sdk.sync.set("world/files/avatar", hash);
sdk.sync.push();
```

Later, resolve bytes by hash:

```ts theme={null}
const hash = sdk.sync.get("world/files/avatar");
if (hash) {
  const payload = sdk.cas.getBlob(hash);
}
```

When a referenced blob is missing locally, request retrieval:

```ts theme={null}
sdk.cas.requestMissingBlobs();
```

This keeps large binary payloads out of normal key/value state while preserving deterministic references.

## Runtime events

Subscribe to runtime messages for observability and integration hooks:

```ts theme={null}
const stop = sdk.on("runtime-message", (msg) => {
  console.log("runtime event", msg.type);
});

// later
stop();
```

Use this stream for diagnostics and advanced UX instrumentation.

## Offline and persistence

SDK can queue and flush writes across reconnect boundaries.

For peer-local durability, enable persistence explicitly:

```ts theme={null}
const sdk = await createNodalMergeSdk({
  wsUrl: "ws://127.0.0.1:7878/ws/runtime",
  roomId: "demo-room",
  persistence: {
    enabled: true,
    adapter: "indexeddb",
    dbName: "nodalmerge-peer-local"
  }
});
```

Validate restart and reconnect behavior with realistic offline drills before shipping.

## Intent vs canonical pattern

Model local optimism and authoritative outcomes in separate namespaces:

* `intent/**` for speculative writes
* `world/**` for canonical accepted state

This keeps your UI responsive while preserving deterministic refinement behavior.

## Replay range read

Retrieve a paginated history of key mutations from the server, useful for activity feeds, audit trails, and undo stacks:

```ts theme={null}
const result = await sdk.query.readReplayRange({
  keyPrefix: "world/",
  fromLamport: 0,
  limit: 100
});

// result.type === "replay.read-range.result"
// result.items — array of mutation records
// result.next_cursor — pass as `cursor` to page forward
```

For local-only history (offline/unsent ops), use `sdk.sync.readLocalReplayRange` which returns synchronously from the WASM store.

## Query and projection helpers

Runtime query APIs support canonical-lane analytics and read-model workflows. Use `sdk.query` when you need structured projections over room state rather than direct key access.

**Register a query spec** (once per descriptor version):

```ts theme={null}
await sdk.query.registerSpec({
  querySpecId: "q.world",
  version: "v1",
  descriptor: { prefix: "world/" }
});
```

**Build a projection** at a specific checkpoint:

```ts theme={null}
const build = await sdk.query.buildProjection({
  projectionId: "p.world",
  querySpecId: "q.world",
  targetCheckpoint: { selector: "latest" }
  // other selectors: { selector: "seq", canonical_seq: 42 }
  //                  { selector: "hash", canonical_hash: "<hex>" }
  //                  { selector: "frontier", frontier: ["seq:42"] }
});
```

**Read paginated results**:

```ts theme={null}
if (build.type === "projection.build.completed") {
  const page = await sdk.query.readProjection({
    projectionId: "p.world",
    limit: 50
  });
  console.log(page.rows, page.digest, page.next_page_token);
}
```

**Invalidate or list projections**:

```ts theme={null}
await sdk.query.invalidateProjection({ projectionId: "p.world", reason: "schema change" });

const list = await sdk.query.listProjections({ querySpecId: "q.world", stateFilter: "active" });
```

## Transport options

`transport.mode` commonly uses:

* `"auto"`: allow negotiated transport enhancements when available
* `"ws-only"`: force WebSocket-only behavior

Start with `auto`, switch to `ws-only` only when environment constraints require strict transport control.

## Error and rejection handling

Treat rejection/error signals as first-class UX and ops inputs:

* Surface user-meaningful failure states
* Log structured rejection context
* Avoid silent rollback paths

In policy-governed systems, rejection handling is part of normal operation.

## Configuration reference

All options passed to `createNodalMergeSdk`:

| Option                          | Type                                         | Default                   | Description                                    |
| ------------------------------- | -------------------------------------------- | ------------------------- | ---------------------------------------------- |
| `wsUrl`                         | `string`                                     | —                         | WebSocket endpoint URL (required)              |
| `roomId`                        | `string`                                     | —                         | Room identifier (required)                     |
| `authorKey`                     | `Uint8Array`                                 | —                         | Ed25519 signing key bytes for this peer        |
| `token`                         | `unknown`                                    | —                         | Auth token forwarded in hello payload          |
| `wasmModule`                    | `RequestInfo \| URL \| ...`                  | —                         | Custom WASM module source; defaults to bundled |
| `tickIntervalMs`                | `number`                                     | server default            | How often local tick loop fires (ms)           |
| `maxOpsPerTick`                 | `number`                                     | server default            | Max ops batched per tick flush                 |
| `reconnect.enabled`             | `boolean`                                    | `false`                   | Enable automatic reconnect                     |
| `reconnect.initialDelayMs`      | `number`                                     | `500`                     | First reconnect delay (ms)                     |
| `reconnect.maxDelayMs`          | `number`                                     | `8000`                    | Reconnect backoff ceiling (ms)                 |
| `reconnect.factor`              | `number`                                     | `2`                       | Backoff multiplier                             |
| `reconnect.maxAttempts`         | `number`                                     | unlimited                 | Stop reconnecting after N failures             |
| `offline.persistenceKey`        | `string`                                     | —                         | localStorage key for outbox persistence        |
| `transport.mode`                | `"auto" \| "ws-only"`                        | `"ws-only"`               | Transport negotiation policy                   |
| `persistence.enabled`           | `boolean`                                    | `false`                   | Enable peer-local graph persistence            |
| `persistence.adapter`           | `"indexeddb" \| PeerLocalPersistenceAdapter` | `"indexeddb"`             | Persistence backend                            |
| `persistence.dbName`            | `string`                                     | `"nodalmerge-peer-local"` | IndexedDB database name                        |
| `persistence.dbVersion`         | `number`                                     | `1`                       | IndexedDB schema version                       |
| `persistence.debounceMs`        | `number`                                     | `500`                     | Debounce interval for auto-save writes (ms)    |
| `persistence.migrateLegacyDemo` | `boolean`                                    | `false`                   | Migrate pre-Phase-C demo key formats           |

`offline.persistenceKey` controls **outbox** persistence (localStorage) and is independent of `persistence.*` which controls **graph/node** durability (IndexedDB).

## Production checklist

* Durable server storage enabled
* Reconnect policy configured and tested
* Local persistence strategy validated
* Intent/canonical namespaces separated
* Presence restricted to ephemeral signals
* Runtime event/rejection telemetry wired

## Common mistakes

* Forgetting `sdk.sync.push()` after local writes
* Using presence for durable domain state
* Storing file/blob bytes directly in sync string keys instead of hash references
* Mixing optimistic and canonical writes in one keyspace
* Assuming online-only behavior during QA
* Ignoring rejection pathways until late integration

## Related pages

* [sdk/overview](/sdk/overview)
* [sdk/react](/sdk/react)
* [sdk/presence](/sdk/presence)
* [sdk/subscriptions](/sdk/subscriptions)
* [protocol/blob-flow](/protocol/blob-flow)
