Skip to content

Commit c8ecfc0

Browse files
committed
feat(collab-doc): multi-replica shared Yjs backend + server-side markdown persistence
Make collaborative file-doc editing correct across multiple ECS tasks (the per-process Y.Doc previously assumed one replica per file). - Shared Yjs backend over Redis Streams (apps/realtime file-doc-store): each file's stream is the ordered, replayable log of updates; a multiplexed XREAD tailer converges every task's in-memory doc. Coordinated single-seeder election (SET NX + empty-stream recheck) fixes split-brain seeding. - Doc-sync fans out to local clients + the stream; awareness stays on the Socket.IO adapter. Snapshot+XTRIM compaction trims only integrated entries. - Server-side persistence: project the live doc back to markdown via a new /api/internal/file-doc/persist endpoint, debounced during editing and flushed on last-disconnect, from the authoritative stream state. Collaborative editors no longer client-autosave, closing the copilot clobber-window. - Copilot merges apply through the stream (reach the live doc on any task) and serialize cross-task via a Redis merge lock. - Degrades to the original single-replica behavior when REDIS_URL is unset.
1 parent 0fd0e89 commit c8ecfc0

15 files changed

Lines changed: 1067 additions & 49 deletions

File tree

apps/realtime/src/handlers/file-doc-app.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,26 @@ export async function fetchFileDocMerge(
7272
}
7373
return new Uint8Array(Buffer.from(body.update, 'base64'))
7474
}
75+
76+
/**
77+
* Ask the app to project a live collaborative document back to durable markdown and write it to the
78+
* file (Yjs → markdown, through the exact editor engine). This is the server-authoritative durable
79+
* path — called debounced while the doc is edited and when the last collaborator leaves — that
80+
* replaces the editor's client autosave, so a server/copilot edit can't be clobbered by a stale
81+
* keystroke. THROWS on a transport failure so the caller can log/retry on the next debounce.
82+
*/
83+
export async function fetchFileDocPersist(
84+
workspaceId: string,
85+
fileId: string,
86+
userId: string,
87+
docState: Uint8Array
88+
): Promise<void> {
89+
const response = await postToApp(
90+
'/api/internal/file-doc/persist',
91+
{ workspaceId, fileId, userId, docState: Buffer.from(docState).toString('base64') },
92+
FILE_DOC_TIMEOUTS.persistRequestMs
93+
)
94+
if (!response.ok) {
95+
throw new Error(`Persist failed for file ${fileId}: ${response.status}`)
96+
}
97+
}
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import * as Y from 'yjs'
6+
7+
/**
8+
* One shared in-memory Redis backing per test, so several {@link FileDocStore} instances (modelling
9+
* several ECS tasks) all talk to the "same Redis". A minimal fake of just the stream/lock ops the
10+
* store uses.
11+
*/
12+
interface Backing {
13+
streams: Map<string, { id: string; message: Record<string, string> }[]>
14+
kv: Map<string, string>
15+
seq: number
16+
}
17+
18+
const state = vi.hoisted(() => ({ backing: null as Backing | null }))
19+
20+
const seqOf = (id: string) => Number(id.split('-')[0])
21+
22+
function makeClient(): any {
23+
const b = () => {
24+
if (!state.backing) throw new Error('backing not initialized')
25+
return state.backing
26+
}
27+
const client: any = {
28+
connect: async () => {},
29+
quit: async () => {},
30+
on: () => client,
31+
duplicate: () => makeClient(),
32+
xAdd: async (key: string, _star: string, fields: Record<string, string>) => {
33+
const id = `${++b().seq}-0`
34+
const arr = b().streams.get(key) ?? []
35+
arr.push({ id, message: { ...fields } })
36+
b().streams.set(key, arr)
37+
return id
38+
},
39+
xRange: async (key: string) => (b().streams.get(key) ?? []).map((e) => ({ ...e })),
40+
xLen: async (key: string) => (b().streams.get(key) ?? []).length,
41+
xTrim: async (key: string, _strategy: string, minid: string) => {
42+
const arr = b().streams.get(key) ?? []
43+
b().streams.set(
44+
key,
45+
arr.filter((e) => seqOf(e.id) >= seqOf(minid))
46+
)
47+
},
48+
xRead: async (streams: { key: string; id: string }[]) => {
49+
const res: { name: string; messages: { id: string; message: Record<string, string> }[] }[] =
50+
[]
51+
for (const { key, id } of streams) {
52+
const after = (b().streams.get(key) ?? []).filter((e) => seqOf(e.id) > seqOf(id))
53+
if (after.length) res.push({ name: key, messages: after.map((e) => ({ ...e })) })
54+
}
55+
if (res.length) return res
56+
await new Promise((r) => setTimeout(r, 5))
57+
return null
58+
},
59+
set: async (key: string, val: string, opts?: { NX?: boolean }) => {
60+
if (opts?.NX && b().kv.has(key)) return null
61+
b().kv.set(key, val)
62+
return 'OK'
63+
},
64+
del: async (key: string) => {
65+
b().kv.delete(key)
66+
return 1
67+
},
68+
expire: async () => 1,
69+
}
70+
return client
71+
}
72+
73+
vi.mock('redis', () => ({ createClient: () => makeClient() }))
74+
75+
import { FileDocStore } from '@/handlers/file-doc-store'
76+
77+
const REDIS_URL = 'redis://fake'
78+
const NAME = 'workspace-file-doc:file-1'
79+
80+
function docWithText(text: string): Y.Doc {
81+
const doc = new Y.Doc()
82+
doc.getText('body').insert(0, text)
83+
return doc
84+
}
85+
86+
/** The delta a doc emits when `text` is inserted — what the relay would `publish`. */
87+
function updateFor(text: string): Uint8Array {
88+
const doc = docWithText(text)
89+
const update = Y.encodeStateAsUpdate(doc)
90+
doc.destroy()
91+
return update
92+
}
93+
94+
let stores: FileDocStore[] = []
95+
async function newStore(): Promise<FileDocStore> {
96+
const store = new FileDocStore(REDIS_URL)
97+
await store.init()
98+
stores.push(store)
99+
return store
100+
}
101+
102+
describe('FileDocStore', () => {
103+
beforeEach(() => {
104+
state.backing = { streams: new Map(), kv: new Map(), seq: 0 }
105+
stores = []
106+
})
107+
108+
afterEach(async () => {
109+
await Promise.all(stores.map((s) => s.shutdown()))
110+
})
111+
112+
it('elects exactly one seeder across tasks (no split-brain seed)', async () => {
113+
const a = await newStore()
114+
const b = await newStore()
115+
const [aWon, bWon] = await Promise.all([a.shouldSeed(NAME), b.shouldSeed(NAME)])
116+
expect([aWon, bWon].filter(Boolean)).toHaveLength(1)
117+
})
118+
119+
it('does not re-seed once the stream already has content (stale lock)', async () => {
120+
const a = await newStore()
121+
expect(await a.shouldSeed(NAME)).toBe(true)
122+
// A seeds and releases its lock.
123+
a.publish(NAME, updateFor('hello'))
124+
await vi.waitFor(async () => expect(await a.getStreamState(NAME)).not.toBeNull())
125+
await a.releaseSeedLock(NAME)
126+
// A different task must NOT seed again — the lock is free but the stream is non-empty.
127+
const b = await newStore()
128+
expect(await b.shouldSeed(NAME)).toBe(false)
129+
})
130+
131+
it('getStreamState reconstructs the shared document from the stream', async () => {
132+
const a = await newStore()
133+
a.publish(NAME, updateFor('shared content'))
134+
let state: Uint8Array | null = null
135+
await vi.waitFor(async () => {
136+
state = await a.getStreamState(NAME)
137+
expect(state).not.toBeNull()
138+
})
139+
const doc = new Y.Doc()
140+
Y.applyUpdate(doc, state!)
141+
expect(doc.getText('body').toString()).toBe('shared content')
142+
doc.destroy()
143+
})
144+
145+
it('attachRoom catches a fresh task up to the current shared state', async () => {
146+
const a = await newStore()
147+
a.publish(NAME, updateFor('already here'))
148+
await vi.waitFor(async () => expect(await a.getStreamState(NAME)).not.toBeNull())
149+
150+
// A second task opens the same file: its doc must load the existing content, not start empty.
151+
const b = await newStore()
152+
const doc = new Y.Doc()
153+
await b.attachRoom(NAME, doc)
154+
expect(doc.getText('body').toString()).toBe('already here')
155+
doc.destroy()
156+
})
157+
158+
it('converges a peer task via the tailer after attach', async () => {
159+
const a = await newStore()
160+
const b = await newStore()
161+
const bDoc = new Y.Doc()
162+
await b.attachRoom(NAME, bDoc)
163+
164+
// A publishes an edit; B's multiplexed reader must apply it to B's attached doc.
165+
a.publish(NAME, updateFor('from task A'))
166+
await vi.waitFor(() => expect(bDoc.getText('body').toString()).toBe('from task A'), {
167+
timeout: 2000,
168+
})
169+
bDoc.destroy()
170+
})
171+
172+
it('compaction never trims peer entries the compacting task has not yet integrated', async () => {
173+
const streamKey = `filedoc:stream:${NAME}`
174+
const noop = Buffer.from(Y.encodeStateAsUpdate(new Y.Doc())).toString('base64')
175+
176+
// Two peer edits published by ANOTHER task that this task's tailer has not read yet.
177+
const peerDoc = new Y.Doc()
178+
const peerUpdates: Uint8Array[] = []
179+
peerDoc.on('update', (u: Uint8Array) => peerUpdates.push(u))
180+
peerDoc.getText('body').insert(0, 'PEER1')
181+
peerDoc.getText('body').insert(5, 'PEER2')
182+
183+
// Backing: 400 already-integrated (no-op) entries this task's doc reflects, then the 2 un-integrated
184+
// peer entries. Enough entries to cross COMPACT_THRESHOLD.
185+
const entries = Array.from({ length: 400 }, (_, i) => ({
186+
id: `${i + 1}-0`,
187+
message: { u: noop },
188+
}))
189+
entries.push({ id: '401-0', message: { u: Buffer.from(peerUpdates[0]).toString('base64') } })
190+
entries.push({ id: '402-0', message: { u: Buffer.from(peerUpdates[1]).toString('base64') } })
191+
state.backing!.streams.set(streamKey, entries)
192+
state.backing!.seq = 402
193+
194+
const a = await newStore()
195+
// This task has integrated only up to entry 400 (all no-ops) — its local doc is empty and lags the
196+
// two peer entries. Inject that lagging room directly.
197+
;(a as any).rooms.set(NAME, { doc: new Y.Doc(), lastId: '400-0', publishes: 0 })
198+
await (a as any).maybeCompact(NAME)
199+
200+
// A fresh catch-up must still reconstruct the peer content — compaction must not have trimmed 401/402.
201+
const doc = new Y.Doc()
202+
Y.applyUpdate(doc, (await a.getStreamState(NAME))!)
203+
expect(doc.getText('body').toString()).toBe('PEER1PEER2')
204+
doc.destroy()
205+
})
206+
207+
it('serializes merges across tasks via the merge lock', async () => {
208+
const a = await newStore()
209+
const b = await newStore()
210+
expect(await a.acquireMergeSlot(NAME, 5_000)).toBe(true)
211+
// A holds it → B is refused until A releases.
212+
expect(await b.acquireMergeSlot(NAME, 5_000)).toBe(false)
213+
await a.releaseMergeSlot(NAME)
214+
expect(await b.acquireMergeSlot(NAME, 5_000)).toBe(true)
215+
await b.releaseMergeSlot(NAME)
216+
})
217+
218+
it('is disabled without a REDIS_URL and behaves single-replica', async () => {
219+
const store = new FileDocStore(undefined)
220+
expect(store.enabled).toBe(false)
221+
// Seeds locally (returns true), never touches a stream.
222+
expect(await store.shouldSeed(NAME)).toBe(true)
223+
expect(await store.getStreamState(NAME)).toBeNull()
224+
const doc = new Y.Doc()
225+
await store.attachRoom(NAME, doc) // no-op, no throw
226+
expect(doc.getText('body').toString()).toBe('')
227+
doc.destroy()
228+
})
229+
})

0 commit comments

Comments
 (0)