Skip to content

Commit c1c8e5d

Browse files
committed
refactor(projects): prepare compatible writers for the SQL backfill
1 parent a14b448 commit c1c8e5d

13 files changed

Lines changed: 103 additions & 1225 deletions

File tree

‎apps/sim/lib/billing/organizations/membership.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -557,7 +557,7 @@ async function reassignOwnedOrganizationResourcesTx({
557557
const ownerId = ownerMembership?.userId
558558
if (!ownerId || ownerId === userId) return 0
559559

560-
await lockProjectBackfillWrites(tx)
560+
await lockProjectBackfillWrites(tx, workspaceIds)
561561
const ownedProjects = await tx
562562
.select({ id: project.id })
563563
.from(project)

‎apps/sim/lib/core/config/env.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -669,7 +669,6 @@ export const env = createEnv({
669669

670670
// SSO Configuration (for script-based registration)
671671
SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality
672-
PROJECT_WRITES_ENABLED: z.boolean().optional(), // Activate new Project assignments after old writers drain
673672
PROJECT_API_ENABLED: z.boolean().optional(), // Expose Projects after backfill and contract enforcement
674673
SCIM_ENABLED: z.boolean().optional(), // Enable SCIM directory provisioning
675674
USAGE_MONITORING_ENABLED: z.boolean().optional(), // Enable organization usage monitoring on self-hosted (bypasses hosted requirements)

‎apps/sim/lib/projects/README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22

33
A Project groups environments. An environment is an existing `workspace` record; there is no separate environment table. Every newly created Project starts with an environment.
44

5-
Project APIs return HTTP 503 until the deployment enables `PROJECT_API_ENABLED`. Automatic assignment on legacy workspace creation requires `PROJECT_WRITES_ENABLED`; both server controls default off. The API requires writers to be enabled. Existing assigned Projects always retain their lifecycle protections, including fork inheritance and disconnect behavior, even if activation is disabled.
5+
Project APIs return HTTP 503 until the deployment enables `PROJECT_API_ENABLED`. Workspace creation always assigns a Project atomically. The API control defaults off and does not disable assignment. Existing assigned Projects always retain their lifecycle protections, including fork inheritance and disconnect behavior, even if activation is disabled.
66

77
## Choose the creation flow
88

99
| Caller | Flow | Result |
1010
| --- | --- | --- |
1111
| New Project onboarding | `POST /api/projects` | Creates a Project and its first environment together, with independently supplied names. |
12-
| Existing workspace creation UI or caller | `POST /api/workspaces` | Creates a workspace and, when Project writers are enabled, automatically creates its Project, preserving the existing workspace response. |
12+
| Existing workspace creation UI or caller | `POST /api/workspaces` | Creates a workspace and automatically creates its Project, preserving the existing workspace response. |
1313
| Create another environment by forking | Existing workspace fork operation | Inherits the source workspace's Project; unassigned legacy families remain unassigned until backfilled. |
1414

1515
Both POST endpoints are internal, session-authenticated APIs. `POST /api/projects` is not a public `/api/v2` endpoint and does not accept API-key principals. This foundation does not remove or deprecate existing workspace creation endpoints.
@@ -69,7 +69,7 @@ Existing callers can continue to use `createWorkspaceContract` and `POST /api/wo
6969

7070
`skipDefaultWorkflow` remains optional and defaults to `false`. The route uses the session's active organization and existing workspace creation policy to resolve ownership and billing. It does not take the explicit Project scope or independent Project name used by `POST /api/projects`.
7171

72-
When Project writers are enabled, the workspace and its Project are created atomically. The generated Project name is `Support workspace - Project`; long names are bounded to 100 characters while retaining the suffix. The response remains `{ "workspace": ... }` with HTTP 200, without a new Project response wrapper. Call `GET /api/projects/by-workspace/[workspaceId]` when an existing workspace caller needs its authorized Project details.
72+
The workspace and its Project are created atomically. The generated Project name is `Support workspace - Project`; long names are bounded to 100 characters while retaining the suffix. The response remains `{ "workspace": ... }` with HTTP 200, without a new Project response wrapper. Call `GET /api/projects/by-workspace/[workspaceId]` when an existing workspace caller needs its authorized Project details.
7373

7474
## Server implementation
7575

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

Lines changed: 54 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ import { createFork } from '@/ee/workspace-forking/lib/create-fork'
5555
import { unlinkForkEdge } from '@/ee/workspace-forking/lib/lineage/unlink'
5656

5757
beforeEach(() => {
58-
vi.stubEnv('PROJECT_WRITES_ENABLED', 'true')
5958
vi.stubEnv('PROJECT_API_ENABLED', 'true')
6059
})
6160

@@ -194,9 +193,8 @@ afterAll(async () => {
194193

195194
describe('Project foundation at the database and application boundary', () => {
196195
check(
197-
'disabled rollout preserves legacy creation and fork/disconnect without Project rows',
196+
'workspace creation and fork/disconnect assign Projects while APIs remain disabled',
198197
async () => {
199-
vi.stubEnv('PROJECT_WRITES_ENABLED', 'false')
200198
vi.stubEnv('PROJECT_API_ENABLED', 'false')
201199
const f = await fixture(false, 1)
202200
const source = await db.transaction((tx) =>
@@ -214,7 +212,7 @@ describe('Project foundation at the database and application boundary', () => {
214212
environments.push(source.id)
215213
expect(
216214
await db.select().from(projectWorkspace).where(eq(projectWorkspace.workspaceId, source.id))
217-
).toEqual([])
215+
).toHaveLength(1)
218216
const parent = await getWorkspaceWithOwner(source.id)
219217
if (!parent) throw new Error('Missing source fixture')
220218
const fork = await createFork({
@@ -229,11 +227,11 @@ describe('Project foundation at the database and application boundary', () => {
229227
.select()
230228
.from(projectWorkspace)
231229
.where(eq(projectWorkspace.workspaceId, fork.workspace.id))
232-
).toEqual([])
230+
).toHaveLength(1)
233231
await unlinkForkEdge({ parentWorkspaceId: source.id, childWorkspaceId: fork.workspace.id })
234232
const [child] = await db.select().from(workspace).where(eq(workspace.id, fork.workspace.id))
235233
expect(child.forkedFromWorkspaceId).toBeNull()
236-
expect(await db.select().from(project).where(eq(project.ownerId, f.ownerId))).toHaveLength(1)
234+
expect(await db.select().from(project).where(eq(project.ownerId, f.ownerId))).toHaveLength(3)
237235
}
238236
)
239237

@@ -242,53 +240,49 @@ describe('Project foundation at the database and application boundary', () => {
242240
async () => {
243241
const f = await fixture(false, 1)
244242
vi.stubEnv('PROJECT_API_ENABLED', 'false')
245-
for (const writes of ['false', 'true']) {
246-
vi.stubEnv('PROJECT_WRITES_ENABLED', writes)
247-
const input = { projectId: f.projectId }
248-
const calls = [
249-
() =>
250-
createProject.execute({
251-
principal: f.owner,
252-
input: {
253-
organizationId: null,
254-
name: 'Blocked',
255-
initialEnvironment: { name: 'Production' },
256-
},
257-
request,
258-
}),
259-
() => getProject.execute({ principal: f.owner, input, request }),
260-
() =>
261-
getWorkspaceProject.execute({
262-
principal: f.owner,
263-
input: { workspaceId: f.ids[0] },
264-
request,
265-
}),
266-
() => listProjects.execute({ principal: f.owner, input: { limit: 10 }, request }),
267-
() =>
268-
renameProject.execute({
269-
principal: f.owner,
270-
input: { ...input, name: 'Blocked' },
271-
request,
272-
}),
273-
() => archiveProject.execute({ principal: f.owner, input, request }),
274-
() => getProjectIssueAccess.execute({ principal: f.owner, input, request }),
275-
]
276-
for (const call of calls) await expect(call()).rejects.toMatchObject({ statusCode: 503 })
277-
expect(
278-
await db.select().from(workspace).where(eq(workspace.ownerId, f.ownerId))
279-
).toHaveLength(1)
280-
const [record] = await db.select().from(project).where(eq(project.id, f.projectId))
281-
expect(record.name).toBe('Environment 0 - Project')
282-
expect(record.archivedAt).toBeNull()
283-
}
243+
const input = { projectId: f.projectId }
244+
const calls = [
245+
() =>
246+
createProject.execute({
247+
principal: f.owner,
248+
input: {
249+
organizationId: null,
250+
name: 'Blocked',
251+
initialEnvironment: { name: 'Production' },
252+
},
253+
request,
254+
}),
255+
() => getProject.execute({ principal: f.owner, input, request }),
256+
() =>
257+
getWorkspaceProject.execute({
258+
principal: f.owner,
259+
input: { workspaceId: f.ids[0] },
260+
request,
261+
}),
262+
() => listProjects.execute({ principal: f.owner, input: { limit: 10 }, request }),
263+
() =>
264+
renameProject.execute({
265+
principal: f.owner,
266+
input: { ...input, name: 'Blocked' },
267+
request,
268+
}),
269+
() => archiveProject.execute({ principal: f.owner, input, request }),
270+
() => getProjectIssueAccess.execute({ principal: f.owner, input, request }),
271+
]
272+
for (const call of calls) await expect(call()).rejects.toMatchObject({ statusCode: 503 })
273+
expect(
274+
await db.select().from(workspace).where(eq(workspace.ownerId, f.ownerId))
275+
).toHaveLength(1)
276+
const [record] = await db.select().from(project).where(eq(project.id, f.projectId))
277+
expect(record.name).toBe('Environment 0 - Project')
278+
expect(record.archivedAt).toBeNull()
284279
}
285280
)
286281

287282
check(
288283
'disabling activation preserves assigned fork membership and lifecycle protections',
289284
async () => {
290285
const f = await fixture(false, 1)
291-
vi.stubEnv('PROJECT_WRITES_ENABLED', 'false')
292286
vi.stubEnv('PROJECT_API_ENABLED', 'false')
293287
const parent = await getWorkspaceWithOwner(f.ids[0])
294288
if (!parent) throw new Error('Missing source fixture')
@@ -316,7 +310,7 @@ describe('Project foundation at the database and application boundary', () => {
316310
}
317311
)
318312

319-
check('writer activation assigns new workspaces while Project APIs remain disabled', async () => {
313+
check('new workspaces receive Projects while Project APIs remain disabled', async () => {
320314
vi.stubEnv('PROJECT_API_ENABLED', 'false')
321315
const f = await fixture(false, 1)
322316
const created = await db.transaction((tx) =>
@@ -335,26 +329,13 @@ describe('Project foundation at the database and application boundary', () => {
335329
expect(
336330
await db.select().from(projectWorkspace).where(eq(projectWorkspace.workspaceId, created.id))
337331
).toHaveLength(1)
338-
vi.stubEnv('PROJECT_WRITES_ENABLED', 'false')
339-
vi.stubEnv('PROJECT_API_ENABLED', 'true')
340-
await expect(
341-
createProject.execute({
342-
principal: f.owner,
343-
input: { organizationId: null, name: 'Invalid', initialEnvironment: { name: 'First' } },
344-
request,
345-
})
346-
).rejects.toThrow('PROJECT_API_ENABLED requires PROJECT_WRITES_ENABLED')
347-
expect(await db.select().from(workspace).where(eq(workspace.ownerId, f.ownerId))).toHaveLength(
348-
2
349-
)
350332
})
351333

352334
check('legacy fork and disconnect refuse a partially assigned subtree', async () => {
353335
const f = await fixture(false, 3)
354336
await db
355337
.delete(projectWorkspace)
356338
.where(inArray(projectWorkspace.workspaceId, f.ids.slice(0, 2)))
357-
vi.stubEnv('PROJECT_WRITES_ENABLED', 'false')
358339
vi.stubEnv('PROJECT_API_ENABLED', 'false')
359340
const parent = await getWorkspaceWithOwner(f.ids[1])
360341
if (!parent) throw new Error('Missing source fixture')
@@ -395,20 +376,23 @@ describe('Project foundation at the database and application boundary', () => {
395376
})
396377
try {
397378
await Promise.race([read.promise, writer])
398-
await expect(
399-
db.transaction(async (tx) => {
400-
await tx.execute(
401-
sql`LOCK TABLE workspace, project, project_workspace IN SHARE ROW EXCLUSIVE MODE NOWAIT`
402-
)
403-
})
404-
).rejects.toSatisfy((error: unknown) => getPostgresErrorCode(error) === '55P03')
379+
await db.transaction(async (tx) => {
380+
const [lock] = await tx.execute<{ acquired: boolean }>(sql`
381+
SELECT pg_try_advisory_xact_lock(hashtextextended(${`project-backfill:${f.ids[0]}`}, 0)) AS acquired
382+
`)
383+
expect(lock.acquired).toBe(false)
384+
const [unrelated] = await tx.execute<{ acquired: boolean }>(sql`
385+
SELECT pg_try_advisory_xact_lock(hashtextextended('project-backfill:unrelated', 0)) AS acquired
386+
`)
387+
expect(unrelated.acquired).toBe(true)
388+
})
405389
} finally {
406390
release.resolve()
407391
await writer
408392
}
409393
await db.transaction(async (tx) => {
410394
await tx.execute(
411-
sql`LOCK TABLE workspace, project, project_workspace IN SHARE ROW EXCLUSIVE MODE NOWAIT`
395+
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`project-backfill:${f.ids[0]}`}, 0))`
412396
)
413397
await createProjectForWorkspace(tx, {
414398
workspaceId: f.ids[0],
@@ -434,7 +418,7 @@ describe('Project foundation at the database and application boundary', () => {
434418
const release = createDeferred<void>()
435419
const backfill = db.transaction(async (tx) => {
436420
await tx.execute(
437-
sql`LOCK TABLE workspace, project, project_workspace IN SHARE ROW EXCLUSIVE MODE NOWAIT`
421+
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`project-backfill:${f.ids[0]}`}, 0))`
438422
)
439423
locked.resolve()
440424
await release.promise
@@ -459,7 +443,7 @@ describe('Project foundation at the database and application boundary', () => {
459443
try {
460444
for (let attempt = 0; attempt < 100; attempt++) {
461445
const rows = await db.execute<{ waiting: boolean }>(sql`SELECT EXISTS (
462-
SELECT 1 FROM pg_locks WHERE relation = 'workspace'::regclass AND mode = 'RowExclusiveLock' AND NOT granted
446+
SELECT 1 FROM pg_locks WHERE locktype = 'advisory' AND mode = 'ShareLock' AND NOT granted
463447
) AS waiting`)
464448
if (rows[0]?.waiting) {
465449
blocked = true

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@ export async function prepareProjectsForAccountDeletion(
1010
userId: string,
1111
doomedWorkspaceIds: string[]
1212
): Promise<void> {
13-
await lockProjectBackfillWrites(tx)
13+
const ownedEnvironments = await tx
14+
.select({ id: workspace.id })
15+
.from(workspace)
16+
.where(eq(workspace.ownerId, userId))
17+
await lockProjectBackfillWrites(tx, [
18+
...doomedWorkspaceIds,
19+
...ownedEnvironments.map((row) => row.id),
20+
])
1421
const records = await tx
1522
.select({ id: project.id })
1623
.from(project)

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

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,21 @@ import { and, asc, eq, inArray, isNull, ne, notInArray, sql } from 'drizzle-orm'
55
import { OrchestrationError } from '@/lib/core/orchestration/types'
66
import type { DbOrTx, DbTransaction } from '@/lib/db/types'
77

8-
/** Prevents backfill from assigning membership between an absence read and the ensuing write. */
9-
export async function lockProjectBackfillWrites(tx: DbTransaction): Promise<void> {
8+
/** Shared per-environment gate keeps membership absence reads stable during SQL backfill. */
9+
export async function lockProjectBackfillWrites(
10+
tx: DbTransaction,
11+
workspaceIds: string[]
12+
): Promise<void> {
13+
if (!workspaceIds.length) return
1014
await tx.execute(sql`SET LOCAL lock_timeout = '5s'`)
1115
try {
12-
await tx.execute(sql`LOCK TABLE workspace IN ROW EXCLUSIVE MODE`)
16+
await tx.execute(sql`
17+
SELECT pg_advisory_xact_lock_shared(hashtextextended('project-backfill:' || id, 0))
18+
FROM (SELECT DISTINCT unnest(ARRAY[${sql.join(
19+
workspaceIds.map((id) => sql`${id}`),
20+
sql`, `
21+
)}]::text[]) AS id ORDER BY id) ids
22+
`)
1323
} catch (error) {
1424
if (getPostgresErrorCode(error) === '55P03')
1525
throw new OrchestrationError('conflict', 'Project backfill is running; retry the operation')
@@ -59,9 +69,9 @@ export async function createProjectForWorkspace(
5969
return id
6070
}
6171

62-
/** Returns null only for a legacy workspace awaiting the operator-run backfill. */
72+
/** Returns null only for a legacy workspace awaiting the SQL backfill. */
6373
export async function lockWorkspaceProject(tx: DbTransaction, workspaceId: string) {
64-
await lockProjectBackfillWrites(tx)
74+
await lockProjectBackfillWrites(tx, [workspaceId])
6575
const [membership] = await tx
6676
.select()
6777
.from(projectWorkspace)
@@ -218,7 +228,7 @@ export async function transferWorkspaceProjects(
218228
ownerId?: string
219229
): Promise<void> {
220230
if (!workspaceIds.length) return
221-
await lockProjectBackfillWrites(tx)
231+
await lockProjectBackfillWrites(tx, workspaceIds)
222232
const owners = await tx
223233
.selectDistinct({ id: projectWorkspace.projectId })
224234
.from(projectWorkspace)
Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,6 @@
11
import { envBoolean, getEnv } from '@/lib/core/config/env'
22
import { HttpError } from '@/lib/core/utils/http-error'
33

4-
/** Deployment-wide controls; existing Project lifecycle maintenance never depends on these. */
5-
export function getProjectRollout() {
6-
const writesEnabled = envBoolean(getEnv('PROJECT_WRITES_ENABLED')) ?? false
7-
const apiEnabled = envBoolean(getEnv('PROJECT_API_ENABLED')) ?? false
8-
if (apiEnabled && !writesEnabled)
9-
throw new Error('PROJECT_API_ENABLED requires PROJECT_WRITES_ENABLED')
10-
return { writesEnabled, apiEnabled }
11-
}
12-
134
class ProjectUnavailableError extends HttpError {
145
readonly statusCode = 503
156
constructor() {
@@ -18,5 +9,5 @@ class ProjectUnavailableError extends HttpError {
189
}
1910

2011
export function requireProjectApiEnabled(): void {
21-
if (!getProjectRollout().apiEnabled) throw new ProjectUnavailableError()
12+
if (!(envBoolean(getEnv('PROJECT_API_ENABLED')) ?? false)) throw new ProjectUnavailableError()
2213
}

0 commit comments

Comments
 (0)