Files
simstudioai--sim/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.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

1159 lines
35 KiB
TypeScript

import { db } from '@sim/db'
import { workspaceFileFolder, workspaceFiles } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, asc, eq, inArray, isNull, min, type SQL, sql } from 'drizzle-orm'
import { isReservedWorkflowAliasBackingDisplayPath } from '@/lib/copilot/vfs/workflow-aliases'
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('WorkspaceFileFolders')
/**
* Bounds the workspace-file-folder advisory-lock wait so a stuck holder fails
* fast (SQLSTATE 55P03) rather than hanging, even if the deployment lacks a
* server-side `lock_timeout`. Transaction-scoped via `set_config(..., true)`.
*/
const WORKSPACE_FILE_FOLDER_LOCK_TIMEOUT_MS = 5_000
export type WorkspaceFileFolderScope = 'active' | 'archived' | 'all'
export class WorkspaceFileFolderConflictError extends Error {
readonly code = 'FOLDER_CONFLICT' as const
constructor(name: string) {
super(`A folder named "${name}" already exists in this location`)
}
}
export class WorkspaceFileMoveConflictError extends Error {
readonly code = 'FILE_MOVE_CONFLICT' as const
constructor(name: string) {
super(`A file named "${name}" already exists in the destination folder`)
}
}
export class WorkspaceFileItemsNotFoundError extends Error {
readonly code = 'WORKSPACE_FILE_ITEMS_NOT_FOUND' as const
constructor(fileIds: string[], folderIds: string[]) {
const parts = [
fileIds.length > 0 ? `files: ${fileIds.join(', ')}` : null,
folderIds.length > 0 ? `folders: ${folderIds.join(', ')}` : null,
].filter(Boolean)
super(`Workspace file items not found (${parts.join('; ')})`)
}
}
export interface WorkspaceFileFolderRecord {
id: string
workspaceId: string
userId: string
name: string
parentId: string | null
path: string
sortOrder: number
deletedAt: Date | null
createdAt: Date
updatedAt: Date
}
interface RawWorkspaceFileFolder {
id: string
workspaceId: string
userId: string
name: string
parentId: string | null
sortOrder: number
deletedAt: Date | null
createdAt: Date
updatedAt: Date
}
interface WorkspaceFileFolderLockTx {
execute(query: SQL): Promise<unknown>
}
export interface WorkspaceFileArchiveResult {
folders: number
files: number
}
export interface WorkspaceFileFolderRestoreResult {
folder: WorkspaceFileFolderRecord
restoredItems: WorkspaceFileArchiveResult
}
export function normalizeWorkspaceFileItemName(name: string, itemLabel: 'File' | 'Folder'): string {
const trimmed = name.trim()
if (!trimmed) {
throw new Error(`${itemLabel} name is required`)
}
if (trimmed === '.' || trimmed === '..' || trimmed.includes('/') || trimmed.includes('\\')) {
throw new Error(`${itemLabel} name cannot contain path separators or dot segments`)
}
return trimmed
}
function normalizeParentId(parentId?: string | null): string | null {
return parentId && parentId.length > 0 ? parentId : null
}
function folderParentCondition(parentId?: string | null) {
const normalized = normalizeParentId(parentId)
return normalized
? eq(workspaceFileFolder.parentId, normalized)
: isNull(workspaceFileFolder.parentId)
}
function fileFolderCondition(folderId?: string | null) {
const normalized = normalizeParentId(folderId)
return normalized ? eq(workspaceFiles.folderId, normalized) : isNull(workspaceFiles.folderId)
}
async function acquireWorkspaceFileFolderMutationLock(
tx: WorkspaceFileFolderLockTx,
workspaceId: string
) {
await tx.execute(
sql`SELECT set_config('lock_timeout', ${`${WORKSPACE_FILE_FOLDER_LOCK_TIMEOUT_MS}ms`}, true)`
)
await tx.execute(
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`workspace_file_folders:${workspaceId}`}, 0))`
)
}
export function buildWorkspaceFileFolderPathMap(
folders: Array<Pick<RawWorkspaceFileFolder, 'id' | 'name' | 'parentId'>>
): Map<string, string> {
const folderMap = new Map(folders.map((folder) => [folder.id, folder]))
const paths = new Map<string, string>()
const resolve = (folderId: string, seen = new Set<string>()): string => {
const cached = paths.get(folderId)
if (cached != null) return cached
const folder = folderMap.get(folderId)
if (!folder || seen.has(folderId)) return ''
const nextSeen = new Set(seen)
nextSeen.add(folderId)
const parentPath = folder.parentId ? resolve(folder.parentId, nextSeen) : ''
const path = parentPath ? `${parentPath}/${folder.name}` : folder.name
paths.set(folderId, path)
return path
}
for (const folder of folders) {
resolve(folder.id)
}
return paths
}
function mapFolder(
folder: RawWorkspaceFileFolder,
paths: Map<string, string>
): WorkspaceFileFolderRecord {
return {
id: folder.id,
workspaceId: folder.workspaceId,
userId: folder.userId,
name: folder.name,
parentId: folder.parentId,
path: paths.get(folder.id) ?? folder.name,
sortOrder: folder.sortOrder,
deletedAt: folder.deletedAt,
createdAt: folder.createdAt,
updatedAt: folder.updatedAt,
}
}
async function getRawWorkspaceFileFolder(
workspaceId: string,
folderId: string,
options?: { includeDeleted?: boolean }
): Promise<RawWorkspaceFileFolder | null> {
const { includeDeleted = false } = options ?? {}
const [folder] = await db
.select()
.from(workspaceFileFolder)
.where(
includeDeleted
? and(
eq(workspaceFileFolder.id, folderId),
eq(workspaceFileFolder.workspaceId, workspaceId)
)
: and(
eq(workspaceFileFolder.id, folderId),
eq(workspaceFileFolder.workspaceId, workspaceId),
isNull(workspaceFileFolder.deletedAt)
)
)
.limit(1)
return folder ?? null
}
async function findRawWorkspaceFileFolderByName(
workspaceId: string,
name: string,
parentId?: string | null
): Promise<RawWorkspaceFileFolder | null> {
const [folder] = await db
.select()
.from(workspaceFileFolder)
.where(
and(
eq(workspaceFileFolder.workspaceId, workspaceId),
eq(workspaceFileFolder.name, name),
folderParentCondition(parentId),
isNull(workspaceFileFolder.deletedAt)
)
)
.limit(1)
return folder ?? null
}
async function buildWorkspaceFileFolderPath(
workspaceId: string,
folder: Pick<RawWorkspaceFileFolder, 'id' | 'name' | 'parentId'>,
options?: { includeDeleted?: boolean }
): Promise<string> {
const segments: string[] = []
const seen = new Set<string>()
let current: Pick<RawWorkspaceFileFolder, 'id' | 'name' | 'parentId'> | null = folder
while (current && !seen.has(current.id)) {
segments.unshift(current.name)
seen.add(current.id)
current = current.parentId
? await getRawWorkspaceFileFolder(workspaceId, current.parentId, options)
: null
}
return segments.join('/')
}
async function mapFolderWithPath(
workspaceId: string,
folder: RawWorkspaceFileFolder,
options?: { includeDeleted?: boolean }
): Promise<WorkspaceFileFolderRecord> {
const path = await buildWorkspaceFileFolderPath(workspaceId, folder, options)
return mapFolder(folder, new Map([[folder.id, path]]))
}
export async function getWorkspaceFileFolderPath(
workspaceId: string,
folderId: string,
options?: { includeDeleted?: boolean }
): Promise<string | null> {
const folder = await getRawWorkspaceFileFolder(workspaceId, folderId, options)
return folder ? buildWorkspaceFileFolderPath(workspaceId, folder, options) : null
}
export async function findWorkspaceFileFolderIdByPath(
workspaceId: string,
pathSegments: string[],
options?: { includeReservedSystemFolders?: boolean }
): Promise<string | null> {
if (
!options?.includeReservedSystemFolders &&
isReservedWorkflowAliasBackingDisplayPath(pathSegments.join('/'))
) {
return null
}
let parentId: string | null = null
for (const rawSegment of pathSegments) {
let name: string
try {
name = normalizeWorkspaceFileItemName(rawSegment, 'Folder')
} catch {
return null
}
const folder = await findRawWorkspaceFileFolderByName(workspaceId, name, parentId)
if (!folder) return null
parentId = folder.id
}
return parentId
}
export async function listWorkspaceFileFolders(
workspaceId: string,
options?: { scope?: WorkspaceFileFolderScope; includeReservedSystemFolders?: boolean }
): Promise<WorkspaceFileFolderRecord[]> {
const { scope = 'active', includeReservedSystemFolders = false } = options ?? {}
const rows = await db
.select()
.from(workspaceFileFolder)
.where(
scope === 'all'
? eq(workspaceFileFolder.workspaceId, workspaceId)
: scope === 'archived'
? and(
eq(workspaceFileFolder.workspaceId, workspaceId),
sql`${workspaceFileFolder.deletedAt} IS NOT NULL`
)
: and(
eq(workspaceFileFolder.workspaceId, workspaceId),
isNull(workspaceFileFolder.deletedAt)
)
)
.orderBy(asc(workspaceFileFolder.sortOrder), asc(workspaceFileFolder.createdAt))
const paths = buildWorkspaceFileFolderPathMap(rows)
return rows
.map((row) => mapFolder(row, paths))
.filter(
(folder) =>
includeReservedSystemFolders || !isReservedWorkflowAliasBackingDisplayPath(folder.path)
)
}
export async function getWorkspaceFileFolder(
workspaceId: string,
folderId: string,
options?: { includeDeleted?: boolean }
): Promise<WorkspaceFileFolderRecord | null> {
const { includeDeleted = false } = options ?? {}
const folder = await getRawWorkspaceFileFolder(workspaceId, folderId, { includeDeleted })
if (!folder) return null
// Load all folders in one query to build the path map instead of chaining
// per-ancestor SELECTs inside buildWorkspaceFileFolderPath.
const allFolders = await db
.select()
.from(workspaceFileFolder)
.where(
includeDeleted
? eq(workspaceFileFolder.workspaceId, workspaceId)
: and(
eq(workspaceFileFolder.workspaceId, workspaceId),
isNull(workspaceFileFolder.deletedAt)
)
)
const paths = buildWorkspaceFileFolderPathMap(allFolders)
return mapFolder(folder, paths)
}
export async function assertWorkspaceFileFolderTarget(
workspaceId: string,
folderId?: string | null
): Promise<string | null> {
const normalized = normalizeParentId(folderId)
if (!normalized) return null
const folder = await getWorkspaceFileFolder(workspaceId, normalized)
if (!folder) {
throw new Error('Target folder not found')
}
return normalized
}
export async function createWorkspaceFileFolder(params: {
workspaceId: string
userId: string
name: string
parentId?: string | null
sortOrder?: number
}): Promise<WorkspaceFileFolderRecord> {
const name = normalizeWorkspaceFileItemName(params.name, 'Folder')
const folder = await db.transaction(async (tx) => {
await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId)
const parentId = normalizeParentId(params.parentId)
if (parentId) {
const [target] = await tx
.select({ id: workspaceFileFolder.id })
.from(workspaceFileFolder)
.where(
and(
eq(workspaceFileFolder.id, parentId),
eq(workspaceFileFolder.workspaceId, params.workspaceId),
isNull(workspaceFileFolder.deletedAt)
)
)
.limit(1)
if (!target) {
throw new Error('Target folder not found')
}
}
const existingFolders = await tx
.select({ id: workspaceFileFolder.id })
.from(workspaceFileFolder)
.where(
and(
eq(workspaceFileFolder.workspaceId, params.workspaceId),
eq(workspaceFileFolder.name, name),
folderParentCondition(parentId),
isNull(workspaceFileFolder.deletedAt)
)
)
.limit(1)
if (existingFolders.length > 0) {
throw new WorkspaceFileFolderConflictError(name)
}
const [sortOrderResult] = await tx
.select({ minSortOrder: min(workspaceFileFolder.sortOrder) })
.from(workspaceFileFolder)
.where(
and(
eq(workspaceFileFolder.workspaceId, params.workspaceId),
folderParentCondition(parentId),
isNull(workspaceFileFolder.deletedAt)
)
)
const id = generateId()
try {
const [inserted] = await tx
.insert(workspaceFileFolder)
.values({
id,
name,
userId: params.userId,
workspaceId: params.workspaceId,
parentId,
sortOrder:
params.sortOrder ??
(sortOrderResult?.minSortOrder != null ? sortOrderResult.minSortOrder - 1 : 0),
})
.returning()
return inserted
} catch (error) {
if (getPostgresErrorCode(error) === '23505') {
throw new WorkspaceFileFolderConflictError(name)
}
throw error
}
})
return mapFolderWithPath(params.workspaceId, folder)
}
export async function ensureWorkspaceFileFolderPath(params: {
workspaceId: string
userId: string
pathSegments: string[]
}): Promise<string | null> {
if (params.pathSegments.length === 0) return null
// Load all active folders once and build a lookup keyed by "name|parentId"
// so we can resolve existing segments without a per-segment SELECT.
const existingFolders = await db
.select()
.from(workspaceFileFolder)
.where(
and(
eq(workspaceFileFolder.workspaceId, params.workspaceId),
isNull(workspaceFileFolder.deletedAt)
)
)
/** Key format: `${name}|${parentId ?? ''}` */
const folderByNameParent = new Map<string, RawWorkspaceFileFolder>()
for (const folder of existingFolders) {
folderByNameParent.set(`${folder.name}|${folder.parentId ?? ''}`, folder)
}
let parentId: string | null = null
for (const rawSegment of params.pathSegments) {
const name = normalizeWorkspaceFileItemName(rawSegment, 'Folder')
const lookupKey = `${name}|${parentId ?? ''}`
const cached = folderByNameParent.get(lookupKey)
if (cached) {
parentId = cached.id
continue
}
try {
const created = await createWorkspaceFileFolder({
workspaceId: params.workspaceId,
userId: params.userId,
name,
parentId,
})
// Insert the newly created folder into the in-memory map so subsequent
// segments in this path can find their parent without extra DB round trips.
folderByNameParent.set(`${created.name}|${created.parentId ?? ''}`, {
id: created.id,
workspaceId: created.workspaceId,
userId: created.userId,
name: created.name,
parentId: created.parentId,
sortOrder: created.sortOrder,
deletedAt: created.deletedAt,
createdAt: created.createdAt,
updatedAt: created.updatedAt,
})
parentId = created.id
} catch (error) {
if (
error instanceof WorkspaceFileFolderConflictError ||
getPostgresErrorCode(error) === '23505'
) {
// A concurrent request created this folder between our initial load and
// the INSERT — fall back to a targeted SELECT to get its id.
const concurrentExisting = await findRawWorkspaceFileFolderByName(
params.workspaceId,
name,
parentId
)
if (concurrentExisting) {
folderByNameParent.set(
`${concurrentExisting.name}|${concurrentExisting.parentId ?? ''}`,
concurrentExisting
)
parentId = concurrentExisting.id
continue
}
}
throw error
}
}
return parentId
}
function collectDescendantFolderIds(
folders: Array<Pick<WorkspaceFileFolderRecord, 'id' | 'parentId'>>,
folderId: string
): string[] {
const childrenByParent = new Map<string, string[]>()
for (const folder of folders) {
if (!folder.parentId) continue
const children = childrenByParent.get(folder.parentId) ?? []
children.push(folder.id)
childrenByParent.set(folder.parentId, children)
}
const descendants: string[] = []
const seen = new Set([folderId])
const visit = (id: string) => {
for (const childId of childrenByParent.get(id) ?? []) {
if (seen.has(childId)) continue
seen.add(childId)
descendants.push(childId)
visit(childId)
}
}
visit(folderId)
return descendants
}
export async function updateWorkspaceFileFolder(params: {
workspaceId: string
folderId: string
name?: string
parentId?: string | null
sortOrder?: number
}): Promise<WorkspaceFileFolderRecord> {
const folder = await db.transaction(async (tx) => {
await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId)
const [existing] = await tx
.select()
.from(workspaceFileFolder)
.where(
and(
eq(workspaceFileFolder.id, params.folderId),
eq(workspaceFileFolder.workspaceId, params.workspaceId),
isNull(workspaceFileFolder.deletedAt)
)
)
.limit(1)
if (!existing) throw new Error('Folder not found')
const updates: Partial<typeof workspaceFileFolder.$inferInsert> = { updatedAt: new Date() }
const finalName =
params.name !== undefined
? normalizeWorkspaceFileItemName(params.name, 'Folder')
: existing.name
const finalParentId =
params.parentId !== undefined ? normalizeParentId(params.parentId) : existing.parentId
if (finalParentId === params.folderId) throw new Error('Folder cannot be its own parent')
if (finalParentId) {
const [target] = await tx
.select({ id: workspaceFileFolder.id })
.from(workspaceFileFolder)
.where(
and(
eq(workspaceFileFolder.id, finalParentId),
eq(workspaceFileFolder.workspaceId, params.workspaceId),
isNull(workspaceFileFolder.deletedAt)
)
)
.limit(1)
if (!target) {
throw new Error('Target folder not found')
}
}
if (params.parentId !== undefined) {
const activeFolders = await tx
.select({ id: workspaceFileFolder.id, parentId: workspaceFileFolder.parentId })
.from(workspaceFileFolder)
.where(
and(
eq(workspaceFileFolder.workspaceId, params.workspaceId),
isNull(workspaceFileFolder.deletedAt)
)
)
const descendants = collectDescendantFolderIds(activeFolders, params.folderId)
if (finalParentId && descendants.includes(finalParentId)) {
throw new Error('Cannot move a folder into one of its descendants')
}
}
if (finalName !== existing.name || finalParentId !== existing.parentId) {
const conflictingFolders = await tx
.select({ id: workspaceFileFolder.id })
.from(workspaceFileFolder)
.where(
and(
eq(workspaceFileFolder.workspaceId, params.workspaceId),
eq(workspaceFileFolder.name, finalName),
folderParentCondition(finalParentId),
isNull(workspaceFileFolder.deletedAt)
)
)
.limit(2)
if (conflictingFolders.some((row) => row.id !== params.folderId)) {
throw new WorkspaceFileFolderConflictError(finalName)
}
}
if (params.name !== undefined) {
updates.name = finalName
}
if (params.parentId !== undefined) {
updates.parentId = finalParentId
}
if (params.sortOrder !== undefined) {
updates.sortOrder = params.sortOrder
}
try {
const [updatedFolder] = await tx
.update(workspaceFileFolder)
.set(updates)
.where(
and(
eq(workspaceFileFolder.id, params.folderId),
eq(workspaceFileFolder.workspaceId, params.workspaceId),
isNull(workspaceFileFolder.deletedAt)
)
)
.returning()
if (!updatedFolder) throw new Error('Folder not found')
return updatedFolder
} catch (error) {
if (getPostgresErrorCode(error) === '23505') {
throw new WorkspaceFileFolderConflictError(finalName)
}
throw error
}
})
return mapFolderWithPath(params.workspaceId, folder)
}
export async function fileNameExistsInWorkspaceFolder(
workspaceId: string,
fileName: string,
folderId?: string | null,
excludeFileId?: string
): Promise<boolean> {
const rows = await db
.select({ id: workspaceFiles.id })
.from(workspaceFiles)
.where(
and(
eq(workspaceFiles.workspaceId, workspaceId),
eq(workspaceFiles.originalName, fileName),
eq(workspaceFiles.context, 'workspace'),
fileFolderCondition(folderId),
isNull(workspaceFiles.deletedAt)
)
)
.limit(2)
return rows.some((row) => row.id !== excludeFileId)
}
export async function moveWorkspaceFileItems(params: {
workspaceId: string
fileIds?: string[]
folderIds?: string[]
targetFolderId?: string | null
}): Promise<{ movedFiles: number; movedFolders: number }> {
const fileIds = Array.from(new Set(params.fileIds ?? []))
const folderIds = Array.from(new Set(params.folderIds ?? []))
const targetFolderId = normalizeParentId(params.targetFolderId)
return db.transaction(async (tx) => {
await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId)
if (targetFolderId) {
const [target] = await tx
.select({ id: workspaceFileFolder.id })
.from(workspaceFileFolder)
.where(
and(
eq(workspaceFileFolder.id, targetFolderId),
eq(workspaceFileFolder.workspaceId, params.workspaceId),
isNull(workspaceFileFolder.deletedAt)
)
)
.limit(1)
if (!target) {
throw new Error('Target folder not found')
}
}
if (folderIds.includes(targetFolderId ?? '')) {
throw new Error('Cannot move a folder into itself')
}
if (folderIds.length > 0) {
const activeFolders = await tx
.select({ id: workspaceFileFolder.id, parentId: workspaceFileFolder.parentId })
.from(workspaceFileFolder)
.where(
and(
eq(workspaceFileFolder.workspaceId, params.workspaceId),
isNull(workspaceFileFolder.deletedAt)
)
)
for (const folderId of folderIds) {
const descendants = collectDescendantFolderIds(activeFolders, folderId)
if (targetFolderId && descendants.includes(targetFolderId)) {
throw new Error('Cannot move a folder into one of its descendants')
}
}
}
const movingFiles =
fileIds.length > 0
? await tx
.select({ id: workspaceFiles.id, name: workspaceFiles.originalName })
.from(workspaceFiles)
.where(
and(
inArray(workspaceFiles.id, fileIds),
eq(workspaceFiles.workspaceId, params.workspaceId),
eq(workspaceFiles.context, 'workspace'),
isNull(workspaceFiles.deletedAt)
)
)
: []
const movingFolders =
folderIds.length > 0
? await tx
.select({ id: workspaceFileFolder.id, name: workspaceFileFolder.name })
.from(workspaceFileFolder)
.where(
and(
inArray(workspaceFileFolder.id, folderIds),
eq(workspaceFileFolder.workspaceId, params.workspaceId),
isNull(workspaceFileFolder.deletedAt)
)
)
: []
const movingFileIds = new Set(movingFiles.map((file) => file.id))
const movingFolderIds = new Set(movingFolders.map((folder) => folder.id))
const missingFileIds = [...new Set(fileIds)].filter((fileId) => !movingFileIds.has(fileId))
const missingFolderIds = [...new Set(folderIds)].filter(
(folderId) => !movingFolderIds.has(folderId)
)
if (missingFileIds.length > 0 || missingFolderIds.length > 0) {
throw new WorkspaceFileItemsNotFoundError(missingFileIds, missingFolderIds)
}
for (const file of movingFiles) {
const conflictingFiles = await tx
.select({ id: workspaceFiles.id })
.from(workspaceFiles)
.where(
and(
eq(workspaceFiles.workspaceId, params.workspaceId),
eq(workspaceFiles.originalName, file.name),
eq(workspaceFiles.context, 'workspace'),
fileFolderCondition(targetFolderId),
isNull(workspaceFiles.deletedAt)
)
)
.limit(2)
if (conflictingFiles.some((row) => row.id !== file.id)) {
throw new WorkspaceFileMoveConflictError(file.name)
}
}
const movingFolderNameCounts = new Map<string, number>()
for (const folder of movingFolders) {
movingFolderNameCounts.set(folder.name, (movingFolderNameCounts.get(folder.name) ?? 0) + 1)
const conflictingFolders = await tx
.select({ id: workspaceFileFolder.id })
.from(workspaceFileFolder)
.where(
and(
eq(workspaceFileFolder.workspaceId, params.workspaceId),
eq(workspaceFileFolder.name, folder.name),
folderParentCondition(targetFolderId),
isNull(workspaceFileFolder.deletedAt)
)
)
.limit(2)
if (conflictingFolders.some((row) => row.id !== folder.id)) {
throw new WorkspaceFileFolderConflictError(folder.name)
}
}
for (const [name, count] of movingFolderNameCounts) {
if (count > 1) {
throw new WorkspaceFileFolderConflictError(name)
}
}
const movedFiles =
fileIds.length > 0
? await tx
.update(workspaceFiles)
.set({ folderId: targetFolderId, updatedAt: new Date() })
.where(
and(
inArray(workspaceFiles.id, fileIds),
eq(workspaceFiles.workspaceId, params.workspaceId),
eq(workspaceFiles.context, 'workspace'),
isNull(workspaceFiles.deletedAt)
)
)
.returning({ id: workspaceFiles.id })
: []
const movedFolders =
folderIds.length > 0
? await tx
.update(workspaceFileFolder)
.set({ parentId: targetFolderId, updatedAt: new Date() })
.where(
and(
inArray(workspaceFileFolder.id, folderIds),
eq(workspaceFileFolder.workspaceId, params.workspaceId),
isNull(workspaceFileFolder.deletedAt)
)
)
.returning({ id: workspaceFileFolder.id })
: []
return { movedFiles: movedFiles.length, movedFolders: movedFolders.length }
})
}
export async function archiveWorkspaceFileFolderRecursive(
workspaceId: string,
folderId: string
): Promise<WorkspaceFileArchiveResult> {
const now = new Date()
return db.transaction(async (tx) => {
await acquireWorkspaceFileFolderMutationLock(tx, workspaceId)
const [folder] = await tx
.select({ id: workspaceFileFolder.id })
.from(workspaceFileFolder)
.where(
and(
eq(workspaceFileFolder.id, folderId),
eq(workspaceFileFolder.workspaceId, workspaceId),
isNull(workspaceFileFolder.deletedAt)
)
)
.limit(1)
if (!folder) throw new Error('Folder not found')
const activeFolders = await tx
.select({ id: workspaceFileFolder.id, parentId: workspaceFileFolder.parentId })
.from(workspaceFileFolder)
.where(
and(eq(workspaceFileFolder.workspaceId, workspaceId), isNull(workspaceFileFolder.deletedAt))
)
const folderIds = [folderId, ...collectDescendantFolderIds(activeFolders, folderId)]
const archivedFiles = await tx
.update(workspaceFiles)
.set({ deletedAt: now, updatedAt: now })
.where(
and(
inArray(workspaceFiles.folderId, folderIds),
eq(workspaceFiles.workspaceId, workspaceId),
eq(workspaceFiles.context, 'workspace'),
isNull(workspaceFiles.deletedAt)
)
)
.returning({ id: workspaceFiles.id })
const archivedFolders = await tx
.update(workspaceFileFolder)
.set({ deletedAt: now, updatedAt: now })
.where(
and(
inArray(workspaceFileFolder.id, folderIds),
eq(workspaceFileFolder.workspaceId, workspaceId),
isNull(workspaceFileFolder.deletedAt)
)
)
.returning({ id: workspaceFileFolder.id })
logger.info('Archived workspace file folder recursively', {
workspaceId,
folderId,
folders: archivedFolders.length,
files: archivedFiles.length,
})
return { folders: archivedFolders.length, files: archivedFiles.length }
})
}
export async function restoreWorkspaceFileFolder(
workspaceId: string,
folderId: string
): Promise<WorkspaceFileFolderRestoreResult> {
const ws = await getWorkspaceWithOwner(workspaceId)
if (!ws || ws.archivedAt) {
throw new Error('Cannot restore folder into an archived workspace')
}
const { restored, restoredItems } = await db.transaction(async (tx) => {
await acquireWorkspaceFileFolderMutationLock(tx, workspaceId)
const raw = await tx
.select()
.from(workspaceFileFolder)
.where(
and(eq(workspaceFileFolder.id, folderId), eq(workspaceFileFolder.workspaceId, workspaceId))
)
.limit(1)
.then((rows) => rows[0] ?? null)
if (!raw) throw new Error('Folder not found')
if (!raw.deletedAt) throw new Error('Folder is not archived')
const folderDeletedAt = raw.deletedAt
// If the parent folder is still archived, restore to root so the folder
// doesn't become an orphan (hidden under an archived parent).
let resolvedParentId = raw.parentId
if (resolvedParentId) {
const parent = await tx
.select({ deletedAt: workspaceFileFolder.deletedAt })
.from(workspaceFileFolder)
.where(
and(
eq(workspaceFileFolder.id, resolvedParentId),
eq(workspaceFileFolder.workspaceId, workspaceId)
)
)
.limit(1)
.then((rows) => rows[0] ?? null)
if (!parent || parent.deletedAt) resolvedParentId = null
}
const stats: WorkspaceFileArchiveResult = { folders: 0, files: 0 }
const seen = new Set<string>()
const restoreFolderSubtree = async (currentFolderId: string): Promise<void> => {
if (seen.has(currentFolderId)) return
seen.add(currentFolderId)
const restoredFiles = await tx
.update(workspaceFiles)
.set({ deletedAt: null, updatedAt: new Date() })
.where(
and(
eq(workspaceFiles.folderId, currentFolderId),
eq(workspaceFiles.workspaceId, workspaceId),
eq(workspaceFiles.context, 'workspace'),
eq(workspaceFiles.deletedAt, folderDeletedAt)
)
)
.returning({ id: workspaceFiles.id })
stats.files += restoredFiles.length
const archivedChildren = await tx
.select({ id: workspaceFileFolder.id })
.from(workspaceFileFolder)
.where(
and(
eq(workspaceFileFolder.parentId, currentFolderId),
eq(workspaceFileFolder.workspaceId, workspaceId),
eq(workspaceFileFolder.deletedAt, folderDeletedAt)
)
)
for (const child of archivedChildren) {
const [restoredChild] = await tx
.update(workspaceFileFolder)
.set({ deletedAt: null, updatedAt: new Date() })
.where(
and(
eq(workspaceFileFolder.id, child.id),
eq(workspaceFileFolder.workspaceId, workspaceId),
eq(workspaceFileFolder.deletedAt, folderDeletedAt)
)
)
.returning({ id: workspaceFileFolder.id })
if (!restoredChild) continue
stats.folders += 1
await restoreFolderSubtree(child.id)
}
}
const [row] = await tx
.update(workspaceFileFolder)
.set({ deletedAt: null, parentId: resolvedParentId, updatedAt: new Date() })
.where(
and(eq(workspaceFileFolder.id, folderId), eq(workspaceFileFolder.workspaceId, workspaceId))
)
.returning()
stats.folders += 1
await restoreFolderSubtree(folderId)
return { restored: row, restoredItems: stats }
})
logger.info('Restored workspace file folder', { workspaceId, folderId, restoredItems })
const allFolders = await db
.select()
.from(workspaceFileFolder)
.where(
and(eq(workspaceFileFolder.workspaceId, workspaceId), isNull(workspaceFileFolder.deletedAt))
)
const paths = buildWorkspaceFileFolderPathMap(allFolders)
return {
folder: mapFolder(restored, paths),
restoredItems,
}
}
export async function bulkArchiveWorkspaceFileItems(params: {
workspaceId: string
fileIds?: string[]
folderIds?: string[]
}): Promise<WorkspaceFileArchiveResult> {
const now = new Date()
const explicitFileIds = Array.from(new Set(params.fileIds ?? []))
const explicitFolderIds = Array.from(new Set(params.folderIds ?? []))
return db.transaction(async (tx) => {
await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId)
const activeFolders =
explicitFolderIds.length > 0
? await tx
.select({ id: workspaceFileFolder.id, parentId: workspaceFileFolder.parentId })
.from(workspaceFileFolder)
.where(
and(
eq(workspaceFileFolder.workspaceId, params.workspaceId),
isNull(workspaceFileFolder.deletedAt)
)
)
: []
const descendantFolderIds = explicitFolderIds.flatMap((folderId) =>
collectDescendantFolderIds(activeFolders, folderId)
)
const allFolderIds = Array.from(new Set([...explicitFolderIds, ...descendantFolderIds]))
const archivedExplicitFiles =
explicitFileIds.length > 0
? await tx
.update(workspaceFiles)
.set({ deletedAt: now, updatedAt: now })
.where(
and(
inArray(workspaceFiles.id, explicitFileIds),
eq(workspaceFiles.workspaceId, params.workspaceId),
eq(workspaceFiles.context, 'workspace'),
isNull(workspaceFiles.deletedAt)
)
)
.returning({ id: workspaceFiles.id })
: []
const archivedDescendantFiles =
allFolderIds.length > 0
? await tx
.update(workspaceFiles)
.set({ deletedAt: now, updatedAt: now })
.where(
and(
inArray(workspaceFiles.folderId, allFolderIds),
eq(workspaceFiles.workspaceId, params.workspaceId),
eq(workspaceFiles.context, 'workspace'),
isNull(workspaceFiles.deletedAt)
)
)
.returning({ id: workspaceFiles.id })
: []
const archivedFolders =
allFolderIds.length > 0
? await tx
.update(workspaceFileFolder)
.set({ deletedAt: now, updatedAt: now })
.where(
and(
inArray(workspaceFileFolder.id, allFolderIds),
eq(workspaceFileFolder.workspaceId, params.workspaceId),
isNull(workspaceFileFolder.deletedAt)
)
)
.returning({ id: workspaceFileFolder.id })
: []
return {
folders: archivedFolders.length,
files: new Set([...archivedExplicitFiles, ...archivedDescendantFiles].map((file) => file.id))
.size,
}
})
}