Files
simstudioai--sim/apps/sim/hooks/queries/workspace-files.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

613 lines
20 KiB
TypeScript

import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { backoffWithJitter } from '@sim/utils/retry'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ApiClientError, isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { getUsageLimitsContract } from '@/lib/api/contracts/usage-limits'
import {
deleteWorkspaceFileContract,
listWorkspaceFilesContract,
registerWorkspaceFileContract,
renameWorkspaceFileContract,
restoreWorkspaceFileContract,
updateWorkspaceFileContentContract,
} from '@/lib/api/contracts/workspace-files'
import {
DirectUploadError,
runUploadStrategy,
type UploadProgressEvent,
} from '@/lib/uploads/client/direct-upload'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import type { UserFile } from '@/executor/types'
import { useFileContentSource } from '@/hooks/use-file-content-source'
const logger = createLogger('WorkspaceFilesQuery')
type WorkspaceFileQueryScope = 'active' | 'archived' | 'all'
/**
* Query key factories for workspace files
*/
export const workspaceFilesKeys = {
all: ['workspaceFiles'] as const,
lists: () => [...workspaceFilesKeys.all, 'list'] as const,
workspaceLists: (workspaceId: string) => [...workspaceFilesKeys.lists(), workspaceId] as const,
list: (workspaceId: string, scope: WorkspaceFileQueryScope = 'active') =>
[...workspaceFilesKeys.workspaceLists(workspaceId), scope] as const,
contents: () => [...workspaceFilesKeys.all, 'content'] as const,
contentFile: (workspaceId: string, fileId: string) =>
[...workspaceFilesKeys.contents(), workspaceId, fileId] as const,
content: (
workspaceId: string,
fileId: string,
mode: 'text' | 'raw' | 'binary' = 'text',
storageKey?: string
) =>
[
...workspaceFilesKeys.contentFile(workspaceId, fileId),
mode,
...(storageKey ? [storageKey] : []),
] as const,
storageInfo: () => [...workspaceFilesKeys.all, 'storageInfo'] as const,
}
export const WORKSPACE_FILES_LIST_STALE_TIME = 30 * 1000
export const WORKSPACE_FILE_CONTENT_STALE_TIME = 30 * 1000
export const WORKSPACE_FILE_BINARY_STALE_TIME = 30 * 1000
export const WORKSPACE_STORAGE_INFO_STALE_TIME = 60 * 1000
/**
* Storage info type
*/
interface StorageInfo {
usedBytes: number
limitBytes: number
percentUsed: number
plan?: string
}
/**
* Hook to fetch a single workspace file record by ID.
* Shares the `list(workspaceId, 'active')` query key with {@link useWorkspaceFiles} so no extra
* network request is made when the list is already cached (warm path).
* On a cold path (e.g. direct navigation to a file URL), this fetches the full active file list
* for the workspace and selects the matching record via `select`.
*/
export function useWorkspaceFileRecord(workspaceId: string, fileId: string) {
return useQuery({
queryKey: workspaceFilesKeys.list(workspaceId, 'active'),
queryFn: ({ signal }) => fetchWorkspaceFiles(workspaceId, 'active', signal),
enabled: !!workspaceId && !!fileId,
staleTime: WORKSPACE_FILES_LIST_STALE_TIME,
select: (files) => files.find((f) => f.id === fileId) ?? null,
})
}
/**
* Fetch workspace files from API
*/
async function fetchWorkspaceFiles(
workspaceId: string,
scope: WorkspaceFileQueryScope = 'active',
signal?: AbortSignal
): Promise<WorkspaceFileRecord[]> {
const data = await requestJson(listWorkspaceFilesContract, {
params: { id: workspaceId },
query: { scope },
signal,
})
return data.success ? data.files : []
}
/**
* Hook to fetch workspace files
*/
export function useWorkspaceFiles(
workspaceId: string,
scope: WorkspaceFileQueryScope = 'active',
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: workspaceFilesKeys.list(workspaceId, scope),
queryFn: ({ signal }) => fetchWorkspaceFiles(workspaceId, scope, signal),
enabled: !!workspaceId && (options?.enabled ?? true),
staleTime: WORKSPACE_FILES_LIST_STALE_TIME, // 30 seconds - files can change frequently
placeholderData: keepPreviousData, // Show cached data immediately
})
}
/**
* Fetch file content as text via a content-source URL
*/
async function fetchWorkspaceFileContent(url: string, signal?: AbortSignal): Promise<string> {
// boundary-raw-fetch: binary/text download, response is not JSON
const response = await fetch(url, { signal, cache: 'no-store' })
if (!response.ok) {
throw new Error('Failed to fetch file content')
}
return response.text()
}
/**
* Hook to fetch workspace file content as text.
* `key` (the storage object key) is forwarded into the query key factory so that a new
* storage key (e.g. after a file is re-uploaded) correctly busts the cache.
*
* `refetchInterval` lets a caller poll while waiting for the server content to advance — the
* editor's post-stream reconcile (see `use-editable-file-content.ts`) exits only when a fetch
* returns content that moved past its baseline, and would otherwise wedge read-only forever if
* its single refetch raced the agent's write. The function form is re-evaluated by react-query
* after every fetch and options pass, so a condition read through a ref stops the polling as soon
* as it flips — no re-render required.
*/
export function useWorkspaceFileContent(
workspaceId: string,
fileId: string,
key: string,
raw?: boolean,
options?: { refetchInterval?: number | false | (() => number | false) }
) {
const source = useFileContentSource()
return useQuery({
queryKey: workspaceFilesKeys.content(workspaceId, fileId, raw ? 'raw' : 'text', key),
queryFn: ({ signal }) =>
fetchWorkspaceFileContent(source.buildUrl(key, { raw, bust: true }), signal),
enabled: !!workspaceId && !!fileId && !!key,
staleTime: WORKSPACE_FILE_CONTENT_STALE_TIME,
refetchOnWindowFocus: 'always',
refetchInterval: options?.refetchInterval ?? false,
})
}
/**
* Thrown when the serve route returns 409 — a generated document (pptx/docx/pdf/
* xlsx) whose source is still being written/compiled. Distinct from a real fetch
* failure so the binary query can keep retrying (and the preview keeps showing
* its loading state) until the compiled artifact is ready.
*/
export class DocNotReadyError extends Error {
constructor() {
super('Document is still being generated')
this.name = 'DocNotReadyError'
}
}
/**
* Fetch compiled/binary file content via the serve URL.
*
* A `version` (the file record's `updatedAt`) makes the URL content-immutable: the
* serve route marks versioned responses `immutable`, so the browser HTTP cache
* resolves re-opens and focus refetches with no round trip. Generated docs are
* edited in place (same storage key), so an unversioned caller cannot assume
* immutability and instead busts + bypasses the cache to always read fresh. A 409
* means a generated doc is still compiling — surfaced as {@link DocNotReadyError}
* so the query keeps polling.
*/
async function fetchWorkspaceFileBinary(
url: string,
version: string | number | undefined,
signal?: AbortSignal
): Promise<ArrayBuffer> {
const init: RequestInit = version != null ? { signal } : { signal, cache: 'no-store' }
// boundary-raw-fetch: binary download consumed as ArrayBuffer
const response = await fetch(url, init)
if (response.status === 409) throw new DocNotReadyError()
if (!response.ok) throw new Error('Failed to fetch file content')
return response.arrayBuffer()
}
/**
* Hook to fetch workspace file content as binary (ArrayBuffer).
* `key` (the storage object key) is forwarded into the query key factory so that a new
* storage key (e.g. after a file is re-uploaded) correctly busts the cache.
*
* `options.version` is a content version (the record's `updatedAt`) folded into the
* query key. Generated docs are edited IN PLACE — `edit_content` keeps the SAME
* storage key — so without a version the cache is never busted and the open
* preview keeps showing the stale binary after a regenerate. Versioning the key
* makes the preview refetch whenever the file's content changes (and on first
* open, keyed to the current content rather than a stale cached entry).
*/
export function useWorkspaceFileBinary(
workspaceId: string,
fileId: string,
key: string,
options?: { enabled?: boolean; version?: string | number }
) {
const source = useFileContentSource()
return useQuery({
queryKey:
options?.version != null
? [...workspaceFilesKeys.content(workspaceId, fileId, 'binary', key), options.version]
: workspaceFilesKeys.content(workspaceId, fileId, 'binary', key),
queryFn: ({ signal }) =>
fetchWorkspaceFileBinary(
source.buildUrl(key, { version: options?.version, bust: true }),
options?.version,
signal
),
// Callers gate this on a readiness signal (e.g. the file has committed
// content) so we don't 409-poll the serve route for a generated doc whose
// compiled artifact hasn't been written yet — the doc is fetched once, when
// it's actually ready, instead of hammering the serve URL through generation.
enabled: !!workspaceId && !!fileId && !!key && (options?.enabled ?? true),
staleTime: WORKSPACE_FILE_BINARY_STALE_TIME,
refetchOnWindowFocus: 'always',
placeholderData: keepPreviousData,
// While a generated doc is still compiling, serve returns 409. Poll (stay in
// the loading state) until the artifact is ready instead of surfacing an
// error. The artifact is written before the source commits, so a fresh serve
// normally hits immediately; this only bridges S3 read-after-write lag and the
// brief mid-generation window. Poll on a short jittered backoff (~0.6s rising
// to ~2.5s, ~30s budget) so the common case recovers fast without hammering the
// serve URL on the long tail. SSE content invalidation also re-fetches when the
// file actually updates.
retry: (failureCount, error) =>
error instanceof DocNotReadyError ? failureCount < 14 : failureCount < 2,
retryDelay: (failureCount, error) =>
error instanceof DocNotReadyError
? backoffWithJitter(failureCount, null, { baseMs: 600, maxMs: 2500 })
: Math.min(1000 * 2 ** failureCount, 5000),
})
}
/**
* Fetch storage info from API
*/
async function fetchStorageInfo(signal?: AbortSignal): Promise<StorageInfo | null> {
try {
const data = await requestJson(getUsageLimitsContract, { signal })
if (data.success && data.storage) {
return {
usedBytes: data.storage.usedBytes,
limitBytes: data.storage.limitBytes,
percentUsed: data.storage.percentUsed,
plan: data.usage?.plan || 'free',
}
}
return null
} catch (error) {
if (isApiClientError(error) && error.status === 404) {
return null
}
throw error
}
}
/**
* Hook to fetch storage info
*/
export function useStorageInfo(enabled = true) {
return useQuery({
queryKey: workspaceFilesKeys.storageInfo(),
queryFn: ({ signal }) => fetchStorageInfo(signal),
enabled,
retry: false, // Don't retry on 404
staleTime: WORKSPACE_STORAGE_INFO_STALE_TIME, // 1 minute - storage info doesn't change often
})
}
/**
* Upload workspace file mutation
*/
interface UploadFileParams {
workspaceId: string
file: File
folderId?: string | null
onProgress?: (event: UploadProgressEvent) => void
signal?: AbortSignal
skipToast?: boolean
skipInvalidation?: boolean
}
interface UploadFileResponse {
success: boolean
file: UserFile
}
async function uploadViaApiFallback(
workspaceId: string,
file: File,
folderId?: string | null,
signal?: AbortSignal
): Promise<UploadFileResponse> {
const formData = new FormData()
formData.append('file', file)
if (folderId) formData.append('folderId', folderId)
// boundary-raw-fetch: multipart/form-data fallback upload, requestJson only supports JSON bodies
const response = await fetch(`/api/workspaces/${workspaceId}/files`, {
method: 'POST',
body: formData,
signal,
})
return parseUploadResponse(response, 'Upload failed')
}
async function parseUploadResponse(
response: Response,
fallbackMessage: string
): Promise<UploadFileResponse> {
let data: { success?: boolean; error?: string; file?: UserFile } | null = null
try {
data = await response.json()
} catch {}
if (!response.ok || !data?.success) {
throw new Error(data?.error || `${fallbackMessage} (${response.status})`)
}
return data as UploadFileResponse
}
async function uploadWorkspaceFile(
workspaceId: string,
file: File,
folderId?: string | null,
onProgress?: (event: UploadProgressEvent) => void,
signal?: AbortSignal
): Promise<UploadFileResponse> {
let result
try {
result = await runUploadStrategy({
file,
presignedEndpoint: `/api/workspaces/${workspaceId}/files/presigned`,
presignedBody: { folderId },
workspaceId,
context: 'workspace',
onProgress,
signal,
})
} catch (error) {
if (error instanceof DirectUploadError && error.code === 'FALLBACK_REQUIRED') {
return uploadViaApiFallback(workspaceId, file, folderId, signal)
}
throw error
}
const data = await registerWithRetry(workspaceId, result, folderId, signal)
if (!data.success || !data.file) {
throw new Error(data.error || 'Failed to register file')
}
return { success: true, file: data.file }
}
const REGISTER_MAX_ATTEMPTS = 3
const REGISTER_RETRY_DELAY_MS = 500
/**
* Register the uploaded object with bounded retries. The server-side handler
* is idempotent (existing-record short-circuit), so safely retrying handles
* dropped responses that would otherwise orphan the object in storage.
*/
async function registerWithRetry(
workspaceId: string,
result: { key: string; name: string; contentType: string },
folderId?: string | null,
signal?: AbortSignal
) {
let lastError: unknown
for (let attempt = 1; attempt <= REGISTER_MAX_ATTEMPTS; attempt++) {
try {
return await requestJson(registerWorkspaceFileContract, {
params: { id: workspaceId },
body: {
key: result.key,
name: result.name,
contentType: result.contentType,
folderId,
},
signal,
})
} catch (error) {
lastError = error
if (signal?.aborted) throw error
const isTransient =
!(error instanceof ApiClientError) || (error.status >= 500 && error.status < 600)
if (!isTransient || attempt === REGISTER_MAX_ATTEMPTS) throw error
await sleep(REGISTER_RETRY_DELAY_MS * attempt)
}
}
throw lastError
}
export function useUploadWorkspaceFile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ workspaceId, file, folderId, onProgress, signal }: UploadFileParams) =>
uploadWorkspaceFile(workspaceId, file, folderId, onProgress, signal),
onSettled: (_data, _error, variables) => {
if (variables.skipInvalidation) return
queryClient.invalidateQueries({
queryKey: workspaceFilesKeys.workspaceLists(variables.workspaceId),
})
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
},
onSuccess: (_data, variables) => {
if (!variables.skipToast) {
toast.success(`Uploaded "${variables.file.name}"`)
}
},
onError: (error, variables) => {
logger.error('Failed to upload file:', error)
if (!variables.skipToast) {
toast.error(`Failed to upload "${variables.file.name}": ${error.message}`, {
duration: 5000,
})
}
},
})
}
/**
* Update workspace file content mutation
*/
interface UpdateFileContentParams {
workspaceId: string
fileId: string
content: string
encoding?: 'base64' | 'utf-8'
}
export function useUpdateWorkspaceFileContent() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, fileId, content, encoding }: UpdateFileContentParams) => {
return requestJson(updateWorkspaceFileContentContract, {
params: { id: workspaceId, fileId },
body: encoding ? { content, encoding } : { content },
})
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceFilesKeys.contentFile(variables.workspaceId, variables.fileId),
})
queryClient.invalidateQueries({
queryKey: workspaceFilesKeys.workspaceLists(variables.workspaceId),
})
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
},
onError: (error) => {
logger.error('Failed to update file content:', error)
},
})
}
/**
* Rename a workspace file
*/
interface RenameFileParams {
workspaceId: string
fileId: string
name: string
}
export function useRenameWorkspaceFile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, fileId, name }: RenameFileParams) =>
requestJson(renameWorkspaceFileContract, {
params: { id: workspaceId, fileId },
body: { name },
}),
onMutate: async ({ workspaceId, fileId, name }) => {
await queryClient.cancelQueries({ queryKey: workspaceFilesKeys.workspaceLists(workspaceId) })
const previous = queryClient.getQueryData<WorkspaceFileRecord[]>(
workspaceFilesKeys.list(workspaceId, 'active')
)
if (previous) {
queryClient.setQueryData<WorkspaceFileRecord[]>(
workspaceFilesKeys.list(workspaceId, 'active'),
previous.map((f) => (f.id === fileId ? { ...f, name } : f))
)
}
return { previous }
},
onError: (error, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(
workspaceFilesKeys.list(variables.workspaceId, 'active'),
context.previous
)
}
toast.error(error.message, { duration: 5000 })
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceFilesKeys.workspaceLists(variables.workspaceId),
})
},
})
}
/**
* Delete workspace file mutation
*/
interface DeleteFileParams {
workspaceId: string
fileId: string
}
export function useDeleteWorkspaceFile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, fileId }: DeleteFileParams) =>
requestJson(deleteWorkspaceFileContract, {
params: { id: workspaceId, fileId },
}),
onMutate: async ({ workspaceId, fileId }) => {
await queryClient.cancelQueries({ queryKey: workspaceFilesKeys.workspaceLists(workspaceId) })
const previousFiles = queryClient.getQueryData<WorkspaceFileRecord[]>(
workspaceFilesKeys.list(workspaceId, 'active')
)
if (previousFiles) {
queryClient.setQueryData<WorkspaceFileRecord[]>(
workspaceFilesKeys.list(workspaceId, 'active'),
previousFiles.filter((f) => f.id !== fileId)
)
}
return { previousFiles }
},
onError: (_err, variables, context) => {
if (context?.previousFiles) {
queryClient.setQueryData(
workspaceFilesKeys.list(variables.workspaceId, 'active'),
context.previousFiles
)
}
logger.error('Failed to delete file')
toast.error(toError(_err).message)
},
onSuccess: () => {
toast.success('File moved to trash')
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceFilesKeys.workspaceLists(variables.workspaceId),
})
queryClient.removeQueries({
queryKey: workspaceFilesKeys.contentFile(variables.workspaceId, variables.fileId),
})
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
},
})
}
export function useRestoreWorkspaceFile() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ workspaceId, fileId }: { workspaceId: string; fileId: string }) =>
requestJson(restoreWorkspaceFileContract, {
params: { id: workspaceId, fileId },
}),
onSuccess: () => {
toast.success('File restored')
},
onError: (err) => {
toast.error(toError(err).message)
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({
queryKey: workspaceFilesKeys.workspaceLists(variables.workspaceId),
})
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
},
})
}