Skip to content

Commit ecf3338

Browse files
committed
fix(files): address review admission and collaboration gaps
1 parent d193f3e commit ecf3338

44 files changed

Lines changed: 1166 additions & 134 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/docs/openapi-v2-files-audit.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"openapi": "3.1.0",
33
"info": {
44
"title": "Sim API v2 — Files & Audit Logs",
5-
"description": "Version 2 of the Sim REST API for workspace and Project files, resumable uploads, public shares, and organization audit logs.",
5+
"description": "Version 2 of the Sim REST API for workspace files, resumable uploads, public shares, and organization audit logs.",
66
"version": "2.0.0",
77
"contact": {
88
"name": "Sim Support",
@@ -23,7 +23,7 @@
2323
"tags": [
2424
{
2525
"name": "Files",
26-
"description": "Create, upload, download, organize, share, version, and delete workspace and Project files."
26+
"description": "Create, upload, download, organize, share, version, and delete workspace files."
2727
},
2828
{
2929
"name": "Audit Logs",

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

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ describe('Project document editor attribution over real Redis', () => {
202202
)
203203

204204
check(
205-
'a late prefix snapshot cannot replace a newer editor and unknown newer edits erase attribution',
205+
'a late prefix snapshot and durable merge preserve the accepted editor without carrying it into a new generation',
206206
async () => {
207207
const f = await fixture()
208208
await store.publishClientUpdateAndWait(
@@ -226,10 +226,35 @@ describe('Project document editor attribution over real Redis', () => {
226226
ei: entry.id,
227227
})
228228
expect((await store.getStreamSnapshot(f.name, f.generation))?.editor).toEqual(bob)
229-
await redis.xAdd(`filedoc:stream:${f.name}`, '*', {
230-
u: Buffer.from(update(f.doc, '?')).toString('base64'),
231-
})
232-
expect((await store.getStreamSnapshot(f.name, f.generation))?.editor).toBeNull()
229+
await store.publishAndWait(f.name, update(f.doc, '-durable'), f.generation)
230+
const merged = await store.getStreamSnapshot(f.name, f.generation)
231+
expect(merged?.editor).toEqual(bob)
232+
const restored = new Y.Doc()
233+
const replacement = new Y.Doc()
234+
try {
235+
if (!merged) throw new Error('Missing merged snapshot')
236+
Y.applyUpdate(restored, merged.docState)
237+
expect(restored.getText('body').toString()).toBe('AB-durable')
238+
await store.invalidateDocument(f.name, 2)
239+
const replacementId = generateId()
240+
replacement.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true)
241+
replacement.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, replacementId)
242+
await store.seedIfEmpty(f.name, Y.encodeStateAsUpdate(replacement), 2)
243+
await store.publishAndWait(f.name, update(replacement, 'only-durable'), replacementId)
244+
const next = await store.getStreamSnapshot(f.name, replacementId)
245+
expect(next?.editor).toBeNull()
246+
const nextDoc = new Y.Doc()
247+
try {
248+
if (!next) throw new Error('Missing replacement snapshot')
249+
Y.applyUpdate(nextDoc, next.docState)
250+
expect(nextDoc.getText('body').toString()).toBe('only-durable')
251+
} finally {
252+
nextDoc.destroy()
253+
}
254+
} finally {
255+
restored.destroy()
256+
replacement.destroy()
257+
}
233258
}
234259
)
235260
})

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

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ function makeClient(): TestRedisClient {
224224
if (members.includes(member)) return 0
225225
const id = nextId()
226226
const arr = b().streams.get(key) ?? []
227-
arr.push({ id, message: { [field]: value } })
227+
arr.push({ id, message: { [field]: value, eu: opts.arguments[6], ec: opts.arguments[7] } })
228228
b().streams.set(key, arr)
229229
members.push(member)
230230
const capacity = Number(capacityText)
@@ -263,6 +263,9 @@ function makeClient(): TestRedisClient {
263263
[marker]: '1',
264264
[generationField]: expectedGeneration,
265265
...(compactionField ? { [compactionField]: '1' } : {}),
266+
eu: opts.arguments[7],
267+
ec: opts.arguments[8],
268+
ei: opts.arguments[9],
266269
},
267270
})
268271
b().streams.set(key, arr)
@@ -393,6 +396,57 @@ async function newStore(): Promise<FileDocStore> {
393396
}
394397

395398
describe('FileDocStore', () => {
399+
it('retains only an accepted editor across durable merges, compaction, and generation replacement', async () => {
400+
const store = await newStore()
401+
const source = docWithText('base')
402+
source.getMap('config').set('docId', 'generation-1')
403+
source.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true)
404+
const replica = new Y.Doc()
405+
try {
406+
await store.seedIfEmpty(NAME, Y.encodeStateAsUpdate(source), 10)
407+
let vector = Y.encodeStateVector(source)
408+
source.getText('body').insert(4, '-durable')
409+
await store.publishAndWait(NAME, Y.encodeStateAsUpdate(source, vector), 'generation-1')
410+
expect((await store.getStreamSnapshot(NAME, 'generation-1'))?.editor).toBeNull()
411+
vector = Y.encodeStateVector(source)
412+
source.getText('body').insert(source.getText('body').length, '-writer')
413+
const actor = { userId: 'actual-editor', connectionId: 'authenticated-socket' }
414+
await store.publishClientUpdateAndWait(
415+
NAME,
416+
'edit-1',
417+
Y.encodeStateAsUpdate(source, vector),
418+
'generation-1',
419+
actor
420+
)
421+
vector = Y.encodeStateVector(source)
422+
source.getText('body').insert(source.getText('body').length, '-external')
423+
await store.publishAndWait(NAME, Y.encodeStateAsUpdate(source, vector), 'generation-1')
424+
const snapshot = await store.getStreamSnapshot(NAME, 'generation-1')
425+
expect(snapshot?.editor).toEqual(actor)
426+
Y.applyUpdate(replica, snapshot!.docState)
427+
expect(replica.getText('body').toString()).toBe('base-durable-writer-external')
428+
await store.attachRoom(NAME, replica)
429+
storeInternals(store).rooms.get(NAME)!.uncompactedDeltaBytes = 12 * 1024 * 1024
430+
await storeInternals(store).maybeCompact(NAME)
431+
expect((await store.getStreamSnapshot(NAME, 'generation-1'))?.editor).toEqual(actor)
432+
store.detachRoom(NAME)
433+
await store.invalidateDocument(NAME, 20)
434+
const replacement = docWithText('replacement')
435+
replacement.getMap('config').set('docId', 'generation-2')
436+
replacement.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true)
437+
try {
438+
await store.seedIfEmpty(NAME, Y.encodeStateAsUpdate(replacement), 20)
439+
expect((await store.getStreamSnapshot(NAME, 'generation-2'))?.editor).toBeNull()
440+
} finally {
441+
replacement.destroy()
442+
}
443+
} finally {
444+
store.detachRoom(NAME)
445+
source.destroy()
446+
replica.destroy()
447+
}
448+
})
449+
396450
beforeEach(() => {
397451
state.backing = {
398452
streams: new Map(),

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -329,11 +329,9 @@ function advanceEditor(
329329
? message.ei
330330
: id
331331
if (current && !isAfterStreamId(sourceId, current.id)) return current
332-
const editor =
333-
message.eu?.trim() && message.ec?.trim()
334-
? { userId: message.eu, connectionId: message.ec }
335-
: null
336-
return { id: sourceId, editor }
332+
/** Durable external merges carry no editor; retain only a previously authenticated writer. */
333+
if (!message.eu?.trim() || !message.ec?.trim()) return current
334+
return { id: sourceId, editor: { userId: message.eu, connectionId: message.ec } }
337335
}
338336

339337
/** One locally-open room the store tracks: its doc and the last stream id applied to it. */

‎apps/sim/app/api/projects/[id]/files/[fileId]/content/route.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,23 @@ import {
1010
internalSessionAuth,
1111
} from '@/lib/api/server/routes'
1212
import { presentProjectFileContent } from '@/lib/projects/files/api'
13-
import { readProjectFileContent, updateProjectFileContent } from '@/lib/projects/files/application'
13+
import {
14+
downloadProjectFileContent,
15+
updateProjectFileContent,
16+
} from '@/lib/projects/files/application'
1417
import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration'
1518

1619
export const GET = defineInternalBinaryRoute({
1720
contract: readProjectFileContentContract,
1821
auth: internalSessionAuth,
1922
headSafe: false,
20-
operation: readProjectFileContent.operation,
23+
operation: downloadProjectFileContent.operation,
2124
rateLimit: internalRateLimits.none({
2225
reason: 'Authenticated internal file byte delivery follows the workspace download policy',
2326
}),
2427
errorPolicy: internalOrchestrationErrorPolicy,
2528
mapInput: ({ params }) => ({ projectId: params.id, fileId: params.fileId }),
26-
useCase: readProjectFileContent,
29+
useCase: downloadProjectFileContent,
2730
present: presentProjectFileContent,
2831
})
2932

‎apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/content/route.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ import {
66
internalSessionAuth,
77
} from '@/lib/api/server/routes'
88
import { presentProjectFileVersionContent } from '@/lib/projects/files/api'
9-
import { readProjectFileVersionContent } from '@/lib/projects/files/application'
9+
import { downloadProjectFileVersionContent } from '@/lib/projects/files/application'
1010
export const GET = defineInternalBinaryRoute({
1111
contract: readProjectFileVersionContentContract,
1212
auth: internalSessionAuth,
1313
headSafe: false,
14-
operation: readProjectFileVersionContent.operation,
14+
operation: downloadProjectFileVersionContent.operation,
1515
rateLimit: internalRateLimits.none({
1616
reason: 'Authenticated historical byte delivery follows the workspace download policy',
1717
}),
@@ -21,6 +21,6 @@ export const GET = defineInternalBinaryRoute({
2121
fileId: params.fileId,
2222
version: params.version,
2323
}),
24-
useCase: readProjectFileVersionContent,
24+
useCase: downloadProjectFileVersionContent,
2525
present: presentProjectFileVersionContent,
2626
})

‎apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/content/route.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import {
1111
} from '@/lib/api/server/routes'
1212
import { presentProjectFileContent } from '@/lib/projects/files/api'
1313
import { toV2ProjectFile } from '@/lib/projects/files/api/presenters'
14-
import { readProjectFileContent, updateProjectFileContent } from '@/lib/projects/files/application'
14+
import {
15+
downloadProjectFileContent,
16+
updateProjectFileContent,
17+
} from '@/lib/projects/files/application'
1518
import { projectFileOperations } from '@/lib/projects/files/application/operations'
1619
import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration'
1720

@@ -22,7 +25,7 @@ export const GET = defineV2BinaryRoute({
2225
rateLimit: v2RateLimits.publicApi,
2326
errorPolicy: v2OrchestrationErrorPolicy,
2427
mapInput: ({ params }) => ({ projectId: params.projectId, fileId: params.fileId }),
25-
useCase: readProjectFileContent,
28+
useCase: downloadProjectFileContent,
2629
present: presentProjectFileContent,
2730
headSafe: false,
2831
})
@@ -34,6 +37,18 @@ export const PUT = defineV2JsonRoute({
3437
rateLimit: v2RateLimits.publicApi,
3538
errorPolicy: v2OrchestrationErrorPolicy,
3639
parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES },
40+
beforeParse: async ({ principal, params }) => {
41+
if (typeof params.projectId === 'string' && typeof params.fileId === 'string')
42+
await updateProjectFileContent.authorize({
43+
principal,
44+
input: {
45+
projectId: params.projectId,
46+
fileId: params.fileId,
47+
content: '',
48+
encoding: 'utf-8',
49+
},
50+
})
51+
},
3752
mapInput: ({ params, body }) => ({
3853
projectId: params.projectId,
3954
fileId: params.fileId,

‎apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/content/route.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
v2RateLimits,
77
} from '@/lib/api/server/routes'
88
import { presentProjectFileVersionContent } from '@/lib/projects/files/api'
9-
import { readProjectFileVersionContent } from '@/lib/projects/files/application'
9+
import { downloadProjectFileVersionContent } from '@/lib/projects/files/application'
1010
import { projectFileOperations } from '@/lib/projects/files/application/operations'
1111
export const GET = defineV2BinaryRoute({
1212
contract: v2ReadProjectFileVersionContentContract,
@@ -20,6 +20,6 @@ export const GET = defineV2BinaryRoute({
2020
fileId: params.fileId,
2121
version: params.version,
2222
}),
23-
useCase: readProjectFileVersionContent,
23+
useCase: downloadProjectFileVersionContent,
2424
present: presentProjectFileVersionContent,
2525
})

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,76 @@ function syncStep1Frame(doc: Y.Doc): Uint8Array {
122122
}
123123

124124
describe('FileDocProvider', () => {
125+
it('never replays rejected document state after write regrant', async () => {
126+
vi.useFakeTimers()
127+
const old = createProvider()
128+
const server = new Y.Doc()
129+
server.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1')
130+
server.getText('body').insert(0, 'accepted')
131+
Y.applyUpdate(old.doc, Y.encodeStateAsUpdate(server), old.provider)
132+
const reset = vi.fn()
133+
old.provider.on('write-access-lost', reset)
134+
try {
135+
acceptJoin(old.fire, old.doc.clientID, 'doc-1')
136+
await vi.advanceTimersByTimeAsync(0)
137+
old.doc.getText('body').insert(8, '-rejected')
138+
await vi.advanceTimersByTimeAsync(UPDATE_BATCH_TEST_WINDOW_MS)
139+
old.fire(FILE_DOC_EVENTS.PERMISSION, { fileId: 'file-1', canWrite: false })
140+
old.fire(FILE_DOC_EVENTS.PERMISSION, { fileId: 'file-1', canWrite: true })
141+
expect(old.provider.canWrite).toBe(false)
142+
old.emit.mockClear()
143+
old.fire(FILE_DOC_EVENTS.MESSAGE, syncStep1Frame(server))
144+
old.doc.getText('body').insert(0, 'also-rejected-')
145+
await vi.advanceTimersByTimeAsync(UPDATE_BATCH_TEST_WINDOW_MS)
146+
expect(reset).toHaveBeenCalledTimes(1)
147+
expect(emittedMessages(old.emit)).toHaveLength(0)
148+
expect(
149+
old.emit.mock.calls.filter(([event]) => event === FILE_DOC_EVENTS.UPDATE)
150+
).toHaveLength(0)
151+
old.provider.destroy()
152+
const journal = new PendingFileDocUpdateJournal({ ...workspaceScope, fileId: 'file-1' })
153+
expect(await journal.load('doc-1')).toBeNull()
154+
expect(server.getText('body').toString()).toBe('accepted')
155+
const fresh = createProvider()
156+
try {
157+
fresh.fire(FILE_DOC_EVENTS.JOIN_SUCCESS, {
158+
schemaVersion: FILE_DOC_SCHEMA_VERSION,
159+
fileId: 'file-1',
160+
clientId: fresh.doc.clientID,
161+
docId: 'doc-1',
162+
acknowledgedUpdates: true,
163+
canWrite: false,
164+
})
165+
await vi.advanceTimersByTimeAsync(0)
166+
const frame = encoding.createEncoder()
167+
encoding.writeVarUint(frame, FILE_DOC_MESSAGE_TYPE.SYNC)
168+
syncProtocol.writeSyncStep2(frame, server)
169+
fresh.fire(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(frame))
170+
expect(fresh.doc.getText('body').toString()).toBe('accepted')
171+
expect(fresh.provider.canWrite).toBe(false)
172+
server.getText('body').insert(8, '-peer')
173+
const update = encoding.createEncoder()
174+
encoding.writeVarUint(update, FILE_DOC_MESSAGE_TYPE.SYNC)
175+
syncProtocol.writeUpdate(update, Y.encodeStateAsUpdate(server))
176+
fresh.fire(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(update))
177+
expect(fresh.doc.getText('body').toString()).toBe('accepted-peer')
178+
fresh.fire(FILE_DOC_EVENTS.PERMISSION, { fileId: 'file-1', canWrite: true })
179+
expect(fresh.provider.canWrite).toBe(true)
180+
expect(fresh.doc.getText('body').toString()).not.toContain('rejected')
181+
} finally {
182+
fresh.provider.destroy()
183+
fresh.awareness.destroy()
184+
fresh.doc.destroy()
185+
}
186+
} finally {
187+
old.provider.destroy()
188+
old.awareness.destroy()
189+
old.doc.destroy()
190+
server.destroy()
191+
vi.useRealTimers()
192+
}
193+
})
194+
125195
it('sends the workspace owner while accepting deployed ownerless join replies', async () => {
126196
vi.useFakeTimers()
127197
const { socket, emit, fire } = createSocket(true)

0 commit comments

Comments
 (0)