/** * Projector registry and the re-projection pass. * * `reproject()` is the single entry point every mutation goes through — * connect, credential change, capability toggle, enable/disable, disconnect. * Having one pass rather than four call sites is what keeps "projections are * derived" true: there is no path that changes an account without the surfaces * being recomputed from it. */ import { integrationAccountQueries, integrationProjectionQueries } from '$backend/database/queries'; import type { IntegrationAccountRow } from '$backend/database/queries'; import type { IntegrationCapability } from '$shared/types/integrations'; import { getProvider } from '../registry'; import { mcpProjector } from './mcp-projector'; import type { Projector, ProjectionContext, ProjectionResult } from './types'; import { debug } from '$shared/utils/logger'; export type { Projector, ProjectionContext, ProjectionResult } from './types'; /** * One projector per capability that has a surface to project onto. * * A capability a provider declares but that has no projector here simply does * not project — the account still records it, so the surface that lands later * finds its accounts already connected. */ const PROJECTORS: Projector[] = [mcpProjector]; const byCapability = new Map(PROJECTORS.map((projector) => [projector.capability, projector])); /** Register a projector from the surface that owns it. Called at module load. */ export function registerProjector(projector: Projector): void { if (byCapability.has(projector.capability)) { throw new Error(`A projector for "${projector.capability}" is already registered`); } byCapability.set(projector.capability, projector); PROJECTORS.push(projector); } function contextFor(account: IntegrationAccountRow): ProjectionContext | null { const provider = getProvider(account.provider); if (!provider) { debug.warn('integrations', `Account ${account.id} references unknown provider "${account.provider}"`); return null; } return { account, provider, credentials: integrationAccountQueries.credentialsOf(account) }; } /** * Recompute every surface row this account owns. * * `wanted` is the set of capabilities that should be projected right now: the * account's enabled capabilities, or nothing at all when the account is * disabled. Anything projected that is not wanted gets released, which is how a * capability toggle and a disconnect end up being the same operation. */ export function reproject(accountId: string, options: { releaseAll?: boolean } = {}): void { const account = integrationAccountQueries.getById(accountId); if (!account) return; const context = contextFor(account); if (!context) return; const wanted: Set = options.releaseAll || account.is_enabled !== 1 ? new Set() : new Set(integrationAccountQueries.capabilitiesOf(account)); const current = integrationProjectionQueries.getForAccount(accountId); // Compute every wanted projection BEFORE releasing anything. // // The release pass needs to know which existing rows survive, and since one // capability can now own several rows, "survives" is a question about a // specific target rather than about the capability. A database account that // keeps one linked project and drops another must release exactly one row — // releasing by capability would tear down both and rebuild the survivor, // which for an adopted row means handing it back to the user and then // re-adopting it, snapshotting our own credential in the process. const projected = new Map(); for (const capability of wanted) { const projector = byCapability.get(capability); if (!projector) continue; try { for (const result of projector.project(context)) { projected.set(keyOf(capability, result.targetKind, result.targetId), { ...result, capability }); } } catch (error) { debug.error('integrations', `Failed to project ${capability} for account ${accountId}:`, error); throw error; } } // Release what is no longer wanted. Doing the releases before the writes // keeps a slug that moved between providers from colliding with its own // stale row. for (const row of current) { if (projected.has(keyOf(row.capability, row.target_kind, row.target_id))) continue; const projector = byCapability.get(row.capability); if (projector) { try { projector.release(context, row.target_id, row.adopted === 1, integrationProjectionQueries.restoreOf(row)); } catch (error) { debug.error('integrations', `Failed to release ${row.capability} for account ${accountId}:`, error); } } integrationProjectionQueries.remove(accountId, row.capability, row.target_kind, row.target_id); } for (const result of projected.values()) { integrationProjectionQueries.upsert({ accountId, capability: result.capability, targetKind: result.targetKind, targetId: result.targetId, adopted: result.adopted, restore: result.restore }); } } function keyOf(capability: string, targetKind: string, targetId: string): string { return `${capability}${targetKind}${targetId}`; } /** Release everything this account owns. Called immediately before deleting it. */ export function releaseAll(accountId: string): void { reproject(accountId, { releaseAll: true }); } /** Capabilities that currently have a surface to project onto. */ export function projectableCapabilities(): IntegrationCapability[] { return [...byCapability.keys()]; }