Julius Marmingefmte7357978 months agoHistory
454 lines · 12.7 KB · TypeScript
RawDownload
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<ProjectScript[], PersistenceDecodeError> {  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<ProjectScript[], ProjectRepositoryError> {  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<typeof ProjectRowSchema>,  operation: string,): Effect.Effect<ProjectRecord, ProjectRepositoryError> {  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);