Skip to content

Commit 2e94a13

Browse files
committed
Merge branch 'feat/project-entity-foundation' into codex/project-entity-enforcement
# Conflicts: # apps/sim/lib/projects/__integration__/foundation.integration.ts
2 parents dc5e301 + c1c8e5d commit 2e94a13

9 files changed

Lines changed: 77 additions & 1171 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/projects/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Project APIs return HTTP 503 until the deployment enables `PROJECT_API_ENABLED`.
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: 50 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ import { createFork } from '@/ee/workspace-forking/lib/create-fork'
5757
import { unlinkForkEdge } from '@/ee/workspace-forking/lib/lineage/unlink'
5858

5959
beforeEach(() => {
60-
vi.stubEnv('PROJECT_WRITES_ENABLED', 'true')
6160
vi.stubEnv('PROJECT_API_ENABLED', 'true')
6261
})
6362

@@ -292,9 +291,8 @@ describe('Project foundation at the database and application boundary', () => {
292291
)
293292

294293
check(
295-
'workspace creation and fork/disconnect assign Projects even with the retired writer flag off',
294+
'workspace creation and fork/disconnect assign Projects while APIs remain disabled',
296295
async () => {
297-
vi.stubEnv('PROJECT_WRITES_ENABLED', 'false')
298296
vi.stubEnv('PROJECT_API_ENABLED', 'false')
299297
const f = await fixture(false, 1)
300298
const source = await db.transaction((tx) =>
@@ -340,53 +338,49 @@ describe('Project foundation at the database and application boundary', () => {
340338
async () => {
341339
const f = await fixture(false, 1)
342340
vi.stubEnv('PROJECT_API_ENABLED', 'false')
343-
for (const writes of ['false', 'true']) {
344-
vi.stubEnv('PROJECT_WRITES_ENABLED', writes)
345-
const input = { projectId: f.projectId }
346-
const calls = [
347-
() =>
348-
createProject.execute({
349-
principal: f.owner,
350-
input: {
351-
organizationId: null,
352-
name: 'Blocked',
353-
initialEnvironment: { name: 'Production' },
354-
},
355-
request,
356-
}),
357-
() => getProject.execute({ principal: f.owner, input, request }),
358-
() =>
359-
getWorkspaceProject.execute({
360-
principal: f.owner,
361-
input: { workspaceId: f.ids[0] },
362-
request,
363-
}),
364-
() => listProjects.execute({ principal: f.owner, input: { limit: 10 }, request }),
365-
() =>
366-
renameProject.execute({
367-
principal: f.owner,
368-
input: { ...input, name: 'Blocked' },
369-
request,
370-
}),
371-
() => archiveProject.execute({ principal: f.owner, input, request }),
372-
() => getProjectIssueAccess.execute({ principal: f.owner, input, request }),
373-
]
374-
for (const call of calls) await expect(call()).rejects.toMatchObject({ statusCode: 503 })
375-
expect(
376-
await db.select().from(workspace).where(eq(workspace.ownerId, f.ownerId))
377-
).toHaveLength(1)
378-
const [record] = await db.select().from(project).where(eq(project.id, f.projectId))
379-
expect(record.name).toBe('Environment 0 - Project')
380-
expect(record.archivedAt).toBeNull()
381-
}
341+
const input = { projectId: f.projectId }
342+
const calls = [
343+
() =>
344+
createProject.execute({
345+
principal: f.owner,
346+
input: {
347+
organizationId: null,
348+
name: 'Blocked',
349+
initialEnvironment: { name: 'Production' },
350+
},
351+
request,
352+
}),
353+
() => getProject.execute({ principal: f.owner, input, request }),
354+
() =>
355+
getWorkspaceProject.execute({
356+
principal: f.owner,
357+
input: { workspaceId: f.ids[0] },
358+
request,
359+
}),
360+
() => listProjects.execute({ principal: f.owner, input: { limit: 10 }, request }),
361+
() =>
362+
renameProject.execute({
363+
principal: f.owner,
364+
input: { ...input, name: 'Blocked' },
365+
request,
366+
}),
367+
() => archiveProject.execute({ principal: f.owner, input, request }),
368+
() => getProjectIssueAccess.execute({ principal: f.owner, input, request }),
369+
]
370+
for (const call of calls) await expect(call()).rejects.toMatchObject({ statusCode: 503 })
371+
expect(
372+
await db.select().from(workspace).where(eq(workspace.ownerId, f.ownerId))
373+
).toHaveLength(1)
374+
const [record] = await db.select().from(project).where(eq(project.id, f.projectId))
375+
expect(record.name).toBe('Environment 0 - Project')
376+
expect(record.archivedAt).toBeNull()
382377
}
383378
)
384379

385380
check(
386381
'disabling activation preserves assigned fork membership and lifecycle protections',
387382
async () => {
388383
const f = await fixture(false, 1)
389-
vi.stubEnv('PROJECT_WRITES_ENABLED', 'false')
390384
vi.stubEnv('PROJECT_API_ENABLED', 'false')
391385
const parent = await getWorkspaceWithOwner(f.ids[0])
392386
if (!parent) throw new Error('Missing source fixture')
@@ -440,7 +434,6 @@ describe('Project foundation at the database and application boundary', () => {
440434
await db
441435
.delete(projectWorkspace)
442436
.where(inArray(projectWorkspace.workspaceId, f.ids.slice(0, 2)))
443-
vi.stubEnv('PROJECT_WRITES_ENABLED', 'false')
444437
vi.stubEnv('PROJECT_API_ENABLED', 'false')
445438
const parent = await getWorkspaceWithOwner(f.ids[1])
446439
if (!parent) throw new Error('Missing source fixture')
@@ -481,20 +474,23 @@ describe('Project foundation at the database and application boundary', () => {
481474
})
482475
try {
483476
await Promise.race([read.promise, writer])
484-
await expect(
485-
db.transaction(async (tx) => {
486-
await tx.execute(
487-
sql`LOCK TABLE workspace, project, project_workspace IN SHARE ROW EXCLUSIVE MODE NOWAIT`
488-
)
489-
})
490-
).rejects.toSatisfy((error: unknown) => getPostgresErrorCode(error) === '55P03')
477+
await db.transaction(async (tx) => {
478+
const [lock] = await tx.execute<{ acquired: boolean }>(sql`
479+
SELECT pg_try_advisory_xact_lock(hashtextextended(${`project-backfill:${f.ids[0]}`}, 0)) AS acquired
480+
`)
481+
expect(lock.acquired).toBe(false)
482+
const [unrelated] = await tx.execute<{ acquired: boolean }>(sql`
483+
SELECT pg_try_advisory_xact_lock(hashtextextended('project-backfill:unrelated', 0)) AS acquired
484+
`)
485+
expect(unrelated.acquired).toBe(true)
486+
})
491487
} finally {
492488
release.resolve()
493489
await writer
494490
}
495491
await db.transaction(async (tx) => {
496492
await tx.execute(
497-
sql`LOCK TABLE workspace, project, project_workspace IN SHARE ROW EXCLUSIVE MODE NOWAIT`
493+
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`project-backfill:${f.ids[0]}`}, 0))`
498494
)
499495
await createProjectForWorkspace(tx, {
500496
workspaceId: f.ids[0],
@@ -520,7 +516,7 @@ describe('Project foundation at the database and application boundary', () => {
520516
const release = createDeferred<void>()
521517
const backfill = db.transaction(async (tx) => {
522518
await tx.execute(
523-
sql`LOCK TABLE workspace, project, project_workspace IN SHARE ROW EXCLUSIVE MODE NOWAIT`
519+
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`project-backfill:${f.ids[0]}`}, 0))`
524520
)
525521
locked.resolve()
526522
await release.promise
@@ -545,7 +541,7 @@ describe('Project foundation at the database and application boundary', () => {
545541
try {
546542
for (let attempt = 0; attempt < 100; attempt++) {
547543
const rows = await db.execute<{ waiting: boolean }>(sql`SELECT EXISTS (
548-
SELECT 1 FROM pg_locks WHERE relation = 'workspace'::regclass AND mode = 'RowExclusiveLock' AND NOT granted
544+
SELECT 1 FROM pg_locks WHERE locktype = 'advisory' AND mode = 'ShareLock' AND NOT granted
549545
) AS waiting`)
550546
if (rows[0]?.waiting) {
551547
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)

‎packages/db/package.json‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,6 @@
2828
"./sso-primary-provider": {
2929
"types": "./sso-primary-provider.ts",
3030
"default": "./sso-primary-provider.ts"
31-
},
32-
"./project-backfill": {
33-
"types": "./project-backfill.ts",
34-
"default": "./project-backfill.ts"
3531
}
3632
},
3733
"scripts": {

0 commit comments

Comments
 (0)