diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index 1f9bac43181..5092c8badc3 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -65,7 +65,10 @@ import { revokeUserSessionsTx, } from '@/lib/organizations/members/revocation' import { reassignOrganizationProjects } from '@/lib/projects/membership' -import { handoffProjectsForOrganizationDepartureTx } from '@/lib/projects/resource-handoff' +import { + handoffProjectsForOrganizationDepartureTx, + lockProjectsForResourceDepartureTx, +} from '@/lib/projects/resource-handoff' import { removeWorkspaceSkillMembershipsTx } from '@/lib/skills/access' import { reassignSharedResourceOwnershipForWorkspaceMemberRemovalTx } from '@/lib/workspaces/resource-handoff' import { WorkspaceBillingAccountRemovalError } from '@/lib/workspaces/utils' @@ -569,6 +572,8 @@ async function reassignOwnedOrganizationResourcesTx({ organizationId: string workspaceIds: string[] }) { + /** Project gates precede the workspace owner rewrite, the order file writers lock in. */ + if (workspaceIds.length > 0) await lockProjectsForResourceDepartureTx(tx, workspaceIds) const ownerId = await reassignOrganizationSharedResourcesTx(tx, organizationId, userId) if (!ownerId) return 0 diff --git a/apps/sim/lib/billing/storage/payer-transfer.test.ts b/apps/sim/lib/billing/storage/payer-transfer.test.ts index 84790597664..ba4c52ebeee 100644 --- a/apps/sim/lib/billing/storage/payer-transfer.test.ts +++ b/apps/sim/lib/billing/storage/payer-transfer.test.ts @@ -67,10 +67,10 @@ vi.mock('drizzle-orm', () => ({ import { changeOrganizationWorkspaceBilledAccountsInTx, + changeProjectAndWorkspaceStoragePayersInTx, changeWorkspaceStoragePayerInTx, - changeWorkspaceStoragePayersInTx, } from '@/lib/billing/storage/payer-transfer' -import type { DbOrTx } from '@/lib/db/types' +import type { DbOrTx, DbTransaction } from '@/lib/db/types' const { warn: loggerWarn } = getMockLogger('WorkspaceStoragePayerTransfer') @@ -277,7 +277,7 @@ function createFakeBatchTx(state: FakeBatchTransferState) { return { execute, locks, - tx: { execute, select, update } as unknown as DbOrTx, + tx: { execute, select, update } as unknown as DbTransaction, updates, } } @@ -508,7 +508,7 @@ describe('changeWorkspaceStoragePayerInTx', () => { }) }) -describe('changeWorkspaceStoragePayersInTx', () => { +describe('changeProjectAndWorkspaceStoragePayersInTx workspace changes', () => { it('uses the same payer lock order for opposite-direction moves', async () => { const personalToOrganization = createFakeBatchTx({ exactBytes: { 'workspace-a': 10 }, @@ -537,41 +537,38 @@ describe('changeWorkspaceStoragePayersInTx', () => { ], }) - await changeWorkspaceStoragePayersInTx(personalToOrganization.tx, [ - { - workspaceId: 'workspace-a', - organizationId: 'org-a', - billedAccountUserId: 'org-owner', - }, - ]) - await changeWorkspaceStoragePayersInTx(organizationToPersonal.tx, [ - { - workspaceId: 'workspace-b', - organizationId: null, - billedAccountUserId: 'user-z', - }, - ]) + await changeProjectAndWorkspaceStoragePayersInTx(personalToOrganization.tx, { + projectChanges: [], + workspaceChanges: [ + { + workspaceId: 'workspace-a', + organizationId: 'org-a', + billedAccountUserId: 'org-owner', + }, + ], + }) + await changeProjectAndWorkspaceStoragePayersInTx(organizationToPersonal.tx, { + projectChanges: [], + workspaceChanges: [ + { + workspaceId: 'workspace-b', + organizationId: null, + billedAccountUserId: 'user-z', + }, + ], + }) + const lockOrder = ['workspace', 'userStats', 'organization'] expect(personalToOrganization.locks.map(({ table }) => table)).toEqual([ - 'workspace', - 'userStats', - 'organization', + ...lockOrder, + ...lockOrder, ]) expect(organizationToPersonal.locks.map(({ table }) => table)).toEqual([ - 'workspace', - 'userStats', - 'organization', - ]) - expect(personalToOrganization.updates.map(({ table }) => table)).toEqual([ - 'userStats', - 'organization', - 'workspace', - ]) - expect(organizationToPersonal.updates.map(({ table }) => table)).toEqual([ - 'userStats', - 'organization', - 'workspace', + ...lockOrder, + ...lockOrder, ]) + expect(personalToOrganization.updates.map(({ table }) => table)).toEqual(lockOrder) + expect(organizationToPersonal.updates.map(({ table }) => table)).toEqual(lockOrder) }) it('aggregates payer deltas once and clamps outgoing bytes before adding incoming bytes', async () => { @@ -594,24 +591,28 @@ describe('changeWorkspaceStoragePayersInTx', () => { ], }) - await changeWorkspaceStoragePayersInTx(fake.tx, [ - { - workspaceId: 'workspace-b', - organizationId: null, - billedAccountUserId: 'destination', - }, - { - workspaceId: 'workspace-a', - organizationId: null, - billedAccountUserId: 'destination', - }, - ]) + await changeProjectAndWorkspaceStoragePayersInTx(fake.tx, { + projectChanges: [], + workspaceChanges: [ + { + workspaceId: 'workspace-b', + organizationId: null, + billedAccountUserId: 'destination', + }, + { + workspaceId: 'workspace-a', + organizationId: null, + billedAccountUserId: 'destination', + }, + ], + }) expect(fake.execute).toHaveBeenCalledTimes(1) - expect(fake.locks).toEqual([ + const sortedLocks = [ { ids: ['workspace-a', 'workspace-b'], table: 'workspace' }, { ids: ['destination', 'source'], table: 'userStats' }, - ]) + ] + expect(fake.locks).toEqual([...sortedLocks, ...sortedLocks]) const userStatsUpdates = fake.updates.filter(({ table }) => table === 'userStats') expect(userStatsUpdates).toHaveLength(1) expect(readCaseAssignments(userStatsUpdates[0]?.values.storageUsedBytes)).toEqual({ @@ -636,22 +637,29 @@ describe('changeWorkspaceStoragePayersInTx', () => { }) await expect( - changeWorkspaceStoragePayersInTx(fake.tx, [ - { - workspaceId: 'workspace-a', - organizationId: null, - billedAccountUserId: 'destination', - expectedCurrentPayer: { + changeProjectAndWorkspaceStoragePayersInTx(fake.tx, { + projectChanges: [], + workspaceChanges: [ + { + workspaceId: 'workspace-a', organizationId: null, - billedAccountUserId: 'stale', + billedAccountUserId: 'destination', + expectedCurrentPayer: { + organizationId: null, + billedAccountUserId: 'stale', + }, }, - }, - ]) + ], + }) ).rejects.toThrow('Workspace workspace-a payer changed before the transaction lock') expect(fake.execute).not.toHaveBeenCalled() expect(fake.updates).toEqual([]) - expect(fake.locks).toEqual([{ ids: ['workspace-a'], table: 'workspace' }]) + expect(fake.locks).toEqual([ + { ids: ['workspace-a'], table: 'workspace' }, + { ids: ['current', 'destination'], table: 'userStats' }, + { ids: ['workspace-a'], table: 'workspace' }, + ]) }) }) diff --git a/apps/sim/lib/billing/storage/payer-transfer.ts b/apps/sim/lib/billing/storage/payer-transfer.ts index 61d004b21d3..5af74809f89 100644 --- a/apps/sim/lib/billing/storage/payer-transfer.ts +++ b/apps/sim/lib/billing/storage/payer-transfer.ts @@ -13,7 +13,7 @@ import { and, asc, eq, inArray, sql } from 'drizzle-orm' import type { BillingEntity } from '@/lib/billing/core/usage-log' import type { ProjectStorageOwnerSnapshot } from '@/lib/billing/storage/context' import type { DbOrTx, DbTransaction } from '@/lib/db/types' -import { lockProject } from '@/lib/projects/membership' +import { lockProjects } from '@/lib/projects/membership' const logger = createLogger('WorkspaceStoragePayerTransfer') @@ -384,25 +384,13 @@ async function setStoragePayerUsagesBatch( } /** - * Changes multiple workspace payers in one short caller-owned transaction. - * - * Workspace rows are locked in ascending ID order, exact live byte totals are - * grouped into one query, and distinct payer rows are locked with all users - * before all organizations and ascending IDs within each type. Source and - * destination deltas are aggregated separately so an underfunded source is - * clamped before incoming bytes are added. No destination quota is enforced. + * Locks workspace rows in ascending ID order and changes their payer metadata and exact + * live byte totals. The caller already holds every affected payer row and applies the + * returned contributions once, so no payer ledger is written here. */ -export async function changeWorkspaceStoragePayersInTx( - tx: DbOrTx, - changes: ChangeWorkspaceStoragePayerParams[] -): Promise { - return changeWorkspaceStoragePayers(tx, changes, false) -} - async function changeWorkspaceStoragePayers( tx: DbOrTx, - changes: ChangeWorkspaceStoragePayerParams[], - deferPayerUpdates: boolean + changes: ChangeWorkspaceStoragePayerParams[] ): Promise { if (changes.length === 0) return [] @@ -442,98 +430,19 @@ async function changeWorkspaceStoragePayers( } } - const payerByKey = new Map() - const payerChangingWorkspaceIds: string[] = [] - for (const workspaceId of workspaceIds) { + const payerChangingWorkspaceIds = workspaceIds.filter((workspaceId) => { const lockedWorkspace = workspaceById.get(workspaceId) const change = changesByWorkspaceId.get(workspaceId) - if (!lockedWorkspace || !change) continue - const oldPayer = getWorkspacePayer(lockedWorkspace) - const newPayer = getWorkspacePayer(change) - if (getPayerKey(oldPayer) === getPayerKey(newPayer)) continue - payerChangingWorkspaceIds.push(workspaceId) - payerByKey.set(getPayerKey(oldPayer), oldPayer) - payerByKey.set(getPayerKey(newPayer), newPayer) - } + if (!lockedWorkspace || !change) return false + return ( + getPayerKey(getWorkspacePayer(lockedWorkspace)) !== getPayerKey(getWorkspacePayer(change)) + ) + }) const exactBytesByWorkspaceId = await getExactWorkspaceStorageBytesBatch( tx, payerChangingWorkspaceIds ) - if (!deferPayerUpdates) { - const payerUsageByKey = await lockStoragePayers(tx, payerByKey) - const deltaByPayerKey = new Map() - - for (const workspaceId of payerChangingWorkspaceIds) { - const lockedWorkspace = workspaceById.get(workspaceId) - const change = changesByWorkspaceId.get(workspaceId) - if (!lockedWorkspace || !change) continue - const exactBytes = exactBytesByWorkspaceId.get(workspaceId) ?? 0 - const oldPayer = getWorkspacePayer(lockedWorkspace) - const newPayer = getWorkspacePayer(change) - const oldPayerKey = getPayerKey(oldPayer) - const newPayerKey = getPayerKey(newPayer) - - if (payerUsageByKey.get(newPayerKey) === null) { - throw new Error(`Storage destination payer ${newPayerKey} not found`) - } - - const sourceDelta = deltaByPayerKey.get(oldPayerKey) ?? { - incomingBytes: 0, - outgoingBytes: 0, - } - sourceDelta.outgoingBytes += exactBytes - if (!Number.isSafeInteger(sourceDelta.outgoingBytes)) { - throw new Error(`Storage source payer ${oldPayerKey} delta exceeds the safe integer range`) - } - deltaByPayerKey.set(oldPayerKey, sourceDelta) - - const destinationDelta = deltaByPayerKey.get(newPayerKey) ?? { - incomingBytes: 0, - outgoingBytes: 0, - } - destinationDelta.incomingBytes += exactBytes - if (!Number.isSafeInteger(destinationDelta.incomingBytes)) { - throw new Error( - `Storage destination payer ${newPayerKey} delta exceeds the safe integer range` - ) - } - deltaByPayerKey.set(newPayerKey, destinationDelta) - } - - const nextUsageByKey = new Map() - for (const [payerKey, delta] of [...deltaByPayerKey.entries()].sort(([left], [right]) => - comparePayerKeys(left, right) - )) { - const currentUsage = payerUsageByKey.get(payerKey) - if (currentUsage === null || currentUsage === undefined) { - logger.warn('Storage source payer is missing during workspace payer batch change', { - sourcePayer: payerKey, - outgoingBytes: delta.outgoingBytes, - }) - continue - } - - const usageAfterOutgoing = Math.max(0, currentUsage - delta.outgoingBytes) - if (currentUsage < delta.outgoingBytes) { - logger.warn( - 'Clamping drifted source storage aggregate during workspace payer batch change', - { - sourcePayer: payerKey, - sourceUsage: currentUsage, - outgoingBytes: delta.outgoingBytes, - } - ) - } - const nextUsage = usageAfterOutgoing + delta.incomingBytes - if (!Number.isSafeInteger(nextUsage)) { - throw new Error(`Storage payer ${payerKey} exceeds the safe integer range`) - } - nextUsageByKey.set(payerKey, nextUsage) - } - - await setStoragePayerUsagesBatch(tx, payerByKey, nextUsageByKey) - } const resultsByWorkspaceId = new Map() const workspaceUpdates = workspaceIds.map((workspaceId) => { @@ -796,9 +705,14 @@ export async function changeProjectStoragePayersInTx( tx: DbTransaction, changes: readonly ChangeProjectStoragePayerParams[] ): Promise { + await lockProjects( + tx, + changes.map((change) => change.projectId) + ) return changeProjectStoragePayers(tx, changes, false) } +/** The caller holds every changed Project's lock. */ async function changeProjectStoragePayers( tx: DbTransaction, changes: readonly ChangeProjectStoragePayerParams[], @@ -809,7 +723,6 @@ async function changeProjectStoragePayers( if (changesById.size !== changes.length) throw new Error('Storage payer batch contains duplicate Project IDs') const projectIds = [...changesById.keys()].sort() - for (const id of projectIds) await lockProject(tx, id) const owners = await tx .select({ id: project.id, ownerId: project.ownerId, organizationId: project.organizationId }) .from(project) @@ -905,6 +818,7 @@ async function applyPayerContributionTransfers( const usageByKey = await lockStoragePayers(tx, payerByKey) const deltas = new Map() for (const result of contributions) { + if (result.billableBytes === 0) continue const sourceKey = getPayerKey(result.oldPayer) const destinationKey = result.newPayer ? getPayerKey(result.newPayer) : null if (sourceKey === destinationKey) continue @@ -967,7 +881,7 @@ export async function changeProjectAndWorkspaceStoragePayersInTx( .sort() if (new Set(projectIds).size !== projectIds.length) throw new Error('Storage lifecycle batch contains duplicate Project IDs') - for (const id of projectIds) await lockProject(tx, id) + await lockProjects(tx, projectIds) const projects = projectIds.length ? await tx .select({ @@ -1043,7 +957,7 @@ export async function changeProjectAndWorkspaceStoragePayersInTx( removal.billableBytes = bytes } const projectResults = await changeProjectStoragePayers(tx, changes.projectChanges, true) - const workspaceResults = await changeWorkspaceStoragePayers(tx, changes.workspaceChanges, true) + const workspaceResults = await changeWorkspaceStoragePayers(tx, changes.workspaceChanges) await applyPayerContributionTransfers(tx, [ ...projectResults, ...workspaceResults, diff --git a/apps/sim/lib/projects/__integration__/foundation.integration.ts b/apps/sim/lib/projects/__integration__/foundation.integration.ts index e6c59213fc3..193dcccaa04 100644 --- a/apps/sim/lib/projects/__integration__/foundation.integration.ts +++ b/apps/sim/lib/projects/__integration__/foundation.integration.ts @@ -46,6 +46,7 @@ import { archiveProjectInTransaction } from '@/lib/projects/lifecycle' import { createProjectRecord, lockProject, + lockWorkspaceProject, splitForkProject, transferWorkspaceProjects, } from '@/lib/projects/membership' @@ -1278,6 +1279,33 @@ describe('Project foundation at the database and application boundary', () => { } ) + check( + 'file writers in sibling environments share the Project lock that still fences a lifecycle holder', + async () => { + const f = await fixture(false) + const held = createDeferred() + const release = createDeferred() + const writer = db.transaction(async (tx) => { + await lockWorkspaceProject(tx, f.ids[0]) + const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + held.resolve(connection.pid) + await release.promise + }) + const blocker = await held.promise + let lifecycle: Promise | undefined + try { + const sibling = await db.transaction((tx) => lockWorkspaceProject(tx, f.ids[1])) + expect(sibling?.id).toBe(f.projectId) + lifecycle = db.transaction((tx) => lockProject(tx, f.projectId)) + await waitUntilBlockedBy(blocker, "lock='project'") + } finally { + release.resolve() + await writer + } + await lifecycle + } + ) + check( 'account teardown transfers surviving Projects and atomically removes wholly private Projects', async () => { diff --git a/apps/sim/lib/projects/membership.ts b/apps/sim/lib/projects/membership.ts index a96e3044359..a9f8e6d5fee 100644 --- a/apps/sim/lib/projects/membership.ts +++ b/apps/sim/lib/projects/membership.ts @@ -134,8 +134,11 @@ export async function createProjectRecord( return id } -/** Locks and rechecks the workspace’s Project; null means the workspace no longer exists. */ -export async function lockWorkspaceProject(tx: DbTransaction, workspaceId: string) { +/** + * Locks and rechecks the workspace’s Project; null means the workspace no longer exists. + * `shared` lets writers of one Project run together while excluding exclusive holders. + */ +async function lockWorkspaceProjectInMode(tx: DbTransaction, workspaceId: string, shared: boolean) { return withProjectLockTimeout(tx, PROJECT_CHANGING, async () => { await acquireBackfillWriteLocks(tx, [workspaceId]) const [membership] = await tx @@ -146,7 +149,9 @@ export async function lockWorkspaceProject(tx: DbTransaction, workspaceId: strin if (!membership) return null if (!membership.projectId) throw new ProjectConflictError('Project membership needs reconciliation') - await acquireAdvisoryXactLock(tx, 'project', projectLockKey(membership.projectId)) + await acquireAdvisoryXactLocks(tx, 'project', [ + { key: projectLockKey(membership.projectId), shared }, + ]) const [current] = await tx .select({ project: { @@ -168,9 +173,33 @@ export async function lockWorkspaceProject(tx: DbTransaction, workspaceId: strin }) } +/** + * Takes the workspace’s Project lock SHARED and rechecks membership; null means the + * workspace no longer exists. For writers inside one environment (workspace files, + * shares): it fences them against exclusive Project holders (fork, split, archive, + * payer transfer, departure handoff, account deletion, retention) without serializing + * writers against each other. A transaction holding it must never later take the same + * Project exclusively ({@link lockProject}, {@link lockProjects}, {@link tryLockProjects}, + * {@link lockWorkspaceProjectExclusive}): two such holders would each wait on the other's + * shared hold. Take {@link lockWorkspaceProjectExclusive} up front instead. + */ +export async function lockWorkspaceProject(tx: DbTransaction, workspaceId: string) { + return lockWorkspaceProjectInMode(tx, workspaceId, true) +} + +/** + * Takes the workspace’s Project lock EXCLUSIVELY and rechecks membership; null means the + * workspace no longer exists. For callers that change Project membership or lifecycle + * (fork, split, environment archive), which must exclude every writer and each other. + * Take it before any shared hold on the same Project in the transaction, never after. + */ +export async function lockWorkspaceProjectExclusive(tx: DbTransaction, workspaceId: string) { + return lockWorkspaceProjectInMode(tx, workspaceId, false) +} + /** Locks the parent's Project for a new fork and refuses an archived Project. */ export async function requireForkProject(tx: DbTransaction, parentWorkspaceId: string) { - const parent = await lockWorkspaceProject(tx, parentWorkspaceId) + const parent = await lockWorkspaceProjectExclusive(tx, parentWorkspaceId) if (!parent) throw new ProjectConflictError('Source workspace no longer exists') if (parent.archivedAt) throw new ProjectConflictError('Cannot fork an archived Project') return parent @@ -214,7 +243,7 @@ export async function splitForkProject( workspaceId: string, newProjectId = generateId() ): Promise { - const owner = await lockWorkspaceProject(tx, workspaceId) + const owner = await lockWorkspaceProjectExclusive(tx, workspaceId) if (!owner) throw new ProjectConflictError('Workspace no longer exists') if (owner.archivedAt) throw new ProjectConflictError('Cannot disconnect an archived Project') const rows = await tx.execute<{ diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 08ef27bbc8c..d2fce3747f8 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -5,6 +5,7 @@ import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq, inArray, isNull, min, sql } from 'drizzle-orm' import { type ListSortOrder, listOrderBy } from '@/lib/api/list-query' +import { lockWorkspaceStorageForMutationInTx } from '@/lib/billing/storage' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx, DbTransaction } from '@/lib/db/types' import { FolderCollectionLimitExceededError } from '@/lib/folders/errors' @@ -308,6 +309,17 @@ async function acquireFileFolderMutationLock(tx: DbTransaction, owner: EditableF await lockFileDirectories(tx, [owner]) } +/** + * For operations that archive or restore many file rows: their search-index trigger upserts the + * dispatch queue row, so the workspace row must be locked first, the order + * {@link lockWorkspaceStorageForMutationInTx} requires of every file writer. + */ +async function acquireFileItemsMutationLock(tx: DbTransaction, owner: EditableFileOwner) { + await acquireFileFolderMutationLock(tx, owner) + if (owner.entityType === 'workspace') + await lockWorkspaceStorageForMutationInTx(tx, owner.entityId) +} + export function buildWorkspaceFileFolderPathMap( folders: Array> ): Map { @@ -1260,7 +1272,7 @@ async function restoreFileFolder( folderId: string, tx: DbTransaction ) { - await acquireFileFolderMutationLock(tx, owner) + await acquireFileItemsMutationLock(tx, owner) const raw = await tx .select() @@ -1433,7 +1445,7 @@ async function archiveFileItems( const explicitFileIds = Array.from(new Set(params.fileIds ?? [])) const explicitFolderIds = Array.from(new Set(params.folderIds ?? [])) - await acquireFileFolderMutationLock(tx, params.owner) + await acquireFileItemsMutationLock(tx, params.owner) await assertFileItemsBelongToOwner(params, tx) const activeFolders = await selectAffectedFileFolders(params.owner, explicitFolderIds, tx) @@ -1701,7 +1713,10 @@ export async function deleteWorkspaceFileFolderByPath(params: { const now = new Date() return db.transaction(async (tx) => { - await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId) + await acquireFileItemsMutationLock(tx, { + entityType: 'workspace', + entityId: params.workspaceId, + }) const index = await loadActiveFileFolderPathIndex( { entityType: 'workspace', entityId: params.workspaceId }, tx diff --git a/apps/sim/lib/workspaces/lifecycle.ts b/apps/sim/lib/workspaces/lifecycle.ts index 525dfb2c50f..7a85dc15fda 100644 --- a/apps/sim/lib/workspaces/lifecycle.ts +++ b/apps/sim/lib/workspaces/lifecycle.ts @@ -21,7 +21,10 @@ import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import type { DbTransaction } from '@/lib/db/types' import { mcpPubSub } from '@/lib/mcp/pubsub' import { mcpService } from '@/lib/mcp/service' -import { archiveProjectWithLastEnvironment, lockWorkspaceProject } from '@/lib/projects/membership' +import { + archiveProjectWithLastEnvironment, + lockWorkspaceProjectExclusive, +} from '@/lib/projects/membership' import { archiveWorkflowsInTransaction, finishWorkflowArchive } from '@/lib/workflows/lifecycle' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' @@ -65,7 +68,7 @@ export async function archiveWorkspace( const now = workspaceRecord.archivedAt ?? new Date() const outcome = await db.transaction(async (tx) => { - const owningProject = await lockWorkspaceProject(tx, workspaceId) + const owningProject = await lockWorkspaceProjectExclusive(tx, workspaceId) /** Waits out in-flight workflow creation and restore, so their rows are archived too. */ const [current] = await tx .select({ ownerId: workspace.ownerId }) diff --git a/packages/testing/src/mocks/billing-payer-transfer.mock.ts b/packages/testing/src/mocks/billing-payer-transfer.mock.ts index 6a0c8993a5d..2d4b3bf8ce8 100644 --- a/packages/testing/src/mocks/billing-payer-transfer.mock.ts +++ b/packages/testing/src/mocks/billing-payer-transfer.mock.ts @@ -4,7 +4,6 @@ import { vi } from 'vitest' export const billingPayerTransferMockFns = { mockChangeOrganizationWorkspaceBilledAccountsInTx: vi.fn(), mockChangeWorkspaceStoragePayerInTx: vi.fn(), - mockChangeWorkspaceStoragePayersInTx: vi.fn(), mockChangeProjectAndWorkspaceStoragePayersInTx: vi.fn(), } @@ -12,8 +11,6 @@ export const billingPayerTransferMock = { changeOrganizationWorkspaceBilledAccountsInTx: billingPayerTransferMockFns.mockChangeOrganizationWorkspaceBilledAccountsInTx, changeWorkspaceStoragePayerInTx: billingPayerTransferMockFns.mockChangeWorkspaceStoragePayerInTx, - changeWorkspaceStoragePayersInTx: - billingPayerTransferMockFns.mockChangeWorkspaceStoragePayersInTx, changeProjectAndWorkspaceStoragePayersInTx: billingPayerTransferMockFns.mockChangeProjectAndWorkspaceStoragePayersInTx, }