import type { IControllerPersistedSessionGroup, IControllerSessionGroupsDocument, } from '../ts/interfaces.projects.js'; import { controllerLayoutItemRefKey, controllerSessionLayoutGroupLimit, controllerSessionLayoutUngroupedLimit, type TControllerLayoutItemRef, } from '../ts_interfaces/index.js'; import type { IV30ItemRefSessionGroupsDocument } from './v30_sessionlayoutitemrefs.js'; /** Mirrors the group id shape the live validation accepts. */ const groupIdentifierPattern = /^[A-Za-z0-9_-]{1,128}$/; const maximumItemsPerGroup = 512; export interface IV32MergeInput { /** Per-project layout documents, already lifted to item refs by v30. */ documents: readonly IV30ItemRefSessionGroupsDocument[]; /** * Registered project ids in registration order. A document whose project is not in the list * is merged last rather than dropped: deciding that a project is gone belongs to the running * controller, which prunes unresolvable rows on the next layout read. */ projectOrder: readonly string[]; /** The controller-wide document, when a previous run already wrote one. */ existing?: IControllerSessionGroupsDocument; } export interface IV32MergeResult { document: IControllerSessionGroupsDocument; /** True when the merge changed anything a rerun would have to repeat. */ migrated: boolean; /** * Items that did not fit the controller-wide bounds. Only their remembered position is lost: * the conversations and resources themselves are untouched and appear in the default order. */ droppedItemCount: number; } const cloneItemRef = (refArg: TControllerLayoutItemRef): TControllerLayoutItemRef => ( refArg.kind === 'session' ? { kind: 'session', id: { ...refArg.id }, projectId: refArg.projectId } : { kind: 'resource', id: refArg.id, projectId: refArg.projectId } ); /** * A group id is client-generated and was only unique inside one project, so merging can collide. * The colliding group keeps a deterministic id derived from its project, which keeps the merge * idempotent: the same inputs always produce the same ids. */ const disambiguateGroupId = ( groupIdArg: string, projectIdArg: string, takenArg: ReadonlySet, ): string => { if (!takenArg.has(groupIdArg)) return groupIdArg; const suffix = projectIdArg.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 16) || 'p'; for (let attempt = 0; attempt < 1_000; attempt += 1) { const candidate = `${groupIdArg}-${suffix}${attempt === 0 ? '' : `-${attempt}`}` .slice(0, 128); if (groupIdentifierPattern.test(candidate) && !takenArg.has(candidate)) return candidate; } throw new Error('The controller-wide layout migration could not disambiguate a group id.'); }; /** * Merges every per-project sidebar layout into the one controller-wide layout. * * Projects contribute in registration order, which is the order the project selector listed them * before the sidebar spanned projects, so the first render after the upgrade reads familiar. A * document whose project is not registered keeps its order too, merged after the known ones and * sorted by project id so the merge is deterministic; whether such rows can still be resolved is * the running controller's question, and it prunes them on the next layout read. * * The merge is resumable. A controller-wide document that already exists is the base, and the * per-project documents that are still present are appended to it, so a crash between writing the * merged document and deleting its sources cannot lose or duplicate an item. */ export const mergeV32ControllerLayoutDocuments = (inputArg: IV32MergeInput): IV32MergeResult => { const controllerId = inputArg.existing?.controllerId ?? inputArg.documents[0]?.controllerId; if (controllerId === undefined) { throw new Error('The controller-wide layout migration has no controller identity.'); } const orderIndex = new Map(inputArg.projectOrder.map((projectId, index) => [projectId, index])); const sortKey = (documentArg: IV30ItemRefSessionGroupsDocument): string => { const index = orderIndex.get(documentArg.projectId); // Unknown projects sort after every known one, then by project id for determinism. return index === undefined ? `1:${documentArg.projectId}` : `0:${String(index).padStart(8, '0')}`; }; const sources = inputArg.documents .slice() .sort((left, right) => (sortKey(left) < sortKey(right) ? -1 : sortKey(left) > sortKey(right) ? 1 : 0)); const groups: IControllerPersistedSessionGroup[] = (inputArg.existing?.groups ?? []).map( (group) => ({ id: group.id, name: group.name, itemIds: group.itemIds.map(cloneItemRef) }), ); const ungroupedItemIds: TControllerLayoutItemRef[] = (inputArg.existing?.ungroupedItemIds ?? []) .map(cloneItemRef); const groupIds = new Set(groups.map((group) => group.id)); const seenItemKeys = new Set([ ...groups.flatMap((group) => group.itemIds.map(controllerLayoutItemRefKey)), ...ungroupedItemIds.map(controllerLayoutItemRefKey), ]); let droppedItemCount = 0; const admitItem = (refArg: TControllerLayoutItemRef): TControllerLayoutItemRef | undefined => { const key = controllerLayoutItemRefKey(refArg); if (seenItemKeys.has(key)) return undefined; seenItemKeys.add(key); return cloneItemRef(refArg); }; const pushUngrouped = (refsArg: readonly TControllerLayoutItemRef[]): void => { for (const ref of refsArg) { if (ungroupedItemIds.length >= controllerSessionLayoutUngroupedLimit) { droppedItemCount += 1; continue; } ungroupedItemIds.push(ref); } }; for (const document of sources) { for (const group of document.groups) { const itemIds = group.itemIds .flatMap((ref) => { const admitted = admitItem(ref); return admitted === undefined ? [] : [admitted]; }) .slice(0, maximumItemsPerGroup); if (groups.length >= controllerSessionLayoutGroupLimit) { // The group itself no longer fits, but its members keep their place in the list. pushUngrouped(itemIds); continue; } const id = disambiguateGroupId(group.id, document.projectId, groupIds); groupIds.add(id); groups.push({ id, name: group.name, itemIds }); } pushUngrouped((document.ungroupedItemIds ?? []).flatMap((ref) => { const admitted = admitItem(ref); return admitted === undefined ? [] : [admitted]; })); } const revision = Math.max( inputArg.existing?.revision ?? 0, ...sources.map((document) => document.revision ?? 0), 0, ) + (sources.length > 0 ? 1 : 0); return { document: { id: `${controllerId}:groups`, controllerId, scope: 'controller', groups, ungroupedItemIds, revision, }, migrated: sources.length > 0 || inputArg.existing === undefined, droppedItemCount, }; };