Skip to content

Commit 1d885a9

Browse files
committed
fix(files): bound seed reads and preserve browser snapshots
1 parent 9ad2320 commit 1d885a9

20 files changed

Lines changed: 194 additions & 67 deletions

File tree

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -423,10 +423,13 @@ describe('FileDocStore', () => {
423423
await store.publishAndWait(NAME, Y.encodeStateAsUpdate(source, vector), 'generation-1')
424424
const snapshot = await store.getStreamSnapshot(NAME, 'generation-1')
425425
expect(snapshot?.editor).toEqual(actor)
426-
Y.applyUpdate(replica, snapshot!.docState)
426+
if (!snapshot) throw new Error('Expected accepted snapshot')
427+
Y.applyUpdate(replica, snapshot.docState)
427428
expect(replica.getText('body').toString()).toBe('base-durable-writer-external')
428429
await store.attachRoom(NAME, replica)
429-
storeInternals(store).rooms.get(NAME)!.uncompactedDeltaBytes = 12 * 1024 * 1024
430+
const room = storeInternals(store).rooms.get(NAME)
431+
if (!room) throw new Error('Expected attached room')
432+
room.uncompactedDeltaBytes = 12 * 1024 * 1024
430433
await storeInternals(store).maybeCompact(NAME)
431434
expect((await store.getStreamSnapshot(NAME, 'generation-1'))?.editor).toEqual(actor)
432435
store.detachRoom(NAME)

‎apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/seed/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const POST = defineInternalJsonRoute({
1515
reason: 'Authenticated realtime relay; socket frame admission bounds collaborative traffic',
1616
}),
1717
errorPolicy: internalOrchestrationErrorPolicy,
18-
mapInput: ({ params }) => params,
18+
mapInput: ({ params }, { request }) => ({ ...params, signal: request.signal }),
1919
useCase: buildProjectFileDocSeed,
2020
present: (result) => ({
2121
update: Buffer.from(result.update).toString('base64'),

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ const FILE: WorkspaceFileRecord = {
111111
size: 30,
112112
uploadedBy: 'user-1',
113113
uploadedAt: new Date('2026-09-03T20:00:00Z'),
114+
updatedAt: new Date('2026-09-03T20:00:00Z'),
114115
}
115116
let root: Root
116117
let container: HTMLDivElement
@@ -378,7 +379,9 @@ describe('loaded rich editor lifecycle', () => {
378379
)
379380
}
380381
await draw()
381-
const firstBinding = vi.mocked(useFileDocCollaboration).mock.calls.at(-1)![0]
382+
const call = vi.mocked(useFileDocCollaboration).mock.calls.at(-1)
383+
if (!call) throw new Error('Expected collaboration binding')
384+
const firstBinding = call[0]
382385
expect(firstBinding.enabled).toBe(true)
383386
await act(async () => firstBinding.onWriteAccessLost?.())
384387
editableFileContent.mockReturnValue({
@@ -457,8 +460,9 @@ describe('loaded rich editor lifecycle', () => {
457460
await act(async () => provider.setSynced(true))
458461
const editor = getEditor()
459462
await act(async () => editor.commands.setContent('<p>Original</p>'))
460-
const input = container.querySelector<HTMLInputElement>('input[type="file"]')!
461-
await act(async () => editor.storage.slashCommand.insertImage(1))
463+
const input = container.querySelector<HTMLInputElement>('input[type="file"]')
464+
if (!input) throw new Error('Image picker is missing')
465+
await act(async () => editor.storage.slashCommand.insertImage?.(1))
462466
Object.defineProperty(input, 'files', {
463467
configurable: true,
464468
value: [
@@ -471,7 +475,7 @@ describe('loaded rich editor lifecycle', () => {
471475
const signal = uploadFile.mock.calls[0][0].signal as AbortSignal
472476
expect(signal.aborted).toBe(false)
473477
expect(container.querySelectorAll('[role="status"]')).toHaveLength(2)
474-
await act(async () => editor.storage.slashCommand.insertImage(1))
478+
await act(async () => editor.storage.slashCommand.insertImage?.(1))
475479
collaborationRef.current = { ...collaboration, canWrite: false }
476480
await render('Original', 'Original', true, { collaborative: true })
477481
expect(signal.aborted).toBe(true)

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx‎

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -663,14 +663,13 @@ export function LoadedRichMarkdownEditor({
663663
/** The picker anchor maps through edits while the operating-system file dialog is open. */
664664
const imageInputRef = useRef<HTMLInputElement>(null)
665665
const pendingImageAnchorRef = useRef<string | null>(null)
666-
const imageUploadBatchesRef = useRef(
667-
new Set<{
668-
editor: Editor
669-
anchors: string[]
670-
controller: AbortController
671-
toastId?: ReturnType<typeof toast.info>
672-
}>()
673-
)
666+
const imageUploadBatchesRef = useRef<Set<{
667+
editor: Editor
668+
anchors: string[]
669+
controller: AbortController
670+
toastId?: ReturnType<typeof toast.info>
671+
}> | null>(null)
672+
imageUploadBatchesRef.current ??= new Set()
674673

675674
/**
676675
* Uploads are sequential; every position is anchored before awaiting so queued images also follow
@@ -697,7 +696,7 @@ export function LoadedRichMarkdownEditor({
697696
controller: new AbortController(),
698697
toastId: undefined as ReturnType<typeof toast.info> | undefined,
699698
}
700-
imageUploadBatchesRef.current.add(batch)
699+
imageUploadBatchesRef.current?.add(batch)
701700
try {
702701
for (const [index, image] of images.entries()) {
703702
if (editor.isDestroyed || !editor.isEditable || batch.controller.signal.aborted) break
@@ -733,7 +732,7 @@ export function LoadedRichMarkdownEditor({
733732
} finally {
734733
if (batch.toastId !== undefined) toast.dismiss(batch.toastId)
735734
for (const anchor of anchors) removeImageUpload(editor, anchor)
736-
imageUploadBatchesRef.current.delete(batch)
735+
imageUploadBatchesRef.current?.delete(batch)
737736
}
738737
}
739738

@@ -1037,12 +1036,12 @@ export function LoadedRichMarkdownEditor({
10371036
useEffect(() => {
10381037
if (!editor || !effectiveCanEdit) return
10391038
return () => {
1040-
for (const batch of imageUploadBatchesRef.current) {
1039+
for (const batch of imageUploadBatchesRef.current ?? []) {
10411040
batch.controller.abort()
10421041
for (const anchor of batch.anchors) removeImageUpload(batch.editor, anchor)
10431042
if (batch.toastId !== undefined) toast.dismiss(batch.toastId)
10441043
}
1045-
imageUploadBatchesRef.current.clear()
1044+
imageUploadBatchesRef.current?.clear()
10461045
const anchor = pendingImageAnchorRef.current
10471046
if (anchor) removeImageUpload(editor, anchor)
10481047
pendingImageAnchorRef.current = null

‎apps/sim/lib/api/contracts/v2/openapi/project-file-uploads.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export const projectFileUploadOpenApiRoutes = [
5656
summary: 'Get Project file upload',
5757
description: `Read the current state of a Project upload, including its file after completion. ${WORKSPACE_API_KEY_DENIED}`,
5858
tags: ['Files'],
59-
errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge', 'UnsupportedMediaType'],
59+
errors: RESOURCE_CONFLICT_ERRORS,
6060
success: { description: 'Get Project File Upload result.', headers: RATE_LIMIT_HEADERS },
6161
},
6262
{
@@ -89,7 +89,7 @@ export const projectFileUploadOpenApiRoutes = [
8989
summary: 'Abort Project file upload',
9090
description: `Abort a pending Project upload and schedule its unregistered bytes for cleanup. ${WORKSPACE_API_KEY_DENIED}`,
9191
tags: ['Files'],
92-
errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge', 'UnsupportedMediaType'],
92+
errors: RESOURCE_CONFLICT_ERRORS,
9393
success: { description: 'Abort Project File Upload result.', headers: RATE_LIMIT_HEADERS },
9494
},
9595
{
@@ -122,7 +122,7 @@ export const projectFileUploadOpenApiRoutes = [
122122
summary: 'Complete Project file upload',
123123
description: `Finalize verified bytes and atomically register one Project file. Retrying completion returns the same file without billing twice. Current edit access is checked again. ${WORKSPACE_API_KEY_DENIED}`,
124124
tags: ['Files'],
125-
errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge', 'UnsupportedMediaType'],
125+
errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'],
126126
success: { description: 'Complete Project File Upload result.', headers: RATE_LIMIT_HEADERS },
127127
},
128128
{

‎apps/sim/lib/api/contracts/v2/project-files.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ export const v2CreateProjectFileBodySchema = z
7777
.object({
7878
name: v2CreateFileBodySchema.shape.name,
7979
contentType: v2CreateFileBodySchema.shape.contentType,
80-
content: v2CreateFileBodySchema.shape.content,
80+
content: v2CreateFileBodySchema.shape.content.describe(
81+
'Initial file content. Omit or send an empty string for a zero-byte file. The complete JSON request is capped at 70 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The decoded-file limit is 50 MiB. Use an upload session for larger payloads.'
82+
),
8183
encoding: v2CreateFileBodySchema.shape.encoding,
8284
folderPath: v2CreateFileBodySchema.shape.folderPath.describe(
8385
'Canonical containing-folder path. Omit for the Project root.'
@@ -89,7 +91,9 @@ export type V2CreateProjectFileBody = z.input<typeof v2CreateProjectFileBodySche
8991

9092
export const v2UpdateProjectFileContentBodySchema = z
9193
.object({
92-
content: v2UpdateFileContentBodySchema.shape.content,
94+
content: v2UpdateFileContentBodySchema.shape.content.describe(
95+
'Complete replacement content for the file. The complete JSON request is capped at 70 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The decoded-file limit is 50 MiB.'
96+
),
9397
encoding: v2UpdateFileContentBodySchema.shape.encoding,
9498
expectedRevision: v2UpdateFileContentBodySchema.shape.expectedRevision,
9599
})

‎apps/sim/lib/projects/files/__integration__/authorization.integration.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case'
4242
import { projectFileOperations } from '@/lib/projects/files/application/operations'
4343
import { resolveFileFolderTarget } from '@/lib/uploads/contexts/workspace'
44+
import { resolveArchivedFileFolderScope } from '@/lib/uploads/contexts/workspace/file-folder-manager'
4445
import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata'
4546

4647
vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock)
@@ -589,6 +590,15 @@ describe('Project file authority at the database boundary', () => {
589590
principal: f.reader,
590591
input: { ...base, scope: 'archived', folderPath, recursive },
591592
})
593+
expect(
594+
await resolveArchivedFileFolderScope(
595+
{ entityType: 'project', entityId: f.projectId },
596+
'/Docs',
597+
false,
598+
2,
599+
db
600+
)
601+
).toEqual(expect.arrayContaining([docsId, archivedDocsId]))
592602
expect((await archivedPage('/Docs')).files.map((file) => file.id)).toEqual([
593603
fileIds[1],
594604
archivedIds[0],
@@ -673,6 +683,17 @@ describe('Project file authority at the database boundary', () => {
673683
expect(result.file.path).toBe(`/api/projects/${f.projectId}/files/${fileId}/content`)
674684
expect(result.vfsPath).toBe(vfsPath)
675685
}
686+
for (const name of ['content', 'meta.json']) {
687+
await db
688+
.update(workspaceFiles)
689+
.set({ originalName: name })
690+
.where(eq(workspaceFiles.id, fileId))
691+
expect((await resolve(`files/Q3%2FQ4/${name}`)).file.id).toBe(fileId)
692+
}
693+
await db
694+
.update(workspaceFiles)
695+
.set({ originalName: 'Architecture overview.md' })
696+
.where(eq(workspaceFiles.id, fileId))
676697
for (const reference of [
677698
foreignId,
678699
archivedId,

‎apps/sim/lib/projects/files/__integration__/browser.integration.ts‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { mkdir, writeFile } from 'node:fs/promises'
22
import { dirname } from 'node:path'
3-
import { db } from '@sim/db'
3+
import { db, runOutsideTransactionContext } from '@sim/db'
44
import { folder, permissions, project, user, workspace, workspaceFiles } from '@sim/db/schema'
55
import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
66
import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
@@ -12,6 +12,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
1212
import { listProjectFileItems } from '@/lib/projects/files/application'
1313
import { deleteUserAccount } from '@/lib/users/account-deletion'
1414
import type { FileBrowserQuery } from '@/lib/workspace-files/browser-query'
15+
import * as browserQuery from '@/lib/workspace-files/browser-query'
1516

1617
interface BrowserFixture {
1718
workspaceId: string
@@ -195,6 +196,33 @@ afterAll(async () => {
195196
})
196197

197198
describe('Project browser real mixed collection', () => {
199+
check('file records preserve page order and snapshot through a concurrent archive', async () => {
200+
const f = await fixture()
201+
const small = await f.addFile('a-small.txt', 1)
202+
const large = await f.addFile('z-large.txt', 20)
203+
const original = browserQuery.queryFileBrowserItems
204+
const query = vi
205+
.spyOn(browserQuery, 'queryFileBrowserItems')
206+
.mockImplementationOnce(async (...args) => {
207+
const page = await original(...args)
208+
await runOutsideTransactionContext(() =>
209+
db
210+
.update(workspaceFiles)
211+
.set({ deletedAt: new Date() })
212+
.where(eq(workspaceFiles.id, large))
213+
)
214+
return page
215+
})
216+
try {
217+
const page = await f.list({ sortBy: 'size', sortOrder: 'desc' })
218+
expect(page.items.map((item) => item.id)).toEqual([large, small])
219+
expect(page.files.map((file) => file.id)).toEqual([large, small])
220+
expect((await f.list()).files.map((file) => file.id)).toEqual([small])
221+
} finally {
222+
query.mockRestore()
223+
}
224+
})
225+
198226
check(
199227
'type, size and creator predicates select the complete collection before pagination',
200228
async () => {

‎apps/sim/lib/projects/files/__integration__/content.integration.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1602,7 +1602,7 @@ describe('Project Copilot content admission', () => {
16021602
const execute = () =>
16031603
operation.execute({
16041604
principal,
1605-
input: { projectId: f.projectId, fileId: file.id, version: 1 },
1605+
input: { projectId: f.projectId, fileId: file.id },
16061606
})
16071607
await expect(
16081608
gate === 'missing'

‎apps/sim/lib/projects/files/__integration__/documents.integration.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { generateId } from '@sim/utils/id'
2424
import { eq, inArray, sql } from 'drizzle-orm'
2525
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
2626
import * as Y from 'yjs'
27+
import * as storage from '@/lib/uploads/core/storage-service'
2728

2829
vi.mock('@/lib/uploads/core/setup.server', () => uploadsSetupMock)
2930

@@ -179,6 +180,36 @@ function documentId(update: Uint8Array) {
179180
}
180181

181182
describe('Project collaborative documents against current policy and durable storage', () => {
183+
check('aborted seeds stop storage reads and do not retry', async () => {
184+
const f = await fixture('read')
185+
const controller = new AbortController()
186+
const reason = new Error('Seed request retired')
187+
const read = vi.spyOn(storage, 'downloadFile').mockImplementationOnce(async (options) => {
188+
expect(options.signal).toBeDefined()
189+
controller.abort(reason)
190+
options.signal?.throwIfAborted()
191+
throw new Error('Expected aborted storage read')
192+
})
193+
try {
194+
await expect(
195+
buildProjectFileDocSeed.execute({
196+
principal: f.principal,
197+
input: { ...f.target, signal: controller.signal },
198+
})
199+
).rejects.toBe(reason)
200+
expect(read).toHaveBeenCalledTimes(1)
201+
await expect(
202+
buildProjectFileDocSeed.execute({
203+
principal: f.principal,
204+
input: { ...f.target, signal: controller.signal },
205+
})
206+
).rejects.toBe(reason)
207+
expect(read).toHaveBeenCalledTimes(1)
208+
} finally {
209+
read.mockRestore()
210+
}
211+
})
212+
182213
check('readers receive a stable seed but cannot publish durable document edits', async () => {
183214
const f = await fixture('read')
184215
expect(

0 commit comments

Comments
 (0)