Skip to content

Commit 8323bd3

Browse files
committed
fix(projects): support trigger-free column rollout
1 parent ce760cc commit 8323bd3

15 files changed

Lines changed: 579 additions & 611 deletions

‎apps/sim/ee/workspace-forking/lib/create-fork.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ function forkParams(selection?: {
140140
describe('createFork storage headroom gate', () => {
141141
beforeEach(() => {
142142
resetDbChainMock()
143+
dbChainMockFns.as.mockReturnValue(workspace)
143144
queueTableRows(workspace, [{ projectId: 'project-source' }])
144145
queueTableRows(workspace, [
145146
{ project: { id: 'project-source', organizationId: null, archivedAt: null } },
@@ -215,6 +216,7 @@ describe('createFork storage headroom gate', () => {
215216

216217
it('refuses when the parent changed organizations after the policy was captured', async () => {
217218
resetDbChainMock()
219+
dbChainMockFns.as.mockReturnValue(workspace)
218220
queueTableRows(workspace, [{ projectId: 'project-source' }])
219221
queueTableRows(workspace, [
220222
{ project: { id: 'project-source', organizationId: null, archivedAt: null } },
@@ -243,6 +245,7 @@ describe('createFork storage headroom gate', () => {
243245
*/
244246
it('gives the child the source workspace personal API-key and fork-sync policies', async () => {
245247
resetDbChainMock()
248+
dbChainMockFns.as.mockReturnValue(workspace)
246249
queueTableRows(workspace, [{ projectId: 'project-source' }])
247250
queueTableRows(workspace, [
248251
{ project: { id: 'project-source', organizationId: null, archivedAt: null } },

‎apps/sim/lib/billing/organizations/lock-order.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,9 @@ describe('workspace payer-change transaction lock ordering', () => {
218218
orderBy() {
219219
return chain
220220
},
221+
as() {
222+
return table
223+
},
221224
for() {
222225
ops.push({ op: 'lock', table })
223226
return chain

‎apps/sim/lib/projects/__integration__/foundation.integration.ts‎

Lines changed: 287 additions & 74 deletions
Large diffs are not rendered by default.

‎apps/sim/lib/projects/account-deletion.ts‎

Lines changed: 41 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,28 @@ import { member, permissions, project, workspace } from '@sim/db/schema'
33
import { ORG_ADMIN_ROLES } from '@sim/platform-authz/workspace'
44
import { and, asc, eq, inArray, ne, or, sql } from 'drizzle-orm'
55
import type { DbOrTx, DbTransaction } from '@/lib/db/types'
6+
import {
7+
deleteObsoleteProjectMemberships,
8+
getProjectEnvironmentSource,
9+
} from '@/lib/projects/environment-source'
610
import {
711
lockProjectBackfillWrites,
812
lockProjects,
913
ProjectConflictError,
1014
} from '@/lib/projects/membership'
1115

1216
/** Two indexed lookups; an `OR` around a membership subquery would scan every Project. */
13-
async function loadRelatedProjects(executor: DbOrTx, userId: string, doomedWorkspaceIds: string[]) {
17+
async function loadRelatedProjects(
18+
executor: DbTransaction,
19+
userId: string,
20+
doomedWorkspaceIds: string[]
21+
) {
22+
const environments = await getProjectEnvironmentSource(executor)
1423
const doomedMemberships = doomedWorkspaceIds.length
1524
? await executor
16-
.select({ projectId: workspace.projectId })
17-
.from(workspace)
18-
.where(inArray(workspace.id, doomedWorkspaceIds))
25+
.select({ projectId: environments.projectId })
26+
.from(environments)
27+
.where(inArray(environments.id, doomedWorkspaceIds))
1928
: []
2029
const projectIds = doomedMemberships.flatMap((row) => (row.projectId ? [row.projectId] : []))
2130
return executor
@@ -100,16 +109,17 @@ interface ProjectEnvironment {
100109
}
101110

102111
/** Every environment of `projectIds`, in one query, keyed by Project. */
103-
async function loadProjectEnvironments(executor: DbOrTx, projectIds: string[]) {
112+
async function loadProjectEnvironments(executor: DbTransaction, projectIds: string[]) {
113+
const environments = await getProjectEnvironmentSource(executor)
104114
const rows = projectIds.length
105115
? await executor
106116
.select({
107-
projectId: workspace.projectId,
108-
id: workspace.id,
109-
archivedAt: workspace.archivedAt,
117+
projectId: environments.projectId,
118+
id: environments.id,
119+
archivedAt: environments.archivedAt,
110120
})
111-
.from(workspace)
112-
.where(inArray(workspace.projectId, projectIds))
121+
.from(environments)
122+
.where(inArray(environments.projectId, projectIds))
113123
: []
114124
const byProject = new Map<string, ProjectEnvironment[]>()
115125
for (const { projectId, ...environment } of rows) {
@@ -157,25 +167,27 @@ export async function getProjectAccountDeletionBlockers(
157167
userId: string,
158168
doomedWorkspaceIds: string[]
159169
): Promise<string[]> {
160-
const records = await loadRelatedProjects(db, userId, doomedWorkspaceIds)
161-
const environments = await loadProjectEnvironments(
162-
db,
163-
records.map((record) => record.id)
164-
)
165-
const doomed = new Set(doomedWorkspaceIds)
166-
const blockers: string[] = []
167-
for (const record of records) {
168-
const decision = await planProjectDeletion(
169-
db,
170-
record,
171-
environments.get(record.id) ?? [],
172-
userId,
173-
doomed,
174-
false
170+
return db.transaction(async (tx) => {
171+
const records = await loadRelatedProjects(tx, userId, doomedWorkspaceIds)
172+
const environments = await loadProjectEnvironments(
173+
tx,
174+
records.map((record) => record.id)
175175
)
176-
if ('blocker' in decision) blockers.push(decision.blocker)
177-
}
178-
return blockers
176+
const doomed = new Set(doomedWorkspaceIds)
177+
const blockers: string[] = []
178+
for (const record of records) {
179+
const decision = await planProjectDeletion(
180+
tx,
181+
record,
182+
environments.get(record.id) ?? [],
183+
userId,
184+
doomed,
185+
false
186+
)
187+
if ('blocker' in decision) blockers.push(decision.blocker)
188+
}
189+
return blockers
190+
})
179191
}
180192

181193
/** Transfers surviving Projects and returns private Projects to delete after their workspaces. */
@@ -235,5 +247,6 @@ export async function prepareProjectsForAccountDeletion(
235247
})
236248
.where(eq(project.id, record.id))
237249
}
250+
await deleteObsoleteProjectMemberships(tx, projectsToDelete)
238251
return projectsToDelete
239252
}

‎apps/sim/lib/projects/application/authorization.ts‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Principal, SessionPrincipal } from '@sim/auth/principal'
2-
import { member, permissionGroup, permissions, project, workspace } from '@sim/db/schema'
2+
import { member, permissionGroup, permissions, project } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
55
import { and, asc, eq, inArray, sql } from 'drizzle-orm'
@@ -11,6 +11,7 @@ import { CAPABILITY_RULES, refuseCapability } from '@/lib/permission-groups/capa
1111
import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks'
1212
import { resolveVerifiedUserAccessControlContext } from '@/lib/permission-groups/resolve.server'
1313
import { type ProjectOperation, projectOperations } from '@/lib/projects/application/operations'
14+
import { getProjectEnvironmentSource } from '@/lib/projects/environment-source'
1415
import { lockProject } from '@/lib/projects/membership'
1516

1617
const logger = createLogger('ProjectAuthorization')
@@ -67,23 +68,24 @@ async function loadProjectAccess(
6768
const [membership] = records.some((record) => record.organizationId)
6869
? await (lock ? memberQuery.for('share') : memberQuery)
6970
: []
71+
const source = await getProjectEnvironmentSource(tx)
7072
const environments = await tx
7173
.select({
72-
projectId: workspace.projectId,
73-
id: workspace.id,
74-
name: workspace.name,
75-
organizationId: workspace.organizationId,
76-
archivedAt: workspace.archivedAt,
77-
parentId: workspace.forkedFromWorkspaceId,
74+
projectId: source.projectId,
75+
id: source.id,
76+
name: source.name,
77+
organizationId: source.organizationId,
78+
archivedAt: source.archivedAt,
79+
parentId: source.parentId,
7880
})
79-
.from(workspace)
81+
.from(source)
8082
.where(
8183
inArray(
82-
workspace.projectId,
84+
source.projectId,
8385
records.map((record) => record.id)
8486
)
8587
)
86-
.orderBy(asc(workspace.id))
88+
.orderBy(asc(source.id))
8789
const grantQuery = tx
8890
.select({ id: permissions.entityId, permission: permissions.permissionType })
8991
.from(permissions)

‎apps/sim/lib/projects/application/use-cases.ts‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
requireProjectPrincipal,
1515
} from '@/lib/projects/application/authorization'
1616
import { projectOperations } from '@/lib/projects/application/operations'
17+
import { getProjectEnvironmentSource } from '@/lib/projects/environment-source'
1718
import { archiveProjectInTransaction, finishProjectArchive } from '@/lib/projects/lifecycle'
1819
import { requireProjectApiEnabled } from '@/lib/projects/rollout.server'
1920

@@ -85,6 +86,7 @@ export const listProjects: OperationUseCase<
8586
requireProjectPrincipal(principal, projectOperations.list)
8687
await requireProjectApiEnabled()
8788
return db.transaction(async (tx) => {
89+
const environments = await getProjectEnvironmentSource(tx)
8890
/** Driven from the caller's grants and admin organization, so cost tracks their reach. */
8991
const candidates = await tx.execute<{ id: string }>(sql`
9092
WITH accessible AS (
@@ -96,15 +98,15 @@ export const listProjects: OperationUseCase<
9698
JOIN ${workspace} ON ${workspace.organizationId} = ${member.organizationId}
9799
WHERE ${member.userId} = ${principal.userId} AND ${inArray(member.role, ORG_ADMIN_ROLES)}
98100
)
99-
SELECT DISTINCT ${workspace.projectId} AS id
101+
SELECT DISTINCT ${environments.projectId} AS id
100102
FROM accessible
101-
JOIN ${workspace}
102-
ON ${workspace.id} = accessible.workspace_id AND ${workspace.archivedAt} IS NULL
103+
JOIN ${environments}
104+
ON ${environments.id} = accessible.workspace_id AND ${environments.archivedAt} IS NULL
103105
JOIN ${project}
104-
ON ${project.id} = ${workspace.projectId} AND ${project.archivedAt} IS NULL
106+
ON ${project.id} = ${environments.projectId} AND ${project.archivedAt} IS NULL
105107
WHERE TRUE
106108
${input.organizationId ? sql`AND ${project.organizationId} = ${input.organizationId}` : sql``}
107-
${input.cursor ? sql`AND ${workspace.projectId} > ${input.cursor}` : sql``}
109+
${input.cursor ? sql`AND ${environments.projectId} > ${input.cursor}` : sql``}
108110
ORDER BY 1
109111
LIMIT ${input.limit + 1}
110112
`)
@@ -210,10 +212,11 @@ export const getWorkspaceProject: OperationUseCase<
210212
requireProjectPrincipal(principal, projectOperations.get)
211213
await requireProjectApiEnabled()
212214
return db.transaction(async (tx) => {
215+
const environments = await getProjectEnvironmentSource(tx)
213216
const [membership] = await tx
214-
.select({ projectId: workspace.projectId })
215-
.from(workspace)
216-
.where(eq(workspace.id, input.workspaceId))
217+
.select({ projectId: environments.projectId })
218+
.from(environments)
219+
.where(eq(environments.id, input.workspaceId))
217220
.limit(1)
218221
if (!membership?.projectId) throw new OrchestrationError('not_found', 'Project not found')
219222
return {
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { projectWorkspace, workspace } from '@sim/db/schema'
2+
import { and, eq, exists, inArray, isNotNull, isNull, ne, sql } from 'drizzle-orm'
3+
import type { SubqueryWithSelection } from 'drizzle-orm/pg-core'
4+
import type { DbTransaction } from '@/lib/db/types'
5+
6+
const fields = {
7+
id: workspace.id,
8+
projectId: workspace.projectId,
9+
name: workspace.name,
10+
ownerId: workspace.ownerId,
11+
organizationId: workspace.organizationId,
12+
archivedAt: workspace.archivedAt,
13+
parentId: workspace.forkedFromWorkspaceId,
14+
}
15+
16+
/**
17+
* Columns win over legacy assignments. The workspace table lock protects the catalog
18+
* check and fallback read from connector retirement until the caller commits. It is
19+
* compatible with ordinary writes; retirement locks workspace and both FK-related
20+
* tables up front with NOWAIT before dropping the connector.
21+
*/
22+
async function hasLegacyMemberships(tx: DbTransaction): Promise<boolean> {
23+
await tx.execute(sql`LOCK TABLE ${workspace} IN ACCESS SHARE MODE`)
24+
const [legacy] = await tx.execute<{ present: boolean }>(
25+
sql`SELECT to_regclass('public.project_workspace') IS NOT NULL AS present`
26+
)
27+
return legacy?.present ?? false
28+
}
29+
30+
/** Every workspace appears once; a populated column supersedes its legacy assignment. */
31+
export async function getProjectEnvironmentSource(
32+
tx: DbTransaction
33+
): Promise<SubqueryWithSelection<typeof fields, 'project_environments'>> {
34+
if (!(await hasLegacyMemberships(tx)))
35+
return tx.select(fields).from(workspace).as('project_environments')
36+
return tx
37+
.select(fields)
38+
.from(workspace)
39+
.where(isNotNull(workspace.projectId))
40+
.unionAll(
41+
tx
42+
.select({ ...fields, projectId: projectWorkspace.projectId })
43+
.from(workspace)
44+
.leftJoin(projectWorkspace, eq(projectWorkspace.workspaceId, workspace.id))
45+
.where(isNull(workspace.projectId))
46+
)
47+
.as('project_environments')
48+
}
49+
50+
/**
51+
* A detached environment can still have a legacy reference to a Project being deleted.
52+
* Remove only references superseded by a different non-null column, under the caller's
53+
* Project locks and deletion transaction. Current memberships remain protected by the FK.
54+
*/
55+
export async function deleteObsoleteProjectMemberships(
56+
tx: DbTransaction,
57+
projectIds: string[]
58+
): Promise<void> {
59+
if (!projectIds.length || !(await hasLegacyMemberships(tx))) return
60+
await tx.delete(projectWorkspace).where(
61+
and(
62+
inArray(projectWorkspace.projectId, projectIds),
63+
exists(
64+
tx
65+
.select({ id: workspace.id })
66+
.from(workspace)
67+
.where(
68+
and(
69+
eq(workspace.id, projectWorkspace.workspaceId),
70+
isNotNull(workspace.projectId),
71+
ne(workspace.projectId, projectWorkspace.projectId)
72+
)
73+
)
74+
)
75+
)
76+
)
77+
}

‎apps/sim/lib/projects/lifecycle.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { project, workspace } from '@sim/db/schema'
2-
import { asc, eq } from 'drizzle-orm'
2+
import { asc, eq, sql } from 'drizzle-orm'
33
import { OrchestrationError } from '@/lib/core/orchestration/types'
44
import type { DbTransaction } from '@/lib/db/types'
5+
import { getProjectEnvironmentSource } from '@/lib/projects/environment-source'
56
import { lockProject } from '@/lib/projects/membership'
67
import {
78
archiveEnvironmentInTransaction,
@@ -18,10 +19,13 @@ export async function archiveProjectInTransaction(
1819
const [record] = await tx.select().from(project).where(eq(project.id, projectId))
1920
if (!record) throw new OrchestrationError('not_found', 'Project not found')
2021
const now = record.archivedAt ?? new Date()
22+
const source = await getProjectEnvironmentSource(tx)
2123
const environments = await tx
2224
.select({ id: workspace.id })
2325
.from(workspace)
24-
.where(eq(workspace.projectId, projectId))
26+
.where(
27+
sql`${workspace.id} IN (SELECT ${source.id} FROM ${source} WHERE ${source.projectId} = ${projectId})`
28+
)
2529
.orderBy(asc(workspace.id))
2630
.for('no key update')
2731
const effects: EnvironmentArchiveEffects[] = []

0 commit comments

Comments
 (0)