Skip to content

Commit 8d054e3

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
refactor(files): align PDF export with Markdown download
1 parent dc1a853 commit 8d054e3

7 files changed

Lines changed: 30 additions & 226 deletions

File tree

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

Lines changed: 1 addition & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import {
4242
} from '@/handlers/file-doc'
4343
import { beginRoomPermissionRead, commitRoomPermission } from '@/middleware/permissions'
4444

45-
type Handler = (...args: unknown[]) => Promise<void> | void
45+
type Handler = (payload?: unknown) => Promise<void> | void
4646

4747
const ROOM_NAME = 'workspace-file-doc:file-1'
4848

@@ -368,56 +368,6 @@ describe('setupWorkspaceFileDocHandlers', () => {
368368
expect(mockFetchFileDocPersist).toHaveBeenCalled()
369369
})
370370

371-
it('acknowledges an export flush only after the latest live edit is persisted', async () => {
372-
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
373-
const { io } = createIo()
374-
const { handlers } = setup('socket-1', io)
375-
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
376-
await flushMicrotasks()
377-
378-
const edit = new Y.Doc()
379-
edit.getText(FILE_DOC_FIELD).insert(0, 'latest edit')
380-
handlers[FILE_DOC_EVENTS.MESSAGE](
381-
frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) =>
382-
syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit))
383-
)
384-
)
385-
await flushMicrotasks()
386-
mockFetchFileDocPersist.mockClear()
387-
const acknowledge = vi.fn()
388-
389-
await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' }, acknowledge)
390-
391-
expect(mockFetchFileDocPersist).toHaveBeenCalledTimes(1)
392-
expect(acknowledge).toHaveBeenCalledWith({ ok: true })
393-
})
394-
395-
it('rejects an export flush when the live document cannot be persisted', async () => {
396-
mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server'))
397-
const { io } = createIo()
398-
const { handlers } = setup('socket-1', io)
399-
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 })
400-
await flushMicrotasks()
401-
402-
const edit = new Y.Doc()
403-
edit.getText(FILE_DOC_FIELD).insert(0, 'latest edit')
404-
handlers[FILE_DOC_EVENTS.MESSAGE](
405-
frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) =>
406-
syncProtocol.writeUpdate(e, Y.encodeStateAsUpdate(edit))
407-
)
408-
)
409-
await flushMicrotasks()
410-
mockFetchFileDocPersist.mockResolvedValueOnce({ status: 'conflict' })
411-
const acknowledge = vi.fn()
412-
413-
await handlers[FILE_DOC_EVENTS.FLUSH]({ fileId: 'file-1' }, acknowledge)
414-
415-
expect(acknowledge).toHaveBeenCalledWith({
416-
ok: false,
417-
error: 'Unable to save the latest document changes for export',
418-
})
419-
})
420-
421371
it('drops document frames and evicts once the editor loses write access mid-session', async () => {
422372
// The join-time check is not a standing right: a collaborator downgraded to `read`
423373
// (or removed) must stop landing durable edits on the socket they already hold.

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

Lines changed: 23 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,6 @@ import {
3131
FILE_DOC_SEED,
3232
FILE_DOC_TIMEOUTS,
3333
type FileDocPresenceUser,
34-
type FlushFileDocPayload,
35-
type FlushFileDocResult,
3634
type JoinFileDocPayload,
3735
type LeaveFileDocPayload,
3836
toFileDocBytes,
@@ -83,16 +81,6 @@ const PERSIST_MAX_WAIT_MS = 20_000
8381
const FINAL_VERSION_RETRIES = 2
8482
const FINAL_VERSION_RETRY_MS = 100
8583

86-
type PersistMode = 'debounced' | 'final' | 'requested'
87-
type PersistOutcome =
88-
| 'unchanged'
89-
| 'persisted'
90-
| 'missing'
91-
| 'deferred'
92-
| 'conflict'
93-
| 'deduplicated'
94-
| 'failed'
95-
9684
/** Cross-task merge lock. The TTL must exceed the whole critical section it guards — stream fold +
9785
* `fetchFileDocMerge` (bounded at `mergeRequestMs`) + the awaited publish — so the lock never expires
9886
* mid-merge and lets a second task race the same base; hence `mergeRequestMs` plus generous headroom.
@@ -272,31 +260,27 @@ function schedulePersist(name: string, room: FileDocRoom): void {
272260
room.persistTimer = setTimeout(() => {
273261
room.persistTimer = null
274262
room.persistDeadline = null
275-
void flushPersist(name, room, 'debounced')
263+
void flushPersist(name, room, false)
276264
}, delay)
277265
}
278266

279267
/**
280-
* Project the live doc to markdown and write it durably via the app. A final or explicitly requested
281-
* flush always writes; a debounced mid-edit flush first claims a best-effort cross-task dedup WINDOW
268+
* Project the live doc to markdown and write it durably via the app. `final` (last collaborator
269+
* leaving) always writes; a debounced mid-edit flush first claims a best-effort cross-task dedup WINDOW
282270
* (a TTL key that just expires, so at most ~one persist per window cluster-wide) so concurrent tasks
283-
* editing the same file don't each write a redundant blob version. Returns an outcome so an export can
284-
* wait for durable success; background callers still treat failures as best-effort.
271+
* editing the same file don't each write a redundant blob version. Best-effort: never throws (a failure
272+
* is retried on the next debounce; the stream holds the state meanwhile).
285273
*
286274
* Persists the AUTHORITATIVE shared state (the stream), not this task's local doc: a copilot merge — or
287275
* a peer's edit — published by another task may not be integrated into `room.doc` yet (and the stream
288276
* holds content even when THIS task's doc was never locally seeded), so a last-disconnect flush can't
289277
* clobber the durable file with a lagging projection. The local doc is captured SYNCHRONOUSLY as a
290-
* fallback before any await, so a `void flushPersist(name, room, 'final')` fired immediately before the
278+
* fallback before any await, so a `void flushPersist(name, room, true)` fired immediately before the
291279
* caller destroys `room.doc` never encodes a destroyed doc, and the disabled path stays authoritative.
292280
*/
293-
async function flushPersist(
294-
name: string,
295-
room: FileDocRoom,
296-
mode: PersistMode
297-
): Promise<PersistOutcome> {
281+
async function flushPersist(name: string, room: FileDocRoom, final: boolean): Promise<void> {
298282
// Never project a doc no user actually edited back over the file (see {@link FileDocRoom.edited}).
299-
if (!room.edited || !room.workspaceId || !room.lastEditorUserId) return 'unchanged'
283+
if (!room.edited || !room.workspaceId || !room.lastEditorUserId) return
300284
const store = getFileDocStore()
301285
const workspaceId = room.workspaceId
302286
const userId = room.lastEditorUserId
@@ -306,10 +290,7 @@ async function flushPersist(
306290

307291
// Capture the AUTHORITATIVE doc state: the shared stream when enabled (a copilot merge or a peer's
308292
// edit published by another task may not be integrated into THIS task's `room.doc` yet), else the
309-
// local snapshot. A requested export flush merges both CRDT snapshots: the socket's immediately
310-
// preceding edit can still be in the stream publisher's fire-and-forget queue, while a peer edit can
311-
// already be in the stream but not this task's doc. The CRDT union covers both without another save
312-
// path or waiting on the normal debounce.
293+
// local snapshot. Re-read each attempt so a post-reconcile retry projects the converged state.
313294
const captureState = async (): Promise<Uint8Array | null> => {
314295
if (!store.enabled) {
315296
// Single-pod: re-read the live doc so a post-reconcile retry projects the CONVERGED state, not the
@@ -321,23 +302,12 @@ async function flushPersist(
321302
: localState
322303
}
323304
try {
324-
const sharedState = await store.getStreamState(name)
325-
if (mode !== 'requested' || !sharedState || !localState) return sharedState ?? localState
326-
327-
const merged = new Y.Doc()
328-
try {
329-
Y.applyUpdate(merged, sharedState)
330-
Y.applyUpdate(merged, localState)
331-
return Y.encodeStateAsUpdate(merged)
332-
} finally {
333-
merged.destroy()
334-
}
305+
return (await store.getStreamState(name)) ?? localState
335306
} catch (streamError) {
336307
// A transient Redis read must NOT drop the write when we already hold a valid local snapshot —
337-
// else the last-disconnect flush loses the session's edits as the room is torn down. An explicit
338-
// export flush can safely fail and retry, so do not risk omitting a peer edit when the shared state
339-
// is temporarily unavailable.
340-
if (mode === 'requested') throw streamError
308+
// else the last-disconnect flush loses the session's edits as the room is torn down. But once a
309+
// reconcile has run, `localState` is NULLED (it predates the merged-in out-of-band edit), so a
310+
// failed read then correctly THROWS and aborts rather than clobbering with the stale snapshot.
341311
if (!localState) throw streamError
342312
logger.warn(`Stream state unavailable for file ${room.fileId}; persisting local snapshot`, {
343313
error: getErrorMessage(streamError),
@@ -357,11 +327,8 @@ async function flushPersist(
357327
}
358328

359329
try {
360-
if (
361-
mode === 'debounced' &&
362-
!(await store.tryClaimPersistWindow(name, FILE_DOC_TIMEOUTS.persistRequestMs))
363-
)
364-
return 'deduplicated'
330+
if (!final && !(await store.tryClaimPersistWindow(name, FILE_DOC_TIMEOUTS.persistRequestMs)))
331+
return
365332

366333
// The If-Match token: the durable content version the live doc is synced to.
367334
let ifMatch = await currentVersion()
@@ -371,7 +338,7 @@ async function flushPersist(
371338
// unset version never appears, and the flush must not stall teardown.
372339
for (
373340
let i = 0;
374-
ifMatch === undefined && mode !== 'debounced' && store.enabled && i < FINAL_VERSION_RETRIES;
341+
ifMatch === undefined && final && store.enabled && i < FINAL_VERSION_RETRIES;
375342
i++
376343
) {
377344
await sleep(FINAL_VERSION_RETRY_MS)
@@ -382,24 +349,19 @@ async function flushPersist(
382349
// still at the version the live doc synced from, so a projection can never silently clobber an
383350
// out-of-band edit. A single attempt — on conflict we STOP rather than retry (see below).
384351
const docState = await captureState()
385-
if (!docState) return 'unchanged' // nothing seeded/authoritative to persist yet
386-
// Make an acknowledged multi-replica flush a real snapshot handshake: the normal keystroke publish
387-
// is fire-and-forget, so append the converged snapshot and await Redis before updating the durable
388-
// blob. If Redis is unavailable, fail the export instead of acknowledging state that a later relay
389-
// persist could overwrite from an incomplete stream.
390-
if (mode === 'requested' && store.enabled) await store.publishAndWait(name, docState)
352+
if (!docState) return // nothing seeded/authoritative to persist yet
391353
const result = await fetchFileDocPersist(workspaceId, room.fileId, userId, docState, ifMatch)
392-
if (result.status === 'missing') return 'missing' // the file was deleted; nothing to write
354+
if (result.status === 'missing') return // the file was deleted; nothing to write
393355
if (result.status === 'deferred') {
394356
// No version token available (momentarily — a Redis blip on a peer-seeded task). Leave the edits in
395357
// the stream; a later persist writes them once the version is re-established.
396358
logger.warn(`Persist deferred for file ${room.fileId} (no synced version available yet)`)
397-
return 'deferred'
359+
return
398360
}
399361
if (result.status === 'persisted') {
400362
room.syncedVersion = Math.max(room.syncedVersion ?? 0, result.version)
401363
void store.setSyncedVersion(name, result.version)
402-
return 'persisted'
364+
return
403365
}
404366
// status === 'conflict': the durable file advanced out-of-band since our If-Match token. We do NOT
405367
// re-persist against the current stream: an external write commits durable BEFORE its chokepoint merge
@@ -413,10 +375,8 @@ async function flushPersist(
413375
logger.warn(
414376
`Persist conflict for file ${room.fileId}; durable content advanced out-of-band, left authoritative`
415377
)
416-
return 'conflict'
417378
} catch (error) {
418379
logger.warn(`Persist failed for file ${room.fileId}`, { error: getErrorMessage(error) })
419-
return 'failed'
420380
}
421381
}
422382

@@ -486,7 +446,7 @@ function destroyRoomIfIdle(name: string) {
486446
}
487447
// Final durable flush BEFORE teardown — `flushPersist` encodes the doc synchronously (before the
488448
// destroy below) and awaits the write in the background. Best-effort; never throws.
489-
void flushPersist(name, room, 'final')
449+
void flushPersist(name, room, true)
490450
getFileDocStore().detachRoom(name)
491451
room.awareness.destroy()
492452
room.doc.destroy()
@@ -501,9 +461,9 @@ function destroyRoomIfIdle(name: string) {
501461
* process is exiting); only their durable state is secured.
502462
*/
503463
export async function flushAllFileDocRooms(): Promise<void> {
504-
const flushes: Promise<PersistOutcome>[] = []
464+
const flushes: Promise<void>[] = []
505465
for (const [name, room] of fileDocRooms) {
506-
if (room.edited) flushes.push(flushPersist(name, room, 'final'))
466+
if (room.edited) flushes.push(flushPersist(name, room, true))
507467
}
508468
await Promise.all(flushes)
509469
}
@@ -1269,44 +1229,6 @@ export function setupWorkspaceFileDocHandlers(
12691229
}
12701230
})
12711231

1272-
socket.on(
1273-
FILE_DOC_EVENTS.FLUSH,
1274-
async (payload: FlushFileDocPayload, acknowledge?: (result: FlushFileDocResult) => void) => {
1275-
if (typeof acknowledge !== 'function') return
1276-
if (!payload || typeof payload.fileId !== 'string' || payload.fileId.length === 0) {
1277-
acknowledge({ ok: false, error: 'Invalid file document flush request' })
1278-
return
1279-
}
1280-
1281-
const name = socketToRoomName.get(socket.id)
1282-
const requestedName = roomName(fileDocRoom(payload.fileId))
1283-
// A file with no live editor on this socket has no pending client edits to flush; its durable
1284-
// blob is already the export source. This also keeps cold-load and read-only exports immediate.
1285-
if (name !== requestedName) {
1286-
acknowledge({ ok: true })
1287-
return
1288-
}
1289-
1290-
const room = fileDocRooms.get(name)
1291-
if (!room || !isFileDocWriteAllowed(socket, io, name)) {
1292-
acknowledge({ ok: false, error: 'Unable to prepare the current document for export' })
1293-
return
1294-
}
1295-
1296-
// Socket.IO preserves event order on one connection, so all Yjs update frames emitted before
1297-
// this request have already been applied. Replace the pending debounce with this awaited write.
1298-
if (room.persistTimer) clearTimeout(room.persistTimer)
1299-
room.persistTimer = null
1300-
room.persistDeadline = null
1301-
const outcome = await flushPersist(name, room, 'requested')
1302-
if (outcome === 'persisted' || outcome === 'unchanged' || outcome === 'missing') {
1303-
acknowledge({ ok: true })
1304-
return
1305-
}
1306-
acknowledge({ ok: false, error: 'Unable to save the latest document changes for export' })
1307-
}
1308-
)
1309-
13101232
socket.on(FILE_DOC_EVENTS.MESSAGE, (data: unknown) => handleMessage(socket, io, data))
13111233

13121234
socket.on(FILE_DOC_EVENTS.LEAVE, (payload?: LeaveFileDocPayload) => {

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import * as awarenessProtocol from 'y-protocols/awareness'
1313
import * as syncProtocol from 'y-protocols/sync'
1414
import * as Y from 'yjs'
1515
import { AGENT_STREAM_ORIGIN } from './apply-streamed-markdown'
16-
import { FileDocProvider, flushFileDocForExport } from './file-doc-provider'
16+
import { FileDocProvider } from './file-doc-provider'
1717

1818
/** A minimal fake Socket.IO client whose server→client events can be fired in tests. */
1919
function createSocket(connected = true) {
@@ -57,34 +57,6 @@ function emittedMessages(emit: ReturnType<typeof vi.fn>) {
5757
.map(([, payload]) => payload as Uint8Array)
5858
}
5959

60-
describe('flushFileDocForExport', () => {
61-
it('resolves only after the relay acknowledges a durable flush', async () => {
62-
const emit = vi.fn(
63-
(event: string, payload: { fileId: string }, acknowledge: (result: { ok: true }) => void) => {
64-
expect(event).toBe(FILE_DOC_EVENTS.FLUSH)
65-
expect(payload).toEqual({ fileId: 'file-1' })
66-
acknowledge({ ok: true })
67-
}
68-
)
69-
const socket = { connected: true, emit } as unknown as Socket
70-
71-
await expect(flushFileDocForExport(socket, 'file-1')).resolves.toBeUndefined()
72-
})
73-
74-
it('surfaces a failed flush and a disconnected socket', async () => {
75-
const emit = vi.fn(
76-
(_event: string, _payload: unknown, acknowledge: (result: unknown) => void) =>
77-
acknowledge({ ok: false, error: 'Persist failed' })
78-
)
79-
await expect(
80-
flushFileDocForExport({ connected: true, emit } as unknown as Socket, 'file-1')
81-
).rejects.toThrow('Persist failed')
82-
await expect(flushFileDocForExport(null, 'file-1')).rejects.toThrow(
83-
'Connect to realtime before exporting your latest changes.'
84-
)
85-
})
86-
})
87-
8860
describe('FileDocProvider', () => {
8961
it('joins immediately with its client id when the socket is already connected', () => {
9062
const { doc, emit } = createProvider(true)

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
FILE_DOC_MESSAGE_TYPE,
88
FILE_DOC_SEED,
99
FILE_DOC_TIMEOUTS,
10-
type FlushFileDocResult,
1110
type JoinFileDocError,
1211
type JoinFileDocSuccess,
1312
toFileDocBytes,
@@ -47,29 +46,6 @@ interface FileDocProviderEvents {
4746
*/
4847
const READINESS_DEADLINE_MS = FILE_DOC_TIMEOUTS.readinessDeadlineMs
4948

50-
/**
51-
* Wait for the realtime relay to persist every file-doc update emitted before this call. Exports read
52-
* the durable file blob, so editable clients use this acknowledgement immediately before requesting an
53-
* export rather than racing the relay's normal edit debounce.
54-
*/
55-
export function flushFileDocForExport(socket: Socket | null, fileId: string): Promise<void> {
56-
if (!socket?.connected) {
57-
return Promise.reject(new Error('Connect to realtime before exporting your latest changes.'))
58-
}
59-
60-
return new Promise((resolve, reject) => {
61-
const timer = setTimeout(() => {
62-
reject(new Error('Timed out while saving the latest document changes.'))
63-
}, FILE_DOC_TIMEOUTS.flushAckMs)
64-
65-
socket.emit(FILE_DOC_EVENTS.FLUSH, { fileId }, (result: FlushFileDocResult) => {
66-
clearTimeout(timer)
67-
if (result.ok) resolve()
68-
else reject(new Error(result.error))
69-
})
70-
})
71-
}
72-
7349
/**
7450
* Live-provider counts per file, per shared socket. Two surfaces in one tab (the Files editor and the
7551
* embedded chat resource panel) share ONE Socket.IO connection, so both a first and a second provider

0 commit comments

Comments
 (0)