import * as plugins from '../ts/plugins.js'; import { ControllerSessionGroupsModel } from '../ts/classes.authmodels.js'; import { AuthError } from '../ts/interfaces.auth.js'; import type { IControllerSessionGroupsDocument } from '../ts/interfaces.projects.js'; import { assertV30ItemRefSessionGroupsDocument, isV30ItemRefLayoutDocument, migrateV30SessionLayoutDocument, type IV30LegacySessionGroupsDocument, } from './v30_sessionlayoutitemrefs.js'; const migrationPageLimit = 128; /** Mirrors the `@managed` collection name on ControllerResourceModel. */ const controllerResourceCollectionName = 'opencode_controller_resources'; const persistedBodyFromRawDocument = ( valueArg: Record, ): IV30LegacySessionGroupsDocument => { const body = { ...valueArg }; delete body._id; delete body._smartdataRevision; return body as unknown as IV30LegacySessionGroupsDocument; }; /** * Lifts every persisted sidebar layout to item refs, so conversations and resources can be * ordered as peers. This deliberately reverses the v18 and v24 exclusions: those removed * resources from the layout because the layout could only express conversations, and it now * expresses both. */ export class SessionLayoutItemRefsV30Migration { 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; let lastId: plugins.smartdata.TStoredDocument['_id'] | undefined; while (true) { const cursor = collection.find(lastId ? { _id: { $gt: lastId } } : {}) .sort({ _id: 1 }) .limit(migrationPageLimit); let documents: Awaited>; try { documents = await cursor.toArray(); } finally { await cursor.close(); } for (const raw of documents) { const body = persistedBodyFromRawDocument(raw); // Asked before the resources are read: an already lifted document — including the // controller-wide layout of v32, which has no project at all — is left exactly as it is, // so it must not pay for a resource query on every start. if (isV30ItemRefLayoutDocument(body)) continue; const migration = migrateV30SessionLayoutDocument( body, await this.projectResourceIds(body.controllerId, body.projectId), ); if (!migration.migrated) continue; assertV30ItemRefSessionGroupsDocument(migration.document); const selector = raw._smartdataRevision === undefined ? { _id: raw._id, _smartdataRevision: { $exists: false } } : { _id: raw._id, _smartdataRevision: raw._smartdataRevision }; const replaced = await collection.findOneAndReplace( selector, { _id: raw._id, ...migration.document, _smartdataRevision: plugins.crypto.randomUUID() }, { returnDocument: 'after', includeResultMetadata: false, upsert: false }, ); if (!replaced) { const concurrent = await collection.findOne({ _id: raw._id }); if (!concurrent) { throw new AuthError( 'concurrent_change', 'The session layout item-ref migration changed concurrently.', ); } const concurrentBody = persistedBodyFromRawDocument(concurrent); const remaining = migrateV30SessionLayoutDocument( concurrentBody, await this.projectResourceIds(concurrentBody.controllerId, concurrentBody.projectId), ); if (remaining.migrated) { throw new AuthError( 'concurrent_change', 'The session layout item-ref migration changed concurrently.', ); } assertV30ItemRefSessionGroupsDocument(remaining.document); } } const last = documents.at(-1); if (!last || documents.length < migrationPageLimit) break; lastId = last._id; } } /** * Active resources of one project in creation order, which is the order the sidebar derived. * * The collection is taken from the database rather than from the model, because this migration * runs before the controller has registered every model and must not depend on the resource * model having been initialised. */ private async projectResourceIds( controllerIdArg: string, projectIdArg: 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 }>(controllerResourceCollectionName) .find({ controllerId: controllerIdArg, projectId: projectIdArg, retiredAt: { $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(); } } }