group conversations with models and local files
0

Configure Feed

Select the types of activity you want to include in your feed.

CRDT sync: adopt peer snapshot on first open, don't merge

Bug: a device joining a project for the first time was creating a
local Automerge doc via Automerge.from(INITIAL) — fresh actor with
its own root operations (create the meta/files/conversations/turns/
bots maps). Merging that empty starter into a peer's populated doc
gave Automerge two concurrent assigns to each root field, and LWW
could pick the empty side, dropping the peer's data on the floor.
Worse, the merged-but-empty state could then be persisted and
broadcast back, propagating the loss.

Fix:
- crdt.ts: new adoptDoc(bytes) — Automerge.load + clone, so the
device shares the peer's root history but writes new changes
under its own actor.
- store.ts: ProjectStore tracks isFresh (true when there was no
IndexedDB entry at open). The first incoming snapshot on a
fresh store is adopted wholesale via adoptDoc rather than
merged. After adoption isFresh flips false and subsequent
snapshots merge normally (which is safe because the histories
now share a common root).

Existing populated-then-corrupted local docs aren't auto-healed
by this — they have to be wiped + re-seeded from a known-good
source (e.g. the push-project script).

+34 -4
+10
src/lib/crdt.ts
··· 115 115 return Automerge.getHeads(doc as Automerge.Doc<Record<string, unknown>>) 116 116 } 117 117 118 + // Adopt a peer's snapshot wholesale, then clone to give this device a 119 + // unique actor for subsequent local changes. Used the first time a 120 + // device sees a project — merging an empty locally-created doc into 121 + // the peer's would conflict on the root maps (each side built its own 122 + // root) and could drop the peer's data via LWW. 123 + export function adoptDoc(bytes: Uint8Array): Doc { 124 + const adopted = Automerge.load(bytes) as Automerge.Doc<Record<string, unknown>> 125 + return Automerge.clone(adopted) as Doc 126 + } 127 + 118 128 // ---- selectors ----------------------------------------------------------- 119 129 120 130 export function botList(doc: ProjectDoc): Bot[] {
+24 -4
src/lib/store.ts
··· 38 38 // one outbox broadcast per ~400ms. 39 39 private broadcastTimer: number | null = null 40 40 private broadcastDirty = false 41 + // True when this device has never persisted state for this project. 42 + // The next incoming peer snapshot is adopted wholesale instead of 43 + // merged — merging an empty locally-created doc into a peer's 44 + // populated doc would conflict on the root maps (each side built its 45 + // own root) and could drop the peer's data via LWW. 46 + private isFresh: boolean 41 47 42 48 private constructor( 43 49 opts: ProjectStoreOpts, 44 50 doc: Automerge.Doc<crdt.ProjectDoc>, 51 + isFresh: boolean, 45 52 ) { 46 53 this.opts = opts 47 54 this.doc = doc 55 + this.isFresh = isFresh 48 56 } 49 57 50 58 static async open(opts: ProjectStoreOpts): Promise<ProjectStore> { 51 59 const existing = await persistence.loadDoc(opts.projectId) 52 60 const doc = existing ? crdt.loadDoc(existing) : crdt.createDoc() 53 - const store = new ProjectStore(opts, doc) 61 + const store = new ProjectStore(opts, doc, !existing) 54 62 await opts.outbox.subscribe(opts.projectId, opts.projectKey, async (msg) => { 55 63 await store.onIncoming(msg.payload, msg.senderUserId) 56 64 }) 57 65 // If we have local state, push it through the outbox so any other 58 66 // members (including this user's other devices) that come online 59 - // later have a recent snapshot waiting. Skip if the doc is empty 60 - // (no heads) — nothing useful to ship yet. 67 + // later have a recent snapshot waiting. Fresh devices don't push — 68 + // their local "empty" doc would clobber peers' real data on merge. 61 69 if (existing && crdt.heads(doc).length > 0) { 62 70 void store.broadcastSnapshot().catch((err) => 63 71 console.warn('[store] open snapshot push failed:', err), ··· 136 144 _sender: UserId, 137 145 ): Promise<void> { 138 146 if (payload.type !== 'crdt-snapshot') return // phase 4 only handles snapshots 147 + const remoteBytes = b64uDecode(payload.state) 148 + if (this.isFresh) { 149 + // First peer snapshot since this device joined the project — 150 + // adopt it wholesale (with a fresh actor for our own future 151 + // changes). Avoids root-map conflicts that would otherwise drop 152 + // the peer's data when we merged our empty starter doc into it. 153 + this.doc = crdt.adoptDoc(remoteBytes) 154 + this.isFresh = false 155 + await persistence.saveDoc(this.opts.projectId, crdt.saveDoc(this.doc)) 156 + this.notify() 157 + return 158 + } 139 159 const before = crdt.heads(this.doc) 140 - this.doc = crdt.merge(this.doc, b64uDecode(payload.state)) 160 + this.doc = crdt.merge(this.doc, remoteBytes) 141 161 const after = crdt.heads(this.doc) 142 162 if (sameHeads(before, after)) return 143 163 await persistence.saveDoc(this.opts.projectId, crdt.saveDoc(this.doc))