chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,199 @@
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { isAppConfigEnabled, isBillingEnabled, isForkingEnabled } from '@/lib/core/config/env-flags'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { HttpError } from '@/lib/core/utils/http-error'
import { checkWorkspaceAccess, type WorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
import { getWorkspaceCreationPolicy, type WorkspaceCreationPolicy } from '@/lib/workspaces/policy'
import { type ForkEdge, resolveForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
/** Direction of a promote, relative to the workspace the caller is acting from. */
export type PromoteDirection = 'push' | 'pull'
/**
* Gate shared by every fork/promote route. The deployment/entitlement gate is
* unchanged: on Sim Cloud the gate is the Enterprise plan; on self-hosted it's
* `FORKING_ENABLED`, which 404s when unset so a newer image doesn't silently expose
* forking. Mirrors the data-drains gate - this repo gates EE features by plan + env
* flag, not by directory.
*
* Layered on top is the runtime `workspace-forking` flag, a rollout switch enforced
* ONLY where AppConfig is the source of truth (Sim Cloud). It lets us dark-launch
* forking to specific orgs/users/admins without a redeploy; self-hosted/local
* deployments have no AppConfig, so their behaviour is untouched by the flag.
*/
async function assertForkingEnabled(organizationId: string | null, userId: string): Promise<void> {
if (!isBillingEnabled && !isForkingEnabled) {
throw new ForkError('Workspace forking is not enabled on this deployment', 404)
}
if (isBillingEnabled) {
const hasEnterprise = organizationId
? await isOrganizationOnEnterprisePlan(organizationId)
: false
if (!hasEnterprise) {
throw new ForkError('Workspace forking is available on Enterprise plans only', 403)
}
}
if (
isAppConfigEnabled &&
!(await isFeatureEnabled('workspace-forking', { userId, orgId: organizationId }))
) {
throw new ForkError('Workspace forking is not enabled on this deployment', 404)
}
}
/**
* Non-throwing availability verdict of the exact {@link assertForkingEnabled} gate
* (env/plan + AppConfig rollout flag), for surfaces that show/hide fork UI. The
* availability route serves this to the client so tab visibility can never drift
* from what the fork routes actually enforce.
*/
export async function isForkingAvailableForWorkspace(
organizationId: string | null,
userId: string
): Promise<boolean> {
try {
await assertForkingEnabled(organizationId, userId)
return true
} catch {
return false
}
}
/**
* Domain error for fork/promote operations. Carries a concrete `statusCode` so
* `withRouteHandler` maps it to the right HTTP status and forwards the
* client-safe `message`.
*/
export class ForkError extends HttpError {
readonly statusCode: number
constructor(message: string, statusCode = 400) {
super(message)
this.name = 'ForkError'
this.statusCode = statusCode
}
}
async function requireWorkspace(
workspaceId: string,
userId: string
): Promise<{ workspace: WorkspaceWithOwner; canAdmin: boolean }> {
const access = await checkWorkspaceAccess(workspaceId, userId)
if (!access.exists || !access.workspace) {
throw new ForkError('Workspace not found', 404)
}
await assertForkingEnabled(access.workspace.organizationId, userId)
return { workspace: access.workspace, canAdmin: access.canAdmin }
}
/** Require admin access; returns the (active) workspace. */
export async function assertWorkspaceAdminAccess(
workspaceId: string,
userId: string
): Promise<WorkspaceWithOwner> {
const { workspace, canAdmin } = await requireWorkspace(workspaceId, userId)
if (!canAdmin) {
throw new ForkError('Admin access is required for this workspace', 403)
}
return workspace
}
export interface ForkAuthorization {
source: WorkspaceWithOwner
policy: WorkspaceCreationPolicy
}
/**
* Authorize forking `sourceWorkspaceId`: the caller needs admin access to the
* source (a fork copies its deployed workflows and resources en masse) and must
* pass the workspace-creation policy for the parent's org (the child inherits the
* parent's org/mode; plan caps apply). Org owners/admins derive workspace admin.
*
* `pinOrganization` makes the child ALWAYS land in the source's org - including a
* personal source (org `null`) - rather than the acting user's membership org,
* which the policy would otherwise fall back to when the source is personal.
*/
export async function assertCanFork(
sourceWorkspaceId: string,
userId: string
): Promise<ForkAuthorization> {
const source = await assertWorkspaceAdminAccess(sourceWorkspaceId, userId)
const policy = await getWorkspaceCreationPolicy({
userId,
activeOrganizationId: source.organizationId,
pinOrganization: true,
})
if (!policy.canCreate) {
throw new ForkError(
policy.reason ?? 'You cannot create another workspace on your current plan',
policy.status >= 400 ? policy.status : 403
)
}
return { source, policy }
}
export interface PromoteAuthorization {
edge: ForkEdge
source: WorkspaceWithOwner
target: WorkspaceWithOwner
sourceWorkspaceId: string
targetWorkspaceId: string
}
/**
* Authorize a promote along the strict edge between `currentWorkspaceId` and
* `otherWorkspaceId`. Requires admin on BOTH the source and the target: a sync
* reads the source's deployed workflows/resources and force-replaces the target's,
* and the sync surface is only ever offered to workspace admins. `push` sends
* current -> other; `pull` brings other -> current.
*/
export async function assertCanPromote(
currentWorkspaceId: string,
otherWorkspaceId: string,
direction: PromoteDirection,
userId: string
): Promise<PromoteAuthorization> {
const edge = await resolveForkEdge(currentWorkspaceId, otherWorkspaceId)
if (!edge) {
throw new ForkError('These workspaces are not a direct fork edge', 400)
}
const sourceWorkspaceId = direction === 'push' ? currentWorkspaceId : otherWorkspaceId
const targetWorkspaceId = direction === 'push' ? otherWorkspaceId : currentWorkspaceId
const source = await assertWorkspaceAdminAccess(sourceWorkspaceId, userId)
const target = await assertWorkspaceAdminAccess(targetWorkspaceId, userId)
return { edge, source, target, sourceWorkspaceId, targetWorkspaceId }
}
/** Authorize rolling back the last promote into `targetWorkspaceId` (admin only). */
export async function assertCanRollback(
targetWorkspaceId: string,
userId: string
): Promise<WorkspaceWithOwner> {
return assertWorkspaceAdminAccess(targetWorkspaceId, userId)
}
export interface UnlinkAuthorization {
edge: ForkEdge
current: WorkspaceWithOwner
}
/**
* Authorize permanently dissolving the fork edge between `currentWorkspaceId` and
* `otherWorkspaceId`. Requires admin on the ACTING side only (mirrors rollback):
* either participant may sever the association about itself — unlinking removes
* shared edge metadata without reading or writing the other workspace's content,
* and requiring both-side admin would strand an edge whose other side lost its
* admins.
*/
export async function assertCanUnlink(
currentWorkspaceId: string,
otherWorkspaceId: string,
userId: string
): Promise<UnlinkAuthorization> {
const current = await assertWorkspaceAdminAccess(currentWorkspaceId, userId)
const edge = await resolveForkEdge(currentWorkspaceId, otherWorkspaceId)
if (!edge) {
throw new ForkError('These workspaces are not a direct fork edge', 400)
}
return { edge, current }
}
@@ -0,0 +1,129 @@
import { db } from '@sim/db'
import { workspace } from '@sim/db/schema'
import { and, desc, eq, isNull, sql } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
export interface ForkLineageNode {
id: string
name: string
organizationId: string | null
}
export interface ForkLineageChild extends ForkLineageNode {
createdAt: Date
}
export interface ForkEdge {
childWorkspaceId: string
parentWorkspaceId: string
}
/**
* The parent workspace id a fork was created from, or null when the workspace
* is not a fork (or has been archived).
*/
export async function getForkParentId(workspaceId: string): Promise<string | null> {
const [row] = await db
.select({ parentId: workspace.forkedFromWorkspaceId })
.from(workspace)
.where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt)))
.limit(1)
return row?.parentId ?? null
}
/** The parent lineage node for a fork, or null when it has no live parent. */
export async function getForkParent(workspaceId: string): Promise<ForkLineageNode | null> {
const parentId = await getForkParentId(workspaceId)
if (!parentId) return null
const [row] = await db
.select({
id: workspace.id,
name: workspace.name,
organizationId: workspace.organizationId,
})
.from(workspace)
.where(and(eq(workspace.id, parentId), isNull(workspace.archivedAt)))
.limit(1)
return row ?? null
}
/**
* The live (non-archived) forks created from this workspace, newest first, for
* the Forks settings page's read-only fork list.
*/
export async function getForkChildren(workspaceId: string): Promise<ForkLineageChild[]> {
return db
.select({
id: workspace.id,
name: workspace.name,
organizationId: workspace.organizationId,
createdAt: workspace.createdAt,
})
.from(workspace)
.where(and(eq(workspace.forkedFromWorkspaceId, workspaceId), isNull(workspace.archivedAt)))
.orderBy(desc(workspace.createdAt))
}
/**
* Resolve the strict fork edge between two workspaces, identifying which is the
* child (the one whose `forkedFromWorkspaceId` points at the other). Returns
* null when the two workspaces are not a direct parent/child pair.
*/
export async function resolveForkEdge(
workspaceAId: string,
workspaceBId: string
): Promise<ForkEdge | null> {
if (workspaceAId === workspaceBId) return null
if ((await getForkParentId(workspaceAId)) === workspaceBId) {
return { childWorkspaceId: workspaceAId, parentWorkspaceId: workspaceBId }
}
if ((await getForkParentId(workspaceBId)) === workspaceAId) {
return { childWorkspaceId: workspaceBId, parentWorkspaceId: workspaceAId }
}
return null
}
/**
* How long a fork transaction waits for a lock before aborting. Bounds the wait on the
* target/edge advisory locks (and any incidental row lock) so a contended sync into the
* same target fails fast and returns its pooled connection instead of piling waiters up
* and stagnating the pool at scale. 10s favors completing a legit sync queued behind an
* in-flight one, while still tripping on a pathological hold. Connection-level timeouts
* are not used (PlanetScale rejects them) - this is transaction-scoped only.
*/
const FORK_LOCK_TIMEOUT_MS = 10_000
/**
* Apply {@link FORK_LOCK_TIMEOUT_MS} to the current transaction (`set_config(local)`),
* so it covers `pg_advisory_xact_lock` waits too. Call at the very start of a fork
* transaction, before acquiring any lock.
*/
export async function setForkLockTimeout(tx: DbOrTx): Promise<void> {
await tx.execute(sql`select set_config('lock_timeout', ${`${FORK_LOCK_TIMEOUT_MS}ms`}, true)`)
}
/**
* Serialize concurrent promote/rollback on a fork edge with a transaction-scoped
* advisory lock keyed by the edge (the child workspace id). `hashtextextended`
* (64-bit, matching every other advisory lock in the repo) makes a collision
* between distinct keys astronomically unlikely; a collision would only cause
* unnecessary serialization, never a correctness issue.
*/
export async function acquireForkEdgeLock(tx: DbOrTx, childWorkspaceId: string): Promise<void> {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${`fork-edge:${childWorkspaceId}`}, 0))`
)
}
/**
* Serialize every promote/rollback whose TARGET is this workspace. Sibling forks
* promote into the same parent on different edge locks, so the edge lock alone does
* not serialize them; this lock does, keeping concurrent syncs into one target from
* interleaving and keeping rollback's "newest sync" check race-free. Always acquire
* this BEFORE {@link acquireForkEdgeLock} so the two are taken in a consistent order.
*/
export async function acquireForkTargetLock(tx: DbOrTx, targetWorkspaceId: string): Promise<void> {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${`fork-target:${targetWorkspaceId}`}, 0))`
)
}
@@ -0,0 +1,65 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockTransaction, mockSetForkLockTimeout, mockAcquireForkEdgeLock } = vi.hoisted(() => ({
mockTransaction: vi.fn(),
mockSetForkLockTimeout: vi.fn(),
mockAcquireForkEdgeLock: vi.fn(),
}))
vi.mock('@sim/db', () => ({ db: { transaction: mockTransaction } }))
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({
setForkLockTimeout: mockSetForkLockTimeout,
acquireForkEdgeLock: mockAcquireForkEdgeLock,
}))
import { unlinkForkEdge } from '@/ee/workspace-forking/lib/lineage/unlink'
/** A fake tx whose update returns `updatedRows` and whose deletes record their calls. */
function fakeTx(updatedRows: Array<{ id: string }>) {
const updateWhere = vi.fn(() => ({ returning: vi.fn().mockResolvedValue(updatedRows) }))
const updateSet = vi.fn(() => ({ where: updateWhere }))
const update = vi.fn(() => ({ set: updateSet }))
const deleteWhere = vi.fn().mockResolvedValue(undefined)
const del = vi.fn(() => ({ where: deleteWhere }))
return { tx: { update, delete: del }, update, updateSet, del }
}
const EDGE = { childWorkspaceId: 'child-ws', parentWorkspaceId: 'parent-ws' }
describe('unlinkForkEdge', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('nulls the child pointer and purges all four edge tables under the edge lock', async () => {
const { tx, update, updateSet, del } = fakeTx([{ id: 'child-ws' }])
mockTransaction.mockImplementation(async (cb: (t: unknown) => unknown) => cb(tx))
const result = await unlinkForkEdge(EDGE, 'req-1')
expect(result).toEqual({ unlinked: true })
expect(mockSetForkLockTimeout).toHaveBeenCalledTimes(1)
expect(mockAcquireForkEdgeLock).toHaveBeenCalledWith(tx, 'child-ws')
expect(update).toHaveBeenCalledTimes(1)
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ forkedFromWorkspaceId: null }))
expect(del).toHaveBeenCalledTimes(4)
})
it('is an idempotent no-op when the edge was already dissolved', async () => {
const { tx, del } = fakeTx([])
mockTransaction.mockImplementation(async (cb: (t: unknown) => unknown) => cb(tx))
const result = await unlinkForkEdge(EDGE)
expect(result).toEqual({ unlinked: false })
expect(del).not.toHaveBeenCalled()
})
it('propagates a transaction failure without swallowing it', async () => {
mockTransaction.mockRejectedValue(new Error('lock timeout'))
await expect(unlinkForkEdge(EDGE)).rejects.toThrow('lock timeout')
})
})
@@ -0,0 +1,78 @@
import { db } from '@sim/db'
import {
workspace,
workspaceForkBlockMap,
workspaceForkDependentValue,
workspaceForkPromoteRun,
workspaceForkResourceMap,
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import {
acquireForkEdgeLock,
type ForkEdge,
setForkLockTimeout,
} from '@/ee/workspace-forking/lib/lineage/lineage'
const logger = createLogger('ForkUnlink')
export interface UnlinkForkResult {
/** False when the edge was already dissolved by a concurrent unlink (idempotent no-op). */
unlinked: boolean
}
/**
* Permanently dissolve a fork edge: null the child's `forkedFromWorkspaceId` (the
* edge's single source of truth) and purge the edge's fork state — resource map,
* block map, dependent values, and promote-run undo points. Both workspaces are
* left untouched; only the association and its metadata are removed.
*
* Runs in one transaction under the edge advisory lock, which every promote and
* rollback on the edge also holds, so an in-flight sync either finishes before the
* unlink or re-resolves the edge afterwards and fails with "not a direct fork edge".
* The edge is re-verified inside the lock; a concurrently-dissolved edge is an
* idempotent success rather than an error.
*/
export async function unlinkForkEdge(
edge: ForkEdge,
requestId?: string
): Promise<UnlinkForkResult> {
const { childWorkspaceId, parentWorkspaceId } = edge
const unlinked = await db.transaction(async (tx) => {
await setForkLockTimeout(tx)
await acquireForkEdgeLock(tx, childWorkspaceId)
const updated = await tx
.update(workspace)
.set({ forkedFromWorkspaceId: null, updatedAt: new Date() })
.where(
and(
eq(workspace.id, childWorkspaceId),
eq(workspace.forkedFromWorkspaceId, parentWorkspaceId)
)
)
.returning({ id: workspace.id })
if (updated.length === 0) return false
await tx
.delete(workspaceForkResourceMap)
.where(eq(workspaceForkResourceMap.childWorkspaceId, childWorkspaceId))
await tx
.delete(workspaceForkBlockMap)
.where(eq(workspaceForkBlockMap.childWorkspaceId, childWorkspaceId))
await tx
.delete(workspaceForkDependentValue)
.where(eq(workspaceForkDependentValue.childWorkspaceId, childWorkspaceId))
await tx
.delete(workspaceForkPromoteRun)
.where(eq(workspaceForkPromoteRun.childWorkspaceId, childWorkspaceId))
return true
})
logger.info(`[${requestId ?? 'unlink'}] Fork edge ${unlinked ? 'dissolved' : 'already gone'}`, {
childWorkspaceId,
parentWorkspaceId,
})
return { unlinked }
}