import { createHash } from "node:crypto"; import { copyFile, mkdir, readdir, readFile, rename, rm, stat, writeFile, } from "node:fs/promises"; import { basename, dirname, join, relative } from "node:path"; type JsonRecord = Record; type Options = { backupDir: string; dataDir: string; dryRun: boolean; fromActorKey: string; fromProjectId?: string; fromProjectKey?: string; fromUserId?: string; metadataDir: string; memoryDir: string; resultRefsDir: string; toActorKey: string; toProjectId?: string; toProjectKey?: string; toUserId?: string; transcriptsDir: string; }; type Change = { detail: string; file: string; kind: "metadata" | "transcript" | "result-ref" | "memory"; }; const usage = `Usage: bun scripts/migrate-session-identity.ts --from-user-id --to-user-id [--write] bun scripts/migrate-session-identity.ts --from-actor-key --to-actor-key [--write] Optional: --from-project-id Match old project id --to-project-id Rewrite project id --from-project-key Match old project key --to-project-key Rewrite project key --data-dir Default: ./data --backup-dir Default: /backup/session-identity-migration/ --write Apply changes. Without this, dry-run only. Examples: bun scripts/migrate-session-identity.ts \\ --from-user-id d1acbc3d-cf62-4049-9939-95e4feb37296 \\ --to-actor-key actor-49a0275a85079f9d bun scripts/migrate-session-identity.ts \\ --from-actor-key actor-da98d99c20c386e7 \\ --to-actor-key actor-49a0275a85079f9d \\ --write `; const main = async () => { const options = parseOptions(process.argv.slice(2)); const changes: Change[] = []; changes.push(...(await migrateMetadata(options))); changes.push(...(await migrateTranscripts(options))); changes.push(...(await migrateResultRefs(options))); changes.push(...(await migrateUserMemory(options))); printSummary(options, changes); }; const parseOptions = (args: string[]): Options => { const values = new Map(); for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (!arg.startsWith("--")) { throw new Error(`unexpected argument: ${arg}\n\n${usage}`); } const key = arg.slice(2); if (key === "help" || key === "h") { console.log(usage); process.exit(0); } if (key === "write") { values.set(key, true); continue; } const value = args[index + 1]; if (!value || value.startsWith("--")) { throw new Error(`missing value for --${key}\n\n${usage}`); } values.set(key, value); index += 1; } const dataDir = stringOption(values, "data-dir") ?? "./data"; const fromUserId = stringOption(values, "from-user-id"); const toUserId = stringOption(values, "to-user-id"); const fromActorKey = stringOption(values, "from-actor-key") ?? actorKeyFromUserId(fromUserId); const toActorKey = stringOption(values, "to-actor-key") ?? actorKeyFromUserId(toUserId); const fromProjectId = stringOption(values, "from-project-id"); const toProjectId = stringOption(values, "to-project-id"); const fromProjectKey = stringOption(values, "from-project-key") ?? projectKeyFromProjectId(fromProjectId); const toProjectKey = stringOption(values, "to-project-key") ?? projectKeyFromProjectId(toProjectId); if (!fromActorKey || !toActorKey) { throw new Error( "provide --from-user-id/--to-user-id or --from-actor-key/--to-actor-key\n\n" + usage, ); } if (fromActorKey === toActorKey && fromProjectKey === toProjectKey) { throw new Error("source and target identity are identical"); } const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const backupDir = stringOption(values, "backup-dir") ?? join(dataDir, "backup", "session-identity-migration", timestamp); return { backupDir, dataDir, dryRun: !values.has("write"), fromActorKey, fromProjectId, fromProjectKey, fromUserId, metadataDir: join(dataDir, "session-metadata"), memoryDir: join(dataDir, "memory"), resultRefsDir: join(dataDir, "result-refs"), toActorKey, toProjectId, toProjectKey, toUserId, transcriptsDir: join(dataDir, "session-transcripts"), }; }; const stringOption = (values: Map, key: string) => { const value = values.get(key); return typeof value === "string" && value.trim() ? value.trim() : undefined; }; const actorKeyFromUserId = (userId?: string) => userId ? scopedKey("actor", userId) : undefined; const projectKeyFromProjectId = (projectId?: string) => projectId ? scopedKey("project", projectId) : undefined; const scopedKey = (prefix: string, value: string) => `${prefix}-${createHash("sha256").update(value.trim()).digest("hex").slice(0, 16)}`; const migrateMetadata = async (options: Options): Promise => { const changes: Change[] = []; for (const file of await listJsonFiles(options.metadataDir)) { const record = await readJson(file); if (!record || record.actorKey !== options.fromActorKey) { continue; } if (options.fromProjectKey && record.projectKey !== options.fromProjectKey) { continue; } if (options.fromProjectId && record.projectId !== options.fromProjectId) { continue; } const next: JsonRecord = { ...record, actorKey: options.toActorKey, ...(options.toUserId ? { ownerUserId: options.toUserId } : {}), ...(options.toProjectId ? { projectId: options.toProjectId } : {}), ...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}), }; await writeJsonWithBackup(options, file, next); changes.push({ detail: `${record.actorKey} -> ${next.actorKey}`, file, kind: "metadata", }); } return changes; }; const migrateTranscripts = async (options: Options): Promise => { const changes: Change[] = []; for (const file of await listJsonFiles(options.transcriptsDir)) { const transcript = await readJson(file); if (!transcript || transcript.actorKey !== options.fromActorKey) { continue; } if (options.fromProjectKey && transcript.projectKey !== options.fromProjectKey) { continue; } const next: JsonRecord = { ...transcript, actorKey: options.toActorKey, ...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}), }; const nextPath = join( dirname(file), `${next.actorKey}__${next.projectKey}__${next.sessionId}.json`, ); if (nextPath !== file && (await exists(nextPath))) { throw new Error(`refusing to overwrite existing transcript: ${nextPath}`); } await writeJsonWithBackup(options, file, next); if (nextPath !== file) { await renameWithBackup(options, file, nextPath); } changes.push({ detail: `${basename(file)} -> ${basename(nextPath)}`, file, kind: "transcript", }); } return changes; }; const migrateResultRefs = async (options: Options): Promise => { const changes: Change[] = []; for (const file of await listJsonFiles(options.resultRefsDir)) { const record = await readJson(file); if (!record || record.actorKey !== options.fromActorKey) { continue; } if (options.fromProjectKey && record.projectKey !== options.fromProjectKey) { continue; } if (options.fromProjectId && record.projectId !== options.fromProjectId) { continue; } const next: JsonRecord = { ...record, actorKey: options.toActorKey, ...(options.toProjectId ? { projectId: options.toProjectId } : {}), ...(options.toProjectKey ? { projectKey: options.toProjectKey } : {}), }; await writeJsonWithBackup(options, file, next); changes.push({ detail: `${record.actorKey} -> ${next.actorKey}`, file, kind: "result-ref", }); } return changes; }; const migrateUserMemory = async (options: Options): Promise => { const fromFile = join(options.memoryDir, "users", `${options.fromActorKey}.md`); if (!(await exists(fromFile))) { return []; } const toFile = join(options.memoryDir, "users", `${options.toActorKey}.md`); if (fromFile === toFile) { return []; } if (await exists(toFile)) { const [fromContent, toContent] = await Promise.all([ readFile(fromFile, "utf8"), readFile(toFile, "utf8"), ]); const merged = mergeMemoryMarkdown(toContent, fromContent); await writeTextWithBackup(options, toFile, merged); await removeWithBackup(options, fromFile); } else { await renameWithBackup(options, fromFile, toFile); } return [ { detail: `${basename(fromFile)} -> ${basename(toFile)}`, file: fromFile, kind: "memory", }, ]; }; const mergeMemoryMarkdown = (target: string, source: string) => { const lines = target.split("\n"); const existing = new Set(lines.map((line) => line.trim()).filter(Boolean)); for (const line of source.split("\n")) { const normalized = line.trim(); if (!normalized || existing.has(normalized)) { continue; } lines.push(line); existing.add(normalized); } return `${lines.join("\n").replace(/\n+$/u, "")}\n`; }; const listJsonFiles = async (dir: string) => { try { const names = await readdir(dir); return names .filter((name) => name.endsWith(".json")) .map((name) => join(dir, name)); } catch (error) { if (isErrno(error, "ENOENT")) { return []; } throw error; } }; const readJson = async (file: string): Promise => { try { const value = JSON.parse(await readFile(file, "utf8")) as unknown; return isRecord(value) ? value : null; } catch (error) { if (isErrno(error, "ENOENT")) { return null; } throw error; } }; const writeJsonWithBackup = async ( options: Options, file: string, value: JsonRecord, ) => { await writeTextWithBackup(options, file, `${JSON.stringify(value, null, 2)}\n`); }; const writeTextWithBackup = async ( options: Options, file: string, content: string, ) => { if (options.dryRun) { return; } await backupFile(options, file); await writeFile(file, content, "utf8"); }; const renameWithBackup = async (options: Options, from: string, to: string) => { if (options.dryRun) { return; } await backupFile(options, from); await mkdir(dirname(to), { recursive: true }); await rename(from, to); }; const removeWithBackup = async (options: Options, file: string) => { if (options.dryRun) { return; } await backupFile(options, file); await rm(file, { force: true }); }; const backupFile = async (options: Options, file: string) => { const relativePath = relative(process.cwd(), file); const target = join(options.backupDir, relativePath); await mkdir(dirname(target), { recursive: true }); await copyFile(file, target); }; const exists = async (file: string) => { try { await stat(file); return true; } catch (error) { if (isErrno(error, "ENOENT")) { return false; } throw error; } }; const isRecord = (value: unknown): value is JsonRecord => typeof value === "object" && value !== null && !Array.isArray(value); const isErrno = (error: unknown, code: string) => error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === code; const printSummary = (options: Options, changes: Change[]) => { const counts = changes.reduce>((acc, change) => { acc[change.kind] = (acc[change.kind] ?? 0) + 1; return acc; }, {}); console.log(options.dryRun ? "DRY RUN: no files changed" : "Migration applied"); console.log(`from actor: ${options.fromActorKey}`); console.log(`to actor: ${options.toActorKey}`); if (options.fromProjectKey || options.toProjectKey) { console.log(`project: ${options.fromProjectKey ?? "*"} -> ${options.toProjectKey ?? "(unchanged)"}`); } if (!options.dryRun) { console.log(`backup dir: ${options.backupDir}`); } console.log(`changes: ${changes.length}`); for (const [kind, count] of Object.entries(counts)) { console.log(` ${kind}: ${count}`); } for (const change of changes.slice(0, 40)) { console.log(`- [${change.kind}] ${change.detail}`); } if (changes.length > 40) { console.log(`... ${changes.length - 40} more`); } }; main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exit(1); });