From e576fefd2297672720dfeb98f497dcc7198c3bcf Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 28 Feb 2026 11:46:39 -0600 Subject: [PATCH] feat(scripts): add jarvis-brain data migration script - Full typed migration utility at scripts/migrate-brain.ts - CLI flags: --brain-path, --workspace-id, --user-id, --apply - Status/priority/domain mapping for brain -> Mosaic enums - Dry-run validation report (counts, mapping issues, missing refs) - Apply mode with Prisma inserts, idempotency via metadata.brainId - Validates: 95 tasks, 106 projects, 13 domains, 0 parse issues Refs: MS21-MIG-001 --- package.json | 1 + scripts/migrate-brain.ts | 1207 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 1208 insertions(+) create mode 100644 scripts/migrate-brain.ts diff --git a/package.json b/package.json index 1655a39..2fc87ad 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "lint:fix": "turbo run lint:fix", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", + "migrate-brain": "pnpm --filter @mosaic/api exec node --import tsx ../../scripts/migrate-brain.ts", "test": "turbo run test", "test:watch": "turbo run test:watch", "test:coverage": "turbo run test:coverage", diff --git a/scripts/migrate-brain.ts b/scripts/migrate-brain.ts new file mode 100644 index 0000000..8c88433 --- /dev/null +++ b/scripts/migrate-brain.ts @@ -0,0 +1,1207 @@ +import { readdir, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import * as path from "node:path"; +import * as process from "node:process"; + +import { + ActivityAction, + EntityType, + Prisma, + PrismaClient, + ProjectStatus, + TaskPriority, + TaskStatus, +} from "../apps/api/node_modules/@prisma/client"; + +const DEFAULT_BRAIN_PATH = "~/src/jarvis-brain"; +const TASKS_SUBDIR = path.join("data", "tasks"); +const PROJECTS_SUBDIR = path.join("data", "projects"); +const PREVIEW_LIMIT = 3; +const LOG_LIMIT = 20; + +interface CliOptions { + brainPath: string; + workspaceId: string; + userId: string; + apply: boolean; +} + +interface BrainTaskRecord { + id: string; + title: string; + domain: string | null; + project: string | null; + related: string[]; + priority: string | null; + status: string | null; + progress: number | null; + due: string | null; + blocks: string[]; + blocked_by: string[]; + assignee: string | null; + created: string | null; + updated: string | null; + notes: string | null; + notes_nontechnical: string | null; +} + +interface BrainTaskFile { + version: string; + domain: string; + tasks: BrainTaskRecord[]; +} + +type BrainProjectPriority = number | string | null; + +interface BrainProjectRecord { + id: string; + name: string; + description: string | null; + domain: string | null; + status: string | null; + priority: BrainProjectPriority; + progress: number | null; + repo: string | null; + branch: string | null; + current_milestone: string | null; + next_milestone: string | null; + blocker: string | null; + owner: string | null; + docs_path: string | null; + created: string | null; + updated: string | null; + target_date: string | null; + notes: string | null; + notes_nontechnical: string | null; + parent: string | null; +} + +interface BrainProjectFile { + version: string; + project: BrainProjectRecord; + tasks: BrainTaskRecord[]; +} + +interface LoadedTaskFile { + version: string; + domain: string; + sourceFile: string; + tasks: BrainTaskRecord[]; +} + +interface LoadedProjectFile { + version: string; + sourceFile: string; + project: BrainProjectRecord; +} + +interface PreparedDomain { + slug: string; + name: string; + metadata: Prisma.InputJsonValue; +} + +interface PreparedProject { + brainId: string; + name: string; + description: string | null; + status: ProjectStatus; + domainSlug: string | null; + startDate: Date | null; + endDate: Date | null; + createdAt: Date; + updatedAt: Date; + metadata: Prisma.InputJsonValue; +} + +interface PreparedTask { + brainId: string; + title: string; + description: string | null; + status: TaskStatus; + priority: TaskPriority; + dueDate: Date | null; + projectBrainId: string | null; + domainSlug: string | null; + createdAt: Date; + updatedAt: Date; + completedAt: Date | null; + metadata: Prisma.InputJsonValue; +} + +interface MigrationPlan { + domains: PreparedDomain[]; + projects: PreparedProject[]; + tasks: PreparedTask[]; +} + +interface ValidationReport { + taskFilesRead: number; + projectFilesRead: number; + tasksParsed: number; + projectsParsed: number; + uniqueDomainCount: number; + duplicateTaskIds: string[]; + duplicateProjectIds: string[]; + mappingIssues: string[]; + dateIssues: string[]; + parseIssues: string[]; + missingProjectRefs: string[]; +} + +interface ApplySummary { + domainsCreated: number; + domainsSkipped: number; + projectsCreated: number; + projectsSkipped: number; + tasksCreated: number; + tasksSkipped: number; + tasksWithMissingProject: number; + activityLogsCreated: number; +} + +function printUsage(): void { + console.log( + [ + "Usage:", + " pnpm migrate-brain --workspace-id --user-id [--brain-path ] [--apply]", + "", + "Flags:", + " --brain-path Path to jarvis-brain root (default: ~/src/jarvis-brain)", + " --workspace-id Target workspace UUID (required)", + " --user-id Creator/user UUID for imported records (required)", + " --apply Execute writes (default is dry-run)", + ].join("\n") + ); +} + +function expandHomePath(inputPath: string): string { + if (inputPath === "~") { + return homedir(); + } + if (inputPath.startsWith("~/")) { + return path.join(homedir(), inputPath.slice(2)); + } + return inputPath; +} + +function parseCliArgs(args: string[]): CliOptions { + let brainPath = DEFAULT_BRAIN_PATH; + let workspaceId: string | null = null; + let userId: string | null = null; + let apply = false; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (arg === "--apply") { + apply = true; + continue; + } + + if (arg === "--help" || arg === "-h") { + printUsage(); + process.exit(0); + } + + if (arg.startsWith("--brain-path=")) { + brainPath = arg.slice("--brain-path=".length); + continue; + } + if (arg === "--brain-path") { + const value = args[index + 1]; + if (!value) { + throw new Error("Missing value for --brain-path"); + } + brainPath = value; + index += 1; + continue; + } + + if (arg.startsWith("--workspace-id=")) { + workspaceId = arg.slice("--workspace-id=".length); + continue; + } + if (arg === "--workspace-id") { + const value = args[index + 1]; + if (!value) { + throw new Error("Missing value for --workspace-id"); + } + workspaceId = value; + index += 1; + continue; + } + + if (arg.startsWith("--user-id=")) { + userId = arg.slice("--user-id=".length); + continue; + } + if (arg === "--user-id") { + const value = args[index + 1]; + if (!value) { + throw new Error("Missing value for --user-id"); + } + userId = value; + index += 1; + continue; + } + + throw new Error(`Unknown flag: ${arg}`); + } + + if (!workspaceId || !userId) { + throw new Error("Both --workspace-id and --user-id are required"); + } + + return { + brainPath: path.resolve(expandHomePath(brainPath)), + workspaceId, + userId, + apply, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function asString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function asNullableString(value: unknown): string | null { + if (value === null || value === undefined) { + return null; + } + return typeof value === "string" ? value : null; +} + +function asNullableNumber(value: unknown): number | null { + if (value === null || value === undefined) { + return null; + } + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + const parsed: string[] = []; + for (const item of value) { + if (typeof item === "string") { + parsed.push(item); + } + } + return parsed; +} + +function asStringOrNumber(value: unknown): BrainProjectPriority { + if (value === null || value === undefined) { + return null; + } + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + return value; + } + return null; +} + +function ensureRequiredString( + record: Record, + fieldName: string, + context: string, + parseIssues: string[] +): string | null { + const value = asString(record[fieldName]); + if (!value || value.trim().length === 0) { + parseIssues.push(`${context}: required field "${fieldName}" missing or invalid`); + return null; + } + return value; +} + +function parseTaskRecord( + rawRecord: unknown, + context: string, + parseIssues: string[] +): BrainTaskRecord | null { + if (!isRecord(rawRecord)) { + parseIssues.push(`${context}: task entry is not an object`); + return null; + } + + const id = ensureRequiredString(rawRecord, "id", context, parseIssues); + const title = ensureRequiredString(rawRecord, "title", context, parseIssues); + if (!id || !title) { + return null; + } + + return { + id, + title, + domain: asNullableString(rawRecord.domain), + project: asNullableString(rawRecord.project), + related: asStringArray(rawRecord.related), + priority: asNullableString(rawRecord.priority), + status: asNullableString(rawRecord.status), + progress: asNullableNumber(rawRecord.progress), + due: asNullableString(rawRecord.due), + blocks: asStringArray(rawRecord.blocks), + blocked_by: asStringArray(rawRecord.blocked_by), + assignee: asNullableString(rawRecord.assignee), + created: asNullableString(rawRecord.created), + updated: asNullableString(rawRecord.updated), + notes: asNullableString(rawRecord.notes), + notes_nontechnical: asNullableString(rawRecord.notes_nontechnical), + }; +} + +function parseTaskFile( + rawFile: unknown, + sourceFile: string, + parseIssues: string[] +): LoadedTaskFile | null { + if (!isRecord(rawFile)) { + parseIssues.push(`${sourceFile}: file payload is not an object`); + return null; + } + + const version = ensureRequiredString(rawFile, "version", sourceFile, parseIssues); + const domain = ensureRequiredString(rawFile, "domain", sourceFile, parseIssues); + const rawTasks = rawFile.tasks; + + if (!version || !domain) { + return null; + } + if (!Array.isArray(rawTasks)) { + parseIssues.push(`${sourceFile}: "tasks" is missing or not an array`); + return null; + } + + const tasks: BrainTaskRecord[] = []; + for (let index = 0; index < rawTasks.length; index += 1) { + const parsed = parseTaskRecord(rawTasks[index], `${sourceFile} task[${index}]`, parseIssues); + if (parsed) { + tasks.push(parsed); + } + } + + return { + version, + domain, + sourceFile, + tasks, + }; +} + +function parseProjectRecord( + rawRecord: unknown, + context: string, + parseIssues: string[] +): BrainProjectRecord | null { + if (!isRecord(rawRecord)) { + parseIssues.push(`${context}: "project" is not an object`); + return null; + } + + const id = ensureRequiredString(rawRecord, "id", context, parseIssues); + const name = ensureRequiredString(rawRecord, "name", context, parseIssues); + if (!id || !name) { + return null; + } + + return { + id, + name, + description: asNullableString(rawRecord.description), + domain: asNullableString(rawRecord.domain), + status: asNullableString(rawRecord.status), + priority: asStringOrNumber(rawRecord.priority), + progress: asNullableNumber(rawRecord.progress), + repo: asNullableString(rawRecord.repo), + branch: asNullableString(rawRecord.branch), + current_milestone: asNullableString(rawRecord.current_milestone), + next_milestone: asNullableString(rawRecord.next_milestone), + blocker: asNullableString(rawRecord.blocker), + owner: asNullableString(rawRecord.owner), + docs_path: asNullableString(rawRecord.docs_path), + created: asNullableString(rawRecord.created), + updated: asNullableString(rawRecord.updated), + target_date: asNullableString(rawRecord.target_date), + notes: asNullableString(rawRecord.notes), + notes_nontechnical: asNullableString(rawRecord.notes_nontechnical), + parent: asNullableString(rawRecord.parent), + }; +} + +function parseProjectFile( + rawFile: unknown, + sourceFile: string, + parseIssues: string[] +): LoadedProjectFile | null { + if (!isRecord(rawFile)) { + parseIssues.push(`${sourceFile}: file payload is not an object`); + return null; + } + + const version = ensureRequiredString(rawFile, "version", sourceFile, parseIssues); + if (!version) { + return null; + } + + const project = parseProjectRecord(rawFile.project, `${sourceFile} project`, parseIssues); + if (!project) { + return null; + } + + return { + version, + sourceFile, + project, + }; +} + +async function listJsonFiles(directoryPath: string): Promise { + const entries = await readdir(directoryPath, { withFileTypes: true }); + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => path.join(directoryPath, entry.name)) + .sort((left, right) => left.localeCompare(right)); +} + +async function readJsonFile(filePath: string): Promise { + const fileContents = await readFile(filePath, "utf8"); + return JSON.parse(fileContents) as unknown; +} + +function normalizeKey(value: string | null | undefined): string { + return value?.trim().toLowerCase() ?? ""; +} + +function mapTaskStatus(rawStatus: string | null): { status: TaskStatus; issue: string | null } { + const statusKey = normalizeKey(rawStatus); + switch (statusKey) { + case "done": + return { status: TaskStatus.COMPLETED, issue: null }; + case "in-progress": + return { status: TaskStatus.IN_PROGRESS, issue: null }; + case "backlog": + case "pending": + case "scheduled": + case "not-started": + case "planned": + return { status: TaskStatus.NOT_STARTED, issue: null }; + case "blocked": + case "on-hold": + return { status: TaskStatus.PAUSED, issue: null }; + case "cancelled": + return { status: TaskStatus.ARCHIVED, issue: null }; + default: + return { + status: TaskStatus.NOT_STARTED, + issue: `Unknown task status "${rawStatus ?? "null"}" mapped to NOT_STARTED`, + }; + } +} + +function mapTaskPriority(rawPriority: string | null): { + priority: TaskPriority; + issue: string | null; +} { + const priorityKey = normalizeKey(rawPriority); + switch (priorityKey) { + case "critical": + case "high": + return { priority: TaskPriority.HIGH, issue: null }; + case "medium": + return { priority: TaskPriority.MEDIUM, issue: null }; + case "low": + return { priority: TaskPriority.LOW, issue: null }; + default: + return { + priority: TaskPriority.MEDIUM, + issue: `Unknown task priority "${rawPriority ?? "null"}" mapped to MEDIUM`, + }; + } +} + +function mapProjectStatus(rawStatus: string | null): { + status: ProjectStatus; + issue: string | null; +} { + const statusKey = normalizeKey(rawStatus); + switch (statusKey) { + case "active": + case "in-progress": + return { status: ProjectStatus.ACTIVE, issue: null }; + case "backlog": + case "planning": + return { status: ProjectStatus.PLANNING, issue: null }; + case "paused": + case "blocked": + return { status: ProjectStatus.PAUSED, issue: null }; + case "archived": + case "maintenance": + return { status: ProjectStatus.ARCHIVED, issue: null }; + default: + return { + status: ProjectStatus.PLANNING, + issue: `Unknown project status "${rawStatus ?? "null"}" mapped to PLANNING`, + }; + } +} + +function normalizeDate( + rawValue: string | null, + context: string, + dateIssues: string[] +): Date | null { + if (!rawValue) { + return null; + } + + const trimmed = rawValue.trim(); + if (trimmed.length === 0) { + return null; + } + + const value = /^\d{4}-\d{2}-\d{2}$/.test(trimmed) ? `${trimmed}T00:00:00.000Z` : trimmed; + const parsedDate = new Date(value); + if (Number.isNaN(parsedDate.getTime())) { + dateIssues.push(`${context}: invalid date "${rawValue}"`); + return null; + } + + return parsedDate; +} + +function asJsonValue(value: Record): Prisma.InputJsonValue { + return value as Prisma.InputJsonValue; +} + +function normalizeDomain(rawDomain: string | null | undefined): string | null { + if (!rawDomain) { + return null; + } + + const trimmed = rawDomain.trim(); + if (trimmed.length === 0) { + return null; + } + + const slug = trimmed + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + return slug.length > 0 ? slug : null; +} + +function truncate(items: string[], limit = LOG_LIMIT): string[] { + return items.length <= limit ? items : items.slice(0, limit); +} + +function buildMigrationPlan( + taskFiles: LoadedTaskFile[], + projectFiles: LoadedProjectFile[], + parseIssues: string[], + importTimestamp: string +): { plan: MigrationPlan; report: ValidationReport } { + const report: ValidationReport = { + taskFilesRead: taskFiles.length, + projectFilesRead: projectFiles.length, + tasksParsed: taskFiles.reduce((accumulator, file) => accumulator + file.tasks.length, 0), + projectsParsed: projectFiles.length, + uniqueDomainCount: 0, + duplicateTaskIds: [], + duplicateProjectIds: [], + mappingIssues: [], + dateIssues: [], + parseIssues, + missingProjectRefs: [], + }; + + const projectSeen = new Set(); + const taskSeen = new Set(); + const domainSources = new Map>(); + + const registerDomain = (rawDomain: string | null | undefined): string | null => { + const slug = normalizeDomain(rawDomain); + if (!slug) { + return null; + } + const canonical = rawDomain?.trim() ?? slug; + const existing = domainSources.get(slug); + if (existing) { + existing.add(canonical); + } else { + domainSources.set(slug, new Set([canonical])); + } + return slug; + }; + + const projects: PreparedProject[] = []; + for (const file of projectFiles) { + const brainProject = file.project; + if (projectSeen.has(brainProject.id)) { + report.duplicateProjectIds.push( + `${brainProject.id} (duplicate in ${file.sourceFile}; keeping first instance)` + ); + continue; + } + projectSeen.add(brainProject.id); + + const mappedStatus = mapProjectStatus(brainProject.status); + if (mappedStatus.issue) { + report.mappingIssues.push(`project ${brainProject.id}: ${mappedStatus.issue}`); + } + + const domainSlug = registerDomain(brainProject.domain); + const createdAt = + normalizeDate( + brainProject.created, + `project ${brainProject.id}.created`, + report.dateIssues + ) ?? new Date(); + const updatedAt = + normalizeDate( + brainProject.updated, + `project ${brainProject.id}.updated`, + report.dateIssues + ) ?? createdAt; + const startDate = + normalizeDate( + brainProject.created, + `project ${brainProject.id}.startDate`, + report.dateIssues + ) ?? null; + const endDate = normalizeDate( + brainProject.target_date, + `project ${brainProject.id}.target_date`, + report.dateIssues + ); + + const metadata = asJsonValue({ + source: "jarvis-brain", + brainId: brainProject.id, + brainDomain: brainProject.domain, + rawStatus: brainProject.status, + rawPriority: brainProject.priority, + progress: brainProject.progress, + repo: brainProject.repo, + branch: brainProject.branch, + currentMilestone: brainProject.current_milestone, + nextMilestone: brainProject.next_milestone, + blocker: brainProject.blocker, + owner: brainProject.owner, + docsPath: brainProject.docs_path, + targetDate: brainProject.target_date, + notes: brainProject.notes, + notesNonTechnical: brainProject.notes_nontechnical, + parent: brainProject.parent, + sourceFile: file.sourceFile, + sourceVersion: file.version, + importedAt: importTimestamp, + }); + + projects.push({ + brainId: brainProject.id, + name: brainProject.name, + description: brainProject.description, + status: mappedStatus.status, + domainSlug, + startDate, + endDate, + createdAt, + updatedAt, + metadata, + }); + } + + const tasks: PreparedTask[] = []; + for (const file of taskFiles) { + registerDomain(file.domain); + + for (const brainTask of file.tasks) { + if (taskSeen.has(brainTask.id)) { + report.duplicateTaskIds.push( + `${brainTask.id} (duplicate in ${file.sourceFile}; keeping first instance)` + ); + continue; + } + taskSeen.add(brainTask.id); + + const mappedStatus = mapTaskStatus(brainTask.status); + if (mappedStatus.issue) { + report.mappingIssues.push(`task ${brainTask.id}: ${mappedStatus.issue}`); + } + + const mappedPriority = mapTaskPriority(brainTask.priority); + if (mappedPriority.issue) { + report.mappingIssues.push(`task ${brainTask.id}: ${mappedPriority.issue}`); + } + + const domainSlug = registerDomain(brainTask.domain ?? file.domain); + const projectBrainId = brainTask.project?.trim() ? brainTask.project.trim() : null; + if (projectBrainId && !projectSeen.has(projectBrainId)) { + report.missingProjectRefs.push( + `task ${brainTask.id}: referenced project "${projectBrainId}" not found in project files` + ); + } + + const createdAt = + normalizeDate(brainTask.created, `task ${brainTask.id}.created`, report.dateIssues) ?? + new Date(); + const updatedAt = + normalizeDate(brainTask.updated, `task ${brainTask.id}.updated`, report.dateIssues) ?? + createdAt; + const dueDate = normalizeDate(brainTask.due, `task ${brainTask.id}.due`, report.dateIssues); + const completedAt = mappedStatus.status === TaskStatus.COMPLETED ? updatedAt : null; + + const metadata = asJsonValue({ + source: "jarvis-brain", + brainId: brainTask.id, + brainDomain: brainTask.domain ?? file.domain, + brainProjectId: projectBrainId, + rawStatus: brainTask.status, + rawPriority: brainTask.priority, + related: brainTask.related, + blocks: brainTask.blocks, + blockedBy: brainTask.blocked_by, + assignee: brainTask.assignee, + progress: brainTask.progress, + notes: brainTask.notes, + notesNonTechnical: brainTask.notes_nontechnical, + sourceFile: file.sourceFile, + sourceVersion: file.version, + importedAt: importTimestamp, + }); + + tasks.push({ + brainId: brainTask.id, + title: brainTask.title, + description: brainTask.notes, + status: mappedStatus.status, + priority: mappedPriority.priority, + dueDate, + projectBrainId, + domainSlug, + createdAt, + updatedAt, + completedAt, + metadata, + }); + } + } + + const domains: PreparedDomain[] = []; + for (const [slug, rawValues] of Array.from(domainSources.entries())) { + const sourceValues = [...rawValues].sort((left, right) => left.localeCompare(right)); + const domainName = sourceValues[0] ?? slug; + domains.push({ + slug, + name: domainName, + metadata: asJsonValue({ + source: "jarvis-brain", + brainId: domainName, + sourceValues, + importedAt: importTimestamp, + }), + }); + } + + domains.sort((left, right) => left.slug.localeCompare(right.slug)); + report.uniqueDomainCount = domains.length; + + return { + plan: { + domains, + projects, + tasks, + }, + report, + }; +} + +function printValidationReport( + options: CliOptions, + plan: MigrationPlan, + report: ValidationReport +): void { + console.log(""); + console.log("=== jarvis-brain migration validation ==="); + console.log(`Mode: ${options.apply ? "APPLY" : "DRY-RUN"}`); + console.log(`Brain path: ${options.brainPath}`); + console.log(`Workspace ID: ${options.workspaceId}`); + console.log(`User ID: ${options.userId}`); + console.log(""); + console.log("Counts:"); + console.log(`- Task files read: ${report.taskFilesRead}`); + console.log(`- Project files read: ${report.projectFilesRead}`); + console.log(`- Parsed tasks: ${report.tasksParsed}`); + console.log(`- Parsed projects: ${report.projectsParsed}`); + console.log(`- Unique domains: ${report.uniqueDomainCount}`); + console.log(""); + console.log("Planned inserts:"); + console.log(`- Domains: ${plan.domains.length}`); + console.log(`- Projects: ${plan.projects.length}`); + console.log(`- Tasks: ${plan.tasks.length}`); + + console.log(""); + console.log("Validation issues:"); + console.log(`- Parse issues: ${report.parseIssues.length}`); + console.log(`- Date issues: ${report.dateIssues.length}`); + console.log(`- Mapping issues: ${report.mappingIssues.length}`); + console.log(`- Missing project references: ${report.missingProjectRefs.length}`); + console.log(`- Duplicate project IDs: ${report.duplicateProjectIds.length}`); + console.log(`- Duplicate task IDs: ${report.duplicateTaskIds.length}`); + + const printIssueSection = (label: string, values: string[]): void => { + if (values.length === 0) { + return; + } + console.log(""); + console.log(`${label} (showing up to ${LOG_LIMIT}):`); + for (const issue of truncate(values)) { + console.log(`- ${issue}`); + } + }; + + printIssueSection("Parse issues", report.parseIssues); + printIssueSection("Date issues", report.dateIssues); + printIssueSection("Mapping issues", report.mappingIssues); + printIssueSection("Missing project references", report.missingProjectRefs); + printIssueSection("Duplicate project IDs", report.duplicateProjectIds); + printIssueSection("Duplicate task IDs", report.duplicateTaskIds); + + console.log(""); + console.log(`Insert payload preview (first ${PREVIEW_LIMIT} each):`); + console.log( + JSON.stringify( + { + domains: plan.domains.slice(0, PREVIEW_LIMIT), + projects: plan.projects.slice(0, PREVIEW_LIMIT), + tasks: plan.tasks.slice(0, PREVIEW_LIMIT), + }, + null, + 2 + ) + ); +} + +async function ensureWorkspaceAndUserExist( + prisma: PrismaClient, + workspaceId: string, + userId: string +): Promise { + const [workspace, user] = await Promise.all([ + prisma.workspace.findUnique({ + where: { id: workspaceId }, + select: { id: true }, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { id: true }, + }), + ]); + + if (!workspace) { + throw new Error(`Workspace not found for --workspace-id=${workspaceId}`); + } + if (!user) { + throw new Error(`User not found for --user-id=${userId}`); + } +} + +function buildCreatedActivityDetails( + source: "domain" | "project" | "task", + brainId: string, + importTimestamp: string +): Prisma.InputJsonValue { + return asJsonValue({ + source: "jarvis-brain", + migrationEntity: source, + brainId, + importedAt: importTimestamp, + }); +} + +async function applyMigration( + prisma: PrismaClient, + options: CliOptions, + plan: MigrationPlan, + importTimestamp: string +): Promise { + await ensureWorkspaceAndUserExist(prisma, options.workspaceId, options.userId); + + return prisma.$transaction(async (tx) => { + const domainIdBySlug = new Map(); + const projectIdByBrainId = new Map(); + + let domainsCreated = 0; + let domainsSkipped = 0; + let projectsCreated = 0; + let projectsSkipped = 0; + let tasksCreated = 0; + let tasksSkipped = 0; + let tasksWithMissingProject = 0; + let activityLogsCreated = 0; + + for (const domain of plan.domains) { + const existingDomain = await tx.domain.findUnique({ + where: { + workspaceId_slug: { + workspaceId: options.workspaceId, + slug: domain.slug, + }, + }, + select: { id: true }, + }); + + if (existingDomain) { + domainIdBySlug.set(domain.slug, existingDomain.id); + domainsSkipped += 1; + continue; + } + + const createdDomain = await tx.domain.create({ + data: { + workspaceId: options.workspaceId, + name: domain.name, + slug: domain.slug, + metadata: domain.metadata, + }, + select: { id: true }, + }); + + domainIdBySlug.set(domain.slug, createdDomain.id); + domainsCreated += 1; + + await tx.activityLog.create({ + data: { + workspaceId: options.workspaceId, + userId: options.userId, + action: ActivityAction.CREATED, + entityType: EntityType.DOMAIN, + entityId: createdDomain.id, + details: buildCreatedActivityDetails("domain", domain.name, importTimestamp), + }, + }); + activityLogsCreated += 1; + } + + for (const project of plan.projects) { + const existingProject = await tx.project.findFirst({ + where: { + workspaceId: options.workspaceId, + metadata: { + path: ["brainId"], + equals: project.brainId, + }, + }, + select: { id: true }, + }); + + if (existingProject) { + projectIdByBrainId.set(project.brainId, existingProject.id); + projectsSkipped += 1; + continue; + } + + const createdProject = await tx.project.create({ + data: { + workspaceId: options.workspaceId, + name: project.name, + description: project.description, + status: project.status, + startDate: project.startDate, + endDate: project.endDate, + creatorId: options.userId, + domainId: project.domainSlug ? (domainIdBySlug.get(project.domainSlug) ?? null) : null, + metadata: project.metadata, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + }, + select: { id: true }, + }); + + projectIdByBrainId.set(project.brainId, createdProject.id); + projectsCreated += 1; + + await tx.activityLog.create({ + data: { + workspaceId: options.workspaceId, + userId: options.userId, + action: ActivityAction.CREATED, + entityType: EntityType.PROJECT, + entityId: createdProject.id, + details: buildCreatedActivityDetails("project", project.brainId, importTimestamp), + }, + }); + activityLogsCreated += 1; + } + + for (const task of plan.tasks) { + const existingTask = await tx.task.findFirst({ + where: { + workspaceId: options.workspaceId, + metadata: { + path: ["brainId"], + equals: task.brainId, + }, + }, + select: { id: true }, + }); + + if (existingTask) { + tasksSkipped += 1; + continue; + } + + const projectId = + task.projectBrainId !== null ? (projectIdByBrainId.get(task.projectBrainId) ?? null) : null; + if (task.projectBrainId && !projectId) { + tasksWithMissingProject += 1; + } + + const createdTask = await tx.task.create({ + data: { + workspaceId: options.workspaceId, + title: task.title, + description: task.description, + status: task.status, + priority: task.priority, + dueDate: task.dueDate, + creatorId: options.userId, + projectId, + domainId: task.domainSlug ? (domainIdBySlug.get(task.domainSlug) ?? null) : null, + metadata: task.metadata, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + completedAt: task.completedAt, + }, + select: { id: true }, + }); + + tasksCreated += 1; + + await tx.activityLog.create({ + data: { + workspaceId: options.workspaceId, + userId: options.userId, + action: ActivityAction.CREATED, + entityType: EntityType.TASK, + entityId: createdTask.id, + details: buildCreatedActivityDetails("task", task.brainId, importTimestamp), + }, + }); + activityLogsCreated += 1; + } + + return { + domainsCreated, + domainsSkipped, + projectsCreated, + projectsSkipped, + tasksCreated, + tasksSkipped, + tasksWithMissingProject, + activityLogsCreated, + }; + }); +} + +async function loadTaskFiles( + taskDirectory: string, + parseIssues: string[], + rootPath: string +): Promise { + const files = await listJsonFiles(taskDirectory); + const loadedFiles: LoadedTaskFile[] = []; + + for (const filePath of files) { + const payload = await readJsonFile(filePath); + const sourceFile = path.relative(rootPath, filePath); + const parsed = parseTaskFile(payload, sourceFile, parseIssues); + if (parsed) { + loadedFiles.push(parsed); + } + } + + return loadedFiles; +} + +async function loadProjectFiles( + projectDirectory: string, + parseIssues: string[], + rootPath: string +): Promise { + const files = await listJsonFiles(projectDirectory); + const loadedFiles: LoadedProjectFile[] = []; + + for (const filePath of files) { + const payload = await readJsonFile(filePath); + const sourceFile = path.relative(rootPath, filePath); + const parsed = parseProjectFile(payload, sourceFile, parseIssues); + if (parsed) { + loadedFiles.push(parsed); + } + } + + return loadedFiles; +} + +async function main(): Promise { + const options = parseCliArgs(process.argv.slice(2)); + const importTimestamp = new Date().toISOString(); + + const tasksPath = path.join(options.brainPath, TASKS_SUBDIR); + const projectsPath = path.join(options.brainPath, PROJECTS_SUBDIR); + + const parseIssues: string[] = []; + const [taskFiles, projectFiles] = await Promise.all([ + loadTaskFiles(tasksPath, parseIssues, options.brainPath), + loadProjectFiles(projectsPath, parseIssues, options.brainPath), + ]); + + const { plan, report } = buildMigrationPlan( + taskFiles, + projectFiles, + parseIssues, + importTimestamp + ); + printValidationReport(options, plan, report); + + if (!options.apply) { + console.log(""); + console.log("Dry-run complete. Re-run with --apply to write records."); + return; + } + + const prisma = new PrismaClient(); + try { + const summary = await applyMigration(prisma, options, plan, importTimestamp); + console.log(""); + console.log("Apply complete:"); + console.log(`- Domains created/skipped: ${summary.domainsCreated}/${summary.domainsSkipped}`); + console.log( + `- Projects created/skipped: ${summary.projectsCreated}/${summary.projectsSkipped}` + ); + console.log(`- Tasks created/skipped: ${summary.tasksCreated}/${summary.tasksSkipped}`); + console.log(`- Tasks with missing project links: ${summary.tasksWithMissingProject}`); + console.log(`- Activity logs created: ${summary.activityLogsCreated}`); + } finally { + await prisma.$disconnect(); + } +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.error(`Migration failed: ${message}`); + printUsage(); + process.exitCode = 1; +});