Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5e8380d
feat(projects): prepare workspace project column compatibility
mzxchandra Oct 9, 2026
235d1e2
test(projects): clean up compatibility fixtures atomically
mzxchandra Oct 9, 2026
e32fda6
Merge remote-tracking branch 'origin/staging' into feat/project-works…
mzxchandra Oct 9, 2026
202c28e
feat(projects): move membership to workspace column with rollout bridge
mzxchandra Oct 9, 2026
9a29636
Merge staging and order Project column expansion after workflow tests
mzxchandra Oct 9, 2026
d6b7a5d
test(projects): align transfer mocks with workspace membership
mzxchandra Oct 9, 2026
031ec26
improvement(workspaces): present API responses explicitly
mzxchandra Oct 9, 2026
49e32c7
Merge remote-tracking branch 'origin/staging' into feat/project-works…
mzxchandra Oct 9, 2026
4a4b85e
fix(projects): support PostgreSQL 16 column migration
mzxchandra Oct 9, 2026
a8c36a1
Merge remote-tracking branch 'origin/staging' into feat/project-works…
mzxchandra Oct 9, 2026
1ce07fa
fix(projects): allow multi-batch copies to finish
mzxchandra Oct 9, 2026
8423fec
fix(projects): reconcile memberships during schema push
mzxchandra Oct 9, 2026
a9c33fe
Merge remote-tracking branch 'origin/staging' into feat/project-works…
mzxchandra Oct 9, 2026
2762a31
fix(projects): use the direct connection for push reconciliation
mzxchandra Oct 9, 2026
1d88662
Merge remote-tracking branch 'origin/staging' into feat/project-works…
mzxchandra Oct 9, 2026
abc8ac6
test(billing): wait for parked transactions before racing writers
mzxchandra Oct 9, 2026
e73ed2d
test(billing): freeze the clock for exact deadline assertions
mzxchandra Oct 9, 2026
ce760cc
ci(projects): use the standard PostgreSQL test coverage
mzxchandra Oct 9, 2026
8323bd3
fix(projects): support trigger-free column rollout
mzxchandra Oct 9, 2026
f0ce089
fix(projects): retain healthy indexes during schema push
mzxchandra Oct 9, 2026
bcc1011
Merge remote-tracking branch 'origin/staging' into feat/project-works…
mzxchandra Oct 9, 2026
0dfc7ab
fix(projects): correct rollout fixtures and schema-push index
mzxchandra Oct 9, 2026
b7ff6b1
Merge staging and renumber Project expansion migration to 0405
mzxchandra Oct 10, 2026
ceb7cf3
fix(projects): switch membership authority after compatible rollout
mzxchandra Oct 10, 2026
b9bc572
Merge staging and advance Project expansion migration to 0406
mzxchandra Oct 10, 2026
8a3993f
test(projects): guard optional authority fixtures explicitly
mzxchandra Oct 10, 2026
9edfcde
test(projects): seed authority in billing lock-order mock
mzxchandra Oct 10, 2026
9856595
fix(projects): bound authority barriers and verify push replay
mzxchandra Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/sim/app/api/workspaces/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { getSession } from '@/lib/auth'
import { changeWorkspaceStoragePayerInTx } from '@/lib/billing/storage/payer-transfer'
import { captureServerEvent } from '@/lib/posthog/server'
import { archiveWorkspace } from '@/lib/workspaces/lifecycle'
import { presentWorkspace } from '@/lib/workspaces/response'

const logger = createLogger('WorkspaceByIdAPI')

Expand Down Expand Up @@ -51,7 +52,7 @@ export const GET = withRouteHandler(

return NextResponse.json({
workspace: {
...workspaceDetails,
...presentWorkspace(workspaceDetails),
permissions: userPermission,
},
})
Expand Down Expand Up @@ -216,7 +217,7 @@ export const PATCH = withRouteHandler(

return NextResponse.json({
workspace: {
...updatedWorkspace,
...(updatedWorkspace ? presentWorkspace(updatedWorkspace) : {}),
permissions: userPermission,
},
})
Expand Down
17 changes: 10 additions & 7 deletions apps/sim/ee/workspace-forking/lib/create-fork.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { projectWorkspace, workspace } from '@sim/db/schema'
import { workspace } from '@sim/db/schema'
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
import { workflowsPersistenceUtilsMock } from '@sim/testing/mocks/workflows-persistence-utils.mock'
import {
Expand Down Expand Up @@ -140,8 +140,9 @@ function forkParams(selection?: {
describe('createFork storage headroom gate', () => {
beforeEach(() => {
resetDbChainMock()
queueTableRows(projectWorkspace, [{ projectId: 'project-source' }])
queueTableRows(projectWorkspace, [
dbChainMockFns.as.mockReturnValue(workspace)
queueTableRows(workspace, [{ projectId: 'project-source' }])
queueTableRows(workspace, [
{ project: { id: 'project-source', organizationId: null, archivedAt: null } },
])
/**
Expand Down Expand Up @@ -215,8 +216,9 @@ describe('createFork storage headroom gate', () => {

it('refuses when the parent changed organizations after the policy was captured', async () => {
resetDbChainMock()
queueTableRows(projectWorkspace, [{ projectId: 'project-source' }])
queueTableRows(projectWorkspace, [
dbChainMockFns.as.mockReturnValue(workspace)
queueTableRows(workspace, [{ projectId: 'project-source' }])
queueTableRows(workspace, [
{ project: { id: 'project-source', organizationId: null, archivedAt: null } },
])
/**
Expand All @@ -243,8 +245,9 @@ describe('createFork storage headroom gate', () => {
*/
it('gives the child the source workspace personal API-key and fork-sync policies', async () => {
resetDbChainMock()
queueTableRows(projectWorkspace, [{ projectId: 'project-source' }])
queueTableRows(projectWorkspace, [
dbChainMockFns.as.mockReturnValue(workspace)
queueTableRows(workspace, [{ projectId: 'project-source' }])
queueTableRows(workspace, [
{ project: { id: 'project-source', organizationId: null, archivedAt: null } },
])
queueTableRows(workspace, [{ organizationId: null, forkSyncNewWorkflowsExcluded: true }])
Expand Down
6 changes: 5 additions & 1 deletion apps/sim/ee/workspace-forking/lib/create-fork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import type { Workspace } from '@/lib/api/contracts/workspaces'
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
import { getProjectMembershipPhase } from '@/lib/projects/environment-source'
import { requireForkProject } from '@/lib/projects/membership'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
import { insertNewWorkflowRow } from '@/lib/workflows/persistence/new-workflow-row'
Expand Down Expand Up @@ -210,6 +211,7 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe
workflowMcpServers: [],
}
const transaction = await db.transaction(async (tx) => {
const phase = await getProjectMembershipPhase(tx)
await setForkLockTimeout(tx)
if (admission) {
await lockWorkspaceOperationRequest(tx, admission.workspaceId, admission.requestId)
Expand Down Expand Up @@ -303,6 +305,7 @@ export async function createFork(params: CreateForkParams): Promise<CreateForkRe

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

if (parentProject)
if (phase === 'connector' && parentProject) {
await tx
.insert(projectWorkspace)
.values({ projectId: parentProject.id, workspaceId: childWorkspaceId })
}

const sourcePermissions = await tx
.select({ userId: permissions.userId, permissionType: permissions.permissionType })
Expand Down
12 changes: 12 additions & 0 deletions apps/sim/lib/api/contracts/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type WorkspacePermission = z.output<typeof workspacePermissionSchema>
export const workspaceSchema = z.object({
id: z.string(),
name: z.string(),
color: z.string().optional(),
logoUrl: z.string().nullable().optional(),
ownerId: z.string(),
organizationId: z.string().nullable(),
Expand All @@ -28,6 +29,17 @@ export const workspaceSchema = z.object({
isOrgAdmin: z.boolean().optional(),
billedAccountUserId: z.string().nullable().optional(),
allowPersonalApiKeys: z.boolean().optional(),
storageUsedBytes: z.number().nonnegative().optional(),
inboxEnabled: z.boolean().optional(),
inboxAddress: z.string().nullable().optional(),
inboxProviderId: z.string().nullable().optional(),
inboxSecretScope: z.string().optional(),
inboxMountedSecrets: z.array(z.string()).optional(),
archivedAt: z.string().datetime().nullable().optional(),
organizationAssignedAt: z.string().datetime().nullable().optional(),
forkSyncNewWorkflowsExcluded: z.boolean().optional(),
createdAt: z.string().datetime().optional(),
updatedAt: z.string().datetime().optional(),
inviteMembersEnabled: z.boolean().optional(),
inviteDisabledReason: z.string().nullable().optional(),
inviteUpgradeRequired: z.boolean().optional(),
Expand Down
7 changes: 6 additions & 1 deletion apps/sim/lib/billing/core/usage-log.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
billingSubscriptionUtilsMock,
billingSubscriptionUtilsMockFns,
} from '@sim/testing/mocks/billing-subscription-utils.mock'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

const {
mockInsert,
Expand Down Expand Up @@ -455,10 +455,15 @@ describe('ledger aggregates', () => {
]

beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] })
installSharedDbMocks()
vi.spyOn(Date, 'now').mockReturnValue(billingPeriod.start.getTime())
})

afterEach(() => {
vi.useRealTimers()
})

for (const aggregate of aggregates) {
it(`${aggregate.name} reads through the bounded ledger transaction`, async () => {
const execute = vi.fn().mockResolvedValue([])
Expand Down
15 changes: 11 additions & 4 deletions apps/sim/lib/billing/organizations/lock-order.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
organization,
outboxEvent,
permissions,
projectMembershipRollout,
subscription as subscriptionTable,
user,
userStats,
Expand Down Expand Up @@ -181,11 +182,14 @@ describe('workspace payer-change transaction lock ordering', () => {
it('locks nonzero workspaces before join billing or aggregate payer changes', async () => {
const ops: Array<{ op: 'lock' | 'payer-transfer' | 'update'; table: unknown }> = []
let memberSelectCount = 0
const rowsForTable = (table: unknown): unknown[] => {
const rowsForTable = (table: unknown, fields?: Record<string, unknown>): unknown[] => {
if (table === projectMembershipRollout) return [{ phase: 'column' }]
if (table === workspace) {
if (fields?.id === workspace.projectId) return [{ id: null }]
return [
{
id: 'workspace-1',
projectId: null,
billedAccountUserId: 'user-1',
organizationId: null,
storageUsedBytes: 128,
Expand All @@ -203,7 +207,7 @@ describe('workspace payer-change transaction lock ordering', () => {
if (table === userStats) return [{ currentPeriodCost: '5' }]
return []
}
const select = () => {
const select = (fields?: Record<string, unknown>) => {
let table: unknown
const chain = {
from(source: unknown) {
Expand All @@ -216,13 +220,16 @@ describe('workspace payer-change transaction lock ordering', () => {
orderBy() {
return chain
},
as() {
return table
},
for() {
ops.push({ op: 'lock', table })
return chain
},
limit: async () => rowsForTable(table),
limit: async () => rowsForTable(table, fields),
then(resolve: (rows: unknown[]) => unknown, reject: (error: unknown) => unknown) {
return Promise.resolve(rowsForTable(table)).then(resolve, reject)
return Promise.resolve(rowsForTable(table, fields)).then(resolve, reject)
},
}
return chain
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type InMemoryStripe,
stripeClientMock,
} from '@sim/testing/mocks/stripe.mock'
import { sleep } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
import { type BetterAuthOptions, betterAuth } from 'better-auth'
import { createAuthMiddleware } from 'better-auth/api'
Expand Down Expand Up @@ -378,7 +379,7 @@ type TestTransaction = Parameters<Parameters<typeof testDatabase.transaction>[0]
* Starts a transaction that takes its locks in `holdLocks`, then parks until released and runs
* `finish`. `untilBlocking` resolves once another backend is waiting on one of its locks.
*/
function startParkedTransaction(
async function startParkedTransaction(
holdLocks: (tx: TestTransaction) => Promise<void>,
finish: (tx: TestTransaction) => Promise<void> = async () => {}
) {
Expand All @@ -399,15 +400,17 @@ function startParkedTransaction(
})
async function untilBlocking() {
const pid = await holderPid
for (let attempt = 0; attempt < 200; attempt++) {
const deadline = Date.now() + 5000
while (Date.now() < deadline) {
const [row] = await connection<{ blocked: number }[]>`
select count(*)::int as blocked from pg_stat_activity
where ${pid}::int = any(pg_blocking_pids(pid))`
if (row.blocked > 0) return
await new Promise<void>((resolve) => setImmediate(resolve))
await sleep(10)
}
throw new Error('No transaction ever waited on the parked one')
}
await Promise.race([holderPid, done])
return { done, release, untilBlocking }
}

Expand Down Expand Up @@ -1025,7 +1028,7 @@ describe('Team activation', () => {
})
stripe.addSubscription({ id: stripeSubscriptionId, customer: `cus_${subscriptionId}` })

const cancelling = startParkedTransaction(async (tx) => {
const cancelling = await startParkedTransaction(async (tx) => {
await tx
.update(subscription)
.set({ cancelAtPeriodEnd: true })
Expand All @@ -1045,8 +1048,12 @@ describe('Team activation', () => {
workspaceIdsToAttach: [],
})
)
await cancelling.untilBlocking()
cancelling.release()
try {
await cancelling.untilBlocking()
} finally {
cancelling.release()
await Promise.allSettled([cancelling.done, activating])
}
await cancelling.done
await expect(activating).resolves.toMatchObject({ success: true })
expect((await storedSubscription(subscriptionId)).cancelAtPeriodEnd).toBe(false)
Expand All @@ -1072,7 +1079,7 @@ describe('operator retry', () => {
)
await deadLetter(pauseSync)

const writing = startParkedTransaction(
const writing = await startParkedTransaction(
async (tx) => {
await tx
.update(subscription)
Expand All @@ -1089,8 +1096,12 @@ describe('operator retry', () => {
}
)
const requeuing = requeueFromAdminApi(pauseSync)
await writing.untilBlocking()
writing.release()
try {
await writing.untilBlocking()
} finally {
writing.release()
await Promise.allSettled([writing.done, requeuing])
}

await expect(Promise.all([writing.done, requeuing])).resolves.toBeDefined()
await deliverUnrelatedUpdate(pro.stripeSubscriptionId)
Expand All @@ -1113,7 +1124,7 @@ describe('operator retry', () => {
)
await deadLetter(cancelSync)

const writing = startParkedTransaction(
const writing = await startParkedTransaction(
async (tx) => {
await tx
.update(subscription)
Expand All @@ -1135,8 +1146,12 @@ describe('operator retry', () => {
timing: 'period_end',
actor,
})
await writing.untilBlocking()
writing.release()
try {
await writing.untilBlocking()
} finally {
writing.release()
await Promise.allSettled([writing.done, retrying])
}

await expect(Promise.all([writing.done, retrying])).resolves.toBeDefined()
expect((await storedSubscription(org.subscriptionId)).cancelAtPeriodEnd).toBe(true)
Expand Down
Loading