@@ -31,6 +31,8 @@ import {
3131 FILE_DOC_SEED ,
3232 FILE_DOC_TIMEOUTS ,
3333 type FileDocPresenceUser ,
34+ type FlushFileDocPayload ,
35+ type FlushFileDocResult ,
3436 type JoinFileDocPayload ,
3537 type LeaveFileDocPayload ,
3638 toFileDocBytes ,
@@ -260,27 +262,52 @@ function schedulePersist(name: string, room: FileDocRoom): void {
260262 room . persistTimer = setTimeout ( ( ) => {
261263 room . persistTimer = null
262264 room . persistDeadline = null
263- void flushPersist ( name , room , false )
265+ void flushPersist ( name , room , 'debounced' )
264266 } , delay )
265267}
266268
267269/**
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
270- * (a TTL key that just expires, so at most ~one persist per window cluster-wide) so concurrent tasks
271- * editing the same file don't each write a redundant blob version. Best-effort: never throws (a failure
270+ * Why a flush is running. Only `debounced` is subject to the cross-task dedup window: the other two
271+ * have a waiter that would mistake a deduped no-op for a completed write.
272+ *
273+ * - `debounced` — the mid-edit timer fired. Coalescable, nobody is waiting.
274+ * - `final` — last collaborator leaving, or shutdown. Last chance before teardown.
275+ * - `requested` — a client asked for it and is waiting on the outcome ({@link FILE_DOC_EVENTS.FLUSH}).
276+ */
277+ type FlushMode = 'debounced' | 'final' | 'requested'
278+
279+ /** What a {@link flushPersist} call actually did. Mirrors {@link FlushFileDocResult}'s status. */
280+ type FlushPersistOutcome =
281+ | { status : 'persisted' ; version : number }
282+ | { status : 'unchanged' }
283+ | { status : 'skipped' }
284+
285+ /**
286+ * Project the live doc to markdown and write it durably via the app. A `debounced` mid-edit flush
287+ * first claims a best-effort cross-task dedup WINDOW (a TTL key that just expires, so at most ~one
288+ * persist per window cluster-wide) so concurrent tasks editing the same file don't each write a
289+ * redundant blob version; `final` and `requested` always write. Best-effort: never throws (a failure
272290 * is retried on the next debounce; the stream holds the state meanwhile).
273291 *
292+ * Returns what actually happened so a `requested` flush can be acked truthfully — several paths here
293+ * complete having written nothing, and a caller that treats "returned" as "persisted" would ship
294+ * exactly the staleness the flush exists to prevent.
295+ *
274296 * Persists the AUTHORITATIVE shared state (the stream), not this task's local doc: a copilot merge — or
275297 * a peer's edit — published by another task may not be integrated into `room.doc` yet (and the stream
276298 * holds content even when THIS task's doc was never locally seeded), so a last-disconnect flush can't
277299 * clobber the durable file with a lagging projection. The local doc is captured SYNCHRONOUSLY as a
278- * fallback before any await, so a `void flushPersist(name, room, true )` fired immediately before the
300+ * fallback before any await, so a `void flushPersist(name, room, 'final' )` fired immediately before the
279301 * caller destroys `room.doc` never encodes a destroyed doc, and the disabled path stays authoritative.
280302 */
281- async function flushPersist ( name : string , room : FileDocRoom , final : boolean ) : Promise < void > {
303+ async function flushPersist (
304+ name : string ,
305+ room : FileDocRoom ,
306+ mode : FlushMode
307+ ) : Promise < FlushPersistOutcome > {
282308 // Never project a doc no user actually edited back over the file (see {@link FileDocRoom.edited}).
283- if ( ! room . edited || ! room . workspaceId || ! room . lastEditorUserId ) return
309+ // Nothing to write is not a failure — the durable content is already current.
310+ if ( ! room . edited || ! room . workspaceId || ! room . lastEditorUserId ) return { status : 'unchanged' }
284311 const store = getFileDocStore ( )
285312 const workspaceId = room . workspaceId
286313 const userId = room . lastEditorUserId
@@ -327,18 +354,24 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr
327354 }
328355
329356 try {
330- if ( ! final && ! ( await store . tryClaimPersistWindow ( name , FILE_DOC_TIMEOUTS . persistRequestMs ) ) )
331- return
357+ // Only a debounced flush may be coalesced away. A `requested` flush has a client waiting on the
358+ // outcome, so losing the claim must not report back as a completed write.
359+ if (
360+ mode === 'debounced' &&
361+ ! ( await store . tryClaimPersistWindow ( name , FILE_DOC_TIMEOUTS . persistRequestMs ) )
362+ )
363+ return { status : 'skipped' }
332364
333365 // The If-Match token: the durable content version the live doc is synced to.
334366 let ifMatch = await currentVersion ( )
335- // FINAL flush = last chance before teardown: if the version read momentarily fails (Redis blip) for a
336- // peer-seeded/tail-only task that never cached it, retry briefly rather than defer and strand the
337- // edits in the TTL'd stream (the version is cluster-wide + heartbeat-refreshed). Bounded — a genuinely
338- // unset version never appears, and the flush must not stall teardown.
367+ // A flush with no second chance (last-leave teardown) or with a waiter (`requested`): if the version
368+ // read momentarily fails (Redis blip) for a peer-seeded/tail-only task that never cached it, retry
369+ // briefly rather than defer and strand the edits in the TTL'd stream (the version is cluster-wide +
370+ // heartbeat-refreshed). Bounded — a genuinely unset version never appears, the flush must not stall
371+ // teardown, and 2x100ms stays far inside the client's flush budget.
339372 for (
340373 let i = 0 ;
341- ifMatch === undefined && final && store . enabled && i < FINAL_VERSION_RETRIES ;
374+ ifMatch === undefined && mode !== 'debounced' && store . enabled && i < FINAL_VERSION_RETRIES ;
342375 i ++
343376 ) {
344377 await sleep ( FINAL_VERSION_RETRY_MS )
@@ -349,19 +382,21 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr
349382 // still at the version the live doc synced from, so a projection can never silently clobber an
350383 // out-of-band edit. A single attempt — on conflict we STOP rather than retry (see below).
351384 const docState = await captureState ( )
352- if ( ! docState ) return // nothing seeded/authoritative to persist yet
385+ // Nothing seeded/authoritative to persist yet.
386+ if ( ! docState ) return { status : 'skipped' }
353387 const result = await fetchFileDocPersist ( workspaceId , room . fileId , userId , docState , ifMatch )
354- if ( result . status === 'missing' ) return // the file was deleted; nothing to write
388+ // The file was deleted; nothing to write.
389+ if ( result . status === 'missing' ) return { status : 'skipped' }
355390 if ( result . status === 'deferred' ) {
356391 // No version token available (momentarily — a Redis blip on a peer-seeded task). Leave the edits in
357392 // the stream; a later persist writes them once the version is re-established.
358393 logger . warn ( `Persist deferred for file ${ room . fileId } (no synced version available yet)` )
359- return
394+ return { status : 'skipped' }
360395 }
361396 if ( result . status === 'persisted' ) {
362397 room . syncedVersion = Math . max ( room . syncedVersion ?? 0 , result . version )
363398 void store . setSyncedVersion ( name , result . version )
364- return
399+ return { status : 'persisted' , version : result . version }
365400 }
366401 // status === 'conflict': the durable file advanced out-of-band since our If-Match token. We do NOT
367402 // re-persist against the current stream: an external write commits durable BEFORE its chokepoint merge
@@ -375,8 +410,10 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr
375410 logger . warn (
376411 `Persist conflict for file ${ room . fileId } ; durable content advanced out-of-band, left authoritative`
377412 )
413+ return { status : 'skipped' }
378414 } catch ( error ) {
379415 logger . warn ( `Persist failed for file ${ room . fileId } ` , { error : getErrorMessage ( error ) } )
416+ return { status : 'skipped' }
380417 }
381418}
382419
@@ -446,7 +483,7 @@ function destroyRoomIfIdle(name: string) {
446483 }
447484 // Final durable flush BEFORE teardown — `flushPersist` encodes the doc synchronously (before the
448485 // destroy below) and awaits the write in the background. Best-effort; never throws.
449- void flushPersist ( name , room , true )
486+ void flushPersist ( name , room , 'final' )
450487 getFileDocStore ( ) . detachRoom ( name )
451488 room . awareness . destroy ( )
452489 room . doc . destroy ( )
@@ -461,9 +498,9 @@ function destroyRoomIfIdle(name: string) {
461498 * process is exiting); only their durable state is secured.
462499 */
463500export async function flushAllFileDocRooms ( ) : Promise < void > {
464- const flushes : Promise < void > [ ] = [ ]
501+ const flushes : Promise < unknown > [ ] = [ ]
465502 for ( const [ name , room ] of fileDocRooms ) {
466- if ( room . edited ) flushes . push ( flushPersist ( name , room , true ) )
503+ if ( room . edited ) flushes . push ( flushPersist ( name , room , 'final' ) )
467504 }
468505 await Promise . all ( flushes )
469506}
@@ -1231,6 +1268,51 @@ export function setupWorkspaceFileDocHandlers(
12311268
12321269 socket . on ( FILE_DOC_EVENTS . MESSAGE , ( data : unknown ) => handleMessage ( socket , io , data ) )
12331270
1271+ /**
1272+ * Persist the live document now, ahead of the debounce, and report what happened.
1273+ *
1274+ * The membership check is the authorization: `socketToRoomName` is only populated by a join that
1275+ * already passed the room's permission middleware, and the payload's file must match the room this
1276+ * socket actually holds — so a socket cannot force a write to a document it never joined.
1277+ *
1278+ * The pending debounce is cancelled first. Leaving it armed would fire a second, redundant blob
1279+ * version moments after this one for content that is already durable.
1280+ */
1281+ socket . on ( FILE_DOC_EVENTS . FLUSH , async ( payload ?: FlushFileDocPayload ) => {
1282+ const fileId = payload ?. fileId
1283+ if ( ! fileId ) return
1284+ const ack = ( status : FlushFileDocResult [ 'status' ] , version ?: number ) => {
1285+ socket . emit ( FILE_DOC_EVENTS . FLUSH_COMPLETE , {
1286+ fileId,
1287+ status,
1288+ ...( version !== undefined ? { version } : { } ) ,
1289+ } satisfies FlushFileDocResult )
1290+ }
1291+
1292+ try {
1293+ const name = socketToRoomName . get ( socket . id )
1294+ // Not in a room, or in a different file's room: nothing of this client's is unpersisted here.
1295+ // Acked as `unchanged` rather than left silent so the caller's wait always resolves.
1296+ if ( ! name || roomName ( fileDocRoom ( fileId ) ) !== name ) return ack ( 'unchanged' )
1297+ const room = fileDocRooms . get ( name )
1298+ if ( ! room ) return ack ( 'unchanged' )
1299+
1300+ if ( room . persistTimer ) {
1301+ clearTimeout ( room . persistTimer )
1302+ room . persistTimer = null
1303+ }
1304+ room . persistDeadline = null
1305+
1306+ const outcome = await flushPersist ( name , room , 'requested' )
1307+ ack ( outcome . status , outcome . status === 'persisted' ? outcome . version : undefined )
1308+ } catch ( error ) {
1309+ logger . error ( 'Error flushing file-doc room:' , error )
1310+ // `flushPersist` never throws, so reaching here means the room lookup did. The write did not
1311+ // happen, and the caller must not read the ack as durable.
1312+ ack ( 'skipped' )
1313+ }
1314+ } )
1315+
12341316 socket . on ( FILE_DOC_EVENTS . LEAVE , ( payload ?: LeaveFileDocPayload ) => {
12351317 try {
12361318 // Cancel an in-flight join whose file the client is now leaving (or an unscoped leave): a
0 commit comments