Skip to content

Commit 1eb5758

Browse files
committed
fix(file-doc): reschedule an unattributed persist and pin admission and persist attribution with tests
1 parent 72dee6c commit 1eb5758

4 files changed

Lines changed: 80 additions & 4 deletions

File tree

‎apps/realtime/src/handlers/file-doc.redis-store.test.ts‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,18 @@ import * as syncProtocol from 'y-protocols/sync'
2020
import * as Y from 'yjs'
2121
import type { IRoomManager } from '@/rooms'
2222

23-
const { mockAuthorizeRoom, mockFetchFileDocSeed } = vi.hoisted(() => ({
23+
const { mockAuthorizeRoom, mockFetchFileDocSeed, mockFetchFileDocPersist } = vi.hoisted(() => ({
2424
mockAuthorizeRoom: vi.fn(),
2525
mockFetchFileDocSeed: vi.fn(),
26+
mockFetchFileDocPersist: vi.fn(),
2627
}))
2728

2829
vi.mock('@sim/platform-authz/rooms', () => ({ authorizeRoom: mockAuthorizeRoom }))
2930

3031
vi.mock('@/handlers/file-doc-app', () => ({
3132
fetchFileDocSeed: mockFetchFileDocSeed,
3233
fetchFileDocMerge: vi.fn(),
33-
fetchFileDocPersist: vi.fn().mockResolvedValue({ status: 'persisted', version: 1 }),
34+
fetchFileDocPersist: mockFetchFileDocPersist,
3435
}))
3536

3637
/** One in-memory Redis backing per test — only the stream/lock ops the store actually uses. */
@@ -269,6 +270,7 @@ describe('file-doc join readiness (shared store enabled)', () => {
269270
workspacePermission: 'write',
270271
})
271272
mockFetchFileDocSeed.mockResolvedValue(null)
273+
mockFetchFileDocPersist.mockResolvedValue({ status: 'persisted', version: 2 })
272274
})
273275

274276
afterEach(() => {
@@ -326,4 +328,49 @@ describe('file-doc join readiness (shared store enabled)', () => {
326328
expect(mockFetchFileDocSeed).not.toHaveBeenCalled()
327329
expect(statesDeliveredTo(socket)).toEqual(['seeded by the writer that held the lock'])
328330
})
331+
332+
it('persists unattributed stream state under the local editor when the last collaborator leaves', async () => {
333+
backing.kv.set(`filedoc:generation:${ROOM_NAME}`, 'doc-1')
334+
backing.kv.set(`filedoc:syncver:${ROOM_NAME}`, '1')
335+
const doc = new Y.Doc()
336+
doc.getText(FIELD).insert(0, 'AAA')
337+
doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true)
338+
doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1')
339+
backing.streams.set(STREAM_KEY, [
340+
{
341+
id: `${++backing.seq}-0`,
342+
message: { u: Buffer.from(Y.encodeStateAsUpdate(doc)).toString('base64'), g: 'doc-1' },
343+
},
344+
])
345+
const afterSeed = Y.encodeStateVector(doc)
346+
doc.getText(FIELD).insert(0, 'BBB')
347+
const edit = Y.encodeStateAsUpdate(doc, afterSeed)
348+
doc.destroy()
349+
350+
const { handlers } = setup('socket-1', sockets)
351+
await handlers[FILE_DOC_EVENTS.JOIN]({
352+
fileId: FILE_ID,
353+
clientId: 1,
354+
schemaVersion: FILE_DOC_SCHEMA_VERSION,
355+
})
356+
const acknowledge = vi.fn()
357+
await (handlers[FILE_DOC_EVENTS.UPDATE] as (...args: unknown[]) => Promise<void>)(
358+
{ fileId: FILE_ID, docId: 'doc-1', updateId: 'update-1', update: edit },
359+
acknowledge
360+
)
361+
await vi.waitFor(() =>
362+
expect(acknowledge).toHaveBeenCalledWith({ status: 'accepted', updateId: 'update-1' })
363+
)
364+
365+
cleanupFileDocForSocket('socket-1', createIo(sockets), true)
366+
await vi.waitFor(() =>
367+
expect(mockFetchFileDocPersist).toHaveBeenCalledWith(
368+
'ws-1',
369+
FILE_ID,
370+
'user-1',
371+
expect.any(Uint8Array),
372+
1
373+
)
374+
)
375+
})
329376
})

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,28 @@ describe('setupWorkspaceFileDocHandlers', () => {
687687
}
688688
})
689689

690+
it('tells an admitted writer it can write even if its cached role lapses before the reply', async () => {
691+
// A distinct user/file: role decisions recorded under fake timers outlive this test.
692+
vi.useFakeTimers({ toFake: ['Date'] })
693+
try {
694+
const { io } = createIo()
695+
const data: Record<string, unknown> = {}
696+
// Registration records presence just before the reply; expire the role cache right there.
697+
Object.defineProperty(data, 'fileDocPresence', {
698+
set: () => vi.setSystemTime(Date.now() + ROLE_REVALIDATION_TTL_MS + 1),
699+
})
700+
const { socket, handlers } = setup('socket-lapse', io, { userId: 'user-lapse', data })
701+
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-lapse', clientId: 9 })
702+
703+
expect(socket.emit).toHaveBeenCalledWith(
704+
FILE_DOC_EVENTS.JOIN_SUCCESS,
705+
expect.objectContaining({ fileId: 'file-lapse', canWrite: true })
706+
)
707+
} finally {
708+
vi.useRealTimers()
709+
}
710+
})
711+
690712
it('publishes a re-validated permission only when it changes what the socket last heard', async () => {
691713
const { io } = createIo()
692714
const { socket, handlers } = setup('socket-sweep', io)

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -467,7 +467,11 @@ async function persistRoom(name: string, room: FileDocRoom, final: boolean): Pro
467467
const docState = await captureState()
468468
if (!docState) return // nothing seeded/authoritative to persist yet
469469
if (!(await store.isDocumentGenerationCurrent(name, generation))) return
470-
if (!snapshotEditor) return
470+
if (!snapshotEditor) {
471+
// The claimed window must not strand the edits; a later flush may find an author.
472+
if (!final && fileDocRooms.get(name) === room) schedulePersist(name, room)
473+
return
474+
}
471475
const result = await fileDocOwnerAdapter(room.owner).persist(
472476
room,
473477
snapshotEditor,

‎apps/realtime/src/handlers/room-permission.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import type { RoomRef, RoomType } from '@sim/realtime-protocol/rooms'
3+
import { getErrorMessage } from '@sim/utils/errors'
34
import type { AuthenticatedSocket } from '@/middleware/auth'
45

56
const logger = createLogger('RoomPermission')
@@ -36,6 +37,8 @@ export function publishRoomPermission(
3637
try {
3738
publisher(socket, room, permission)
3839
} catch (error) {
39-
logger.warn(`Room permission publish failed for socket ${socket.id} on ${room.type}`, error)
40+
logger.warn(`Room permission publish failed for socket ${socket.id} on ${room.type}`, {
41+
error: getErrorMessage(error),
42+
})
4043
}
4144
}

0 commit comments

Comments
 (0)