import type { OrchestrationProjectShell, OrchestrationV2ShellSnapshot, OrchestrationV2ShellStreamItem, } from "@t3tools/contracts"; function upsertById( items: ReadonlyArray, item: T, ): ReadonlyArray { const index = items.findIndex((candidate) => candidate.id === item.id); if (index === -1) return [...items, item]; return items.map((candidate, candidateIndex) => (candidateIndex === index ? item : candidate)); } function retainRepositoryIdentity( previous: OrchestrationProjectShell | undefined, next: OrchestrationProjectShell, ): OrchestrationProjectShell { if ( next.repositoryIdentity == null && previous?.repositoryIdentity != null && previous.workspaceRoot === next.workspaceRoot ) { return { ...next, repositoryIdentity: previous.repositoryIdentity }; } return next; } export interface MergeShellSnapshotOptions { /** * Metadata-only enrichment refresh: structure and sequence never change; * listed roots accept identity exactly (including null). * Omit this options object for authoritative HTTP/initial WebSocket snapshots. */ readonly resolvedRepositoryIdentityRoots: ReadonlyArray; } /** * Merge an incoming full shell snapshot into prior client state. * * Authoritative snapshots (no options) replace structure and sequence even when * lower than cache, while retaining a prior non-null identity when the candidate * is still unresolved/null for the same root. * * Enrichment snapshots (options present) only patch repository identity for * matching current projects. They never replace projects, threads, archives, * or sequence, regardless of the incoming snapshot sequence. */ export function mergeShellSnapshotProjects( previous: OrchestrationV2ShellSnapshot | null | undefined, next: OrchestrationV2ShellSnapshot, options?: MergeShellSnapshotOptions, ): OrchestrationV2ShellSnapshot { if (previous === null || previous === undefined) { return next; } const isEnrichment = options !== undefined; const resolvedRootSet = isEnrichment ? new Set(options.resolvedRepositoryIdentityRoots) : null; if (isEnrichment) { const nextById = new Map(next.projects.map((project) => [project.id, project] as const)); return { ...previous, projects: previous.projects.map((project) => { const candidate = nextById.get(project.id); if (candidate === undefined || candidate.workspaceRoot !== project.workspaceRoot) { return project; } if (resolvedRootSet?.has(project.workspaceRoot) === true) { return { ...project, repositoryIdentity: candidate.repositoryIdentity }; } if (project.repositoryIdentity == null && candidate.repositoryIdentity != null) { return { ...project, repositoryIdentity: candidate.repositoryIdentity }; } return project; }), }; } const previousById = new Map(previous.projects.map((project) => [project.id, project] as const)); return { ...next, projects: next.projects.map((project) => { const prior = previousById.get(project.id); if (resolvedRootSet?.has(project.workspaceRoot) === true) { return project; } return retainRepositoryIdentity(prior, project); }), }; } /** Applies one committed V2 shell delta while preserving active/archive exclusivity. */ export function applyShellStreamEvent( snapshot: OrchestrationV2ShellSnapshot, event: Exclude< OrchestrationV2ShellStreamItem, { readonly kind: "snapshot" } | { readonly kind: "synchronized" } >, ): OrchestrationV2ShellSnapshot { if (event.sequence <= snapshot.snapshotSequence) return snapshot; switch (event.kind) { case "project.updated": { // Enrichment is async. A project mutation can land with null // repositoryIdentity while an earlier snapshot already resolved it. // Keep the prior identity for the same workspace root so multi-env // grouping does not split until a full snapshot refresh arrives. const previous = snapshot.projects.find((project) => project.id === event.project.id); const project = retainRepositoryIdentity(previous, event.project); return { ...snapshot, projects: upsertById(snapshot.projects, project), snapshotSequence: event.sequence, }; } case "project.removed": return { ...snapshot, projects: snapshot.projects.filter((project) => project.id !== event.projectId), snapshotSequence: event.sequence, }; case "thread.updated": { const withoutThread = (threads: OrchestrationV2ShellSnapshot["threads"]) => threads.filter((thread) => thread.id !== event.thread.id); return { ...snapshot, threads: event.location === "active" ? upsertById(withoutThread(snapshot.threads), event.thread) : withoutThread(snapshot.threads), // The archive has its own bounded query/subscription. Older servers may // still send archive-located deltas here; remove them from the normal // shell instead of growing its persisted cache again. archivedThreads: withoutThread(snapshot.archivedThreads), snapshotSequence: event.sequence, }; } case "thread.removed": return { ...snapshot, threads: snapshot.threads.filter((thread) => thread.id !== event.threadId), archivedThreads: snapshot.archivedThreads.filter((thread) => thread.id !== event.threadId), snapshotSequence: event.sequence, }; default: return snapshot; } }