Skip to content

Commit 3492598

Browse files
authored
feat(projects): move Project membership to the workspace column (#8830)
* feat(projects): prepare workspace project column compatibility * test(projects): clean up compatibility fixtures atomically * feat(projects): move membership to workspace column with rollout bridge * test(projects): align transfer mocks with workspace membership * improvement(workspaces): present API responses explicitly * fix(projects): support PostgreSQL 16 column migration * fix(projects): allow multi-batch copies to finish * fix(projects): reconcile memberships during schema push * fix(projects): use the direct connection for push reconciliation * test(billing): wait for parked transactions before racing writers * test(billing): freeze the clock for exact deadline assertions * ci(projects): use the standard PostgreSQL test coverage * fix(projects): support trigger-free column rollout * fix(projects): retain healthy indexes during schema push * fix(projects): correct rollout fixtures and schema-push index * fix(projects): switch membership authority after compatible rollout * test(projects): guard optional authority fixtures explicitly * test(projects): seed authority in billing lock-order mock * fix(projects): bound authority barriers and verify push replay * docs(projects): record native constraint rollout decisions * fix(projects): recognize completed rollout without its marker * test(projects): model expansion authority in workspace move fixtures
1 parent c1c5ae0 commit 3492598

34 files changed

Lines changed: 32978 additions & 350 deletions

‎apps/sim/app/api/workspaces/[id]/route.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { getSession } from '@/lib/auth'
1010
import { changeWorkspaceStoragePayerInTx } from '@/lib/billing/storage/payer-transfer'
1111
import { captureServerEvent } from '@/lib/posthog/server'
1212
import { archiveWorkspace } from '@/lib/workspaces/lifecycle'
13+
import { presentWorkspace } from '@/lib/workspaces/response'
1314

1415
const logger = createLogger('WorkspaceByIdAPI')
1516

@@ -51,7 +52,7 @@ export const GET = withRouteHandler(
5152

5253
return NextResponse.json({
5354
workspace: {
54-
...workspaceDetails,
55+
...presentWorkspace(workspaceDetails),
5556
permissions: userPermission,
5657
},
5758
})
@@ -216,7 +217,7 @@ export const PATCH = withRouteHandler(
216217

217218
return NextResponse.json({
218219
workspace: {
219-
...updatedWorkspace,
220+
...(updatedWorkspace ? presentWorkspace(updatedWorkspace) : {}),
220221
permissions: userPermission,
221222
},
222223
})

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { projectWorkspace, workspace } from '@sim/db/schema'
1+
import { workspace } from '@sim/db/schema'
22
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
33
import { workflowsPersistenceUtilsMock } from '@sim/testing/mocks/workflows-persistence-utils.mock'
44
import {
@@ -140,8 +140,9 @@ function forkParams(selection?: {
140140
describe('createFork storage headroom gate', () => {
141141
beforeEach(() => {
142142
resetDbChainMock()
143-
queueTableRows(projectWorkspace, [{ projectId: 'project-source' }])
144-
queueTableRows(projectWorkspace, [
143+
dbChainMockFns.as.mockReturnValue(workspace)
144+
queueTableRows(workspace, [{ projectId: 'project-source' }])
145+
queueTableRows(workspace, [
145146
{ project: { id: 'project-source', organizationId: null, archivedAt: null } },
146147
])
147148
/**
@@ -215,8 +216,9 @@ describe('createFork storage headroom gate', () => {
215216

216217
it('refuses when the parent changed organizations after the policy was captured', async () => {
217218
resetDbChainMock()
218-
queueTableRows(projectWorkspace, [{ projectId: 'project-source' }])
219-
queueTableRows(projectWorkspace, [
219+
dbChainMockFns.as.mockReturnValue(workspace)
220+
queueTableRows(workspace, [{ projectId: 'project-source' }])
221+
queueTableRows(workspace, [
220222
{ project: { id: 'project-source', organizationId: null, archivedAt: null } },
221223
])
222224
/**
@@ -243,8 +245,9 @@ describe('createFork storage headroom gate', () => {
243245
*/
244246
it('gives the child the source workspace personal API-key and fork-sync policies', async () => {
245247
resetDbChainMock()
246-
queueTableRows(projectWorkspace, [{ projectId: 'project-source' }])
247-
queueTableRows(projectWorkspace, [
248+
dbChainMockFns.as.mockReturnValue(workspace)
249+
queueTableRows(workspace, [{ projectId: 'project-source' }])
250+
queueTableRows(workspace, [
248251
{ project: { id: 'project-source', organizationId: null, archivedAt: null } },
249252
])
250253
queueTableRows(workspace, [{ organizationId: null, forkSyncNewWorkflowsExcluded: true }])

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { generateId } from '@sim/utils/id'
77
import { and, eq } from 'drizzle-orm'
88
import type { Workspace } from '@/lib/api/contracts/workspaces'
99
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
10+
import { getProjectMembershipPhase } from '@/lib/projects/environment-source'
1011
import { requireForkProject } from '@/lib/projects/membership'
1112
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
1213
import { insertNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row'
@@ -210,6 +211,7 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
210211
workflowMcpServers: [],
211212
}
212213
const transaction = await db.transaction(async (tx) => {
214+
const phase = await getProjectMembershipPhase(tx)
213215
await setForkLockTimeout(tx)
214216
if (admission) {
215217
await lockWorkspaceOperationRequest(tx, admission.workspaceId, admission.requestId)
@@ -303,6 +305,7 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
303305

304306
await tx.insert(workspace).values({
305307
id: childWorkspaceId,
308+
projectId: phase === 'column' ? (parentProject?.id ?? null) : null,
306309
name: childName,
307310
ownerId: userId,
308311
organizationId: policy.organizationId,
@@ -316,10 +319,11 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
316319
updatedAt: now,
317320
})
318321

319-
if (parentProject)
322+
if (phase === 'connector' && parentProject) {
320323
await tx
321324
.insert(projectWorkspace)
322325
.values({ projectId: parentProject.id, workspaceId: childWorkspaceId })
326+
}
323327

324328
const sourcePermissions = await tx
325329
.select({ userId: permissions.userId, permissionType: permissions.permissionType })

‎apps/sim/lib/api/contracts/workspaces.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type WorkspacePermission = z.output<typeof workspacePermissionSchema>
1313
export const workspaceSchema = z.object({
1414
id: z.string(),
1515
name: z.string(),
16+
color: z.string().optional(),
1617
logoUrl: z.string().nullable().optional(),
1718
ownerId: z.string(),
1819
organizationId: z.string().nullable(),
@@ -28,6 +29,17 @@ export const workspaceSchema = z.object({
2829
isOrgAdmin: z.boolean().optional(),
2930
billedAccountUserId: z.string().nullable().optional(),
3031
allowPersonalApiKeys: z.boolean().optional(),
32+
storageUsedBytes: z.number().nonnegative().optional(),
33+
inboxEnabled: z.boolean().optional(),
34+
inboxAddress: z.string().nullable().optional(),
35+
inboxProviderId: z.string().nullable().optional(),
36+
inboxSecretScope: z.string().optional(),
37+
inboxMountedSecrets: z.array(z.string()).optional(),
38+
archivedAt: z.string().datetime().nullable().optional(),
39+
organizationAssignedAt: z.string().datetime().nullable().optional(),
40+
forkSyncNewWorkflowsExcluded: z.boolean().optional(),
41+
createdAt: z.string().datetime().optional(),
42+
updatedAt: z.string().datetime().optional(),
3143
inviteMembersEnabled: z.boolean().optional(),
3244
inviteDisabledReason: z.string().nullable().optional(),
3345
inviteUpgradeRequired: z.boolean().optional(),

‎apps/sim/lib/billing/core/usage-log.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
billingSubscriptionUtilsMock,
66
billingSubscriptionUtilsMockFns,
77
} from '@sim/testing/mocks/billing-subscription-utils.mock'
8-
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
8+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
99

1010
const {
1111
mockInsert,
@@ -455,10 +455,15 @@ describe('ledger aggregates', () => {
455455
]
456456

457457
beforeEach(() => {
458+
vi.useFakeTimers({ toFake: ['Date'] })
458459
installSharedDbMocks()
459460
vi.spyOn(Date, 'now').mockReturnValue(billingPeriod.start.getTime())
460461
})
461462

463+
afterEach(() => {
464+
vi.useRealTimers()
465+
})
466+
462467
for (const aggregate of aggregates) {
463468
it(`${aggregate.name} reads through the bounded ledger transaction`, async () => {
464469
const execute = vi.fn().mockResolvedValue([])

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
organization,
1212
outboxEvent,
1313
permissions,
14+
projectMembershipRollout,
1415
subscription as subscriptionTable,
1516
user,
1617
userStats,
@@ -181,11 +182,14 @@ describe('workspace payer-change transaction lock ordering', () => {
181182
it('locks nonzero workspaces before join billing or aggregate payer changes', async () => {
182183
const ops: Array<{ op: 'lock' | 'payer-transfer' | 'update'; table: unknown }> = []
183184
let memberSelectCount = 0
184-
const rowsForTable = (table: unknown): unknown[] => {
185+
const rowsForTable = (table: unknown, fields?: Record<string, unknown>): unknown[] => {
186+
if (table === projectMembershipRollout) return [{ phase: 'column' }]
185187
if (table === workspace) {
188+
if (fields?.id === workspace.projectId) return [{ id: null }]
186189
return [
187190
{
188191
id: 'workspace-1',
192+
projectId: null,
189193
billedAccountUserId: 'user-1',
190194
organizationId: null,
191195
storageUsedBytes: 128,
@@ -203,7 +207,7 @@ describe('workspace payer-change transaction lock ordering', () => {
203207
if (table === userStats) return [{ currentPeriodCost: '5' }]
204208
return []
205209
}
206-
const select = () => {
210+
const select = (fields?: Record<string, unknown>) => {
207211
let table: unknown
208212
const chain = {
209213
from(source: unknown) {
@@ -216,19 +220,22 @@ describe('workspace payer-change transaction lock ordering', () => {
216220
orderBy() {
217221
return chain
218222
},
223+
as() {
224+
return table
225+
},
219226
for() {
220227
ops.push({ op: 'lock', table })
221228
return chain
222229
},
223-
limit: async () => rowsForTable(table),
230+
limit: async () => rowsForTable(table, fields),
224231
then(resolve: (rows: unknown[]) => unknown, reject: (error: unknown) => unknown) {
225-
return Promise.resolve(rowsForTable(table)).then(resolve, reject)
232+
return Promise.resolve(rowsForTable(table, fields)).then(resolve, reject)
226233
},
227234
}
228235
return chain
229236
}
230237
const tx = {
231-
execute: async () => [],
238+
execute: async () => [{ marker: true, complete: false }],
232239
select,
233240
selectDistinct: select,
234241
insert: () => ({

0 commit comments

Comments
 (0)