import type { IControllerResourceAttachmentEntryDocument, IControllerResourceDocument, } from '../ts/interfaces.projects.js'; import type { TControllerSessionId } from '../ts_interfaces/index.js'; type TObject = Record; /** * The resource document as it was persisted while a resource had at most one attachment subject. * Frozen here rather than imported, because the live assertion now describes the set and would * reject every historical document. */ export interface IV31LegacyResourceDocument { id: string; controllerId: string; projectId: string; kind: 'terminal' | 'browser'; attachmentAuthorityId: string; attachmentRevision: number; sessionId: TControllerSessionId | null; sessionIdentityId?: string; terminalTarget?: { resourceId: string; agentSessionId?: string }; updatedAt: Date; [key: string]: unknown; } export interface IV31ResourceMigrationResult { document: IControllerResourceDocument; migrated: boolean; } const isPlainObject = (valueArg: unknown): valueArg is TObject => { if (typeof valueArg !== 'object' || valueArg === null || Array.isArray(valueArg)) return false; const prototype = Object.getPrototypeOf(valueArg); return prototype === Object.prototype || prototype === null; }; /** * A document that already carries the set must be left exactly as it is. * * `attachments` is the positive signal: the lift always writes it, including as an empty array * for a detached resource. Testing for an absent `sessionId` instead would read a detached legacy * document as already lifted and never migrate it. */ export const isV31AttachmentSetDocument = ( valueArg: unknown, ): valueArg is IControllerResourceDocument => ( isPlainObject(valueArg) && Object.hasOwn(valueArg, 'attachments') && Array.isArray(valueArg.attachments) && !Object.hasOwn(valueArg, 'sessionId') && !Object.hasOwn(valueArg, 'terminalTarget') ); /** * Lifts a single-subject resource document into the attachment set. * * The one subject it carried becomes a one-element set; a detached resource becomes an empty one. * `attachedAt` is the document's own `updatedAt`, which is the last moment the attachment can * have changed. A pending single-subject intent is dropped rather than translated: it is a * write-before-side-effect obligation whose side effect is re-driven by the coordinator's * recovery pass, and inventing an `op` for it would guess at an intent the document never stated. */ export const migrateV31ResourceDocument = ( valueArg: IV31LegacyResourceDocument | IControllerResourceDocument, ): IV31ResourceMigrationResult => { if (isV31AttachmentSetDocument(valueArg)) { return { document: valueArg, migrated: false }; } const legacy = valueArg as IV31LegacyResourceDocument; const attachments: IControllerResourceAttachmentEntryDocument[] = []; if (legacy.sessionId !== null && legacy.sessionId !== undefined) { attachments.push({ kind: 'session', id: { ...legacy.sessionId }, projectId: legacy.projectId, attachedAt: new Date(legacy.updatedAt), ...(legacy.sessionIdentityId === undefined ? {} : { sessionIdentityId: legacy.sessionIdentityId }), }); } else if (legacy.terminalTarget !== undefined) { attachments.push({ kind: 'terminal', id: legacy.terminalTarget.resourceId, projectId: legacy.projectId, attachedAt: new Date(legacy.updatedAt), ...(legacy.terminalTarget.agentSessionId === undefined ? {} : { agentSessionId: legacy.terminalTarget.agentSessionId }), }); } const lifted: TObject = { ...legacy, attachments }; delete lifted.sessionId; delete lifted.sessionIdentityId; delete lifted.terminalTarget; delete lifted.pendingAttachment; return { document: lifted as unknown as IControllerResourceDocument, migrated: true }; };