425 lines
12 KiB
TypeScript
425 lines
12 KiB
TypeScript
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<string, unknown>;
|
|
|
|
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 <old> --to-user-id <new> [--write]
|
|
bun scripts/migrate-session-identity.ts --from-actor-key <old> --to-actor-key <new> [--write]
|
|
|
|
Optional:
|
|
--from-project-id <old> Match old project id
|
|
--to-project-id <new> Rewrite project id
|
|
--from-project-key <old> Match old project key
|
|
--to-project-key <new> Rewrite project key
|
|
--data-dir <dir> Default: ./data
|
|
--backup-dir <dir> Default: <data-dir>/backup/session-identity-migration/<timestamp>
|
|
--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<string, string | boolean>();
|
|
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<string, string | boolean>, 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<Change[]> => {
|
|
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<Change[]> => {
|
|
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<Change[]> => {
|
|
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<Change[]> => {
|
|
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<JsonRecord | null> => {
|
|
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<Record<string, number>>((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);
|
|
});
|