/** * Stable envelope for account transfers between minddy instances. * * The transfer format is intentionally separate from database migrations. An * instance may have a newer schema while still accepting older transfer files; * the importer owns the small compatibility layer at the boundary. */ export const ACCOUNT_TRANSFER_FORMAT = "minddy-account-transfer" as const; export const ACCOUNT_TRANSFER_VERSION = 1; /** * Version 4 (MIN-721) no longer carries the retired code-agent sections, so an * older reader that still requires them reports an unsupported version instead * of a missing field. Versions 2 and 3 remain readable. */ export const CURRENT_ACCOUNT_EXPORT_VERSION = 4; export const SUPPORTED_ACCOUNT_EXPORT_VERSIONS = [2, 3, CURRENT_ACCOUNT_EXPORT_VERSION] as const; /** * Sections written by older exports for retired features. For the code agent * (MIN-721), `preferences` held its default model, reasoning, branch prefix and * sandbox settings, and `code_agent_conversations` its conversation history. * `saved_views` held the command palette bookmarks (MIN-725). Their tables no * longer exist, so these sections are accepted without validation and left out * of the normalized document. */ export const RETIRED_ACCOUNT_TRANSFER_SECTIONS = [ "preferences", "code_agent_conversations", "saved_views", ] as const; export const MAX_ACCOUNT_TRANSFER_BYTES = 100 * 1024 * 1024; export type TransferRow = Record; export interface AccountTransferDocument { transfer_format?: typeof ACCOUNT_TRANSFER_FORMAT; transfer_version?: number; format_version: number; exported_at: string; readme?: Record; account: TransferRow; numo_preferences?: TransferRow[]; /** Numo's memory (MIN-915). Optional in older exports. */ numo_memories?: TransferRow[]; owned_projects: TransferRow[]; memberships: TransferRow[]; issues: TransferRow[]; comments: TransferRow[]; attachments: TransferRow[]; page_files: TransferRow[]; pages: TransferRow[]; objectives: TransferRow[]; categories?: TransferRow[]; issue_categories?: TransferRow[]; views: TransferRow[]; agent_routines?: TransferRow[]; cycles: TransferRow[]; scratchpad: TransferRow | null; assistant_conversations: TransferRow[]; notifications: TransferRow[]; push_devices: TransferRow[]; statistics: TransferRow[]; billing: TransferRow | null; ai_usage: TransferRow[]; api_keys: TransferRow[]; connected_apps: TransferRow[]; git_connections: TransferRow[]; git_user_identities: TransferRow[]; model_keys: TransferRow[]; } export type TransferValidationError = | "notObject" | "unsupportedVersion" | "missingField" | "invalidField"; export type TransferValidationResult = | { ok: true; document: AccountTransferDocument } | { ok: false; error: TransferValidationError; field?: string }; const REQUIRED_ARRAYS = [ "owned_projects", "memberships", "issues", "comments", "attachments", "page_files", "pages", "objectives", "views", "cycles", "assistant_conversations", "notifications", "push_devices", "statistics", "ai_usage", "api_keys", "connected_apps", "git_connections", "git_user_identities", "model_keys", ] as const; function isRecord(value: unknown): value is TransferRow { return typeof value === "object" && value !== null && !Array.isArray(value); } function isStringRecord(value: unknown): value is Record { return ( isRecord(value) && Object.values(value).every((item) => typeof item === "string") ); } /** * Validate and normalize the current envelope and legacy format v2 and v3 files. * Retired sections (`RETIRED_ACCOUNT_TRANSFER_SECTIONS`) are ignored. */ export function validateAccountTransfer(input: unknown): TransferValidationResult { if (!isRecord(input)) return { ok: false, error: "notObject" }; const formatVersion = input.format_version; if ( typeof formatVersion !== "number" || !SUPPORTED_ACCOUNT_EXPORT_VERSIONS.includes( formatVersion as (typeof SUPPORTED_ACCOUNT_EXPORT_VERSIONS)[number], ) ) { return { ok: false, error: "unsupportedVersion", field: "format_version" }; } if ( input.transfer_format !== undefined && input.transfer_format !== ACCOUNT_TRANSFER_FORMAT ) { return { ok: false, error: "invalidField", field: "transfer_format" }; } if ( input.transfer_version !== undefined && input.transfer_version !== ACCOUNT_TRANSFER_VERSION ) { return { ok: false, error: "unsupportedVersion", field: "transfer_version" }; } if (typeof input.exported_at !== "string") { return { ok: false, error: "missingField", field: "exported_at" }; } if (!isRecord(input.account)) { return { ok: false, error: "missingField", field: "account" }; } for (const field of REQUIRED_ARRAYS) { if (!Array.isArray(input[field])) { return { ok: false, error: "missingField", field }; } if (!input[field].every(isRecord)) { return { ok: false, error: "invalidField", field }; } } for (const field of ["categories", "issue_categories", "agent_routines", "numo_preferences", "numo_memories"] as const) { if (input[field] !== undefined && (!Array.isArray(input[field]) || !input[field].every(isRecord))) { return { ok: false, error: "invalidField", field }; } } for (const field of ["scratchpad", "billing"] as const) { if (input[field] !== null && input[field] !== undefined && !isRecord(input[field])) { return { ok: false, error: "invalidField", field }; } } if (input.readme !== undefined && !isStringRecord(input.readme)) { return { ok: false, error: "invalidField", field: "readme" }; } return { ok: true, document: { transfer_format: ACCOUNT_TRANSFER_FORMAT, transfer_version: ACCOUNT_TRANSFER_VERSION, format_version: formatVersion, exported_at: input.exported_at as string, readme: input.readme as Record | undefined, account: input.account, numo_preferences: (input.numo_preferences as TransferRow[] | undefined) ?? [], numo_memories: (input.numo_memories as TransferRow[] | undefined) ?? [], owned_projects: input.owned_projects as TransferRow[], memberships: input.memberships as TransferRow[], issues: input.issues as TransferRow[], comments: input.comments as TransferRow[], attachments: input.attachments as TransferRow[], page_files: input.page_files as TransferRow[], pages: input.pages as TransferRow[], objectives: input.objectives as TransferRow[], categories: (input.categories as TransferRow[] | undefined) ?? [], issue_categories: (input.issue_categories as TransferRow[] | undefined) ?? [], views: input.views as TransferRow[], agent_routines: (input.agent_routines as TransferRow[] | undefined) ?? [], cycles: input.cycles as TransferRow[], scratchpad: (input.scratchpad as TransferRow | null | undefined) ?? null, assistant_conversations: input.assistant_conversations as TransferRow[], notifications: input.notifications as TransferRow[], push_devices: input.push_devices as TransferRow[], statistics: input.statistics as TransferRow[], billing: (input.billing as TransferRow | null | undefined) ?? null, ai_usage: input.ai_usage as TransferRow[], api_keys: input.api_keys as TransferRow[], connected_apps: input.connected_apps as TransferRow[], git_connections: input.git_connections as TransferRow[], git_user_identities: input.git_user_identities as TransferRow[], model_keys: input.model_keys as TransferRow[], }, }; }