import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import type { ProjectAddInput, ProjectAddResult, ProjectListResult, ProjectRecord, ProjectScript, ProjectUpdateScriptsInput, ProjectUpdateScriptsResult, } from "@t3tools/contracts"; import { normalizeProjectScripts, projectAddInputSchema, projectRecordSchema, projectRemoveInputSchema, projectScriptsSchema, projectUpdateScriptsInputSchema, } from "@t3tools/contracts"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { Effect, Layer, Option, Schema } from "effect"; import { PersistenceDecodeError, ProjectNotFoundError, ProjectPathMissingError, ProjectValidationError, toPersistenceDecodeCauseError, toPersistenceSerializationError, toPersistenceSqlError, type ProjectRepositoryError, } from "../Errors.ts"; import { ProjectRepository, type ProjectRepositoryShape } from "../Services/Projects.ts"; const ProjectRowSchema = Schema.Struct({ id: Schema.String, cwd: Schema.String, name: Schema.String, scriptsJson: Schema.String, createdAt: Schema.String, updatedAt: Schema.String, }); const FindByIdRequestSchema = Schema.Struct({ id: Schema.String, }); const FindByCwdRequestSchema = Schema.Struct({ cwd: Schema.String, }); const InsertProjectRequestSchema = Schema.Struct({ id: Schema.String, cwd: Schema.String, name: Schema.String, scriptsJson: Schema.String, createdAt: Schema.String, updatedAt: Schema.String, }); const UpdateScriptsRequestSchema = Schema.Struct({ id: Schema.String, scriptsJson: Schema.String, updatedAt: Schema.String, }); const DeleteProjectRequestSchema = Schema.Struct({ id: Schema.String, }); function cloneScripts(scripts: readonly ProjectScript[]): ProjectScript[] { const cloned: ProjectScript[] = []; for (const script of scripts) { cloned.push({ id: script.id, name: script.name, command: script.command, icon: script.icon, runOnWorktreeCreate: script.runOnWorktreeCreate, }); } return cloned; } function normalizeCwd(rawCwd: string): string { const resolved = path.resolve(rawCwd.trim()); const normalized = path.normalize(resolved); if (process.platform === "win32") { return normalized.toLowerCase(); } return normalized; } function isDirectory(cwd: string): boolean { try { return fs.statSync(cwd).isDirectory(); } catch { return false; } } function inferProjectName(cwd: string): string { const name = path.basename(cwd); return name.length > 0 ? name : "project"; } function parseProjectScripts( value: unknown, operation: string, ): Effect.Effect { const parsed = projectScriptsSchema.safeParse(value); if (!parsed.success) { return Effect.fail( new PersistenceDecodeError({ operation, issue: parsed.error.message, cause: (parsed.error as { cause?: unknown }).cause, }), ); } return Effect.succeed(normalizeProjectScripts(parsed.data)); } function decodeScriptsJson( raw: string, operation: string, ): Effect.Effect { return Effect.try({ try: () => JSON.parse(raw) as unknown, catch: toPersistenceDecodeCauseError(`${operation}:parseScriptsJson`), }).pipe(Effect.flatMap((value) => parseProjectScripts(value, `${operation}:decodeScripts`))); } function rowToProjectRecord( row: Schema.Schema.Type, operation: string, ): Effect.Effect { return decodeScriptsJson(row.scriptsJson, `${operation}:scripts`).pipe( Effect.flatMap((scripts) => { const parsed = projectRecordSchema.safeParse({ id: row.id, cwd: row.cwd, name: row.name, scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, }); if (!parsed.success) { return Effect.fail( new PersistenceDecodeError({ operation: `${operation}:projectRecord`, issue: parsed.error.message, cause: (parsed.error as { cause?: unknown }).cause, }), ); } return Effect.succeed(parsed.data); }), ); } function decodeAddInput(rawInput: ProjectAddInput) { const parsed = projectAddInputSchema.safeParse(rawInput); if (!parsed.success) { return Effect.fail( new ProjectValidationError({ operation: "ProjectRepository.add", issue: parsed.error.message, cause: (parsed.error as { cause?: unknown }).cause, }), ); } return Effect.succeed(parsed.data); } function decodeUpdateScriptsInput(rawInput: ProjectUpdateScriptsInput) { const parsed = projectUpdateScriptsInputSchema.safeParse(rawInput); if (!parsed.success) { return Effect.fail( new ProjectValidationError({ operation: "ProjectRepository.updateScripts", issue: parsed.error.message, cause: (parsed.error as { cause?: unknown }).cause, }), ); } return Effect.succeed(parsed.data); } function decodeRemoveInput(rawInput: unknown) { const parsed = projectRemoveInputSchema.safeParse(rawInput); if (!parsed.success) { return Effect.fail( new ProjectValidationError({ operation: "ProjectRepository.remove", issue: parsed.error.message, cause: (parsed.error as { cause?: unknown }).cause, }), ); } return Effect.succeed(parsed.data); } const makeRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const listProjectRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectRowSchema, execute: () => sql` SELECT id, cwd, name, scripts_json AS "scriptsJson", created_at AS "createdAt", updated_at AS "updatedAt" FROM projects ORDER BY created_at DESC `, }); const findProjectById = SqlSchema.findOneOption({ Request: FindByIdRequestSchema, Result: ProjectRowSchema, execute: ({ id }) => sql` SELECT id, cwd, name, scripts_json AS "scriptsJson", created_at AS "createdAt", updated_at AS "updatedAt" FROM projects WHERE id = ${id} `, }); const findProjectByCwd = SqlSchema.findOneOption({ Request: FindByCwdRequestSchema, Result: ProjectRowSchema, execute: ({ cwd }) => sql` SELECT id, cwd, name, scripts_json AS "scriptsJson", created_at AS "createdAt", updated_at AS "updatedAt" FROM projects WHERE cwd = ${cwd} `, }); const insertProject = SqlSchema.findOne({ Request: InsertProjectRequestSchema, Result: ProjectRowSchema, execute: (request) => sql` INSERT INTO projects ( id, cwd, name, scripts_json, created_at, updated_at ) VALUES ( ${request.id}, ${request.cwd}, ${request.name}, ${request.scriptsJson}, ${request.createdAt}, ${request.updatedAt} ) RETURNING id, cwd, name, scripts_json AS "scriptsJson", created_at AS "createdAt", updated_at AS "updatedAt" `, }); const updateProjectScripts = SqlSchema.findOne({ Request: UpdateScriptsRequestSchema, Result: ProjectRowSchema, execute: (request) => sql` UPDATE projects SET scripts_json = ${request.scriptsJson}, updated_at = ${request.updatedAt} WHERE id = ${request.id} RETURNING id, cwd, name, scripts_json AS "scriptsJson", created_at AS "createdAt", updated_at AS "updatedAt" `, }); const deleteProject = SqlSchema.void({ Request: DeleteProjectRequestSchema, execute: ({ id }) => sql`DELETE FROM projects WHERE id = ${id}`, }); const list: ProjectRepositoryShape["list"] = () => listProjectRows(undefined).pipe( Effect.mapError(toPersistenceSqlError("ProjectRepository.list:query")), Effect.flatMap((rows) => Effect.forEach(rows, (row) => rowToProjectRecord(row, "ProjectRepository.list:rowToProject"), ), ), Effect.map( (projects): ProjectListResult => projects.map((project) => ({ ...project, scripts: cloneScripts(project.scripts) })), ), ); const add: ProjectRepositoryShape["add"] = (rawInput) => Effect.gen(function* () { const input = yield* decodeAddInput(rawInput); const normalizedCwd = normalizeCwd(input.cwd); if (!isDirectory(normalizedCwd)) { return yield* Effect.fail(new ProjectPathMissingError({ cwd: normalizedCwd })); } const existing = yield* findProjectByCwd({ cwd: normalizedCwd }).pipe( Effect.mapError(toPersistenceSqlError("ProjectRepository.add:findByCwd")), ); if (Option.isSome(existing)) { const project = yield* rowToProjectRecord(existing.value, "ProjectRepository.add:existing"); const result: ProjectAddResult = { project: { ...project, scripts: cloneScripts(project.scripts), }, created: false, }; return result; } const now = new Date().toISOString(); const row = yield* insertProject({ id: randomUUID(), cwd: normalizedCwd, name: inferProjectName(normalizedCwd), scriptsJson: "[]", createdAt: now, updatedAt: now, }).pipe(Effect.mapError(toPersistenceSqlError("ProjectRepository.add:insert"))); const created = yield* rowToProjectRecord(row, "ProjectRepository.add:created"); const result: ProjectAddResult = { project: { ...created, scripts: cloneScripts(created.scripts), }, created: true, }; return result; }); const remove: ProjectRepositoryShape["remove"] = (rawInput) => Effect.gen(function* () { const input = yield* decodeRemoveInput(rawInput); const existing = yield* findProjectById({ id: input.id }).pipe( Effect.mapError(toPersistenceSqlError("ProjectRepository.remove:findById")), ); if (Option.isNone(existing)) { return yield* new ProjectNotFoundError({ projectId: input.id }); } yield* deleteProject({ id: input.id }).pipe( Effect.mapError(toPersistenceSqlError("ProjectRepository.remove:delete")), ); }); const updateScripts: ProjectRepositoryShape["updateScripts"] = (rawInput) => Effect.gen(function* () { const input = yield* decodeUpdateScriptsInput(rawInput); const existing = yield* findProjectById({ id: input.id }).pipe( Effect.mapError(toPersistenceSqlError("ProjectRepository.updateScripts:findById")), ); if (Option.isNone(existing)) { return yield* new ProjectNotFoundError({ projectId: input.id }); } const nextScripts = yield* parseProjectScripts( input.scripts, "ProjectRepository.updateScripts:decodeScripts", ); const scriptsJson = yield* Effect.try({ try: () => JSON.stringify(nextScripts), catch: toPersistenceSerializationError("ProjectRepository.updateScripts:encodeScripts"), }); const nextUpdatedAt = new Date().toISOString(); const updatedRow = yield* updateProjectScripts({ id: input.id, scriptsJson, updatedAt: nextUpdatedAt, }).pipe(Effect.mapError(toPersistenceSqlError("ProjectRepository.updateScripts:update"))); const updated = yield* rowToProjectRecord( updatedRow, "ProjectRepository.updateScripts:updated", ); const result: ProjectUpdateScriptsResult = { project: { ...updated, scripts: cloneScripts(updated.scripts), }, }; return result; }); const pruneMissing: ProjectRepositoryShape["pruneMissing"] = () => listProjectRows(undefined).pipe( Effect.mapError(toPersistenceSqlError("ProjectRepository.pruneMissing:query")), Effect.flatMap((rows) => Effect.forEach(rows, (row) => { const normalizedCwd = normalizeCwd(row.cwd); if (isDirectory(normalizedCwd)) { return Effect.void; } return deleteProject({ id: row.id }).pipe( Effect.mapError(toPersistenceSqlError("ProjectRepository.pruneMissing:delete")), ); }), ), Effect.asVoid, ); return { list, add, remove, updateScripts, pruneMissing, } satisfies ProjectRepositoryShape; }); export const ProjectRepositoryLive = Layer.effect(ProjectRepository, makeRepository);