lib/account-transfer.ts
Rama main · réplica de lectura del repositorio original.
/**
* 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<string, unknown>;
export interface AccountTransferDocument {
transfer_format?: typeof ACCOUNT_TRANSFER_FORMAT;
transfer_version?: number;
format_version: number;
exported_at: string;
readme?: Record<string, string>;
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<string, string> {
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<string, string> | 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[],
},
};
}