Files
simstudioai--sim/apps/sim/ee/workspace-forking/lib/mapping/mapping-service.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

437 lines
17 KiB
TypeScript

import { db } from '@sim/db'
import type { ForkMappableResourceType, ForkMappingEntry } from '@/lib/api/contracts/workspace-fork'
import type { DbOrTx } from '@/lib/db/types'
import {
listDeployedWorkflows,
readDeployedState,
} from '@/ee/workspace-forking/lib/copy/deploy-bridge'
import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
import type { ForkEdge } from '@/ee/workspace-forking/lib/lineage/lineage'
import { detectForkCascadeReferences } from '@/ee/workspace-forking/lib/mapping/cascade'
import {
buildForkResolver,
deleteEdgeMappingsByChildResources,
type ForkResourceType,
getEdgeMappingRows,
nonCredentialForkKindToResourceType,
resourceTypeToForkKind,
upsertEdgeMappings,
} from '@/ee/workspace-forking/lib/mapping/mapping-store'
import {
CANDIDATE_LIMIT,
classifyCredentialResourceType,
type ForkResourceCandidate,
filterExistingForkTargets,
getCredentialProvidersByIds,
getWorkspaceEnvKeys,
listForkResourceCandidates,
} from '@/ee/workspace-forking/lib/mapping/resources'
import { toScannerBlocks } from '@/ee/workspace-forking/lib/remap/reference-scan'
import {
type ForkReference,
type ForkRemapKind,
scanWorkflowReferences,
} from '@/ee/workspace-forking/lib/remap/remap-references'
interface ForkMappingViewParams {
edge: ForkEdge
sourceWorkspaceId: string
targetWorkspaceId: string
}
export function suggestTarget(
kind: ForkRemapKind,
sourceLabel: string,
sourceProviderId: string | undefined,
candidates: ForkResourceCandidate[]
): string | null {
const normalized = sourceLabel.trim().toLowerCase()
const byLabel = candidates.filter((c) => c.label.trim().toLowerCase() === normalized)
if (kind === 'credential' && sourceProviderId) {
const match = byLabel.find((c) => c.providerId === sourceProviderId)
if (match) return match.id
}
if (byLabel.length === 1) return byLabel[0].id
return null
}
/**
* Build the direction-oriented mapping view: every detected source reference with
* its current target (persisted or env identity), an auto-suggested target by
* name/provider, and the list of target candidates the UI can choose from.
*/
export async function getForkMappingView(
params: ForkMappingViewParams
): Promise<{ entries: ForkMappingEntry[] }> {
const { edge, sourceWorkspaceId, targetWorkspaceId } = params
const sourceIsParent = sourceWorkspaceId === edge.parentWorkspaceId
const [mappingRows, targetEnvKeys, sourceEnvKeys, sourceCandidates, targetCandidates] =
await Promise.all([
getEdgeMappingRows(db, edge.childWorkspaceId),
getWorkspaceEnvKeys(db, targetWorkspaceId),
getWorkspaceEnvKeys(db, sourceWorkspaceId),
listForkResourceCandidates(db, sourceWorkspaceId),
listForkResourceCandidates(db, targetWorkspaceId),
])
const resolver = buildForkResolver(mappingRows, { sourceIsParent, targetEnvKeys, sourceEnvKeys })
const resourceTypeBySourceId = new Map<string, ForkMappableResourceType>()
for (const row of mappingRows) {
// Workflow + workflow-publishing-server identity rows are system-managed and document rows
// ride their parent KB - none is user-mappable. Skip them so a scanned reference can never
// be labeled with a non-mappable type and the view stays within the mappable-type contract.
if (
row.resourceType === 'workflow' ||
row.resourceType === 'workflow_mcp_server' ||
row.resourceType === 'knowledge_document'
) {
continue
}
const key = sourceIsParent ? row.parentResourceId : row.childResourceId
if (key) resourceTypeBySourceId.set(key, row.resourceType)
}
// Scan one deployed workflow state at a time and merge deduped references, so
// peak memory stays at a single workflow state rather than all of them at once.
const deployedWorkflows = await listDeployedWorkflows(db, sourceWorkspaceId)
const referenceByKey = new Map<string, ForkReference>()
for (const wf of deployedWorkflows) {
const state = await readDeployedState(wf.id, sourceWorkspaceId)
if (!state) continue
for (const reference of scanWorkflowReferences(toScannerBlocks(state), () => null).references) {
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
}
}
const cascade = await detectForkCascadeReferences({
executor: db,
sourceWorkspaceId,
references: Array.from(referenceByKey.values()),
resolve: () => null,
})
for (const reference of cascade.references) {
referenceByKey.set(`${reference.kind}:${reference.sourceId}`, reference)
}
const references: ForkReference[] = Array.from(referenceByKey.values())
// First pass: resolve each reference's stored target + the data to build its entry,
// collecting stored target ids so existence is checked by exact id (cap-free) - a
// valid mapping to a target past the display cap must be RETAINED, not shown unmapped.
interface PendingEntry {
reference: ForkReference
resourceType: ForkMappableResourceType
sourceLabel: string
sourceProviderId: string | undefined
candidates: ForkResourceCandidate[]
storedTargetId: string | null
}
const pending: PendingEntry[] = []
const storedTargetIdsByKind: Partial<Record<ForkRemapKind, Set<string>>> = {}
for (const reference of references) {
// Only SOURCE workspace secrets are mappable; a `{{KEY}}` that isn't a source
// workspace env var is a personal (user-scoped) secret - leave it as-is.
if (reference.kind === 'env-var' && !sourceEnvKeys.has(reference.sourceId)) continue
// Knowledge documents are not a standalone mappable kind: a document is a dependent field
// of its knowledge base (the `document-selector` dependsOn the KB selector), re-picked in
// that KB's reconfigure flow and auto-remapped when the KB is copied. So a document never
// gets its own mapping entry - it follows its parent KB's target.
if (reference.kind === 'knowledge-document') continue
let resourceType = resourceTypeBySourceId.get(reference.sourceId)
if (!resourceType) {
resourceType =
reference.kind === 'credential'
? await classifyCredentialResourceType(db, reference.sourceId, sourceWorkspaceId)
: nonCredentialForkKindToResourceType(reference.kind)
}
const sourceCandidate = sourceCandidates[reference.kind].find(
(c) => c.id === reference.sourceId
)
const sourceLabel = sourceCandidate?.label ?? reference.sourceId
const sourceProviderId = sourceCandidate?.providerId
// A credential reference only maps to a target credential of the SAME OAuth
// provider - a Gmail (google-email) reference must never offer a Google Calendar
// credential. Non-credential kinds carry no provider, so their full list stands.
const candidates =
reference.kind === 'credential' && sourceProviderId
? targetCandidates[reference.kind].filter(
(candidate) => candidate.providerId === sourceProviderId
)
: targetCandidates[reference.kind]
const storedTargetId = resolver(reference.kind, reference.sourceId) ?? null
if (storedTargetId && reference.kind !== 'env-var') {
;(storedTargetIdsByKind[reference.kind] ??= new Set()).add(storedTargetId)
}
pending.push({
reference,
resourceType,
sourceLabel,
sourceProviderId,
candidates,
storedTargetId,
})
}
// Cap-free existence of every stored target (env vars validated against env keys).
const existingStoredTargets = await filterExistingForkTargets(
db,
targetWorkspaceId,
storedTargetIdsByKind
)
const entries: ForkMappingEntry[] = []
for (const p of pending) {
const targetExists =
p.storedTargetId != null &&
(p.reference.kind === 'env-var'
? targetEnvKeys.has(p.storedTargetId)
: (existingStoredTargets[p.reference.kind]?.has(p.storedTargetId) ?? false))
const currentTargetId = targetExists ? p.storedTargetId : null
// If the retained current target isn't in the (capped) candidate list, append it
// so the picker can still display the current selection.
let candidates = p.candidates
if (currentTargetId && !candidates.some((candidate) => candidate.id === currentTargetId)) {
candidates = [...candidates, { id: currentTargetId, label: currentTargetId }]
}
const targetId =
currentTargetId ??
suggestTarget(p.reference.kind, p.sourceLabel, p.sourceProviderId, candidates)
// True when `targetId` is an unconfirmed name/provider suggestion (no persisted
// mapping). The modal treats a suggestion as a pending change so it shows the
// pre-sync reconfigure rather than letting an accepted suggestion silently clear
// dependents and surface them only after the sync.
const suggested = currentTargetId == null && targetId != null
entries.push({
kind: p.reference.kind,
resourceType: p.resourceType,
sourceId: p.reference.sourceId,
sourceLabel: p.sourceLabel,
targetId,
suggested,
// Every entry here is a reference a synced workflow actually carries, and a sync is
// blocked while ANY reference would clear - so every entry is required. Copyable kinds
// (table / KB / file / custom tool / skill) also satisfy the gate by being selected for
// copy; map-only kinds (credential / env-var / MCP server) and source-deleted resources
// (no copy candidate) must be mapped.
required: true,
candidates,
// The full (unfiltered) target list for this kind hit the cap, so the picker is
// showing a partial list - the UI tells the user to refine.
candidatesTruncated: targetCandidates[p.reference.kind].length >= CANDIDATE_LIMIT,
})
}
return { entries }
}
export interface ApplyForkMappingEntry {
resourceType: ForkResourceType
sourceId: string
targetId: string | null
}
/**
* The first target two distinct sources are mapped to (same resourceType + targetId,
* different sourceId), or null when every target is used by at most one source. Cleared
* entries (null target) are ignored. Used by the PUSH path only: a push row is unique on
* the parent (target) side, so such a pair collides on that unique index and one mapping
* would be silently dropped - the caller rejects it instead. Pull is the inverse (many
* parent sources may share one child target, which the resolver handles), so pull does not
* use this guard.
*/
export function findDuplicateTargetEntry(
entries: ApplyForkMappingEntry[]
): { resourceType: ForkResourceType; targetId: string } | null {
const sourcesByTarget = new Map<string, Set<string>>()
for (const entry of entries) {
if (entry.targetId == null) continue
// Null-byte separator so a targetId containing ':' can't be confused with a
// different (resourceType, targetId) pair.
const key = `${entry.resourceType}\u0000${entry.targetId}`
const sources = sourcesByTarget.get(key)
if (!sources) {
sourcesByTarget.set(key, new Set([entry.sourceId]))
continue
}
sources.add(entry.sourceId)
if (sources.size > 1) return { resourceType: entry.resourceType, targetId: entry.targetId }
}
return null
}
/**
* Persist mapping edits for a direction. Pull maps a parent source to a child
* target; push maps a child source to a parent target (clearing a push mapping
* deletes the row).
*/
export async function applyForkMappingEntries(
tx: DbOrTx,
edge: ForkEdge,
userId: string,
direction: 'push' | 'pull',
entries: ApplyForkMappingEntry[]
): Promise<number> {
if (entries.length === 0) return 0
if (direction === 'pull') {
// Pull maps a parent source to a child target - one batched upsert.
await upsertEdgeMappings(
tx,
edge.childWorkspaceId,
userId,
entries.map((entry) => ({
resourceType: entry.resourceType,
parentResourceId: entry.sourceId,
childResourceId: entry.targetId,
}))
)
return entries.length
}
// Push rows are unique on the parent (target) side, so two distinct sources mapped to
// the same target would collide on that index and one would be silently dropped (its
// reference then resolves unmapped). Reject loudly - on push each parent target can back
// only one source. (Pull is the inverse: many parent sources may share one child target,
// which the resolver handles, so pull skips this guard. The modal also disables an
// already-taken target on push so users never reach this error normally.)
const collision = findDuplicateTargetEntry(entries)
if (collision) {
const kind = resourceTypeToForkKind(collision.resourceType) ?? collision.resourceType
throw new ForkError(
`Two sources are mapped to the same ${kind} target. Each target can be mapped from only one source.`,
400
)
}
// Push rows are keyed by the child (source) side, but the table's unique key is on
// the parent side - so clear any existing row for each source first (one grouped
// delete), otherwise changing a push target leaves the old (parent, source) row
// behind and resolution becomes ambiguous. Then upsert the new (target, source)
// rows in one batch; a null target is a cleared mapping (delete only, no reinsert).
await deleteEdgeMappingsByChildResources(
tx,
edge.childWorkspaceId,
entries.map((entry) => ({ resourceType: entry.resourceType, childResourceId: entry.sourceId }))
)
await upsertEdgeMappings(
tx,
edge.childWorkspaceId,
userId,
entries
.filter((entry) => entry.targetId != null)
.map((entry) => ({
resourceType: entry.resourceType,
parentResourceId: entry.targetId as string,
childResourceId: entry.sourceId,
}))
)
return entries.length
}
/**
* Reject mapping entries whose chosen target does not belong to the target
* workspace, so a caller cannot point a remapped reference (or credential-access
* propagation) at a resource in a workspace they do not administer. Entries whose
* resource type is not user-mappable (only `workflow`, whose identity is
* system-managed) are rejected outright. Credential targets must additionally share
* the source credential's OAuth provider, so a Gmail reference can never be pointed
* at a Google Calendar credential (the UI enforces this; this is the write-side
* boundary that catches direct API calls and stale rows).
*/
export async function validateForkMappingTargets(
sourceWorkspaceId: string,
targetWorkspaceId: string,
entries: ApplyForkMappingEntry[]
): Promise<void> {
const withTarget = entries.filter((entry) => entry.targetId != null)
if (withTarget.length === 0) return
// Collect the exact target ids per kind so existence is checked by id, NOT against
// the display-capped candidate list - a valid target that simply sits past the cap
// must never be rejected on save.
const targetIdsByKind: Partial<Record<ForkRemapKind, Set<string>>> = {}
let hasEnvVar = false
for (const entry of withTarget) {
const kind = resourceTypeToForkKind(entry.resourceType)
if (!kind) {
// `workflow` is the only null-kind type, and its identity is system-managed by
// fork/promote/rollback. A non-null target for it here is an invalid (or
// crafted) entry the editor must never persist.
throw new ForkError(
`Resource type "${entry.resourceType}" cannot be mapped via the mapping editor`,
400
)
}
if (kind === 'env-var') {
hasEnvVar = true
continue
}
;(targetIdsByKind[kind] ??= new Set()).add(entry.targetId as string)
}
const credentialEntries = withTarget.filter(
(entry) => resourceTypeToForkKind(entry.resourceType) === 'credential'
)
const [existingTargets, targetEnvKeys, sourceProviders, targetProviders] = await Promise.all([
filterExistingForkTargets(db, targetWorkspaceId, targetIdsByKind),
hasEnvVar ? getWorkspaceEnvKeys(db, targetWorkspaceId) : Promise.resolve(new Set<string>()),
getCredentialProvidersByIds(
db,
sourceWorkspaceId,
credentialEntries.map((entry) => entry.sourceId)
),
getCredentialProvidersByIds(
db,
targetWorkspaceId,
credentialEntries.map((entry) => entry.targetId as string)
),
])
for (const entry of withTarget) {
const kind = resourceTypeToForkKind(entry.resourceType)
if (!kind) continue
const targetId = entry.targetId as string
if (kind === 'env-var') {
if (!targetEnvKeys.has(targetId)) {
throw new ForkError(
`Mapping target "${targetId}" is not an environment variable in the target workspace`,
400
)
}
continue
}
if (!existingTargets[kind]?.has(targetId)) {
throw new ForkError(
`Mapping target "${targetId}" is not a valid ${kind} in the target workspace`,
400
)
}
if (kind === 'credential') {
// The source must be a real credential in the source workspace. A foreign id
// (not present) would skip the provider check and let a crafted mapping drive
// cross-workspace credential-access propagation on promote.
if (!sourceProviders.has(entry.sourceId)) {
throw new ForkError(
`Source credential "${entry.sourceId}" is not a credential in the source workspace`,
400
)
}
const sourceProviderId = sourceProviders.get(entry.sourceId)
const targetProviderId = targetProviders.get(targetId) ?? null
if (sourceProviderId && targetProviderId !== sourceProviderId) {
throw new ForkError(
`Mapping target "${targetId}" must use the same provider as the source credential`,
400
)
}
}
}
}