@luispm/zflow-graph 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ # 4 · Performance at Scale
2
+
3
+ zflow-graph is engineered to keep 60 fps at 100k nodes. This isn't an accident — it's the result of specific architectural choices you should understand if you want to push it.
4
+
5
+ ## What runs where
6
+
7
+ ```
8
+ JS heap WASM linear memory
9
+ ───────────── ──────────────────
10
+ canvas2d ctx pos_x[] ← Float32 view
11
+ WebGL context (opt) pos_y[] ← Float32 view
12
+ event listeners size_w[] ← Float32 view
13
+ plugin instances size_h[] ← Float32 view
14
+ metric history selected[] ← Uint8 view
15
+ notes, frames (small) edge_from[] ← Uint32 view
16
+ edge_to[] ← Uint32 view
17
+ spatial grid ← Zig-only
18
+ undo stack ← Zig-only
19
+ ```
20
+
21
+ Reading `flow.V.posX[i]` is a **direct memory access** into WASM. There is no copy.
22
+
23
+ Mutating these arrays from JS works too — `flow.V.posX[5] = 100` moves node 5. But invalidate caches with `flow._gl?.markNodeDirty(5)` if WebGL is on.
24
+
25
+ ## The three speeds
26
+
27
+ ### Speed 1: small graphs (< 1k nodes)
28
+ Canvas2D handles everything. Pan, zoom, drag, rich text rendering — all at 60 fps. Don't even think about WebGL.
29
+
30
+ ### Speed 2: medium graphs (1k – 5k nodes)
31
+ Canvas2D still works but starts to wobble with shadows and gradients. Two options:
32
+
33
+ ```js
34
+ flow.options.edgeFlowSpeed = 0; // disable particle animation
35
+ flow.options.hoverPreview = false; // disable popover
36
+ ```
37
+
38
+ Or just turn on WebGL:
39
+
40
+ ```js
41
+ await flow.enableWebGL();
42
+ ```
43
+
44
+ ### Speed 3: large graphs (> 5k nodes)
45
+ WebGL is mandatory. Canvas2D for the body + edges is too slow. With WebGL:
46
+
47
+ - All node bodies → **one** instanced draw call
48
+ - All edges → **one** line draw call
49
+ - Text/badges/ports → still Canvas2D, but **LOD-gated** (skipped below zoom 0.4)
50
+ - Camera moves → uniform-only, **zero buffer uploads**
51
+
52
+ ```js
53
+ const flow = await ZFlow.create({
54
+ container,
55
+ wasmUrl: '/zflow.wasm',
56
+ webglThreshold: 2000, // auto-enable past this many nodes
57
+ });
58
+ ```
59
+
60
+ ## Bulk operations
61
+
62
+ Adding nodes one by one is fine for ~100. Above that, bulk:
63
+
64
+ ```js
65
+ // SLOW — emits change + runs hooks + dirties everything 50,000 times
66
+ for (let i = 0; i < 50000; i++) flow.addNode({ kind: 'process', x: i * 10, y: 0 });
67
+
68
+ // FAST — same result in ~50ms
69
+ flow.addNodesBulk(
70
+ Array.from({ length: 50000 }, (_, i) => ({ kind: 'process', x: i * 10, y: 0 }))
71
+ );
72
+ ```
73
+
74
+ Same for edges:
75
+
76
+ ```js
77
+ flow.addEdgesBulk(specs);
78
+ ```
79
+
80
+ Both return arrays of created ids in input order. They suspend event emission during the loop and emit a single `change` at the end.
81
+
82
+ ## LOD (Level of Detail)
83
+
84
+ The library auto-degrades detail when zoom is low:
85
+
86
+ | Zoom | What renders |
87
+ | ------------ | --------------------------------------------- |
88
+ | `> 0.4` | Everything: text, ports, badges, shadows, descriptions, sparklines |
89
+ | `0.35 – 0.4` | Skip text. Ports + selection visible. |
90
+ | `0.25 – 0.35`| Skip ports too. Bodies + edges only. |
91
+ | `< 0.25` | Skip canvas overlay entirely. WebGL bodies only. |
92
+
93
+ With > 5,000 nodes, the threshold is bumped to `0.55` automatically — the rationale is that 5k nodes at zoom 0.4 are unreadable anyway.
94
+
95
+ You can override:
96
+
97
+ ```js
98
+ // Currently the thresholds are internal constants. To override, fork the lib
99
+ // or skip your own detail-drawing in a plugin's beforeRender hook.
100
+ ```
101
+
102
+ ## Viewport culling
103
+
104
+ Two layers:
105
+
106
+ 1. **Spatial grid in Zig** — `queryRect(minX, minY, maxX, maxY)` returns node ids whose AABB intersects the rect. O(1) average, O(k) where k = visible nodes. The library uses this for any graph > 300 nodes.
107
+
108
+ 2. **JS frustum check** — for every returned id, verify it's actually inside the viewport. Cheap second pass.
109
+
110
+ This means the cost of rendering doesn't scale with total nodes — it scales with **visible** nodes. Zoomed out so 5 nodes are visible? You pay for 5 draws (plus the GL buffer which is constant per-frame).
111
+
112
+ ## Memory profile
113
+
114
+ At 100k nodes, the WASM uses ~40 MB of linear memory:
115
+
116
+ - Node SoA: ~5 MB (8 typed arrays × 100k × varying widths)
117
+ - Edge SoA: ~3 MB (200k edges)
118
+ - Spatial grid: ~1 MB
119
+ - Snapshot stack (8 deep): ~33 MB
120
+
121
+ JS-side maps (titles, descriptions, colors) only allocate for nodes that actually have those fields. With `null` rich content, JS heap is < 5 MB even at 100k nodes.
122
+
123
+ If you need more than 100k nodes per graph, you have two options:
124
+ - Edit `NODE_CAP` in `src/core.zig` and rebuild WASM
125
+ - Use multiple flow instances and link them via a parent UI
126
+
127
+ ## Drag performance
128
+
129
+ When you drag N selected nodes, the runtime needs to mark all edges touching them as dirty (so the GL bezier buffer regenerates). The naive implementation is `O(N × edges)` — for 25 nodes × 200k edges = 5M ops per frame, unusable.
130
+
131
+ zflow caches an adjacency map per-node:
132
+
133
+ ```js
134
+ flow._ensureAdj(); // O(edges) once
135
+ flow._nodeAdj[id] // → [edgeIdx, edgeIdx, ...] for that node
136
+ ```
137
+
138
+ Drag becomes `O(N × avgDegree)` which is microseconds. The cache invalidates on add/delete edge.
139
+
140
+ ## Memoization
141
+
142
+ For grids with many sources that don't change often:
143
+
144
+ ```js
145
+ flow.setMemoization(true);
146
+ ```
147
+
148
+ Each node's input hash (FNV-1a) is recorded after exec. On the next run, if the hash matches, the node is skipped and `node:cached` fires. Fast (~80µs per node for 1k-entry inputs).
149
+
150
+ Caveats:
151
+ - Skipped nodes don't update sparkline / status
152
+ - If `execute` has side effects (DB writes, network), you don't want this on
153
+ - Memoization invalidates per-node, not graph-wide — a downstream node may still re-run if its inputs hash changed
154
+
155
+ ## Streaming + downstream propagation
156
+
157
+ Each `yield` from an async generator propagates through downstream nodes **synchronously** before the next yield. This means 10 yields × 4 downstream nodes = 40 executions, all in sequence.
158
+
159
+ If you need parallel pipelines, use separate streams:
160
+
161
+ ```js
162
+ const a = flow.addNode({ kind: 'stream' });
163
+ const b = flow.addNode({ kind: 'stream' });
164
+ // They run independently — flow.run() will start both in parallel because
165
+ // async generators are awaited per node, but the topo walker processes them
166
+ // in order. For true parallelism, use Promise.all in your executor.
167
+ ```
168
+
169
+ ## Web Workers (manual)
170
+
171
+ The library runs entirely on the main thread. For CPU-heavy executors:
172
+
173
+ ```js
174
+ flow.registerKind({
175
+ name: 'heavy-compute',
176
+ execute: async (ctx, ins) => {
177
+ const worker = new Worker('./heavy-worker.js', { type: 'module' });
178
+ return new Promise((resolve, reject) => {
179
+ worker.onmessage = (e) => { worker.terminate(); resolve(e.data); };
180
+ worker.onerror = reject;
181
+ ctx.signal.addEventListener('abort', () => worker.terminate());
182
+ worker.postMessage(ins);
183
+ });
184
+ },
185
+ });
186
+ ```
187
+
188
+ (Future versions may move auto-layout and force-layout to workers automatically.)
189
+
190
+ ## Profiling
191
+
192
+ Easy fps monitor:
193
+
194
+ ```js
195
+ let frames = 0, t0 = performance.now();
196
+ const orig = flow._loop.bind(flow);
197
+ flow._loop = () => { frames++; orig(); };
198
+ setInterval(() => {
199
+ console.log('fps:', frames * 1000 / (performance.now() - t0));
200
+ frames = 0; t0 = performance.now();
201
+ }, 500);
202
+ ```
203
+
204
+ Or use the Chrome DevTools Performance tab. Look for:
205
+ - `_render` taking > 16ms per frame → CPU bound, try WebGL
206
+ - `_drawNode` calls > 1000/frame → enable viewport culling (already on > 300 nodes)
207
+ - Long GC pauses → check if you're allocating in a render hook
208
+
209
+ ## What we don't optimize (yet)
210
+
211
+ - **Layout for huge graphs** — Sugiyama and force layout both run on main thread. Above 5k nodes they freeze the UI. Run them in a worker for now.
212
+ - **Streaming with very high frequency** — yielding > 200x/sec saturates the event loop with bubble animations. Throttle inside your generator.
213
+ - **HTML overlay nodes** — each one is a real DOM element. Don't create 1000 of them. Use canvas-rendered kinds at scale, HTML only for special-case interactive nodes.
214
+ - **Search at very large graphs** — `flow.search(query)` is O(n). For > 10k nodes, build your own index.
215
+
216
+ ## Next
217
+
218
+ → [Plugins](./05-plugins.md) — extend behavior without forking
@@ -0,0 +1,235 @@
1
+ # 5 · Plugin System
2
+
3
+ Plugins are how you extend zflow without forking. A plugin is a plain object with optional lifecycle hooks.
4
+
5
+ ## Anatomy
6
+
7
+ ```js
8
+ const myPlugin = {
9
+ name: 'autosave', // for logging / display
10
+
11
+ init: (flow) => { /* called once on use() */ },
12
+ dispose: (flow) => { /* called when removed */ },
13
+
14
+ // Render hooks
15
+ beforeRender: (flow, ctx) => {},
16
+ afterRender: (flow, ctx) => {},
17
+
18
+ // Mutation hooks
19
+ onNodeAdd: (flow, nodeId, spec) => {},
20
+ onNodeDelete: (flow, nodeId) => {},
21
+ onEdgeAdd: (flow, edgeIdx, spec) => {},
22
+
23
+ // Runtime hooks
24
+ onBeforeExec: (flow, nodeId, inputs) => {}, // return false → skip exec
25
+ onAfterExec: (flow, nodeId, output) => {},
26
+
27
+ // Other
28
+ onConnect: (flow, fromN, fp, toN, tp) => {}, // return false → reject
29
+ onChange: (flow) => {},
30
+ onSelectionChange: (flow, ids) => {},
31
+
32
+ // Bulk additions
33
+ kinds: [{ name: 'foo', execute: ... }],
34
+ commands: [{ label: 'Do thing', run: () => {} }],
35
+ extendAPI: (flow) => { flow.myMethod = () => 42; },
36
+ };
37
+
38
+ const dispose = flow.use(myPlugin);
39
+ // ... later ...
40
+ dispose(); // remove the plugin
41
+ ```
42
+
43
+ All fields are optional. Plugins with no hooks are valid (e.g., for bundling kinds).
44
+
45
+ ## Example: autosave to localStorage
46
+
47
+ ```js
48
+ flow.use({
49
+ name: 'autosave',
50
+ init: (f) => {
51
+ const saved = localStorage.getItem('graph');
52
+ if (saved) f.loadJSON(JSON.parse(saved));
53
+ },
54
+ onChange: (f) => {
55
+ localStorage.setItem('graph', JSON.stringify(f.toJSON()));
56
+ },
57
+ });
58
+ ```
59
+
60
+ ## Example: fps overlay
61
+
62
+ ```js
63
+ flow.use({
64
+ name: 'fps',
65
+ _frames: 0, _t0: 0, _fps: 0,
66
+ init() { this._t0 = performance.now(); },
67
+ afterRender: function (f, ctx) {
68
+ this._frames++;
69
+ const now = performance.now();
70
+ if (now - this._t0 > 500) {
71
+ this._fps = Math.round(this._frames * 1000 / (now - this._t0));
72
+ this._frames = 0; this._t0 = now;
73
+ }
74
+ ctx.save();
75
+ ctx.font = '600 11px ui-monospace, Consolas, monospace';
76
+ ctx.fillStyle = this._fps >= 55 ? '#5bd17a' : this._fps >= 30 ? '#f0b93a' : '#e8462b';
77
+ ctx.fillText(`${this._fps} fps`, f.canvas.width - 80, 22);
78
+ ctx.restore();
79
+ },
80
+ });
81
+ ```
82
+
83
+ Note: hooks receive `flow` and `ctx` as the **first arguments**. If you use a method on the plugin object (with `function () {}`), `this` is the plugin itself. With arrow functions, `this` is the surrounding scope. Choose accordingly.
84
+
85
+ ## Example: confirmation before destructive ops
86
+
87
+ ```js
88
+ flow.use({
89
+ name: 'confirm-delete',
90
+ onNodeDelete: (f, nodeId) => {
91
+ const title = f.titles.get(nodeId);
92
+ if (title === 'production-db') {
93
+ const ok = confirm(`Really delete '${title}'?`);
94
+ if (!ok) return false; // (currently not enforced — see note below)
95
+ }
96
+ },
97
+ });
98
+ ```
99
+
100
+ > **Note:** `onNodeDelete` doesn't yet support cancellation via `return false`. The hook fires after the deletion. Wrap `flow.deleteSelection()` in your own UI code if you need to block.
101
+
102
+ ## Example: registering many kinds at once
103
+
104
+ ```js
105
+ flow.use({
106
+ name: 'database-kinds',
107
+ kinds: [
108
+ { name: 'db-query', execute: async (ctx, ins) => {/*...*/} },
109
+ { name: 'db-insert', execute: async (ctx, ins) => {/*...*/} },
110
+ { name: 'db-update', execute: async (ctx, ins) => {/*...*/} },
111
+ ],
112
+ });
113
+ ```
114
+
115
+ The `kinds:` array is shorthand for calling `flow.registerKind()` for each entry during `init`.
116
+
117
+ ## Example: extending the API
118
+
119
+ ```js
120
+ flow.use({
121
+ name: 'graph-stats',
122
+ extendAPI: (f) => {
123
+ f.stats = () => ({
124
+ nodes: f.nodeCount(),
125
+ edges: f.edgeCount(),
126
+ avgDegree: f.edgeCount() * 2 / Math.max(1, f.nodeCount()),
127
+ });
128
+ },
129
+ });
130
+
131
+ console.log(flow.stats());
132
+ ```
133
+
134
+ Consumers now use `flow.stats()` as if it were built-in.
135
+
136
+ ## Example: command palette entries
137
+
138
+ ```js
139
+ flow.use({
140
+ name: 'export-pdf',
141
+ commands: [
142
+ {
143
+ label: 'Export to PDF',
144
+ hotkey: 'Ctrl+Shift+P',
145
+ run: () => myPDFExport(flow.toJSON()),
146
+ },
147
+ {
148
+ label: 'Sync to backend',
149
+ run: async () => {
150
+ await fetch('/api/save', { method: 'POST', body: JSON.stringify(flow.toJSON()) });
151
+ },
152
+ },
153
+ ],
154
+ });
155
+ ```
156
+
157
+ These appear in `Ctrl+K`. The `hotkey` field is display-only — wire actual hotkeys yourself with `keydown` listeners if you want them globally bound.
158
+
159
+ ## Example: rejecting connections
160
+
161
+ ```js
162
+ flow.use({
163
+ name: 'no-cross-region',
164
+ onConnect: (f, fromN, fp, toN, tp) => {
165
+ const fromRegion = f.tags.get(fromN)?.find(t => t.startsWith('region:'));
166
+ const toRegion = f.tags.get(toN)?.find(t => t.startsWith('region:'));
167
+ if (fromRegion && toRegion && fromRegion !== toRegion) {
168
+ console.warn('Cross-region connections forbidden');
169
+ return false;
170
+ }
171
+ },
172
+ });
173
+ ```
174
+
175
+ Returning `false` causes the connection to be rejected. The user sees the "type mismatch" toast (we should add a way to customize the message — TODO).
176
+
177
+ ## Example: instrument executor calls (poor-man's APM)
178
+
179
+ ```js
180
+ flow.use({
181
+ name: 'apm',
182
+ _starts: new Map(),
183
+ onBeforeExec(f, id) { this._starts.set(id, performance.now()); },
184
+ onAfterExec(f, id) {
185
+ const dur = performance.now() - this._starts.get(id);
186
+ if (dur > 100) console.warn(`slow node ${id}: ${dur.toFixed(1)}ms`);
187
+ },
188
+ });
189
+ ```
190
+
191
+ ## Example: a complete logging plugin
192
+
193
+ ```js
194
+ function loggingPlugin(opts = {}) {
195
+ const prefix = opts.prefix || '[flow]';
196
+ return {
197
+ name: 'logger',
198
+ onNodeAdd: (f, id, spec) => console.log(prefix, 'add node', id, spec.kind),
199
+ onEdgeAdd: (f, id, spec) => console.log(prefix, 'add edge', spec.from, '→', spec.to),
200
+ onBeforeExec: (f, id) => console.log(prefix, '▸', id),
201
+ onAfterExec: (f, id, out) => console.log(prefix, '✓', id, out),
202
+ onNodeDelete: (f, id) => console.log(prefix, '✗', id),
203
+ };
204
+ }
205
+
206
+ flow.use(loggingPlugin({ prefix: '[myapp]' }));
207
+ ```
208
+
209
+ You can also pass a function — it's called with `flow` and should return a plugin object:
210
+
211
+ ```js
212
+ flow.use((flow) => ({
213
+ name: 'auto-fit',
214
+ onChange: () => flow.fitView(),
215
+ }));
216
+ ```
217
+
218
+ ## What plugins **cannot** do
219
+
220
+ - They cannot prevent rendering (only modify it via `afterRender`)
221
+ - They cannot replace the WASM core
222
+ - They cannot intercept events between the WASM and JS layers — the hooks only fire at the JS-side public API boundary
223
+ - They cannot stop another plugin from running (no ordering control yet)
224
+
225
+ If you need any of these, you've reached the limits of plugins — fork the source.
226
+
227
+ ## Security note
228
+
229
+ A plugin runs with **full access** to your page. It can read DOM, send fetches, modify global state. Treat plugins like npm packages: only install what you trust.
230
+
231
+ If you build a plugin marketplace where users install third-party plugins, you'd need to sandbox each plugin in a Worker or iframe — out of scope for the core lib.
232
+
233
+ ## Next
234
+
235
+ → [Multiplayer](./06-multiplayer.md) — real-time co-editing with Yjs
@@ -0,0 +1,180 @@
1
+ # 6 · Multiplayer with Yjs
2
+
3
+ @luispm/zflow-graph ships an opt-in [Yjs](https://github.com/yjs/yjs) adapter for real-time collaborative editing. Two browser tabs (or two users on different machines) editing the same graph see each other's changes within ~30ms.
4
+
5
+ ## How it works
6
+
7
+ - Nodes live in a `Y.Map` keyed by stable UUIDs
8
+ - Edges live in another `Y.Map`
9
+ - Position updates are throttled to 30 Hz
10
+ - Awareness (cursors, selection) uses Yjs' built-in awareness protocol
11
+ - Conflicts resolve via Yjs' CRDT semantics — last write wins per field, no merge UI needed
12
+
13
+ The adapter is **opt-in**. If you don't import it, Yjs isn't loaded and `flow.toJSON()` works as a standalone snapshot.
14
+
15
+ ## Quick start
16
+
17
+ You'll need `yjs` and a provider. For the public demo server:
18
+
19
+ ```bash
20
+ npm install yjs y-websocket
21
+ ```
22
+
23
+ ```js
24
+ import * as Y from 'yjs';
25
+ import { WebsocketProvider } from 'y-websocket';
26
+ import { ZFlow } from '@luispm/zflow-graph';
27
+ import { bindYjs } from '@luispm/zflow-graph/adapters/yjs';
28
+
29
+ const flow = await ZFlow.create({ container, wasmUrl: '/zflow.wasm' });
30
+
31
+ const ydoc = new Y.Doc();
32
+ const provider = new WebsocketProvider('wss://demos.yjs.dev/ws', 'my-room', ydoc);
33
+
34
+ bindYjs(flow, ydoc, {
35
+ userName: 'Alice',
36
+ color: '#c062e8',
37
+ awareness: provider.awareness,
38
+ });
39
+ ```
40
+
41
+ Now open the same URL in another tab. Add a node in tab A — it appears in tab B.
42
+
43
+ ## What syncs
44
+
45
+ | Operation | Synced? |
46
+ | -------------------------- | :-----: |
47
+ | `flow.addNode(...)` | ✅ |
48
+ | `flow.addEdge(...)` | ✅ |
49
+ | `flow.moveNode(id, x, y)` | ✅ (throttled to 30 Hz) |
50
+ | Drag a node | ✅ |
51
+ | `flow.setNodeTitle(...)` | ✅ |
52
+ | `flow.setNodeColor(...)` | ✅ |
53
+ | `flow.deleteSelection()` | ✅ |
54
+ | Resize a node | ✅ |
55
+ | Cursor movement | ✅ via awareness |
56
+ | Selection | ⚠️ adapter has the hook but doesn't broadcast yet |
57
+ | Frames / notes | ❌ not wired through yet |
58
+ | Runtime state (`flow._values`) | ❌ runtime is local-only by design |
59
+
60
+ ## Configuration
61
+
62
+ ```js
63
+ const binding = bindYjs(flow, ydoc, {
64
+ // Required for awareness (cursors)
65
+ awareness: provider.awareness,
66
+
67
+ // Your identity
68
+ userId: 'alice@example.com', // unique, stable
69
+ userName: 'Alice', // displayed in remote cursors
70
+ color: '#c062e8', // displayed in remote cursors
71
+ });
72
+
73
+ // Returned handle:
74
+ binding.ynodes // Y.Map of nodes
75
+ binding.yedges // Y.Map of edges
76
+ binding.ymeta // Y.Map for arbitrary app metadata
77
+ binding.destroy() // unhooks, restores original flow methods
78
+ ```
79
+
80
+ ## Providers
81
+
82
+ Yjs is provider-agnostic. Common choices:
83
+
84
+ - **y-websocket** — your own relay server (the simplest)
85
+ - **y-webrtc** — peer-to-peer, no server, works through a signaling channel
86
+ - **y-indexeddb** — local persistence (combine with one of the above for offline-first)
87
+ - **Liveblocks**, **Hocuspocus**, **Tiptap Hub** — commercial providers with auth + history
88
+ - **Custom** — talk directly to Yjs sync protocol over any transport
89
+
90
+ ```js
91
+ import { IndexeddbPersistence } from 'y-indexeddb';
92
+ new IndexeddbPersistence('zflow-room', ydoc); // offline-first
93
+ ```
94
+
95
+ ## Naming rooms
96
+
97
+ By default everyone using the public demo server shares the room `'my-room'`. Pick something specific:
98
+
99
+ ```js
100
+ const ROOM = `zflow:${projectId}:${graphId}`;
101
+ const provider = new WebsocketProvider('wss://your-relay/ws', ROOM, ydoc);
102
+ ```
103
+
104
+ Or use a hash in the URL so users can share links:
105
+
106
+ ```js
107
+ const ROOM = location.hash.slice(1) || 'default';
108
+ ```
109
+
110
+ ## Hosting your own relay
111
+
112
+ The simplest setup:
113
+
114
+ ```bash
115
+ npm install -g @y/y-websocket-server
116
+ PORT=1234 npx y-websocket-server
117
+ ```
118
+
119
+ ```js
120
+ new WebsocketProvider('ws://your-server:1234', 'my-room', ydoc);
121
+ ```
122
+
123
+ For production, put it behind nginx/Caddy with TLS and authentication. Yjs has no built-in auth — that's the provider's job.
124
+
125
+ ## Local persistence
126
+
127
+ Combine multiple providers — they all sync the same `Y.Doc`:
128
+
129
+ ```js
130
+ new IndexeddbPersistence('local-cache', ydoc);
131
+ new WebsocketProvider('wss://...', 'room', ydoc);
132
+ ```
133
+
134
+ Now your app works offline: writes go to IndexedDB immediately, then sync to the websocket when online.
135
+
136
+ ## Handling auth
137
+
138
+ The adapter doesn't know about auth — that's between you and your provider. Pattern:
139
+
140
+ ```js
141
+ const session = await getUser();
142
+ const provider = new WebsocketProvider('wss://...', ROOM, ydoc, {
143
+ params: { token: session.jwt },
144
+ });
145
+ provider.awareness.setLocalStateField('user', {
146
+ id: session.id,
147
+ name: session.name,
148
+ color: session.color,
149
+ });
150
+ ```
151
+
152
+ Your relay server validates the JWT, refuses connection if invalid.
153
+
154
+ ## Conflict resolution
155
+
156
+ Yjs is a CRDT — it merges all concurrent changes automatically, without ever asking. Specific behaviors:
157
+
158
+ - Two users edit the same node title → last write wins (per field)
159
+ - Two users move the same node → last position wins (you'll see it snap once)
160
+ - Two users delete the same node → both deletes are idempotent
161
+ - Two users add nodes simultaneously → both nodes appear (no conflict)
162
+ - One user deletes a node while another adds an edge to it → the edge ends up dangling (Yjs doesn't enforce referential integrity)
163
+
164
+ For graph integrity guarantees, you'd need to layer your own validation on top.
165
+
166
+ ## Limits and quirks
167
+
168
+ - **No history replay yet.** The adapter syncs current state. To play back history, use Yjs' `UndoManager` or a provider with versioning (Liveblocks, Hocuspocus).
169
+ - **Position throttling is 30 Hz** — if you drag a node very fast, peers see it jump. Tweak the throttle in `src/adapters/yjs.js` if you need higher rate.
170
+ - **No selection sync.** Each user has their own selection. The remote cursor only shows their pointer position.
171
+ - **Memory leak risk with very long sessions.** Yjs accumulates operations. Periodically snapshot (`Y.encodeStateAsUpdate`) and start fresh if your sessions span weeks.
172
+ - **Frames and sticky notes don't sync yet.** TODO.
173
+
174
+ ## Example: complete multi-user editor
175
+
176
+ See [examples/multiplayer.html](../examples/multiplayer.html) for a working demo. Open it in two tabs, add nodes, drag them — they sync.
177
+
178
+ ## Next
179
+
180
+ → [Recipes](./07-recipes.md) — concrete worked examples