Skip to content

Commit cd413c5

Browse files
committed
fix(files): name every collaborative document, and only cache what the URL names
Two findings from review, both real. A document stored before identities existed is returned by the seed's fast path on every open, and that path never named one — so those files could never acquire an identity, and the join-ack guard could never fire for them. That is the population most likely to have a tab that outlived its room, which is the case the guard exists for. The fast path now names an unnamed document and stores it, once: minting without storing would name it differently on every open and the guard would start refusing clients that hold the very same document. The inline route marked a response immutable whenever the caller passed a key, but a key is resolved to a FILE and the file's current key is what gets streamed. A content write landing between those two reads would serve the new bytes under a URL naming the old object — and cached for a year, that is wrong forever. The flag is now what it always meant: the URL names the exact object that was streamed.
1 parent 35f1597 commit cd413c5

6 files changed

Lines changed: 138 additions & 41 deletions

File tree

apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe('GET /api/workspaces/[id]/files/inline', () => {
3030
mockReadInline.mockResolvedValue({
3131
file: { name: 'photo.png', type: 'image/png', size: PNG.length },
3232
stream: new Blob([new Uint8Array(PNG)]).stream(),
33-
addressedBy: 'fileId',
33+
contentAddressed: false,
3434
})
3535
})
3636

@@ -66,11 +66,11 @@ describe('GET /api/workspaces/[id]/files/inline', () => {
6666
* rendered by two editors (the read-only placeholder, then the live one) and each renders the image
6767
* twice, so the image was fetched again on every one of those passes.
6868
*/
69-
it('lets the browser keep an image addressed by storage key', async () => {
69+
it('lets the browser keep an image whose URL names the object that was streamed', async () => {
7070
mockReadInline.mockResolvedValue({
7171
file: { name: 'photo.png', type: 'image/png', size: PNG.length },
7272
stream: new Blob([new Uint8Array(PNG)]).stream(),
73-
addressedBy: 'key',
73+
contentAddressed: true,
7474
})
7575

7676
const res = await GET(req('key=workspace%2Fws-1%2Fphoto.png'), params)

apps/sim/app/api/workspaces/[id]/files/inline/route.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,22 @@ import { encodeFilenameForHeader, getSecureFileHeaders } from '@/app/api/files/u
1111
export const dynamic = 'force-dynamic'
1212

1313
/**
14-
* How long the browser may reuse an embedded image, by how the request addressed it (see
15-
* {@link ReadWorkspaceInlineFileResult.addressedBy}).
14+
* How long the browser may reuse an embedded image, decided by whether the URL names the exact object
15+
* that was streamed (see {@link ReadWorkspaceInlineFileResult.contentAddressed}).
1616
*
17-
* A `key` names one storage object and a content write never rewrites one, so those bytes are
18-
* immutable and the browser needs no round trip — which is the difference between an embedded image
19-
* reappearing instantly and it being downloaded again. Every document render asks for the same image
20-
* at least twice (ProseMirror's own DOM, then the React node view) and every editor mounts twice (the
17+
* A content write never rewrites a storage object, so a URL that names one addresses bytes that can
18+
* never change and the browser needs no round trip — which is the difference between an embedded image
19+
* reappearing instantly and being downloaded again. Every document render asks for the same image at
20+
* least twice (ProseMirror's own DOM, then the React node view) and every editor mounts twice (the
2121
* read-only placeholder, then the live editor), so revalidating each time meant re-fetching the whole
2222
* image on every open and reload — measured at ~1 MB per open on a real document, with the image area
2323
* blank until it landed. `private` keeps it out of shared caches: the bytes are authorized per user.
2424
*
25-
* A `fileId` names the FILE, and its bytes move under it on every edit, so that form keeps revalidating.
25+
* Anything else — a request that names the FILE, whose bytes move under it, or one whose object was
26+
* rotated away mid-request — keeps revalidating.
2627
*/
27-
const INLINE_CACHE_CONTROL = {
28-
key: 'private, max-age=31536000, immutable',
29-
fileId: 'private, no-cache, must-revalidate',
30-
} as const
28+
const IMMUTABLE_CACHE_CONTROL = 'private, max-age=31536000, immutable'
29+
const REVALIDATE_CACHE_CONTROL = 'private, no-cache, must-revalidate'
3130

3231
/**
3332
* GET /api/workspaces/[id]/files/inline?key=<cloudKey>|fileId=<id>
@@ -48,12 +47,12 @@ export const GET = defineInternalBinaryRoute({
4847
fileId: query.fileId,
4948
}),
5049
useCase: readWorkspaceInlineFile,
51-
present: ({ file, stream, addressedBy }) => {
50+
present: ({ file, stream, contentAddressed }) => {
5251
const secure = getSecureFileHeaders(file.name, file.type)
5352
const headers = new Headers({
5453
'Content-Type': secure.contentType,
5554
'Content-Disposition': `${secure.disposition}; ${encodeFilenameForHeader(file.name)}`,
56-
'Cache-Control': INLINE_CACHE_CONTROL[addressedBy],
55+
'Cache-Control': contentAddressed ? IMMUTABLE_CACHE_CONTROL : REVALIDATE_CACHE_CONTROL,
5756
'X-Content-Type-Options': 'nosniff',
5857
})
5958
if (secure.contentType === 'image/svg+xml') {

apps/sim/lib/collab-doc/seed.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ describe('buildFileDocSeed', () => {
6969
// repaired on the way through and this would assert the fast path while never taking it.
7070
const cachedDoc = markdownToYDoc('# Anything')
7171
cachedDoc.getText('marker').insert(0, 'cached')
72+
// Named, as anything the current seed stored would be — an unnamed document is rewritten once to
73+
// give it an identity, which has its own test below.
74+
cachedDoc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-already-named')
7275
const cached = Y.encodeStateAsUpdate(cachedDoc)
7376
mockFetchBuffer.mockResolvedValue(Buffer.from('# Anything', 'utf-8'))
7477
mockLoadState.mockResolvedValue({ docState: cached, sourceHash: 'test-source-hash' })
@@ -273,6 +276,35 @@ describe('buildFileDocSeed — document identity', () => {
273276
doc.destroy()
274277
})
275278

279+
/**
280+
* A document stored before identities existed is returned by the fast path on every open, so if it
281+
* were named only where documents are BUILT those files would never acquire one — and the join-ack
282+
* guard could never fire for them, which is the population most likely to have a tab that outlived
283+
* its room. Naming it must also be stored, or every open would name it differently and the guard
284+
* would refuse a client holding the very same document.
285+
*/
286+
it('names a stored document that predates identities, once, and keeps that name', async () => {
287+
const legacy = markdownToYDoc('# Legacy')
288+
mockFetchBuffer.mockResolvedValue(Buffer.from('# Legacy', 'utf-8'))
289+
mockLoadState.mockResolvedValue({
290+
docState: Y.encodeStateAsUpdate(legacy),
291+
sourceHash: 'test-source-hash',
292+
})
293+
294+
const first = await buildFileDocSeed('ws-1', 'file-1')
295+
const docId = docIdOf(first!.update)
296+
expect(typeof docId).toBe('string')
297+
expect(mockSaveState).toHaveBeenCalledWith('file-1', first!.update, 'test-source-hash')
298+
299+
// The next open finds it named and hands back the stored bytes untouched.
300+
mockSaveState.mockClear()
301+
mockLoadState.mockResolvedValue({ docState: first!.update, sourceHash: 'test-source-hash' })
302+
const second = await buildFileDocSeed('ws-1', 'file-1')
303+
expect(docIdOf(second!.update)).toBe(docId)
304+
expect(mockSaveState).not.toHaveBeenCalled()
305+
legacy.destroy()
306+
})
307+
276308
it('still seeds when the document cannot be stored (the write is best-effort)', async () => {
277309
mockFetchBuffer.mockResolvedValue(Buffer.from('# Title', 'utf-8'))
278310
mockSaveState.mockRejectedValue(new Error('db down'))

apps/sim/lib/collab-doc/seed.ts

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -41,19 +41,57 @@ export interface FileDocSeed {
4141
* that ever repairs it. Running the round-trip here is that repair, and it doubles as the bound on this
4242
* branch: the cached path never calls `parseMarkdownToDoc`, so it is the one way into a room that the
4343
* parse-side limits do not cover. Returns the original bytes untouched when the snapshot is already
44-
* canonical (the common case) — no re-encode — and a fresh encode, preserving the CRDT's client ids, when
45-
* it repaired one.
44+
* canonical AND already named (the common case) — no re-encode — and a fresh encode, preserving the
45+
* CRDT's client ids, when it had to repair or name one.
46+
*
47+
* Naming happens here too, not only where a document is built: a document stored before identities
48+
* existed is returned by this path on every open, so if it were skipped here those files would never
49+
* acquire one and the join-ack guard could never fire for them — which is the population most likely to
50+
* have a tab that outlived its room. `changed` tells the caller to store what it got back, so the
51+
* identity is minted ONCE and every later open agrees with it (a re-minted one would make the guard
52+
* refuse a client holding the very same document).
4653
*/
47-
function canonicalSeedUpdate(cached: Uint8Array): Uint8Array {
54+
function prepareCachedSeed(cached: Uint8Array): { update: Uint8Array; changed: boolean } {
4855
const doc = new Y.Doc()
4956
try {
5057
Y.applyUpdate(doc, cached)
51-
return canonicalizeYDoc(doc) ? Y.encodeStateAsUpdate(doc) : cached
58+
const repaired = canonicalizeYDoc(doc)
59+
const named = ensureDocumentIdentity(doc)
60+
return repaired || named
61+
? { update: Y.encodeStateAsUpdate(doc), changed: true }
62+
: { update: cached, changed: false }
5263
} finally {
5364
doc.destroy()
5465
}
5566
}
5667

68+
/**
69+
* Give a document an identity if it has none, and report whether it needed one. A resumed document
70+
* keeps the identity its clients already know it by — re-minting would make the join-ack guard refuse
71+
* a client that holds this exact document.
72+
*/
73+
function ensureDocumentIdentity(ydoc: Y.Doc): boolean {
74+
const config = ydoc.getMap(FILE_DOC_SEED.configMap)
75+
if (typeof config.get(FILE_DOC_SEED.docIdKey) === 'string') return false
76+
config.set(FILE_DOC_SEED.docIdKey, generateId())
77+
return true
78+
}
79+
80+
/** Store the file's collaborative document. Best-effort: the durable markdown is the source of truth. */
81+
async function storeDocument(
82+
fileId: string,
83+
update: Uint8Array,
84+
sourceHash: string
85+
): Promise<void> {
86+
try {
87+
await saveCollabDocState(fileId, update, sourceHash)
88+
} catch (error) {
89+
logger.warn(`Failed to store the collaborative document for file ${fileId}`, {
90+
error: getErrorMessage(error),
91+
})
92+
}
93+
}
94+
5795
/**
5896
* Build the server-side seed for a file's collaborative document: load the file's current markdown
5997
* and convert it — through the exact client engine (see {@link markdownToYDoc}) — into a Yjs update.
@@ -97,7 +135,11 @@ export async function buildFileDocSeed(
97135
// Cold-start fast path: the stored document already projects to THIS markdown, so apply it verbatim
98136
// (the Hocuspocus load-document pattern) instead of re-converting.
99137
if (stored?.sourceHash === sourceHash) {
100-
return { update: canonicalSeedUpdate(stored.docState), version }
138+
const prepared = prepareCachedSeed(stored.docState)
139+
// Store a repair or a freshly-minted identity so the next open finds it done — and, for the
140+
// identity, so every open names the SAME document.
141+
if (prepared.changed) await storeDocument(fileId, prepared.update, sourceHash)
142+
return { update: prepared.update, version }
101143
}
102144

103145
const { frontmatter, body } = splitFrontmatter(buffer.toString('utf-8'))
@@ -113,23 +155,13 @@ export async function buildFileDocSeed(
113155
// Carry the frontmatter in the doc (not the body) so it merges across clients and a later
114156
// server-side edit can update it — the editor re-attaches this on autosave.
115157
config.set(FILE_DOC_SEED.frontmatterKey, frontmatter)
116-
// Mint an identity only for a document that has none; a resumed one keeps the identity its clients
117-
// already know it by.
118-
if (typeof config.get(FILE_DOC_SEED.docIdKey) !== 'string') {
119-
config.set(FILE_DOC_SEED.docIdKey, generateId())
120-
}
158+
ensureDocumentIdentity(ydoc)
121159
const update = Y.encodeStateAsUpdate(ydoc)
122160
// Store it NOW, not at the next persist. Until this row exists every cold open builds the document
123161
// again from markdown, minting a new identity each time — so a file that is opened but never edited
124162
// has a different document on every open, and any client that outlives a room (a laptop that slept
125163
// past the shared stream's TTL) reconnects into one and merges its content in twice.
126-
try {
127-
await saveCollabDocState(fileId, update, sourceHash)
128-
} catch (error) {
129-
logger.warn(`Failed to store the collaborative document for file ${fileId}`, {
130-
error: getErrorMessage(error),
131-
})
132-
}
164+
await storeDocument(fileId, update, sourceHash)
133165
return { update, version }
134166
} finally {
135167
ydoc.destroy()

apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,39 @@ describe('readWorkspaceInlineFile', () => {
7676
expect(mockLoadContext).toHaveBeenCalledWith('f1')
7777
})
7878

79+
/**
80+
* The response is marked immutable only when the URL names the object that was streamed. The key is
81+
* resolved to a file and the file's CURRENT key is what gets downloaded, so a rotation landing
82+
* between those two reads would serve new bytes under a URL naming the old object — cached, that
83+
* would be wrong forever.
84+
*/
85+
it('is content-addressed only when the requested key is the one it streamed', async () => {
86+
mockGetMetadataByKey.mockResolvedValue({ id: 'f1', workspaceId: 'ws-1' })
87+
88+
const match = await readWorkspaceInlineFile.execute({
89+
principal,
90+
input: { workspaceId: 'ws-1', key: file.key },
91+
})
92+
expect(match.contentAddressed).toBe(true)
93+
94+
// The file's content was replaced between resolving the key and reading the row.
95+
mockGetWorkspaceFile.mockResolvedValue({ ...file, key: 'workspace/ws-1/rotated-photo.png' })
96+
const rotated = await readWorkspaceInlineFile.execute({
97+
principal,
98+
input: { workspaceId: 'ws-1', key: file.key },
99+
})
100+
expect(rotated.contentAddressed).toBe(false)
101+
})
102+
103+
it('is never content-addressed when the caller named the file rather than an object', async () => {
104+
const result = await readWorkspaceInlineFile.execute({
105+
principal,
106+
input: { workspaceId: 'ws-1', fileId: 'f1' },
107+
})
108+
109+
expect(result.contentAddressed).toBe(false)
110+
})
111+
79112
it('conceals a key belonging to another workspace before authorization', async () => {
80113
mockGetMetadataByKey.mockResolvedValue({ id: 'other', workspaceId: 'ws-other' })
81114

apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,16 @@ export interface ReadWorkspaceInlineFileResult {
2222
file: WorkspaceFileRecord
2323
stream: ReadableStream<Uint8Array>
2424
/**
25-
* How the caller addressed the file, which decides whether the response may be cached.
25+
* Whether the URL that produced this response names the exact storage object it streamed — the only
26+
* condition under which the bytes may be cached, since a content write never rewrites an object (it
27+
* uploads under a fresh key and repoints the row).
2628
*
27-
* `key` names a storage object, and a content write never rewrites one — it uploads under a fresh
28-
* key and repoints the row, so the lookup that resolved this request (an exact match on the CURRENT
29-
* key) either found bytes that can never change or found nothing at all. The response is therefore
30-
* immutable. `fileId` names the FILE, whose bytes move as it is edited, so those responses must keep
31-
* revalidating.
29+
* It is deliberately not "the caller passed a key": the key is resolved to a file and the file's
30+
* CURRENT key is what gets streamed, so a rotation landing between those two reads would serve the
31+
* new bytes under a URL naming the old object — cached, that would be wrong forever. A `fileId`
32+
* request names the FILE, whose bytes move as it is edited, and is never content-addressed.
3233
*/
33-
addressedBy: 'key' | 'fileId'
34+
contentAddressed: boolean
3435
}
3536

3637
async function executeReadWorkspaceInlineFile({
@@ -50,7 +51,7 @@ async function executeReadWorkspaceInlineFile({
5051
return {
5152
file,
5253
stream: nodeReadableToWebStream(stream),
53-
addressedBy: input.key ? 'key' : 'fileId',
54+
contentAddressed: input.key !== undefined && input.key === file.key,
5455
}
5556
}
5657

0 commit comments

Comments
 (0)