import * as plugins from '../ts/plugins.js'; import { assertControllerSessionGroupsDocument, ControllerSessionGroupsModel, } from '../ts/classes.authmodels.js'; import { AuthError } from '../ts/interfaces.auth.js'; import type { IControllerSessionGroupsDocument } from '../ts/interfaces.projects.js'; import type { IV30ItemRefSessionGroupsDocument } from './v30_sessionlayoutitemrefs.js'; import { mergeV32ControllerLayoutDocuments } from './v32_controllerlayout.js'; /** Mirrors the `@managed` collection name on ControllerProjectModel. */ const controllerProjectCollectionName = 'opencode_controller_projects'; const migrationDocumentLimit = 2_048; const persistedBodyFromRawDocument = ( valueArg: Record, ): Record => { const body = { ...valueArg }; delete body._id; delete body._smartdataRevision; return body; }; /** * Merges the per-project sidebar layouts into one controller-wide layout, so conversations and * resources of every project can be ordered in a single list. * * The lift is one-way and recognised by a positive signal: the merged document carries the * required `scope: 'controller'` key, which no per-project document ever had. A document that * already carries it is the merge target rather than another source, which makes a resumed run * after a crash append the remaining sources instead of starting over. */ export class ControllerLayoutV32Migration { constructor(private readonly database: plugins.smartdata.SmartdataDb | undefined) {} public async run(): Promise { if (!this.database) { throw new AuthError('not_initialized', 'The authentication store database is unavailable.'); } const collection = ControllerSessionGroupsModel.collection.mongoDbCollection; const cursor = collection.find({}).limit(migrationDocumentLimit + 1); let raw: Awaited>; try { raw = await cursor.toArray(); } finally { await cursor.close(); } if (raw.length > migrationDocumentLimit) { throw new AuthError( 'invalid_project', 'The controller-wide layout migration exceeded its document limit.', ); } const byController = new Map; existing?: { id: unknown; body: IControllerSessionGroupsDocument }; }>(); for (const document of raw) { const body = persistedBodyFromRawDocument(document); const controllerId = typeof body.controllerId === 'string' ? body.controllerId : undefined; if (controllerId === undefined) { throw new AuthError( 'invalid_project', 'A persisted sidebar layout has no controller identity.', ); } const entry = byController.get(controllerId) ?? { sources: [] }; if (body.scope === 'controller') { entry.existing = { id: document._id, body: body as unknown as IControllerSessionGroupsDocument, }; } else { entry.sources.push({ id: document._id, body: body as unknown as IV30ItemRefSessionGroupsDocument, }); } byController.set(controllerId, entry); } for (const [controllerId, entry] of byController) { if (entry.sources.length === 0) continue; const merge = mergeV32ControllerLayoutDocuments({ documents: entry.sources.map((source) => source.body), projectOrder: await this.registeredProjectIds(controllerId), ...(entry.existing === undefined ? {} : { existing: entry.existing.body }), }); assertControllerSessionGroupsDocument(merge.document); if (merge.droppedItemCount > 0) { console.error( `The controller-wide layout migration dropped ${merge.droppedItemCount} remembered ` + 'sidebar positions that exceeded the layout bounds. The conversations and resources ' + 'themselves are untouched.', ); } // The merged document is written before its sources are removed, so a crash in between // leaves the sources to be appended by the next run rather than losing them. await collection.replaceOne( { id: merge.document.id }, { ...merge.document, _smartdataRevision: plugins.crypto.randomUUID() }, { upsert: true }, ); for (const source of entry.sources) { await collection.deleteOne({ _id: source.id as never }); } } } /** * Registered project ids in registration order. * * The collection is read from the database rather than through the model, because this * migration runs before the controller has registered every model. */ private async registeredProjectIds(controllerIdArg: string): Promise { const database = this.database; if (!database) { throw new AuthError('not_initialized', 'The authentication store database is unavailable.'); } const cursor = database.mongoDb .collection<{ id?: unknown }>(controllerProjectCollectionName) .find({ controllerId: controllerIdArg, removedAt: { $exists: false } }) .sort({ createdAt: 1, id: 1 }); try { const documents = await cursor.toArray(); return documents .map((document) => document.id) .filter((id): id is string => typeof id === 'string' && id.length > 0); } finally { await cursor.close(); } } }