@agent-native/core 0.45.1 → 0.46.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.
Files changed (65) hide show
  1. package/README.md +1 -0
  2. package/dist/client/components/LiveCursorOverlay.d.ts +46 -0
  3. package/dist/client/components/LiveCursorOverlay.d.ts.map +1 -0
  4. package/dist/client/components/LiveCursorOverlay.js +137 -0
  5. package/dist/client/components/LiveCursorOverlay.js.map +1 -0
  6. package/dist/client/components/PresenceBar.d.ts +11 -1
  7. package/dist/client/components/PresenceBar.d.ts.map +1 -1
  8. package/dist/client/components/PresenceBar.js +39 -7
  9. package/dist/client/components/PresenceBar.js.map +1 -1
  10. package/dist/client/components/RemoteSelectionRings.d.ts +43 -0
  11. package/dist/client/components/RemoteSelectionRings.d.ts.map +1 -0
  12. package/dist/client/components/RemoteSelectionRings.js +116 -0
  13. package/dist/client/components/RemoteSelectionRings.js.map +1 -0
  14. package/dist/client/index.d.ts +4 -0
  15. package/dist/client/index.d.ts.map +1 -1
  16. package/dist/client/index.js +5 -0
  17. package/dist/client/index.js.map +1 -1
  18. package/dist/collab/awareness.d.ts +25 -0
  19. package/dist/collab/awareness.d.ts.map +1 -1
  20. package/dist/collab/awareness.js +42 -5
  21. package/dist/collab/awareness.js.map +1 -1
  22. package/dist/collab/client.d.ts +19 -1
  23. package/dist/collab/client.d.ts.map +1 -1
  24. package/dist/collab/client.js +362 -57
  25. package/dist/collab/client.js.map +1 -1
  26. package/dist/collab/follow-mode.d.ts +56 -0
  27. package/dist/collab/follow-mode.d.ts.map +1 -0
  28. package/dist/collab/follow-mode.js +54 -0
  29. package/dist/collab/follow-mode.js.map +1 -0
  30. package/dist/collab/index.d.ts +3 -1
  31. package/dist/collab/index.d.ts.map +1 -1
  32. package/dist/collab/index.js +5 -1
  33. package/dist/collab/index.js.map +1 -1
  34. package/dist/collab/presence.d.ts +56 -0
  35. package/dist/collab/presence.d.ts.map +1 -0
  36. package/dist/collab/presence.js +98 -0
  37. package/dist/collab/presence.js.map +1 -0
  38. package/dist/collab/routes.d.ts.map +1 -1
  39. package/dist/collab/routes.js +33 -6
  40. package/dist/collab/routes.js.map +1 -1
  41. package/dist/collab/struct-routes.d.ts.map +1 -1
  42. package/dist/collab/struct-routes.js +24 -4
  43. package/dist/collab/struct-routes.js.map +1 -1
  44. package/dist/collab/ydoc-manager.d.ts +13 -0
  45. package/dist/collab/ydoc-manager.d.ts.map +1 -1
  46. package/dist/collab/ydoc-manager.js +51 -15
  47. package/dist/collab/ydoc-manager.js.map +1 -1
  48. package/dist/server/collab-plugin.d.ts +6 -0
  49. package/dist/server/collab-plugin.d.ts.map +1 -1
  50. package/dist/server/collab-plugin.js +105 -5
  51. package/dist/server/collab-plugin.js.map +1 -1
  52. package/dist/server/poll-events.d.ts +5 -0
  53. package/dist/server/poll-events.d.ts.map +1 -1
  54. package/dist/server/poll-events.js +27 -4
  55. package/dist/server/poll-events.js.map +1 -1
  56. package/dist/templates/default/.agents/skills/real-time-collab/SKILL.md +185 -37
  57. package/dist/templates/default/.agents/skills/real-time-sync/SKILL.md +12 -2
  58. package/dist/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +185 -37
  59. package/dist/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +12 -2
  60. package/docs/content/real-time-collaboration.md +481 -97
  61. package/package.json +1 -1
  62. package/src/templates/default/.agents/skills/real-time-collab/SKILL.md +185 -37
  63. package/src/templates/default/.agents/skills/real-time-sync/SKILL.md +12 -2
  64. package/src/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +185 -37
  65. package/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +12 -2
@@ -1,9 +1,10 @@
1
1
  ---
2
2
  name: real-time-collab
3
3
  description: >-
4
- Multi-user collaborative editing with Yjs CRDT and live cursors. Use when
5
- adding real-time collaborative editing to a template, debugging sync issues,
6
- or understanding how the agent and humans edit documents simultaneously.
4
+ Multi-user collaborative editing with Yjs CRDT, SSE fast-path transport, and
5
+ granular server-side merge. Use when adding real-time collaborative editing to
6
+ a template, debugging sync issues, or understanding how the agent and humans
7
+ edit documents simultaneously.
7
8
  metadata:
8
9
  internal: true
9
10
  ---
@@ -12,39 +13,57 @@ metadata:
12
13
 
13
14
  ## Rule
14
15
 
15
- Collaborative editing uses Yjs CRDT via TipTap. The agent and human users are equal participants — both edit the same Y.Doc and changes merge cleanly without conflicts.
16
+ Collaborative editing uses Yjs CRDT via TipTap. The agent and human users are
17
+ equal participants — both edit the same Y.Doc and changes merge cleanly without
18
+ conflicts. Always set `resourceType` on `createCollabPlugin`.
16
19
 
17
20
  ## How It Works
18
21
 
19
22
  - **`Y.Doc`** stores the document as a `Y.XmlFragment` (ProseMirror node tree)
20
- - **TipTap's Collaboration extension** binds the editor to the Y.XmlFragment via `ySyncPlugin`
21
- - **CollaborationCaret extension** renders remote users' cursors with names and colors
22
- - **Polling** (every 2s) syncs Y.Doc updates and awareness state between clients and server
23
- - **SQL `_collab_docs` table** persists Yjs state as base64-encoded binary (works across SQLite/Postgres)
23
+ - **TipTap's Collaboration extension** binds the editor to the Y.XmlFragment
24
+ via `ySyncPlugin`
25
+ - **CollaborationCaret extension** renders remote users' cursors with names and
26
+ colors
27
+ - **SSE fast-path** — `/_agent-native/poll-events` `EventSource` delivers collab
28
+ events push-style; while SSE is healthy the collab poll interval relaxes to
29
+ ~12 s
30
+ - **Polling fallback** — `/_agent-native/poll` is polled every 2 s when SSE is
31
+ unavailable; this is the universal serverless fallback
32
+ - **Update batching** — local Yjs updates are debounced ~80 ms and coalesced
33
+ with `Y.mergeUpdates` before sending; flushed immediately on
34
+ `visibilitychange` / `pagehide`
35
+ - **SQL `_collab_docs` table** persists Yjs state as base64 (SQLite/Postgres
36
+ compatible). Tombstone compaction fires automatically when the stored blob
37
+ exceeds 4× the fresh encoded size.
24
38
 
25
39
  ## Agent + Human Editing
26
40
 
27
41
  1. **Human edits** → TipTap → ySyncPlugin → Y.XmlFragment → `POST /_agent-native/collab/:docId/update`
28
42
  2. **Agent edits** → action edits canonical SQL content + bumps `updatedAt` → change-sync refetch → the open editor reconciles the new content into the live Y.Doc (see below) → poll update → all clients
29
43
 
30
- Both produce Yjs operations that merge cleanly. Agent edits appear without destroying cursor position, selection, or undo history.
44
+ Both produce Yjs operations that merge cleanly. Agent edits appear without
45
+ destroying cursor position, selection, or undo history.
31
46
 
32
- This is how content (documents) and slides now work. The agent does **not** push edits into Yjs in-process, and it does **not** call any `findCollabOrigin()` / localhost probe — that approach silently no-op'd on serverless (the action runs in a different process), so agent edits didn't show up live until the user navigated away and back. Nor does it search-and-replace inside existing Y.XmlText nodes, which could never create new block structure (lists, headings, tables). The peer-editor model below replaces both.
47
+ The agent does **not** push edits into Yjs in-process and does **not** call any
48
+ localhost probe — those approaches silently no-op on serverless (the action runs
49
+ in a different process). The peer-editor model below replaced them.
33
50
 
34
51
  ## Agent Edits As A Real-Time Peer Editor
35
52
 
36
- The agent edits documents the same way a human collaborator does: its change lands in the shared Y.Doc, propagates to every connected client, and persists. It gets there without any in-process Yjs push from the action.
53
+ **SQL is the durable source of truth for document body content.** The agent
54
+ action edits the canonical content column and bumps `updatedAt`. No localhost
55
+ calls, no in-process Yjs mutation.
37
56
 
38
- **SQL is the durable source of truth for document body content.** The agent action edits the canonical content (e.g. `documents.content`) and bumps `updatedAt`. That's the whole server side — no localhost calls, no Yjs mutation from the action.
39
-
40
- **The open editor reconciles authoritative external content into the live Y.Doc.** The action's `updatedAt` bump flows through the change-sync system (see `real-time-sync`), which refetches the record. The editor applies the new content through its real markdown/HTML pipeline via `setContent`, so new block structure (lists, headings, tables) renders correctly and merges with concurrent human edits through the Yjs CRDT diff. The result: the agent's edit propagates to every connected client and persists, exactly like a human collaborator's edit.
57
+ **The open editor reconciles authoritative external content into the live
58
+ Y.Doc.** The `updatedAt` bump flows through change-sync, which refetches the
59
+ record. The lead client applies the new content via `setContent`, producing Yjs
60
+ operations that merge with concurrent human edits. Every connected client
61
+ receives the result through normal Yjs sync.
41
62
 
42
63
  ### The `updatedAt` gate
43
64
 
44
- The editor only adopts content that is genuinely **newer** than what it already reflects. An older-or-equal `updatedAt` is a lagging poll or a stale snapshot and is **ignored**.
45
-
46
65
  ```ts
47
- // Pseudocode in the editor's reconcile effect
66
+ // In the editor's reconcile effect
48
67
  if (loaded.updatedAt > lastAppliedUpdatedAt.current) {
49
68
  applyAuthoritativeContent(loaded.content); // adopt
50
69
  lastAppliedUpdatedAt.current = loaded.updatedAt;
@@ -52,56 +71,107 @@ if (loaded.updatedAt > lastAppliedUpdatedAt.current) {
52
71
  // else: lagging poll / stale snapshot → ignore
53
72
  ```
54
73
 
55
- **Why:** without the gate, a slightly-behind poll response re-applies old content right after the agent's edit, so the edit "reverts on the next poll" / "doesn't show until refresh" — the whack-a-mole we kept hitting. A **fresh mount or doc-switch has no baseline**, so it always adopts whatever content it loaded — which is why a manual refresh is always correct.
74
+ Without the gate, a slightly-behind poll response re-applies old content and
75
+ the edit "reverts on next poll". A fresh mount always adopts whatever content
76
+ it loaded.
56
77
 
57
78
  ### Lead-client election
58
79
 
59
- Exactly ONE connected client applies an authoritative snapshot into the shared Y.Doc; the rest receive it through normal Yjs sync. The lead is the present client with the lowest Yjs `clientID`, decided by the core helper:
80
+ Exactly ONE connected client applies the authoritative snapshot; the rest
81
+ receive it through Yjs sync:
60
82
 
61
83
  ```ts
62
84
  import { isReconcileLeadClient } from "@agent-native/core/client";
63
85
 
64
86
  if (
65
87
  loaded.updatedAt > lastAppliedUpdatedAt.current &&
66
- isReconcileLeadClient(provider.awareness, ydoc.clientID)
88
+ isReconcileLeadClient(awareness, ydoc.clientID)
67
89
  ) {
68
90
  applyAuthoritativeContent(loaded.content);
69
91
  }
70
92
  ```
71
93
 
72
- **Why:** if every open editor independently diffed the same snapshot into the CRDT, each would insert the changed region at the same position, duplicating it N times (concurrent inserts → duplicated text). Electing one lead avoids that. The agent's awareness id (`AGENT_CLIENT_ID`, max int) can never win, and a client editing alone is always the lead. The election is deterministic across clients with no coordination round-trip.
94
+ The agent's awareness entry (`AGENT_CLIENT_ID`, max int) can never be the
95
+ lead. A sole client is always the lead. The election is deterministic with no
96
+ coordination round-trip.
73
97
 
74
98
  ### v1 limitation
75
99
 
76
- A full-content reconcile is **last-writer-wins for the rare case** where a human has unsaved edits in the exact region the agent simultaneously rewrites — the agent's snapshot can clobber that in-flight human edit. Inline and structural edits in **different** regions merge fine through the CRDT; only same-region simultaneous rewrites are at risk.
100
+ Full-content reconcile is **last-writer-wins** for the rare case where a human
101
+ has unsaved edits in the exact region the agent simultaneously rewrites. Edits
102
+ in **different** regions merge fine through the CRDT.
103
+
104
+ ## Security
105
+
106
+ ### Always set `resourceType`
107
+
108
+ ```ts
109
+ // server/plugins/collab.ts
110
+ import { createCollabPlugin } from "@agent-native/core/server";
111
+
112
+ export default createCollabPlugin({
113
+ table: "documents",
114
+ contentColumn: "content",
115
+ idColumn: "id",
116
+ resourceType: "document", // required
117
+ });
118
+ ```
119
+
120
+ Without `resourceType`, the server logs a one-time warning and collab push
121
+ events are delivered to **all authenticated users** without document-level
122
+ scoping. Set it to the resource type name registered via
123
+ `registerShareableResource`.
124
+
125
+ Non-owner sharees who have explicit access fall back to state-vector catch-up
126
+ (safe, slightly higher latency). Awareness routes require the same viewer
127
+ access as read routes.
128
+
129
+ ### Payload limits
130
+
131
+ Write routes reject payloads exceeding `maxPayloadBytes` (default 2 MB) with
132
+ HTTP 413. Override:
133
+
134
+ ```ts
135
+ createCollabPlugin({ resourceType: "document", maxPayloadBytes: 512 * 1024 });
136
+ ```
77
137
 
78
138
  ## Enabling Collaboration
79
139
 
80
140
  ### 1. Install packages
81
141
 
82
142
  ```bash
83
- pnpm add @tiptap/extension-collaboration @tiptap/extension-collaboration-caret @tiptap/y-tiptap
143
+ pnpm add @tiptap/extension-collaboration @tiptap/extension-collaboration-caret @tiptap/y-tiptap @tiptap/core
84
144
  ```
85
145
 
86
- ### 2. Add collab server plugin
146
+ ### 2. Add collab server plugin (with `resourceType`)
87
147
 
88
148
  ```ts
89
149
  // server/plugins/collab.ts
90
- import { createCollabPlugin } from "@agent-native/core/collab";
150
+ import { createCollabPlugin } from "@agent-native/core/server";
91
151
 
92
152
  export default createCollabPlugin({
93
153
  table: "documents",
94
154
  contentColumn: "content",
95
155
  idColumn: "id",
156
+ resourceType: "document",
96
157
  });
97
158
  ```
98
159
 
99
160
  ### 3. Use the client hook
100
161
 
101
162
  ```ts
102
- import { useCollaborativeDoc } from "@agent-native/core/client";
103
-
104
- const { ydoc, provider } = useCollaborativeDoc(documentId);
163
+ import { useCollaborativeDoc, emailToColor, emailToName } from "@agent-native/core/client";
164
+
165
+ const { ydoc, awareness, activeUsers, agentActive, agentPresent } =
166
+ useCollaborativeDoc({
167
+ docId: documentId,
168
+ requestSource: TAB_ID,
169
+ user: {
170
+ name: emailToName(session.email),
171
+ email: session.email,
172
+ color: emailToColor(session.email),
173
+ },
174
+ });
105
175
  ```
106
176
 
107
177
  ### 4. Add TipTap extensions
@@ -112,12 +182,14 @@ import { CollaborationCaret } from "@tiptap/extension-collaboration-caret";
112
182
 
113
183
  const editor = useEditor({
114
184
  extensions: [
185
+ StarterKit.configure({ history: false }), // Yjs handles undo
115
186
  Collaboration.configure({ document: ydoc }),
116
187
  CollaborationCaret.configure({
117
- provider,
188
+ provider: { awareness },
118
189
  user: { name: session.email, color: "#6366f1" },
119
190
  }),
120
191
  ],
192
+ // Do NOT pass content — Yjs owns it
121
193
  });
122
194
  ```
123
195
 
@@ -126,6 +198,9 @@ const editor = useEditor({
126
198
  ```ts
127
199
  optimizeDeps: {
128
200
  include: [
201
+ "yjs",
202
+ "y-protocols/awareness",
203
+ "@tiptap/core",
129
204
  "@tiptap/extension-collaboration",
130
205
  "@tiptap/extension-collaboration-caret",
131
206
  "@tiptap/y-tiptap",
@@ -137,22 +212,95 @@ optimizeDeps: {
137
212
 
138
213
  | Route | Purpose |
139
214
  | ----- | ------- |
140
- | `GET /_agent-native/collab/:docId/state` | Fetch full Y.Doc state |
215
+ | `GET /_agent-native/collab/:docId/state` | Fetch full Y.Doc state (accepts `?stateVector=` for diff) |
141
216
  | `POST /_agent-native/collab/:docId/update` | Apply client Yjs update |
142
217
  | `POST /_agent-native/collab/:docId/text` | Apply full text (diff-based) |
143
218
  | `POST /_agent-native/collab/:docId/search-replace` | Surgical find/replace in Y.XmlFragment |
219
+ | `POST /_agent-native/collab/:docId/json` | Apply full JSON diff to Y.Map/Y.Array |
220
+ | `GET /_agent-native/collab/:docId/json` | Read current JSON state |
221
+ | `POST /_agent-native/collab/:docId/patch` | Surgical JSON patch ops |
144
222
  | `POST /_agent-native/collab/:docId/awareness` | Sync cursor/presence state |
145
223
  | `GET /_agent-native/collab/:docId/users` | List active users |
146
224
 
225
+ ## Granular Server-Side Merge Pattern
226
+
227
+ For structured documents (slides, forms, design files) where body collab would
228
+ cause LWW conflicts at the container level, use **granular server-side merge**:
229
+ define an action with targeted per-item operations.
230
+
231
+ **When to use granular merge vs body collab:**
232
+
233
+ | Scenario | Recommended approach |
234
+ | -------- | -------------------- |
235
+ | Free-form rich text, cursor-level CRDT matters | Body collab (Y.XmlFragment + TipTap) |
236
+ | Structured items (slides, fields) where different users edit different items | Granular server-side merge (action with patch ops) |
237
+
238
+ Example operation shape for slides:
239
+
240
+ ```ts
241
+ type PatchDeckOp =
242
+ | { type: "patch"; slideId: string; fields: Partial<SlideFields> }
243
+ | { type: "add"; position: number; slide: SlideData }
244
+ | { type: "delete"; slideId: string }
245
+ | { type: "reorder"; slideId: string; newIndex: number };
246
+ ```
247
+
248
+ Concurrent edits to different slides both succeed at the action level; there
249
+ is no whole-deck LWW. Forms use the same shape with field-level ops.
250
+
251
+ ## Collaborative Undo Scoping (Y.UndoManager)
252
+
253
+ Scope undo/redo to the local user's own edits so peer and agent changes are
254
+ never accidentally reversed:
255
+
256
+ ```ts
257
+ import * as Y from "yjs";
258
+
259
+ const LOCAL_EDIT_ORIGIN = "local";
260
+
261
+ const undoManager = new Y.UndoManager(ydoc.getText("content"), {
262
+ trackedOrigins: new Set([LOCAL_EDIT_ORIGIN]),
263
+ captureTimeout: 800, // coalesces rapid slider drags into one undo step
264
+ });
265
+
266
+ // Mark local edits with the tracked origin
267
+ ydoc.transact(() => {
268
+ // apply local change
269
+ }, LOCAL_EDIT_ORIGIN);
270
+
271
+ undoManager.undo(); // only reverses LOCAL_EDIT_ORIGIN transactions
272
+ undoManager.redo();
273
+ ```
274
+
275
+ Rules:
276
+ - Pass a `Set` to `trackedOrigins` — not an array.
277
+ - Remote (`"remote"`) and agent (`"agent"`) origins are never captured.
278
+ - Recreate and destroy the manager when the active document changes.
279
+
147
280
  ## Common Pitfalls
148
281
 
149
- - **Don't pass `content` as a TipTap prop** when Collaboration is enabled Yjs owns the content. Set initial content via the Y.Doc instead.
150
- - **Don't call `editor.setContent()` ad hoc for agent edits.** The only sanctioned `setContent` is the editor's reconcile path described above — gated by `updatedAt` and guarded by `isReconcileLeadClient`. Calling it from elsewhere (e.g. on every poll, or from every client) re-applies stale content or duplicates the changed region across the CRDT.
151
- - **Add packages to `optimizeDeps`** — Vite won't pre-bundle Yjs packages correctly otherwise, causing runtime errors in dev.
152
- - **One `Y.Doc` per document** — Don't create multiple Y.Doc instances for the same document ID. Use the `useCollaborativeDoc` hook which caches by ID.
282
+ - **Missing `resourceType`**The server logs a warning on startup and
283
+ delivers collab events to all authenticated users without access scoping.
284
+ Always set `resourceType`.
285
+ - **Don't pass `content` as a TipTap prop** when Collaboration is enabled
286
+ Yjs owns the content. Seed via `editor.commands.setContent()` only when the
287
+ Y.XmlFragment is empty.
288
+ - **Don't call `editor.setContent()` ad hoc for agent edits** — the only
289
+ sanctioned `setContent` is gated by `updatedAt` and guarded by
290
+ `isReconcileLeadClient`. Calling it from elsewhere duplicates content across
291
+ the CRDT or re-applies stale snapshots.
292
+ - **Add packages to `optimizeDeps`** — Vite won't pre-bundle Yjs correctly
293
+ otherwise, causing runtime errors in dev.
294
+ - **One `Y.Doc` per document** — Don't create multiple Y.Doc instances for the
295
+ same document ID. `useCollaborativeDoc` caches by ID.
296
+ - **Destroy Y.UndoManager on doc change** — Stale managers hold Y.Doc
297
+ references and grow unboundedly. Recreate on `docId` change.
153
298
 
154
299
  ## Related Skills
155
300
 
156
- - `real-time-sync` — The change-sync system that delivers the `updatedAt` bump driving editor reconciliation; also `useReconciledState` for non-collaborative "copy a server value into local edit state" surfaces
157
- - `storing-data` The `_collab_docs` table where Yjs state is persisted; SQL holds the canonical document body that the editor reconciles from
158
- - `self-modifying-code` — Agent edits to collaborative documents edit canonical SQL content, not raw Yjs
301
+ - `real-time-sync` — The change-sync system that delivers the `updatedAt` bump
302
+ driving editor reconciliation; also `useReconciledState` for non-Yjs surfaces
303
+ - `storing-data` — The `_collab_docs` table and SQL canonical content
304
+ - `security` — `registerShareableResource`, `resolveAccess`, `assertAccess`
305
+ - `self-modifying-code` — Agent edits to collaborative documents edit canonical
306
+ SQL content, not raw Yjs
@@ -49,7 +49,7 @@ The agent modifies data in SQL, but the UI runs in the browser. SSE bridges same
49
49
 
50
50
  For list/sidebar queries, use the same pattern — pass the counter into the queryKey of every list query you want to keep fresh.
51
51
 
52
- 4. **Fallback** polling calls `/_agent-native/poll?since=N`. It runs every 2 seconds until SSE is connected, then relaxes to 15 seconds. If SSE is disabled or unavailable, polling continues at the normal cadence.
52
+ 4. **Fallback** polling calls `/_agent-native/poll?since=N`. It runs every 2 seconds until SSE is connected, then relaxes to 15 seconds (`SSE_FALLBACK_INTERVAL_MS`). If SSE is disabled or unavailable (e.g., edge/serverless deployments), polling continues at the 2 s cadence. Polling is the universal serverless fallback — it detects DB timestamp changes even when the write happened in a different process or invocation.
53
53
 
54
54
  5. When the agent writes to the database, the version increments, SSE/polling detects it, and React Query refetches the affected queries.
55
55
 
@@ -196,6 +196,16 @@ const [title, setTitle] = useReconciledState(props.title, { active: isEditing })
196
196
  | Local edit state copied from a server value (inputs, popovers, inline editors) | `useReconciledState(externalValue, { active })` |
197
197
  | Collaborative rich-text editor (Yjs) | `updatedAt`-gated reconcile + `isReconcileLeadClient` — see `real-time-collab` |
198
198
 
199
+ ## Granular server-side merge for non-body fields
200
+
201
+ For structured documents (slide decks, form builders, design files) where the
202
+ Yjs body collab would cause LWW conflicts at the container level, pair the
203
+ change-sync `updatedAt` bump with a **granular server-side merge action** that
204
+ accepts targeted per-item operations (add/patch/delete/reorder). Concurrent
205
+ edits to different items both survive at the action level; the `collab` source
206
+ version bump then propagates the merged state to all open clients. See
207
+ `real-time-collab` for the pattern and examples.
208
+
199
209
  ## Related Skills
200
210
 
201
211
  - **storing-data** — Application-state and settings are data stores that sync through change events
@@ -203,4 +213,4 @@ const [title, setTitle] = useReconciledState(props.title, { active: isEditing })
203
213
  - **actions** — Mutating actions trigger change events
204
214
  - **client-methods** — Route details belong in helpers/hooks, not components
205
215
  - **self-modifying-code** — Agent code edits trigger change events; rapid edits can cause event storms
206
- - **real-time-collab** — Collaborative editors reconcile agent edits into a shared Y.Doc, driven by the same change-sync `updatedAt` bump
216
+ - **real-time-collab** — Collaborative editors reconcile agent edits into a shared Y.Doc, driven by the same change-sync `updatedAt` bump; also the granular server-side merge pattern for structured data