diff --git a/apps/sim/lib/api/contracts/project-file-doc.ts b/apps/sim/lib/api/contracts/project-file-doc.ts index d1589f6e66e..264cfa4b9ca 100644 --- a/apps/sim/lib/api/contracts/project-file-doc.ts +++ b/apps/sim/lib/api/contracts/project-file-doc.ts @@ -2,20 +2,18 @@ import { z } from 'zod' import { persistFileDocResponseSchema } from '@/lib/api/contracts/file-doc' import { defineRouteContract } from '@/lib/api/contracts/types' -export const projectFileDocParamsSchema = z.object({ +const projectFileDocParamsSchema = z.object({ projectId: z.string().min(1), fileId: z.string().min(1), }) -export type ProjectFileDocParams = z.output -export const projectFileDocAccessResponseSchema = z.object({ +const projectFileDocAccessResponseSchema = z.object({ projectId: z.string(), fileId: z.string(), canRead: z.literal(true), canWrite: z.boolean(), docId: z.string().min(1).max(128).nullable(), }) -export type ProjectFileDocAccessResponse = z.output export const projectFileDocAccessContract = defineRouteContract({ method: 'POST', @@ -24,11 +22,10 @@ export const projectFileDocAccessContract = defineRouteContract({ response: { mode: 'json', schema: projectFileDocAccessResponseSchema }, }) -export const projectFileDocSeedResponseSchema = z.object({ +const projectFileDocSeedResponseSchema = z.object({ update: z.string(), version: z.number().int(), }) -export type ProjectFileDocSeedResponse = z.output export const projectFileDocSeedContract = defineRouteContract({ method: 'POST', @@ -37,7 +34,7 @@ export const projectFileDocSeedContract = defineRouteContract({ response: { mode: 'json', schema: projectFileDocSeedResponseSchema }, }) -export const projectFileDocPersistBodySchema = z +const projectFileDocPersistBodySchema = z .object({ docState: z .string() @@ -46,7 +43,6 @@ export const projectFileDocPersistBodySchema = z expectedVersion: z.number().int().optional(), }) .strict() -export type ProjectFileDocPersistBody = z.input export const projectFileDocPersistContract = defineRouteContract({ method: 'POST', diff --git a/apps/sim/lib/api/contracts/project-file-folders.ts b/apps/sim/lib/api/contracts/project-file-folders.ts index 730ac106604..7abc4f1ac32 100644 --- a/apps/sim/lib/api/contracts/project-file-folders.ts +++ b/apps/sim/lib/api/contracts/project-file-folders.ts @@ -15,7 +15,6 @@ import { export const projectFileFolderParamsSchema = projectFilesParamsSchema.extend({ folderId: nonEmptyIdSchema, }) -export type ProjectFileFolderParams = z.input export const projectFileFolderRecordSchema = workspaceFileFolderSchema .omit({ workspaceId: true }) @@ -28,11 +27,10 @@ export type ProjectFileFolderRecord = z.output -export const listProjectFileFoldersResponseSchema = z.object({ +const listProjectFileFoldersResponseSchema = z.object({ folders: z.array(projectFileFolderRecordSchema), capabilities: projectFileCapabilitiesSchema, }) -export type ListProjectFileFoldersResponse = z.output export const listProjectFileFoldersContract = defineRouteContract({ method: 'GET', @@ -48,8 +46,7 @@ export type CreateProjectFileFolderBody = z.input -export const projectFileFolderResponseSchema = z.object({ folder: projectFileFolderRecordSchema }) -export type ProjectFileFolderResponse = z.output +const projectFileFolderResponseSchema = z.object({ folder: projectFileFolderRecordSchema }) export const createProjectFileFolderContract = defineRouteContract({ method: 'POST', diff --git a/apps/sim/lib/api/contracts/project-file-lifecycle.ts b/apps/sim/lib/api/contracts/project-file-lifecycle.ts index cb300dfbc07..96122930a4f 100644 --- a/apps/sim/lib/api/contracts/project-file-lifecycle.ts +++ b/apps/sim/lib/api/contracts/project-file-lifecycle.ts @@ -16,11 +16,10 @@ import { } from '@/lib/api/contracts/workspace-file-folders' import { renameWorkspaceFileBodySchema } from '@/lib/api/contracts/workspace-files' -export const renameProjectFileBodySchema = renameWorkspaceFileBodySchema.strict() +const renameProjectFileBodySchema = renameWorkspaceFileBodySchema.strict() export type RenameProjectFileBody = z.input -export const renameProjectFileResponseSchema = z.object({ file: projectFileRecordSchema }) -export type RenameProjectFileResponse = z.output +const renameProjectFileResponseSchema = z.object({ file: projectFileRecordSchema }) export const renameProjectFileContract = defineRouteContract({ method: 'PATCH', @@ -30,7 +29,7 @@ export const renameProjectFileContract = defineRouteContract({ response: { mode: 'json', schema: renameProjectFileResponseSchema }, }) -export const moveProjectFileItemsBodySchema = moveWorkspaceFileItemsBodySchema.strict() +const moveProjectFileItemsBodySchema = moveWorkspaceFileItemsBodySchema.strict() export type MoveProjectFileItemsBody = z.input export const moveProjectFileItemsResponseSchema = z.object({ @@ -39,7 +38,6 @@ export const moveProjectFileItemsResponseSchema = z.object({ movedFileIds: z.array(nonEmptyIdSchema).describe('Identifiers of moved files.'), movedFolderIds: z.array(nonEmptyIdSchema).describe('Identifiers of moved folders.'), }) -export type MoveProjectFileItemsResponse = z.output export const moveProjectFileItemsContract = defineRouteContract({ method: 'POST', @@ -66,7 +64,6 @@ export const archiveProjectFileItemsResponseSchema = z.object({ }) .describe('Identifiers of affected file items.'), }) -export type ArchiveProjectFileItemsResponse = z.output export const archiveProjectFileItemsContract = defineRouteContract({ method: 'POST', @@ -76,11 +73,10 @@ export const archiveProjectFileItemsContract = defineRouteContract({ response: { mode: 'json', schema: archiveProjectFileItemsResponseSchema }, }) -export const restoreProjectFileResponseSchema = z.object({ +const restoreProjectFileResponseSchema = z.object({ restored: z.literal(true), file: projectFileRecordSchema, }) -export type RestoreProjectFileResponse = z.output export const restoreProjectFileContract = defineRouteContract({ method: 'POST', @@ -99,9 +95,6 @@ export const restoreProjectFileFolderResponseSchema = z.object({ }) .describe('Counts of affected file items.'), }) -export type RestoreProjectFileFolderResponse = z.output< - typeof restoreProjectFileFolderResponseSchema -> export const restoreProjectFileFolderContract = defineRouteContract({ method: 'POST', diff --git a/apps/sim/lib/api/contracts/project-file-uploads.ts b/apps/sim/lib/api/contracts/project-file-uploads.ts index 1d71eb7a92d..d6118f0f224 100644 --- a/apps/sim/lib/api/contracts/project-file-uploads.ts +++ b/apps/sim/lib/api/contracts/project-file-uploads.ts @@ -14,18 +14,16 @@ import { v2UploadTransferSchema, } from '@/lib/api/contracts/v2/uploads' -export const projectFileUploadParamsSchema = projectFilesParamsSchema.extend({ +const projectFileUploadParamsSchema = projectFilesParamsSchema.extend({ uploadId: nonEmptyIdSchema, }) -export type ProjectFileUploadParams = z.input export const createProjectFileUploadBodySchema = v2CreateFileUploadBodySchema .omit({ workspaceId: true }) .extend({ folderId: folderIdSchema.nullish() }) .strict() -export type CreateProjectFileUploadBody = z.input -export const projectFileUploadSessionSchema = z +const projectFileUploadSessionSchema = z .object({ id: nonEmptyIdSchema, purpose: z.literal('project_file'), @@ -40,14 +38,13 @@ export const projectFileUploadSessionSchema = z .strict() export type ProjectFileUploadSession = z.output -export const createProjectFileUploadResponseSchema = z +const createProjectFileUploadResponseSchema = z .object({ session: projectFileUploadSessionSchema, uploadToken: z.string().min(1), transfer: v2UploadTransferSchema, }) .strict() -export type CreateProjectFileUploadResponse = z.output export const createProjectFileUploadContract = defineRouteContract({ method: 'POST', diff --git a/apps/sim/lib/api/contracts/project-file-versions.ts b/apps/sim/lib/api/contracts/project-file-versions.ts index e754ff9fc8b..d9079faa61e 100644 --- a/apps/sim/lib/api/contracts/project-file-versions.ts +++ b/apps/sim/lib/api/contracts/project-file-versions.ts @@ -15,36 +15,27 @@ export const projectFileVersionSchema = v2FileVersionSchema.meta({ title: 'Project file version', description: 'One recorded version of a shared Project file.', }) -export type ProjectFileVersion = z.output -export const projectFileVersionParamsSchema = projectFileParamsSchema.extend({ +const projectFileVersionParamsSchema = projectFileParamsSchema.extend({ version: versionNumberPathSchema.describe('Version number.'), }) -export type ProjectFileVersionParams = z.input export const listProjectFileVersionsQuerySchema = v2ListFileVersionsQuerySchema.omit({ workspaceId: true, }) -export type ListProjectFileVersionsQuery = z.output -export const listProjectFileVersionsResponseSchema = z.object({ +const listProjectFileVersionsResponseSchema = z.object({ revision: writtenFileRevisionSchema, versions: z.array(projectFileVersionSchema), nextCursor: z.string().nullable(), }) -export type ListProjectFileVersionsResponse = z.output -export const getProjectFileVersionResponseSchema = z.object({ version: projectFileVersionSchema }) -export type GetProjectFileVersionResponse = z.output +const getProjectFileVersionResponseSchema = z.object({ version: projectFileVersionSchema }) export const revertProjectFileVersionBodySchema = v2RevertFileVersionBodySchema.omit({ workspaceId: true, }) -export type RevertProjectFileVersionBody = z.input -export const revertProjectFileVersionResponseSchema = z.object({ +const revertProjectFileVersionResponseSchema = z.object({ reverted: z.boolean(), file: projectFileRecordSchema, version: projectFileVersionSchema, revision: writtenFileRevisionSchema, }) -export type RevertProjectFileVersionResponse = z.output< - typeof revertProjectFileVersionResponseSchema -> export const deleteProjectFileVersionResponseSchema = v2DeleteFileVersionResultSchema.extend({ deleted: z .literal(true) @@ -52,9 +43,6 @@ export const deleteProjectFileVersionResponseSchema = v2DeleteFileVersionResultS 'The version is no longer available; stored-object cleanup is retried asynchronously when needed.' ), }) -export type DeleteProjectFileVersionResponse = z.output< - typeof deleteProjectFileVersionResponseSchema -> export const listProjectFileVersionsContract = defineRouteContract({ method: 'GET', diff --git a/apps/sim/lib/api/contracts/project-files.ts b/apps/sim/lib/api/contracts/project-files.ts index 9f7926edaeb..75b8ecca271 100644 --- a/apps/sim/lib/api/contracts/project-files.ts +++ b/apps/sim/lib/api/contracts/project-files.ts @@ -27,10 +27,8 @@ import { import { FILE_BROWSER_SORTS } from '@/lib/workspace-files/browser' export const projectFilesParamsSchema = z.object({ id: nonEmptyIdSchema }) -export type ProjectFilesParams = z.input export const projectFileParamsSchema = projectFilesParamsSchema.extend({ fileId: nonEmptyIdSchema }) -export type ProjectFileParams = z.input export const projectFileRecordSchema = workspaceFileRecordSchema .omit({ workspaceId: true, storageContext: true, vfsNamespace: true }) @@ -44,9 +42,8 @@ export const projectFileCapabilitiesSchema = z.object({ canRead: z.boolean(), canWrite: z.boolean(), }) -export type ProjectFileCapabilities = z.output -export const listProjectFilesQuerySchema = z +const listProjectFilesQuerySchema = z .object({ scope: workspaceFileScopeSchema.default('active'), folderId: folderIdSchema.optional(), @@ -64,7 +61,7 @@ export const listProjectFilesQuerySchema = z }) export type ListProjectFilesQuery = z.output -export const listProjectFilesResponseSchema = z.object({ +const listProjectFilesResponseSchema = z.object({ files: z.array(projectFileRecordSchema), items: z.array(fileBrowserItemSchema), creators: z.array(fileBrowserCreatorSchema), @@ -81,11 +78,10 @@ export const listProjectFilesContract = defineRouteContract({ response: { mode: 'json', schema: listProjectFilesResponseSchema }, }) -export const getProjectFileResponseSchema = z.object({ +const getProjectFileResponseSchema = z.object({ file: projectFileRecordSchema, capabilities: projectFileCapabilitiesSchema, }) -export type GetProjectFileResponse = z.output export const getProjectFileContract = defineRouteContract({ method: 'GET', @@ -94,7 +90,7 @@ export const getProjectFileContract = defineRouteContract({ response: { mode: 'json', schema: getProjectFileResponseSchema }, }) -export const createProjectFileBodySchema = createWorkspaceFileBodySchema.safeExtend({ +const createProjectFileBodySchema = createWorkspaceFileBodySchema.safeExtend({ exactName: z.boolean().default(false), folderPath: z.string().max(4096).optional(), }) @@ -108,20 +104,16 @@ export const createProjectFileContract = defineRouteContract({ response: { mode: 'json', schema: getProjectFileResponseSchema, status: 201 }, }) -export const updateProjectFileContentBodySchema = updateWorkspaceFileContentBodySchema +const updateProjectFileContentBodySchema = updateWorkspaceFileContentBodySchema .safeExtend({ expectedRevision: z.string().min(1).max(1024).optional(), contentType: z.string().trim().min(1).max(255).optional(), }) .strict() -export type UpdateProjectFileContentBody = z.input -export const updateProjectFileContentResponseSchema = getProjectFileResponseSchema.safeExtend({ +const updateProjectFileContentResponseSchema = getProjectFileResponseSchema.safeExtend({ file: projectFileRecordSchema.extend({ currentVersion: z.number().int().positive() }), }) -export type UpdateProjectFileContentResponse = z.output< - typeof updateProjectFileContentResponseSchema -> export const updateProjectFileContentContract = defineRouteContract({ method: 'PUT', @@ -138,12 +130,11 @@ export const readProjectFileContentContract = defineRouteContract({ response: { mode: 'binary' }, }) -export const projectFileArtifactQuerySchema = z.object({ +const projectFileArtifactQuerySchema = z.object({ preview: z.enum(['1', '0']).optional(), v: z.string().max(128).optional(), t: z.string().max(32).optional(), }) -export type ProjectFileArtifactQuery = z.input export const readProjectFileArtifactContract = defineRouteContract({ method: 'GET', diff --git a/apps/sim/lib/api/contracts/v2/project-file-folders.ts b/apps/sim/lib/api/contracts/v2/project-file-folders.ts index a923438bf80..869a8614d17 100644 --- a/apps/sim/lib/api/contracts/v2/project-file-folders.ts +++ b/apps/sim/lib/api/contracts/v2/project-file-folders.ts @@ -11,12 +11,11 @@ import { defineRouteContract } from '@/lib/api/contracts/types' import { v2ProjectFilesParamsSchema } from '@/lib/api/contracts/v2/project-files' import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' -export const v2ProjectFileFolderParamsSchema = v2ProjectFilesParamsSchema.extend({ +const v2ProjectFileFolderParamsSchema = v2ProjectFilesParamsSchema.extend({ folderId: nonEmptyIdSchema.describe('Folder identifier within the Project.'), }) -export type V2ProjectFileFolderParams = z.input -export const v2ProjectFileFolderSchema = projectFileFolderRecordSchema.extend({ +const v2ProjectFileFolderSchema = projectFileFolderRecordSchema.extend({ id: nonEmptyIdSchema.describe('Stable folder identifier.'), owner: projectFileFolderRecordSchema.shape.owner .extend({ @@ -36,14 +35,13 @@ export const v2ProjectFileFolderSchema = projectFileFolderRecordSchema.extend({ }) export type V2ProjectFileFolder = z.output -export const v2ListProjectFileFoldersQuerySchema = listProjectFileFoldersQuerySchema +const v2ListProjectFileFoldersQuerySchema = listProjectFileFoldersQuerySchema .extend({ scope: listProjectFileFoldersQuerySchema.shape.scope.describe('Folder lifecycle scope.'), }) .strict() -export type V2ListProjectFileFoldersQuery = z.output -export const v2CreateProjectFileFolderBodySchema = createProjectFileFolderBodySchema +const v2CreateProjectFileFolderBodySchema = createProjectFileFolderBodySchema .extend({ name: createProjectFileFolderBodySchema.shape.name.describe('Name for the new folder.'), parentId: nonEmptyIdSchema @@ -52,9 +50,8 @@ export const v2CreateProjectFileFolderBodySchema = createProjectFileFolderBodySc .describe('Parent folder identifier; omit or use null for the root.'), }) .strict() -export type V2CreateProjectFileFolderBody = z.input -export const v2UpdateProjectFileFolderBodySchema = updateProjectFileFolderBodySchema +const v2UpdateProjectFileFolderBodySchema = updateProjectFileFolderBodySchema .extend({ name: updateProjectFileFolderBodySchema.shape.name.describe( 'New folder name; omit to leave unchanged.' @@ -70,15 +67,10 @@ export const v2UpdateProjectFileFolderBodySchema = updateProjectFileFolderBodySc ), }) .strict() -export type V2UpdateProjectFileFolderBody = z.input -export const v2RestoreProjectFileFolderResponseSchema = - restoreProjectFileFolderResponseSchema.extend({ - folder: v2ProjectFileFolderSchema.describe('Restored Project folder.'), - }) -export type V2RestoreProjectFileFolderResponse = z.output< - typeof v2RestoreProjectFileFolderResponseSchema -> +const v2RestoreProjectFileFolderResponseSchema = restoreProjectFileFolderResponseSchema.extend({ + folder: v2ProjectFileFolderSchema.describe('Restored Project folder.'), +}) export const v2ListProjectFileFoldersContract = defineRouteContract({ method: 'GET', diff --git a/apps/sim/lib/api/contracts/v2/project-file-lifecycle.ts b/apps/sim/lib/api/contracts/v2/project-file-lifecycle.ts index 68f782f1177..a2083b92e60 100644 --- a/apps/sim/lib/api/contracts/v2/project-file-lifecycle.ts +++ b/apps/sim/lib/api/contracts/v2/project-file-lifecycle.ts @@ -1,4 +1,3 @@ -import type { z } from 'zod' import { noInputSchema } from '@/lib/api/contracts/primitives' import { archiveProjectFileItemsBodySchema, @@ -14,10 +13,9 @@ import { } from '@/lib/api/contracts/v2/project-files' import { v2DataResponse } from '@/lib/api/contracts/v2/shared' -export const v2RenameProjectFileBodySchema = v2RenameFileBodySchema.omit({ workspaceId: true }) -export type V2RenameProjectFileBody = z.input +const v2RenameProjectFileBodySchema = v2RenameFileBodySchema.omit({ workspaceId: true }) -export const v2ArchiveProjectFileItemsBodySchema = archiveProjectFileItemsBodySchema.safeExtend({ +const v2ArchiveProjectFileItemsBodySchema = archiveProjectFileItemsBodySchema.safeExtend({ fileIds: archiveProjectFileItemsBodySchema.shape.fileIds.describe( 'Identifiers of the files to archive.' ), @@ -25,9 +23,8 @@ export const v2ArchiveProjectFileItemsBodySchema = archiveProjectFileItemsBodySc 'Identifiers of folders to archive recursively, including their files and descendants.' ), }) -export type V2ArchiveProjectFileItemsBody = z.input -export const v2MoveProjectFileItemsBodySchema = archiveProjectFileItemsBodySchema.safeExtend({ +const v2MoveProjectFileItemsBodySchema = archiveProjectFileItemsBodySchema.safeExtend({ fileIds: archiveProjectFileItemsBodySchema.shape.fileIds.describe( 'Identifiers of the files to move.' ), @@ -38,7 +35,6 @@ export const v2MoveProjectFileItemsBodySchema = archiveProjectFileItemsBodySchem 'Existing destination folder path within the Project. Omit to move items to the Project root.' ), }) -export type V2MoveProjectFileItemsBody = z.input export const v2RenameProjectFileContract = defineRouteContract({ method: 'PATCH', diff --git a/apps/sim/lib/api/contracts/v2/project-file-uploads.ts b/apps/sim/lib/api/contracts/v2/project-file-uploads.ts index 108c9403236..1628bb45033 100644 --- a/apps/sim/lib/api/contracts/v2/project-file-uploads.ts +++ b/apps/sim/lib/api/contracts/v2/project-file-uploads.ts @@ -14,12 +14,11 @@ import { v2UploadTokenHeadersSchema, } from '@/lib/api/contracts/v2/uploads' -export const v2ProjectFileUploadParamsSchema = v2ProjectFilesParamsSchema.extend({ +const v2ProjectFileUploadParamsSchema = v2ProjectFilesParamsSchema.extend({ uploadId: nonEmptyIdSchema.describe('Upload session identifier within the Project.'), }) -export type V2ProjectFileUploadParams = z.input -export const v2CreateProjectFileUploadBodySchema = createProjectFileUploadBodySchema.extend({ +const v2CreateProjectFileUploadBodySchema = createProjectFileUploadBodySchema.extend({ folderId: createProjectFileUploadBodySchema.shape.folderId.describe( 'Destination folder identifier; omit or use null for the Project root.' ), @@ -27,9 +26,8 @@ export const v2CreateProjectFileUploadBodySchema = createProjectFileUploadBodySc 'Canonical destination folder path. Specify either folderId or folderPath, not both.' ), }) -export type V2CreateProjectFileUploadBody = z.input -export const v2ProjectFileUploadSchema = v2FileUploadSchema +const v2ProjectFileUploadSchema = v2FileUploadSchema .extend({ file: v2ProjectFileSchema .nullable() @@ -40,12 +38,11 @@ export const v2ProjectFileUploadSchema = v2FileUploadSchema .meta({ id: 'V2ProjectFileUpload', title: 'Project file upload session' }) export type V2ProjectFileUpload = z.output -export const v2CreateProjectFileUploadDataSchema = v2CreateFileUploadDataSchema +const v2CreateProjectFileUploadDataSchema = v2CreateFileUploadDataSchema .extend({ session: v2ProjectFileUploadSchema.describe('New Project upload session.'), }) .meta({ id: 'V2CreateProjectFileUploadData', title: 'Create Project file upload data' }) -export type V2CreateProjectFileUploadData = z.output export const v2CreateProjectFileUploadContract = defineRouteContract({ method: 'POST', diff --git a/apps/sim/lib/api/contracts/v2/project-file-versions.ts b/apps/sim/lib/api/contracts/v2/project-file-versions.ts index 5c6fb93d17c..10f240211fe 100644 --- a/apps/sim/lib/api/contracts/v2/project-file-versions.ts +++ b/apps/sim/lib/api/contracts/v2/project-file-versions.ts @@ -14,19 +14,15 @@ import { } from '@/lib/api/contracts/v2/project-files' import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' -export const v2ProjectFileVersionParamsSchema = v2ProjectFileParamsSchema.extend({ +const v2ProjectFileVersionParamsSchema = v2ProjectFileParamsSchema.extend({ version: versionNumberPathSchema.describe('Version number.'), }) -export type V2ProjectFileVersionParams = z.input -export const v2RevertProjectFileVersionResultSchema = z.object({ +const v2RevertProjectFileVersionResultSchema = z.object({ reverted: z.boolean().describe('False if the selected version is already current.'), file: v2ProjectFileSchema, version: projectFileVersionSchema.describe('The current version after the revert.'), revision: writtenFileRevisionSchema, }) -export type V2RevertProjectFileVersionResult = z.output< - typeof v2RevertProjectFileVersionResultSchema -> export const v2ListProjectFileVersionsContract = defineRouteContract({ method: 'GET', diff --git a/apps/sim/lib/api/contracts/v2/project-files.ts b/apps/sim/lib/api/contracts/v2/project-files.ts index 901c6db07aa..608e9666f06 100644 --- a/apps/sim/lib/api/contracts/v2/project-files.ts +++ b/apps/sim/lib/api/contracts/v2/project-files.ts @@ -18,12 +18,10 @@ import { export const v2ProjectFilesParamsSchema = z.object({ projectId: nonEmptyIdSchema.describe('Project identifier.'), }) -export type V2ProjectFilesParams = z.input export const v2ProjectFileParamsSchema = v2ProjectFilesParamsSchema.extend({ fileId: nonEmptyIdSchema.describe('File identifier within the Project.'), }) -export type V2ProjectFileParams = z.input export const v2ProjectFileSchema = v2FileSchema .omit({ uploadedByEmail: true, webUrl: true }) @@ -43,7 +41,7 @@ export const v2ProjectFileSchema = v2FileSchema .meta({ id: 'V2ProjectFile', title: 'Project file' }) export type V2ProjectFile = z.output -export const v2ListProjectFilesQuerySchema = v2ListFilesQuerySchema +const v2ListProjectFilesQuerySchema = v2ListFilesQuerySchema .omit({ workspaceId: true }) .extend(v2PaginationFields({ max: 1000, fallback: 100, description: 'Maximum files per page.' })) export type V2ListProjectFilesQuery = z.output @@ -73,7 +71,7 @@ function validateContentEncoding( } } -export const v2CreateProjectFileBodySchema = z +const v2CreateProjectFileBodySchema = z .object({ name: v2CreateFileBodySchema.shape.name, contentType: v2CreateFileBodySchema.shape.contentType, @@ -87,9 +85,8 @@ export const v2CreateProjectFileBodySchema = z }) .strict() .superRefine(validateContentEncoding) -export type V2CreateProjectFileBody = z.input -export const v2UpdateProjectFileContentBodySchema = z +const v2UpdateProjectFileContentBodySchema = z .object({ content: v2UpdateFileContentBodySchema.shape.content.describe( 'Complete replacement content for the file. The complete JSON request is capped at 70 MiB, including UTF-8 encoding, escaping, base64, and the envelope. The decoded-file limit is 50 MiB.' @@ -99,7 +96,6 @@ export const v2UpdateProjectFileContentBodySchema = z }) .strict() .superRefine(validateContentEncoding) -export type V2UpdateProjectFileContentBody = z.input export const v2CreateProjectFileContract = defineRouteContract({ method: 'POST', diff --git a/apps/sim/lib/billing/storage/accounting.ts b/apps/sim/lib/billing/storage/accounting.ts index c050c647ef7..057065f2b94 100644 --- a/apps/sim/lib/billing/storage/accounting.ts +++ b/apps/sim/lib/billing/storage/accounting.ts @@ -33,11 +33,11 @@ export function prepareFileAccountingInTx( ): Promise /** * Prepares retained-head accounting in the caller's transaction, after authorization and before - * directory, file, history or cleanup locks. Locks lifecycle then the owner row; a Project also - * locks its payer here, a workspace only when its non-zero delta is applied. A Project's lifecycle - * lock is exclusive, so its caller must not already hold that Project shared. Multi-owner callers - * must acquire all lifecycle locks first. Resolve from the canonical owner, never actor or - * creator. Apply one signed delta at finalization and notify only after the caller commits. + * directory, file, history or cleanup locks. Locks lifecycle then the owner row; the payer is + * locked last, only when a non-zero delta is applied. A Project's lifecycle lock is exclusive, so + * its caller must not already hold that Project shared. Multi-owner callers must acquire all + * lifecycle locks first. Resolve from the canonical owner, never actor or creator. Apply one + * signed delta at finalization and notify only after the caller commits. */ export async function prepareFileAccountingInTx( tx: DbTransaction, diff --git a/apps/sim/lib/billing/storage/tracking.ts b/apps/sim/lib/billing/storage/tracking.ts index 01ce54f9807..2f4875d6ff7 100644 --- a/apps/sim/lib/billing/storage/tracking.ts +++ b/apps/sim/lib/billing/storage/tracking.ts @@ -158,30 +158,29 @@ async function mutateStorageUsage( } /** - * Locks and reads the payer ledger after the workspace row has been locked. + * Reads the payer ledger, locking it after the owner row has been locked unless `lock` is false. * `FOR NO KEY UPDATE` for the reason documented at the top of this module. */ -async function lockStorageUsageForMutation( +async function readStorageUsageInTx( tx: DbOrTx, - billingEntity: Readonly + billingEntity: Readonly, + lock = true ): Promise { if (billingEntity.type === 'organization') { - const [row] = await tx + const query = tx .select({ storageUsedBytes: organization.storageUsedBytes }) .from(organization) .where(eq(organization.id, billingEntity.id)) - .for('no key update') - .limit(1) + const [row] = lock ? await query.for('no key update').limit(1) : await query.limit(1) if (!row) throw new Error(`Storage payer organization:${billingEntity.id} not found`) return row.storageUsedBytes } - const [row] = await tx + const query = tx .select({ storageUsedBytes: userStats.storageUsedBytes }) .from(userStats) .where(eq(userStats.userId, billingEntity.id)) - .for('no key update') - .limit(1) + const [row] = lock ? await query.for('no key update').limit(1) : await query.limit(1) if (!row) throw new Error(`Storage payer user:${billingEntity.id} not found`) return row.storageUsedBytes } @@ -221,8 +220,9 @@ function assertWorkspaceStorageContext( /** * Locks the asserted owner before directory/file/version mutation. The caller holds lifecycle * locks and owns the transaction. The one-shot signed delta admits finalization, not a - * reservation. Workspace files update both ledgers and lock the shared payer only when a non-zero - * delta is applied (`undefined` for zero); Projects lock their payer here and update only it. + * reservation. The shared payer is locked last, only when a non-zero delta is applied: workspace + * files update both ledgers (`undefined` for zero); Projects update only the payer and report its + * usage, read unlocked for zero. Locking the Project row here keeps the payer it names fixed. */ export async function prepareFileStorageMutationInTx( tx: DbTransaction, @@ -280,14 +280,13 @@ export async function prepareFileStorageMutationInTx( `Storage payer changed for Project ${context.projectId}; resolve a fresh billing context` ) } - const currentUsage = await lockStorageUsageForMutation(tx, billingEntity) - if (!Number.isSafeInteger(currentUsage) || currentUsage < 0) { - throw new Error(`Invalid storage usage for payer ${getPayerKey(billingEntity)}`) - } return { async applyDelta(deltaBytes) { admitDelta(deltaBytes) - const latestUsage = await lockStorageUsageForMutation(tx, billingEntity) + const latestUsage = await readStorageUsageInTx(tx, billingEntity, deltaBytes !== 0) + if (!Number.isSafeInteger(latestUsage) || latestUsage < 0) { + throw new Error(`Invalid storage usage for payer ${getPayerKey(billingEntity)}`) + } const nextUsage = Math.max(0, latestUsage + deltaBytes) if (!Number.isSafeInteger(nextUsage)) throw new Error('Invalid file storage total') if (deltaBytes > 0 && limit !== undefined && nextUsage > limit) { @@ -606,7 +605,7 @@ async function mutateWorkspaceStorageUsage( const workspacePayer = await lockWorkspaceStorageForMutationInTx(tx, workspaceId) const billingEntity = assertWorkspaceStorageContext(workspacePayer, context) - const currentPayerUsage = await lockStorageUsageForMutation(tx, billingEntity) + const currentPayerUsage = await readStorageUsageInTx(tx, billingEntity) if (mutation === 'decrement' && workspacePayer.storageUsedBytes < bytes) { logger.error('Clamping workspace storage ledger underflow', { diff --git a/apps/sim/lib/db/advisory-locks.ts b/apps/sim/lib/db/advisory-locks.ts index 862e952b36e..2fbdeea6e98 100644 --- a/apps/sim/lib/db/advisory-locks.ts +++ b/apps/sim/lib/db/advisory-locks.ts @@ -16,6 +16,24 @@ function lockTag(tag: string): SQL { return sql.raw(`/*lock='${tag}'*/`) } +/** + * Bounds lock waits in `acquire` by `timeoutMs` (SQLSTATE 55P03 past it), then restores the + * caller's own `lock_timeout` so the bound never reaches the work done under the locks. + */ +export async function withLockTimeout( + tx: DbTransaction, + timeoutMs: number, + acquire: () => Promise +): Promise { + const [setting] = await tx.execute<{ previous: string }>( + sql`SELECT current_setting('lock_timeout') AS previous` + ) + await tx.execute(sql`SELECT set_config('lock_timeout', ${`${timeoutMs}ms`}, true)`) + const result = await acquire() + await tx.execute(sql`SELECT set_config('lock_timeout', ${setting?.previous ?? '0'}, true)`) + return result +} + /** * Blocks until the transaction-scoped advisory lock for `key` is held. The lock * releases on commit or rollback. It takes a transaction, never the pool: on a diff --git a/apps/sim/lib/permission-groups/locks.ts b/apps/sim/lib/permission-groups/locks.ts index d52c8c552b5..f2404f39ca9 100644 --- a/apps/sim/lib/permission-groups/locks.ts +++ b/apps/sim/lib/permission-groups/locks.ts @@ -1,5 +1,9 @@ import { sql } from 'drizzle-orm' -import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks' +import { + acquireAdvisoryXactLock, + acquireAdvisoryXactLocks, + withLockTimeout, +} from '@/lib/db/advisory-locks' import type { DbTransaction } from '@/lib/db/types' const PERMISSION_GROUP_LOCK_TIMEOUT_MS = 5_000 @@ -55,5 +59,30 @@ export async function acquirePermissionGroupOrgLock( sql`select set_config('lock_timeout', ${`${PERMISSION_GROUP_LOCK_TIMEOUT_MS}ms`}, true)` ) } - await acquireAdvisoryXactLock(tx, 'permission_group', `permission_group:${organizationId}`) + await acquireAdvisoryXactLock(tx, 'permission_group', permissionGroupLockKey(organizationId)) +} + +/** + * Takes the same organization lock SHARED, for a reader whose capability decision must stay + * current through commit but who writes no permission-group state: it waits out and excludes + * every exclusive holder of {@link acquirePermissionGroupOrgLock}, never another shared reader. + * The wait is bounded at the same 5000ms, and the caller's own `lock_timeout` is restored + * afterwards so the bound never reaches the work done under the lock. Exclusive holders take + * nothing after it (the LEAF rule above), so the shared holder's later locks cannot close a cycle + * through it; it must never later take this lock exclusively, or two such holders would wait on + * each other. + */ +export async function acquirePermissionGroupOrgReadLock( + tx: DbTransaction, + organizationId: string +): Promise { + await withLockTimeout(tx, PERMISSION_GROUP_LOCK_TIMEOUT_MS, () => + acquireAdvisoryXactLocks(tx, 'permission_group', [ + { key: permissionGroupLockKey(organizationId), shared: true }, + ]) + ) +} + +function permissionGroupLockKey(organizationId: string): string { + return `permission_group:${organizationId}` } diff --git a/apps/sim/lib/projects/application/authorization.ts b/apps/sim/lib/projects/application/authorization.ts index d300aca2a7c..75fe2291a15 100644 --- a/apps/sim/lib/projects/application/authorization.ts +++ b/apps/sim/lib/projects/application/authorization.ts @@ -21,7 +21,7 @@ import { type ProjectOperation, projectOperations, } from '@/lib/projects/application/operations' -import { lockProject } from '@/lib/projects/membership' +import { lockProject, lockProjectShared } from '@/lib/projects/membership' const logger = createLogger('ProjectAuthorization') @@ -188,13 +188,20 @@ function projectVisibility( return { record, rows, active, visible, orgAdmin, canAdminister } } -/** Loads and holds canonical Project membership for a resource-specific access policy. */ +/** + * Loads and holds canonical Project membership for a resource-specific access policy. `lock` is + * the mode the Project is held in: `shared` for a caller that only needs it fenced against + * lifecycle and payer changes, `exclusive` for one that will take it exclusively later in the + * transaction, which must never upgrade a shared hold. + */ export async function loadProjectAccess( tx: DbTransaction, userId: string, - input: ProjectAuthorizationInput & { projectId: string } + input: ProjectAuthorizationInput & { projectId: string }, + lock: 'shared' | 'exclusive' = 'exclusive' ) { - await lockProject(tx, input.projectId) + if (lock === 'shared') await lockProjectShared(tx, input.projectId) + else await lockProject(tx, input.projectId) const [record] = await tx.select().from(project).where(eq(project.id, input.projectId)).limit(1) if ( !record || diff --git a/apps/sim/lib/projects/errors.ts b/apps/sim/lib/projects/errors.ts index 29887e13f32..cb069617062 100644 --- a/apps/sim/lib/projects/errors.ts +++ b/apps/sim/lib/projects/errors.ts @@ -7,3 +7,11 @@ export class ProjectConflictError extends OrchestrationError { this.name = 'ProjectConflictError' } } + +/** The file's content revision moved during an optimistic read-then-write; a fresh read may succeed. */ +export class ProjectFileRevisionConflictError extends OrchestrationError { + constructor(message: string) { + super('conflict', message) + this.name = 'ProjectFileRevisionConflictError' + } +} diff --git a/apps/sim/lib/projects/files/__integration__/authorization.integration.ts b/apps/sim/lib/projects/files/__integration__/authorization.integration.ts index 5c76fac41ee..ad6bf49b792 100644 --- a/apps/sim/lib/projects/files/__integration__/authorization.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/authorization.integration.ts @@ -29,7 +29,9 @@ import { generateId } from '@sim/utils/id' import { and, eq, inArray, sql } from 'drizzle-orm' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { DbTransaction } from '@/lib/db/types' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' import { renameProject } from '@/lib/projects/application' +import { ProjectConflictError } from '@/lib/projects/errors' import { createProjectFileFolder, getProjectFileMetadata, @@ -40,6 +42,7 @@ import { } from '@/lib/projects/files/application' import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { tryLockProjects } from '@/lib/projects/membership' import { resolveFileFolderTarget } from '@/lib/uploads/contexts/workspace' import { resolveArchivedFileFolderScope } from '@/lib/uploads/contexts/workspace/file-folder-manager' import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' @@ -999,6 +1002,80 @@ describe('Project file authority at the database boundary', () => { } }) + check( + 'reads and non-charging writes share the Project while exclusive holders still wait', + async () => { + const f = await fixture() + await grant(f.readerId, f.workspaces[0], 'admin') + const [baseline] = await db.execute<{ value: string }>( + sql`SELECT current_setting('lock_timeout') AS value` + ) + const entered = createDeferred() + const release = createDeferred() + const read = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.list, + async execute({ tx }) { + const [setting] = await tx.execute<{ value: string }>( + sql`SELECT current_setting('lock_timeout') AS value` + ) + entered.resolve(setting?.value) + await release.promise + }, + }).execute({ principal: f.reader, input: { projectId: f.projectId } }) + try { + expect(await entered.promise).toBe(baseline?.value) + expect( + await readAccess.execute({ principal: f.reader, input: { projectId: f.projectId } }) + ).toEqual({ projectId: f.projectId, canWrite: true }) + const { folder: created } = await createProjectFileFolder.execute({ + principal: f.reader, + input: { projectId: f.projectId, name: 'Concurrent' }, + }) + expect(created.name).toBe('Concurrent') + await expect( + db.transaction((tx) => tryLockProjects(tx, [f.projectId])) + ).rejects.toBeInstanceOf(ProjectConflictError) + const groupWrite = db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL lock_timeout = '100ms'`) + await acquirePermissionGroupOrgLock(tx, f.organizationId, { + lockTimeoutAlreadyBounded: true, + }) + }) + expect(await groupWrite.then(() => null, getPostgresErrorCode)).toBe('55P03') + } finally { + release.resolve() + await read + } + } + ) + + check('storage-charging writes hold the Project exclusively from authorization', async () => { + const f = await fixture() + await grant(f.readerId, f.workspaces[0], 'admin') + const entered = createDeferred() + const release = createDeferred() + const write = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.create, + async execute() { + entered.resolve() + await release.promise + }, + }).execute({ principal: f.reader, input: { projectId: f.projectId } }) + try { + await entered.promise + const read = db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL lock_timeout = '100ms'`) + await tx.execute( + sql`SELECT pg_advisory_xact_lock_shared(hashtextextended(${`project:${f.projectId}`}, 0))` + ) + }) + expect(await read.then(() => null, getPostgresErrorCode)).toBe('55P03') + } finally { + release.resolve() + await write + } + }) + check( 'file-bound realtime grants cannot become listing authority or cross-file reads', async () => { diff --git a/apps/sim/lib/projects/files/application/authorization.ts b/apps/sim/lib/projects/files/application/authorization.ts index 17e3958f226..b7fc97ce446 100644 --- a/apps/sim/lib/projects/files/application/authorization.ts +++ b/apps/sim/lib/projects/files/application/authorization.ts @@ -20,7 +20,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbTransaction } from '@/lib/db/types' import { conversationModeSelection } from '@/lib/mothership/chat/intent' import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' -import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { acquirePermissionGroupOrgReadLock } from '@/lib/permission-groups/locks' import { loadProjectAccess } from '@/lib/projects/application/authorization' import { resolveCopilotProjectScope } from '@/lib/projects/application/discovery' import { @@ -48,6 +48,8 @@ export interface ProjectFileAuthorizationContext { owner: { entityType: 'project'; entityId: string } canWrite: boolean visibleWorkspaceIds: readonly string[] + /** Set when authorization holds the Project exclusively, which storage accounting requires. */ + projectLock?: 'exclusive' file?: WorkspaceFileRow } @@ -257,7 +259,13 @@ export async function createProjectFileAuthorizer( return async (tx) => { requirePrincipal(principal, operation, input) - const access = await loadProjectAccess(tx, userId, { projectId: input.projectId }) + const exclusive = 'chargesStorage' in operation + const access = await loadProjectAccess( + tx, + userId, + { projectId: input.projectId }, + exclusive ? 'exclusive' : 'shared' + ) const context = await requireProjectFileOwnerRole(tx, principal, access, operation.access) let file: WorkspaceFileRow | undefined if (operation.target === 'file') { @@ -280,7 +288,7 @@ export async function createProjectFileAuthorizer( } } if (access.record.organizationId) - await acquirePermissionGroupOrgLock(tx, access.record.organizationId) + await acquirePermissionGroupOrgReadLock(tx, access.record.organizationId) if (principal.kind === 'resource_delegated' && principal.serviceId === 'copilot') { if (!invocationScope) throw new Error('Copilot Project invocation was not prepared') await requireCurrentCopilotProjectInvocation(tx, principal, access, invocationScope) @@ -288,6 +296,7 @@ export async function createProjectFileAuthorizer( await requireProjectFileOwnerCapabilities(tx, principal, access, operation.capability) return { ...context, + ...(exclusive ? { projectLock: 'exclusive' as const } : {}), ...(file ? { file } : {}), } } diff --git a/apps/sim/lib/projects/files/application/authorized-use-case.ts b/apps/sim/lib/projects/files/application/authorized-use-case.ts index d23c1f25c9f..9ef12f3f6fa 100644 --- a/apps/sim/lib/projects/files/application/authorized-use-case.ts +++ b/apps/sim/lib/projects/files/application/authorized-use-case.ts @@ -37,6 +37,11 @@ export function defineAuthorizedProjectFileUseCase< operation: O isolationLevel?: 'repeatable read' prepare?(args: ProjectFileUseCaseContext): Promise

+ /** + * A `prepare` that authorizes through its own snapshot use case and so needs no preflight + * transaction; it runs its outbound I/O in the snapshot's organization itself. + */ + prepareFromSnapshot?(args: Omit, 'context'>): Promise

execute(args: ProjectFileUseCaseContext & { tx: DbTransaction; prepared?: P }): Promise onCommitFailure?( args: ProjectFileUseCaseContext & { prepared: P; error: unknown } @@ -49,6 +54,8 @@ export function defineAuthorizedProjectFileUseCase< | boolean | ((args: ProjectFileUseCaseContext & { result: NoInfer }) => boolean) }): AuthorizingUseCase { + if (definition.prepareFromSnapshot && (definition.prepare || definition.onCommitFailure)) + throw new Error('A snapshot-prepared use case cannot also define prepare or onCommitFailure') return { operation: definition.operation, delegationAudience: definition.operation.delegationAudience, @@ -75,6 +82,8 @@ export function defineAuthorizedProjectFileUseCase< prepared = await runWithOutboundOrganization(context.organizationId, () => prepare({ ...args, context }) ) + } else if (definition.prepareFromSnapshot) { + prepared = await definition.prepareFromSnapshot(args) } let committed: { context: ProjectFileAuthorizationContext; result: R } let callbackCompleted = false diff --git a/apps/sim/lib/projects/files/application/content.ts b/apps/sim/lib/projects/files/application/content.ts index eb7e8dd722e..73b4005d11d 100644 --- a/apps/sim/lib/projects/files/application/content.ts +++ b/apps/sim/lib/projects/files/application/content.ts @@ -6,6 +6,7 @@ import { runWithOutboundOrganization } from '@/lib/core/network/context.server' import { OrchestrationError } from '@/lib/core/orchestration/types' import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' import type { DbTransaction } from '@/lib/db/types' +import { ProjectFileRevisionConflictError } from '@/lib/projects/errors' import type { ProjectFileAuthorizationContext, ProjectFileTarget, @@ -220,7 +221,7 @@ export const updateProjectFileContent = defineAuthorizedProjectFileUseCase({ }) } catch (error) { if (error instanceof ContentVersionConflictError) - throw new OrchestrationError('conflict', error.message) + throw new ProjectFileRevisionConflictError(error.message) throw error } const liveDocEventId = @@ -270,10 +271,13 @@ const readDeliveryProvenance = new WeakMap, 'tx'>): Promise { + }: Omit< + ContentUseCaseArgs, + 'tx' | 'context' + >): Promise { const snapshot = await captureContentDownload.execute({ principal, input }) const file = snapshot.file await requireAdmittedContent(principal, { @@ -282,12 +286,14 @@ export const readProjectFileContent = defineAuthorizedProjectFileUseCase({ provenance: snapshot.secretProvenance, view: 'opaque', }) - const content = await downloadFile({ - key: file.key, - context: 'project', - maxBytes: input.maxBytes ?? MAX_BUFFERED_TRANSFER_BYTES, - signal: input.signal, - }) + const content = await runWithOutboundOrganization(snapshot.organizationId, () => + downloadFile({ + key: file.key, + context: 'project', + maxBytes: input.maxBytes ?? MAX_BUFFERED_TRANSFER_BYTES, + signal: input.signal, + }) + ) return { key: file.key, revision: file.contentUpdatedAt, content } }, async execute({ @@ -303,7 +309,7 @@ export const readProjectFileContent = defineAuthorizedProjectFileUseCase({ file.key !== prepared.key || file.contentUpdatedAt.getTime() !== prepared.revision.getTime() ) - throw new OrchestrationError('conflict', 'File changed while its content was being read') + throw new ProjectFileRevisionConflictError('File changed while its content was being read') const provenance = input.includeSecretProvenance || hasWorkspaceFileDeliveryObserver() || diff --git a/apps/sim/lib/projects/files/application/document-lifecycle.ts b/apps/sim/lib/projects/files/application/document-lifecycle.ts index 52a8427ecf6..ef964da39b4 100644 --- a/apps/sim/lib/projects/files/application/document-lifecycle.ts +++ b/apps/sim/lib/projects/files/application/document-lifecycle.ts @@ -1,8 +1,9 @@ -import { workspaceFiles } from '@sim/db/schema' +import { workspaceFileCollabState, workspaceFiles } from '@sim/db/schema' import { FILE_DOC_SEED } from '@sim/realtime-protocol/file-doc' import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' -import { and, eq } from 'drizzle-orm' +import { compareStrings } from '@sim/utils/string' +import { and, eq, inArray } from 'drizzle-orm' import * as Y from 'yjs' import { loadCollabDocState, saveCollabDocStateInTx } from '@/lib/collab-doc/state' import { @@ -40,6 +41,22 @@ export async function readProjectFileDocIdInTx(tx: DbTransaction, fileId: string return cached ? documentIdentity(cached.docState) : null } +/** + * The files among `fileIds` that have cached document history, in code-unit order, read in one + * query. The caller holds the files' row locks, so no seed can add history before it commits. + */ +export async function listProjectFileDocIdsInTx( + tx: DbTransaction, + fileIds: readonly string[] +): Promise { + if (!fileIds.length) return [] + const rows = await tx + .select({ fileId: workspaceFileCollabState.fileId }) + .from(workspaceFileCollabState) + .where(inArray(workspaceFileCollabState.fileId, [...new Set(fileIds)])) + return rows.map((row) => row.fileId).sort(compareStrings) +} + /** Archive and restore retire the cached history atomically with the file lifecycle mutation. */ export async function rotateProjectFileDocInTx( tx: DbTransaction, diff --git a/apps/sim/lib/projects/files/application/documents.test.ts b/apps/sim/lib/projects/files/application/documents.test.ts new file mode 100644 index 00000000000..36cacc5a09e --- /dev/null +++ b/apps/sim/lib/projects/files/application/documents.test.ts @@ -0,0 +1,43 @@ +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' +import { ProjectConflictError, ProjectFileRevisionConflictError } from '@/lib/projects/errors' + +const { mockReadContent } = vi.hoisted(() => ({ mockReadContent: vi.fn() })) + +vi.mock('@/lib/projects/files/application/authorization', () => ({ + createProjectFileAuthorizer: async () => async () => ({ + file: { originalName: 'notes.md', contentType: 'text/markdown' }, + }), +})) +vi.mock('@/lib/projects/files/application/content', () => ({ + readProjectFileContent: { execute: mockReadContent }, + updateProjectFileContent: { execute: vi.fn() }, +})) + +import { persistProjectFileDoc } from '@/lib/projects/files/application/documents' + +const args = { + principal: createSessionPrincipal({ userId: 'user-1' }), + input: { + projectId: 'project-1', + fileId: 'file-1', + docState: Y.encodeStateAsUpdate(new Y.Doc()), + expectedVersion: 1, + }, +} + +describe('persistProjectFileDoc conflict retries', () => { + it('retries a moving revision before reporting a document conflict', async () => { + mockReadContent.mockRejectedValue(new ProjectFileRevisionConflictError('File changed')) + await expect(persistProjectFileDoc.execute(args)).resolves.toEqual({ status: 'conflict' }) + expect(mockReadContent).toHaveBeenCalledTimes(3) + }) + + it('surfaces a Project lock conflict instead of reporting a document conflict', async () => { + const lockConflict = new ProjectConflictError('Project is changing; retry the operation') + mockReadContent.mockRejectedValue(lockConflict) + await expect(persistProjectFileDoc.execute(args)).rejects.toBe(lockConflict) + expect(mockReadContent).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/lib/projects/files/application/documents.ts b/apps/sim/lib/projects/files/application/documents.ts index 9c434a31f64..db0fa3a9baa 100644 --- a/apps/sim/lib/projects/files/application/documents.ts +++ b/apps/sim/lib/projects/files/application/documents.ts @@ -16,6 +16,8 @@ import { } from '@/lib/collab-doc/state' import type { AuthorizingUseCase } from '@/lib/core/application/authorized-workspace-use-case' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { ProjectFileRevisionConflictError } from '@/lib/projects/errors' import { createProjectFileAuthorizer, type ProjectFileAuthorizationContext, @@ -28,6 +30,7 @@ import { import { readProjectFileDocIdInTx } from '@/lib/projects/files/application/document-lifecycle' import { projectFileOperations } from '@/lib/projects/files/application/operations' import { isMarkdownFile } from '@/lib/uploads/utils/file' +import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration' interface ProjectDocumentTarget { projectId: string @@ -98,7 +101,7 @@ function cacheUseCase( .limit(1) if (!file) throw new OrchestrationError('not_found', 'File not found') if (file.version.getTime() !== input.expectedVersion) - throw new OrchestrationError('conflict', 'File changed while preparing its document') + throw new ProjectFileRevisionConflictError('File changed while preparing its document') await saveCollabDocStateInTx(tx, input.fileId, input.state) return { version: file.version.getTime() } }, @@ -108,10 +111,11 @@ function cacheUseCase( const seedCache = cacheUseCase(projectFileOperations.readContent) const persistCache = cacheUseCase(projectFileOperations.updateContent) +/** Only a moved revision or cached history is worth a fresh read; other conflicts are final. */ function isDocumentConflict(error: unknown) { return ( error instanceof CollabDocStateConflictError || - (error instanceof OrchestrationError && error.code === 'conflict') + error instanceof ProjectFileRevisionConflictError ) } @@ -190,7 +194,7 @@ export const persistProjectFileDoc: AuthorizingUseCase< requireProjectDocument(await db.transaction(authorize)) }, async execute(args) { - await this.authorize(args) + await persistProjectFileDoc.authorize(args) if (args.input.expectedVersion === undefined) return { status: 'deferred' } assertCollabDocStateSize(args.input.docState) const doc = new Y.Doc() @@ -203,7 +207,17 @@ export const persistProjectFileDoc: AuthorizingUseCase< } for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { try { - const read = await readProjectFileContent.execute(args) + const read = await readProjectFileContent + .execute({ + ...args, + input: { ...args.input, maxBytes: MAX_WORKSPACE_FILE_CONTENT_BYTES }, + }) + .catch((error: unknown) => { + // No snapshot can replace or account for bytes past the largest editable file. + if (isPayloadSizeLimitError(error)) return null + throw error + }) + if (!read) return { status: 'conflict' } const version = read.file.contentUpdatedAt.getTime() if (!isMarkdownFile(read.file)) { throw new OrchestrationError( diff --git a/apps/sim/lib/projects/files/application/lifecycle.ts b/apps/sim/lib/projects/files/application/lifecycle.ts index 21ecc724423..ec346778ac5 100644 --- a/apps/sim/lib/projects/files/application/lifecycle.ts +++ b/apps/sim/lib/projects/files/application/lifecycle.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import type { Principal } from '@sim/auth/principal' import type { WorkspaceFileRow } from '@sim/db/schema' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import type { DbTransaction } from '@/lib/db/types' import type { ProjectFileAuthorizationContext, @@ -9,6 +10,7 @@ import type { } from '@/lib/projects/files/application/authorization' import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' import { + listProjectFileDocIdsInTx, processProjectFileDocRetirementNow, rotateProjectFileDocInTx, } from '@/lib/projects/files/application/document-lifecycle' @@ -42,6 +44,7 @@ interface MoveInput extends SelectionInput { } const retirementEffects = new WeakMap() +const RETIREMENT_CONCURRENCY = 8 async function retireDocuments( tx: DbTransaction, @@ -50,7 +53,7 @@ async function retireDocuments( result: object ) { const eventIds: string[] = [] - for (const fileId of [...new Set(fileIds)].sort()) { + for (const fileId of await listProjectFileDocIdsInTx(tx, fileIds)) { const retired = await rotateProjectFileDocInTx(tx, { projectId, fileId }) if (retired) eventIds.push(retired.outboxEventId) } @@ -60,7 +63,7 @@ async function retireDocuments( async function finishRetirements(result: object) { const eventIds = retirementEffects.get(result) ?? [] retirementEffects.delete(result) - await Promise.all(eventIds.map(processProjectFileDocRetirementNow)) + await mapWithConcurrency(eventIds, RETIREMENT_CONCURRENCY, processProjectFileDocRetirementNow) } function boundedSelection(input: SelectionInput) { @@ -163,10 +166,9 @@ export const restoreProjectFile = defineAuthorizedProjectFileUseCase({ operation: projectFileOperations.restore, invalidatesFileList: ({ context }) => Boolean(context.file?.deletedAt), async execute({ input, context, tx }: LifecycleArgs) { - const restored = await restoreFileInTx(tx, context.owner, input.fileId) - const result = { restored: true as const, file: await projectFileRecord(tx, context, restored) } - if (context.file?.deletedAt) - await retireDocuments(tx, context.projectId, [input.fileId], result) + const { file, restored } = await restoreFileInTx(tx, context.owner, input.fileId) + const result = { restored: true as const, file: await projectFileRecord(tx, context, file) } + if (restored) await retireDocuments(tx, context.projectId, [input.fileId], result) return result }, projectAudit: ({ context, result }) => ({ diff --git a/apps/sim/lib/projects/files/application/operations.ts b/apps/sim/lib/projects/files/application/operations.ts index f3087085fac..fa3e6032f21 100644 --- a/apps/sim/lib/projects/files/application/operations.ts +++ b/apps/sim/lib/projects/files/application/operations.ts @@ -14,6 +14,12 @@ interface ProjectFilePolicy extends ApplicationOperation { readonly access: 'read' | 'write' readonly target: 'project' | 'file' | 'collection_observation' readonly fileScope?: 'active' | 'all' + /** + * Charges the Project storage payer, whose accounting holds the Project exclusively, so + * authorization takes it exclusively up front instead of upgrading a shared hold. Every other + * operation holds the Project shared. + */ + readonly chargesStorage?: true readonly principalKinds: readonly ProjectFilePrincipal['kind'][] readonly delegatedServices: readonly ResourceDelegatedPrincipal['serviceId'][] readonly delegationAudience: 'sim:project-files' | 'sim:file-list-observation' @@ -83,6 +89,7 @@ export const projectFileOperations = { access: 'write', target: 'file', oauthScope: 'api:write', + chargesStorage: true, }), downloadItems: defineProjectFileOperation({ ...POLICY, @@ -155,6 +162,7 @@ export const projectFileOperations = { access: 'write', target: 'file', oauthScope: 'api:write', + chargesStorage: true, }), deleteVersion: defineProjectFileOperation({ ...HISTORY_POLICY, @@ -203,6 +211,7 @@ export const projectFileOperations = { access: 'write', target: 'project', oauthScope: 'api:write', + chargesStorage: true, }), uploadCancel: defineProjectFileOperation({ ...POLICY, @@ -284,6 +293,7 @@ export const projectFileOperations = { access: 'write', target: 'project', oauthScope: 'api:write', + chargesStorage: true, }), updateContent: defineProjectFileOperation({ ...POLICY, @@ -292,6 +302,7 @@ export const projectFileOperations = { access: 'write', target: 'file', oauthScope: 'api:write', + chargesStorage: true, delegatedServices: ['copilot', 'realtime'], }), rename: defineProjectFileOperation({ diff --git a/apps/sim/lib/projects/files/application/versions.ts b/apps/sim/lib/projects/files/application/versions.ts index 3d6516f27fb..d2f3cac0662 100644 --- a/apps/sim/lib/projects/files/application/versions.ts +++ b/apps/sim/lib/projects/files/application/versions.ts @@ -253,10 +253,13 @@ export const readProjectFileVersion = defineAuthorizedProjectFileUseCase({ const readVersionContent = defineAuthorizedProjectFileUseCase({ operation: projectFileOperations.readVersionContent, - async prepare({ + async prepareFromSnapshot({ principal, input, - }: Omit, 'tx'>): Promise { + }: Omit< + VersionArgs, + 'tx' | 'context' + >): Promise { const snapshot = await snapshotRead.execute({ principal, input }) await requireAdmittedContent(principal, { kind: 'workspace-file', @@ -264,7 +267,10 @@ const readVersionContent = defineAuthorizedProjectFileUseCase({ provenance: workspaceFileSecretProvenanceFromSnapshot(snapshot.provenance), view: 'opaque', }) - return { snapshot, content: await readVersionBytes(snapshot.version, input.maxBytes) } + const content = await runWithOutboundOrganization(snapshot.organizationId, () => + readVersionBytes(snapshot.version, input.maxBytes) + ) + return { snapshot, content } }, async execute({ input, diff --git a/apps/sim/lib/projects/files/application/write-effects.ts b/apps/sim/lib/projects/files/application/write-effects.ts index f06b063a224..a2c9f5eff47 100644 --- a/apps/sim/lib/projects/files/application/write-effects.ts +++ b/apps/sim/lib/projects/files/application/write-effects.ts @@ -55,10 +55,12 @@ export async function mapProjectFileResult( } } -/** Locks the canonical Project payer before content, directory, and history mutation locks. */ +/** Locks the Project and its row before content, directory, and history locks; see `chargesStorage`. */ export async function prepareProjectFileAccounting( tx: DbTransaction, context: ProjectFileAuthorizationContext ) { + if (context.projectLock !== 'exclusive') + throw new Error('Project storage accounting needs a `chargesStorage` operation') return prepareFileAccountingInTx(tx, { entityType: 'project', entityId: context.projectId }) } diff --git a/apps/sim/lib/projects/membership.ts b/apps/sim/lib/projects/membership.ts index 95bc518c063..54aa3b522a3 100644 --- a/apps/sim/lib/projects/membership.ts +++ b/apps/sim/lib/projects/membership.ts @@ -12,6 +12,7 @@ import { acquireAdvisoryXactLock, acquireAdvisoryXactLocks, tryAcquireAdvisoryXactLocks, + withLockTimeout, } from '@/lib/db/advisory-locks' import { textArrayLiteral } from '@/lib/db/arrays' import type { DbTransaction } from '@/lib/db/types' @@ -30,20 +31,13 @@ async function withProjectLockTimeout( message: string, acquire: () => Promise ): Promise { - const [setting] = await tx.execute<{ previous: string }>( - sql`SELECT current_setting('lock_timeout') AS previous` - ) - await tx.execute(sql`SELECT set_config('lock_timeout', ${`${PROJECT_LOCK_TIMEOUT_MS}ms`}, true)`) - let result: T try { - result = await acquire() + return await withLockTimeout(tx, PROJECT_LOCK_TIMEOUT_MS, acquire) } catch (error) { const code = getPostgresErrorCode(error) if (code === '55P03' || code === '40P01') throw new ProjectConflictError(message) throw error } - await tx.execute(sql`SELECT set_config('lock_timeout', ${setting?.previous ?? '0'}, true)`) - return result } /** @@ -83,6 +77,17 @@ export async function lockProject(tx: DbTransaction, projectId: string): Promise ) } +/** + * Holds the Project lock SHARED: fences the holder against every exclusive holder (lifecycle, + * payer transfer, retention, Project storage accounting) without serializing it against other + * shared holders. A transaction holding it must never later take the same Project exclusively. + */ +export async function lockProjectShared(tx: DbTransaction, projectId: string): Promise { + await withProjectLockTimeout(tx, PROJECT_CHANGING, () => + acquireAdvisoryXactLocks(tx, 'project', [{ key: projectLockKey(projectId), shared: true }]) + ) +} + /** Takes several Project mutexes in code-unit id order, the order every multi-lock holder uses. */ export async function lockProjects(tx: DbTransaction, projectIds: string[]): Promise { if (!projectIds.length) return diff --git a/apps/sim/lib/uploads/contexts/workspace/file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/file-manager.ts index 0ef7e5e5422..2b973a65d81 100644 --- a/apps/sim/lib/uploads/contexts/workspace/file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/file-manager.ts @@ -2075,9 +2075,11 @@ export async function resolveFileReference( } if (segments.length === 0) throw new OrchestrationError('validation', 'File reference is empty') - const folders = await loadActiveFileFolderPathIndex(owner, client, { - maxRows: MAX_FOLDERS_PER_WORKSPACE, - }) + let folders: Awaited> | undefined + const loadFolders = async () => + (folders ??= await loadActiveFileFolderPathIndex(owner, client, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + })) let selector: SQL if (isFileId) { selector = eq(workspaceFiles.id, localReference) @@ -2085,7 +2087,7 @@ export async function resolveFileReference( const folderPath = buildFolderPath(segments.slice(0, -1)) let folderId: string | null = null if (folderPath !== '/') { - const matchingFolders = [...folders.pathById].filter( + const matchingFolders = [...(await loadFolders()).pathById].filter( ([, path]) => parseFolderPath(path).map(normalizeVfsSegment).join('/') === segments.slice(0, -1).map(normalizeVfsSegment).join('/') @@ -2112,7 +2114,8 @@ export async function resolveFileReference( if (rows.length > 1) throw new OrchestrationError('conflict', 'File reference is ambiguous') const row = rows[0] if (!row) return null - return mapFileRecord(row, owner, buildWorkspaceFileFolderPathMap([...folders.rowById.values()])) + const folderRows = row.folderId ? [...(await loadFolders()).rowById.values()] : [] + return mapFileRecord(row, owner, buildWorkspaceFileFolderPathMap(folderRows)) } async function getWorkspaceFileByExactReference( @@ -3045,12 +3048,15 @@ export async function restoreWorkspaceFile(workspaceId: string, fileId: string): } } -/** Restores a retained head, re-rooting and deduplicating within its canonical owner. */ +/** + * Restores a retained head, re-rooting and deduplicating within its canonical owner. `restored` + * reports whether this call restored it, read under the file's row lock. + */ export async function restoreFileInTx( tx: DbTransaction, owner: EditableFileOwner, fileId: string -): Promise { +): Promise<{ file: WorkspaceFileRow; restored: boolean }> { if (owner.entityType === 'workspace') await lockWorkspaceProject(tx, owner.entityId) await lockFileDirectories(tx, [owner]) const [file] = await tx @@ -3060,7 +3066,7 @@ export async function restoreFileInTx( .for('update') .limit(1) if (!file) throw new OrchestrationError('not_found', 'File not found') - if (!file.deletedAt) return file + if (!file.deletedAt) return { file, restored: false } let folderId = file.folderId if (folderId) { const [parent] = await tx @@ -3116,5 +3122,5 @@ export async function restoreFileInTx( throw error }) if (!restored) throw new OrchestrationError('conflict', 'File changed during restoration') - return restored + return { file: restored, restored: true } } diff --git a/knip.jsonc b/knip.jsonc index d5b95f13062..a5142e18537 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -48,11 +48,12 @@ // Generated contracts mirror their source of truth; regenerating them must not // trip the unused-export ratchet, and hand edits would be overwritten. "lib/mothership/generated/**": ["exports", "types", "duplicates"], - // API conventions require exported named wire schemas and aliases, including before client adoption. - "lib/api/contracts/projects.ts": ["exports", "types"], - "lib/api/contracts/project-file*.ts": ["exports", "types"], - "lib/api/contracts/v2/project-file*.ts": ["exports", "types"], - "lib/api/contracts/v2/uploads.ts": ["types"], + // Consumed only by the stacked Project file PRs: the rendered-content contracts by #9017, + // the client aliases by #9034. Remove each entry once its consumer merges. + "lib/api/contracts/projects.ts": ["types"], + "lib/api/contracts/project-files.ts": ["exports", "types"], + "lib/api/contracts/project-file-folders.ts": ["types"], + "lib/api/contracts/project-file-lifecycle.ts": ["types"], "sandboxes/index.ts": ["files"], "components/mcp/index.ts": ["files"], "triggers/quickbooks/index.ts": ["files"], diff --git a/packages/testing/src/mocks/permission-group-locks.mock.ts b/packages/testing/src/mocks/permission-group-locks.mock.ts index d2e9ed50a3c..beb2b778f79 100644 --- a/packages/testing/src/mocks/permission-group-locks.mock.ts +++ b/packages/testing/src/mocks/permission-group-locks.mock.ts @@ -21,6 +21,9 @@ export const permissionGroupLocksMockFns = { _options?: { lockTimeoutAlreadyBounded?: boolean } ): Promise => undefined ), + mockAcquirePermissionGroupOrgReadLock: vi.fn( + async (_tx: unknown, _organizationId: string): Promise => undefined + ), } /** @@ -33,4 +36,6 @@ export const permissionGroupLocksMockFns = { */ export const permissionGroupLocksMock = { acquirePermissionGroupOrgLock: permissionGroupLocksMockFns.mockAcquirePermissionGroupOrgLock, + acquirePermissionGroupOrgReadLock: + permissionGroupLocksMockFns.mockAcquirePermissionGroupOrgReadLock, }