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
1159 lines
35 KiB
TypeScript
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,
|
|
}
|
|
})
|
|
}
|