@@ -3,7 +3,7 @@ import { workspaceFileFolder, workspaceFiles } from '@sim/db/schema'
33import { createLogger } from '@sim/logger'
44import { getPostgresErrorCode } from '@sim/utils/errors'
55import { generateId } from '@sim/utils/id'
6- import { and , asc , eq , inArray , isNull , min , sql } from 'drizzle-orm'
6+ import { and , asc , eq , inArray , isNull , min , type SQL , sql } from 'drizzle-orm'
77
88const logger = createLogger ( 'WorkspaceFileFolders' )
99
@@ -50,6 +50,10 @@ interface RawWorkspaceFileFolder {
5050 updatedAt : Date
5151}
5252
53+ interface WorkspaceFileFolderLockTx {
54+ execute ( query : SQL ) : Promise < unknown >
55+ }
56+
5357export interface WorkspaceFileArchiveResult {
5458 folders : number
5559 files : number
@@ -82,6 +86,15 @@ function fileFolderCondition(folderId?: string | null) {
8286 return normalized ? eq ( workspaceFiles . folderId , normalized ) : isNull ( workspaceFiles . folderId )
8387}
8488
89+ async function acquireWorkspaceFileFolderMutationLock (
90+ tx : WorkspaceFileFolderLockTx ,
91+ workspaceId : string
92+ ) {
93+ await tx . execute (
94+ sql `SELECT pg_advisory_xact_lock(hashtextextended(${ `workspace_file_folders:${ workspaceId } ` } , 0))`
95+ )
96+ }
97+
8598export function buildWorkspaceFileFolderPathMap (
8699 folders : Array < Pick < RawWorkspaceFileFolder , 'id' | 'name' | 'parentId' > >
87100) : Map < string , string > {
@@ -300,34 +313,38 @@ export async function createWorkspaceFileFolder(params: {
300313 parentId ?: string | null
301314 sortOrder ?: number
302315} ) : Promise < WorkspaceFileFolderRecord > {
303- const parentId = await assertWorkspaceFileFolderTarget ( params . workspaceId , params . parentId )
304316 const name = normalizeWorkspaceFileItemName ( params . name , 'Folder' )
305317
306- if ( await workspaceFileFolderExists ( params . workspaceId , name , parentId ) ) {
307- throw new WorkspaceFileFolderConflictError ( name )
308- }
318+ const folder = await db . transaction ( async ( tx ) => {
319+ await acquireWorkspaceFileFolderMutationLock ( tx , params . workspaceId )
309320
310- const id = generateId ( )
311- let folder : RawWorkspaceFileFolder
312- try {
313- const [ inserted ] = await db
314- . insert ( workspaceFileFolder )
315- . values ( {
316- id,
317- name,
318- userId : params . userId ,
319- workspaceId : params . workspaceId ,
320- parentId,
321- sortOrder : params . sortOrder ?? ( await nextFolderSortOrder ( params . workspaceId , parentId ) ) ,
322- } )
323- . returning ( )
324- folder = inserted
325- } catch ( error ) {
326- if ( getPostgresErrorCode ( error ) === '23505' ) {
321+ const parentId = await assertWorkspaceFileFolderTarget ( params . workspaceId , params . parentId )
322+
323+ if ( await workspaceFileFolderExists ( params . workspaceId , name , parentId ) ) {
327324 throw new WorkspaceFileFolderConflictError ( name )
328325 }
329- throw error
330- }
326+
327+ const id = generateId ( )
328+ try {
329+ const [ inserted ] = await tx
330+ . insert ( workspaceFileFolder )
331+ . values ( {
332+ id,
333+ name,
334+ userId : params . userId ,
335+ workspaceId : params . workspaceId ,
336+ parentId,
337+ sortOrder : params . sortOrder ?? ( await nextFolderSortOrder ( params . workspaceId , parentId ) ) ,
338+ } )
339+ . returning ( )
340+ return inserted
341+ } catch ( error ) {
342+ if ( getPostgresErrorCode ( error ) === '23505' ) {
343+ throw new WorkspaceFileFolderConflictError ( name )
344+ }
345+ throw error
346+ }
347+ } )
331348
332349 return mapFolderWithPath ( params . workspaceId , folder )
333350}
@@ -407,6 +424,31 @@ async function getDescendantFolderIds(
407424 return descendants
408425}
409426
427+ function collectDescendantFolderIds (
428+ folders : Array < Pick < WorkspaceFileFolderRecord , 'id' | 'parentId' > > ,
429+ folderId : string
430+ ) : string [ ] {
431+ const childrenByParent = new Map < string , string [ ] > ( )
432+
433+ for ( const folder of folders ) {
434+ if ( ! folder . parentId ) continue
435+ const children = childrenByParent . get ( folder . parentId ) ?? [ ]
436+ children . push ( folder . id )
437+ childrenByParent . set ( folder . parentId , children )
438+ }
439+
440+ const descendants : string [ ] = [ ]
441+ const visit = ( id : string ) => {
442+ for ( const childId of childrenByParent . get ( id ) ?? [ ] ) {
443+ descendants . push ( childId )
444+ visit ( childId )
445+ }
446+ }
447+ visit ( folderId )
448+
449+ return descendants
450+ }
451+
410452export async function updateWorkspaceFileFolder ( params : {
411453 workspaceId : string
412454 folderId : string
@@ -611,13 +653,33 @@ export async function archiveWorkspaceFileFolderRecursive(
611653 workspaceId : string ,
612654 folderId : string
613655) : Promise < WorkspaceFileArchiveResult > {
614- const folder = await getWorkspaceFileFolder ( workspaceId , folderId )
615- if ( ! folder ) throw new Error ( 'Folder not found' )
616-
617656 const now = new Date ( )
618- const folderIds = [ folderId , ...( await getDescendantFolderIds ( workspaceId , folderId ) ) ]
619657
620658 return db . transaction ( async ( tx ) => {
659+ await acquireWorkspaceFileFolderMutationLock ( tx , workspaceId )
660+
661+ const [ folder ] = await tx
662+ . select ( { id : workspaceFileFolder . id } )
663+ . from ( workspaceFileFolder )
664+ . where (
665+ and (
666+ eq ( workspaceFileFolder . id , folderId ) ,
667+ eq ( workspaceFileFolder . workspaceId , workspaceId ) ,
668+ isNull ( workspaceFileFolder . deletedAt )
669+ )
670+ )
671+ . limit ( 1 )
672+
673+ if ( ! folder ) throw new Error ( 'Folder not found' )
674+
675+ const activeFolders = await tx
676+ . select ( { id : workspaceFileFolder . id , parentId : workspaceFileFolder . parentId } )
677+ . from ( workspaceFileFolder )
678+ . where (
679+ and ( eq ( workspaceFileFolder . workspaceId , workspaceId ) , isNull ( workspaceFileFolder . deletedAt ) )
680+ )
681+ const folderIds = [ folderId , ...collectDescendantFolderIds ( activeFolders , folderId ) ]
682+
621683 const archivedFiles = await tx
622684 . update ( workspaceFiles )
623685 . set ( { deletedAt : now , updatedAt : now } )
@@ -662,14 +724,27 @@ export async function bulkArchiveWorkspaceFileItems(params: {
662724 const now = new Date ( )
663725 const explicitFileIds = Array . from ( new Set ( params . fileIds ?? [ ] ) )
664726 const explicitFolderIds = Array . from ( new Set ( params . folderIds ?? [ ] ) )
665- const descendantFolderIds = (
666- await Promise . all (
667- explicitFolderIds . map ( ( folderId ) => getDescendantFolderIds ( params . workspaceId , folderId ) )
668- )
669- ) . flat ( )
670- const allFolderIds = Array . from ( new Set ( [ ...explicitFolderIds , ...descendantFolderIds ] ) )
671727
672728 return db . transaction ( async ( tx ) => {
729+ await acquireWorkspaceFileFolderMutationLock ( tx , params . workspaceId )
730+
731+ const activeFolders =
732+ explicitFolderIds . length > 0
733+ ? await tx
734+ . select ( { id : workspaceFileFolder . id , parentId : workspaceFileFolder . parentId } )
735+ . from ( workspaceFileFolder )
736+ . where (
737+ and (
738+ eq ( workspaceFileFolder . workspaceId , params . workspaceId ) ,
739+ isNull ( workspaceFileFolder . deletedAt )
740+ )
741+ )
742+ : [ ]
743+ const descendantFolderIds = explicitFolderIds . flatMap ( ( folderId ) =>
744+ collectDescendantFolderIds ( activeFolders , folderId )
745+ )
746+ const allFolderIds = Array . from ( new Set ( [ ...explicitFolderIds , ...descendantFolderIds ] ) )
747+
673748 const archivedExplicitFiles =
674749 explicitFileIds . length > 0
675750 ? await tx
0 commit comments