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
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:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user