/* eslint-disable max-classes-per-file -- deep Effect v4 domain module: branded schemas, tagged errors, and context services */ import { Context, Effect, Layer, Schema } from "effect"; import { OrganizationId, ProjectId, TimestampMs } from "./signal"; export type { OrganizationId, ProjectId } from "./signal"; // --------------------------------------------------------------------------- // Domain constants // --------------------------------------------------------------------------- export const MAX_CONTEXT_DOCUMENT_CHARACTERS = 200_000; export const MAX_CONTEXT_BATCH_CHARACTERS = 600_000; export const CONTEXT_KINDS = [ "readme", "agents", "product", "business", "design", "tech", ] as const; export type ContextKind = (typeof CONTEXT_KINDS)[number]; const CONTEXT_KIND_TO_PATH: Readonly> = { agents: "AGENTS.md", business: "business.md", design: "design.md", product: "product.md", readme: "README.md", tech: "tech.md", }; const PATH_TO_CONTEXT_KIND: Readonly> = Object.fromEntries( Object.entries(CONTEXT_KIND_TO_PATH).map(([kind, path]) => [ path, kind as ContextKind, ]) ); export const contextPathForKind = (kind: ContextKind): string => CONTEXT_KIND_TO_PATH[kind]; export const contextKindForPath = (path: string): ContextKind | null => PATH_TO_CONTEXT_KIND[path] ?? null; const ContextKindSchema = Schema.Literals([...CONTEXT_KINDS]); const MeaningfulString = Schema.String.check( Schema.makeFilter((value) => value.trim().length > 0, { expected: "a non-empty string", }) ); // --------------------------------------------------------------------------- // Shared data shapes // --------------------------------------------------------------------------- export const PreparedPublicGitSource = Schema.Struct({ host: Schema.String, normalizedUrl: Schema.String, projectName: MeaningfulString, repositoryPath: MeaningfulString, url: Schema.String, }); export type PreparedPublicGitSource = typeof PreparedPublicGitSource.Type; export const RepositoryContextDocument = Schema.Struct({ content: Schema.String, kind: ContextKindSchema, path: Schema.String, }); export type RepositoryContextDocument = typeof RepositoryContextDocument.Type; export const ProjectImportWarning = Schema.Struct({ message: Schema.String, path: Schema.String, }); export type ProjectImportWarning = typeof ProjectImportWarning.Type; export const PublicGitImportResult = Schema.Struct({ defaultBranch: Schema.UndefinedOr(Schema.String), documents: Schema.Array(RepositoryContextDocument), warnings: Schema.Array(ProjectImportWarning), }); export type PublicGitImportResult = typeof PublicGitImportResult.Type; export const PublicTextResult = Schema.Struct({ content: Schema.String, finalUrl: Schema.String, }); export type PublicTextResult = typeof PublicTextResult.Type; export const ContextOrigin = Schema.Literals([ "repository", "paste", "upload", "public-url", ]); export type ContextOrigin = typeof ContextOrigin.Type; export const ContextDocumentState = Schema.Struct({ content: Schema.String, kind: ContextKindSchema, origin: ContextOrigin, path: Schema.String, revision: Schema.Number, sourceUrl: Schema.UndefinedOr(Schema.String), }); export type ContextDocumentState = typeof ContextDocumentState.Type; const RepositoryWrite = Schema.Struct({ _tag: Schema.Literal("Repository"), content: Schema.String, kind: ContextKindSchema, sourceUrl: Schema.String, }); const PublicTextUrlWrite = Schema.Struct({ _tag: Schema.Literal("PublicTextUrl"), content: Schema.String, kind: ContextKindSchema, sourceUrl: Schema.String, }); const UserTextWrite = Schema.Struct({ _tag: Schema.Literal("UserText"), content: Schema.String, kind: ContextKindSchema, origin: Schema.Literals(["paste", "upload"]), }); export const ContextWrite = Schema.Union([ RepositoryWrite, PublicTextUrlWrite, UserTextWrite, ]); export type ContextWrite = typeof ContextWrite.Type; export const ProjectSourceView = Schema.Struct({ createdAt: TimestampMs, defaultBranch: Schema.UndefinedOr(Schema.String), host: Schema.String, kind: Schema.Literal("git"), normalizedUrl: Schema.String, projectId: ProjectId, repositoryPath: Schema.String, updatedAt: TimestampMs, url: Schema.String, }); export type ProjectSourceView = typeof ProjectSourceView.Type; export const ProjectView = Schema.Struct({ contextDocuments: Schema.Array(ContextDocumentState), createdAt: TimestampMs, id: ProjectId, name: Schema.String, organizationId: OrganizationId, sources: Schema.Array(ProjectSourceView), updatedAt: TimestampMs, }); export type ProjectView = typeof ProjectView.Type; export const ProjectImportOutcome = Schema.Struct({ contextDocuments: Schema.Array(ContextDocumentState), createdAt: TimestampMs, id: ProjectId, name: Schema.String, organizationId: OrganizationId, source: ProjectSourceView, updatedAt: TimestampMs, }); export type ProjectImportOutcome = typeof ProjectImportOutcome.Type; // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- export const PublicGitErrorReason = Schema.Literals([ "InvalidUrl", "Unreachable", "InvalidResponse", "Cancelled", ]); export type PublicGitErrorReason = typeof PublicGitErrorReason.Type; export class PublicGitError extends Schema.TaggedErrorClass()( "PublicGitError", { message: Schema.String, reason: PublicGitErrorReason, } ) {} export const ProjectStoreErrorReason = Schema.Literals([ "NotFound", "Forbidden", "Conflict", "Persistence", ]); export type ProjectStoreErrorReason = typeof ProjectStoreErrorReason.Type; export class ProjectStoreError extends Schema.TaggedErrorClass()( "ProjectStoreError", { message: Schema.String, reason: ProjectStoreErrorReason, } ) {} export const ProjectApplicationErrorReason = Schema.Literals([ "Authentication", "InvalidPublicGitUrl", "PublicGitUnreachable", "InvalidRemoteResult", "InvalidContextInput", "ContextDocumentTooLarge", "ContextBatchTooLarge", "ProjectNotFound", "Persistence", ]); export type ProjectApplicationErrorReason = typeof ProjectApplicationErrorReason.Type; export class ProjectApplicationError extends Schema.TaggedErrorClass()( "ProjectApplicationError", { message: Schema.String, reason: ProjectApplicationErrorReason, } ) {} // --------------------------------------------------------------------------- // Pure domain functions // --------------------------------------------------------------------------- const isWhitespaceOnly = (value: string): boolean => value.trim().length === 0; const INVALID_GIT_URL = new ProjectApplicationError({ message: "Use a public http(s) Git repository URL", reason: "InvalidPublicGitUrl", }); const INVALID_REMOTE = new ProjectApplicationError({ message: "Invalid remote result", reason: "InvalidRemoteResult", }); export const preparePublicGitSource = ( rawUrl: string ): Effect.Effect => Effect.gen(function* prepareSource() { let parsed: URL; try { parsed = new URL(rawUrl); } catch { return yield* Effect.fail(INVALID_GIT_URL); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return yield* Effect.fail(INVALID_GIT_URL); } if (parsed.username || parsed.password || parsed.search || parsed.hash) { return yield* Effect.fail(INVALID_GIT_URL); } const host = parsed.hostname.toLowerCase(); let repositoryPath = parsed.pathname; if (repositoryPath.startsWith("/")) { repositoryPath = repositoryPath.slice(1); } if (repositoryPath.endsWith("/")) { repositoryPath = repositoryPath.slice(0, -1); } if (repositoryPath.endsWith(".git")) { repositoryPath = repositoryPath.slice(0, -4); } if (repositoryPath.length === 0) { return yield* Effect.fail(INVALID_GIT_URL); } const segments = repositoryPath.split("/").filter((s) => s.length > 0); // eslint-disable-next-line unicorn/prefer-at -- backend Convex tsconfig targets pre-ES2022 const lastSegment = segments[segments.length - 1]; if (!lastSegment || isWhitespaceOnly(lastSegment)) { return yield* Effect.fail(INVALID_GIT_URL); } const projectName = lastSegment; const normalizedUrl = `${parsed.protocol}//${host}/${repositoryPath}`; return { host, normalizedUrl, projectName, repositoryPath, url: normalizedUrl, }; }); export const decodePublicGitImportResult = ( raw: unknown ): Effect.Effect => Effect.gen(function* decodeImport() { const result = yield* Schema.decodeUnknownEffect(PublicGitImportResult)( raw ).pipe(Effect.mapError(() => INVALID_REMOTE)); const seenKinds = new Set(); const seenPaths = new Set(); let totalChars = 0; for (const doc of result.documents) { const canonicalPath = CONTEXT_KIND_TO_PATH[doc.kind]; if (doc.path !== canonicalPath) { return yield* Effect.fail(INVALID_REMOTE); } if (seenKinds.has(doc.kind) || seenPaths.has(doc.path)) { return yield* Effect.fail(INVALID_REMOTE); } if (isWhitespaceOnly(doc.content)) { return yield* Effect.fail(INVALID_REMOTE); } if (doc.content.length > MAX_CONTEXT_DOCUMENT_CHARACTERS) { return yield* Effect.fail( new ProjectApplicationError({ message: "Context document exceeds 200000 characters", reason: "ContextDocumentTooLarge", }) ); } seenKinds.add(doc.kind); seenPaths.add(doc.path); totalChars += doc.content.length; } if (totalChars > MAX_CONTEXT_BATCH_CHARACTERS) { return yield* Effect.fail( new ProjectApplicationError({ message: "Context batch exceeds 600000 characters", reason: "ContextBatchTooLarge", }) ); } return result; }); export const decodePublicTextResult = ( raw: unknown ): Effect.Effect => Effect.gen(function* decodeText() { const result = yield* Schema.decodeUnknownEffect(PublicTextResult)( raw ).pipe(Effect.mapError(() => INVALID_REMOTE)); if (isWhitespaceOnly(result.content)) { return yield* Effect.fail( new ProjectApplicationError({ message: "Context document cannot be empty", reason: "InvalidContextInput", }) ); } if (result.content.length > MAX_CONTEXT_DOCUMENT_CHARACTERS) { return yield* Effect.fail( new ProjectApplicationError({ message: "Context document exceeds 200000 characters", reason: "ContextDocumentTooLarge", }) ); } if (isWhitespaceOnly(result.finalUrl)) { return yield* Effect.fail(INVALID_REMOTE); } return result; }); const resolveKindFromPath = (path: string): ContextKind => { const kind = contextKindForPath(path); if (kind === null) { throw new Error(`Unknown canonical context path: ${path}`); } return kind; }; export const makeInitialContext = ( projectName: string, repositoryUrl: string ): readonly ContextDocumentState[] => { const placeholder = (path: string): ContextDocumentState => ({ content: `# ${path}\n\nContext not imported yet.\n`, kind: resolveKindFromPath(path), origin: "repository", path, revision: 1, sourceUrl: repositoryUrl, }); return [ { content: `# ${projectName}\n\nRepository: ${repositoryUrl}\n`, kind: "readme", origin: "repository", path: "README.md", revision: 1, sourceUrl: repositoryUrl, }, placeholder("AGENTS.md"), placeholder("product.md"), placeholder("business.md"), placeholder("design.md"), placeholder("tech.md"), ]; }; const resolveWriteOrigin = (write: ContextWrite): ContextOrigin => { if (write._tag === "Repository") { return "repository"; } if (write._tag === "PublicTextUrl") { return "public-url"; } return write.origin; }; export interface DecideContextWriteInput { readonly existing: ContextDocumentState | undefined; readonly write: ContextWrite; } export interface DecideContextWriteResult { readonly document: ContextDocumentState; readonly changed: boolean; } export const decideContextWrite = ( input: DecideContextWriteInput ): Effect.Effect => Effect.gen(function* decideWrite() { const { write } = input; const { content } = write; const { kind } = write; const path = contextPathForKind(kind); if (isWhitespaceOnly(content)) { return yield* Effect.fail( new ProjectApplicationError({ message: "Context document cannot be empty", reason: "InvalidContextInput", }) ); } if (content.length > MAX_CONTEXT_DOCUMENT_CHARACTERS) { return yield* Effect.fail( new ProjectApplicationError({ message: "Context document exceeds 200000 characters", reason: "ContextDocumentTooLarge", }) ); } const origin: ContextOrigin = resolveWriteOrigin(write); const sourceUrl: string | undefined = write._tag === "UserText" ? undefined : write.sourceUrl; const { existing } = input; if ( existing && existing.content === content && existing.origin === origin && existing.sourceUrl === sourceUrl ) { return { changed: false, document: existing }; } const nextRevision = (existing?.revision ?? 0) + 1; return { changed: true, document: { content, kind, origin, path, revision: nextRevision, sourceUrl, }, }; }); // --------------------------------------------------------------------------- // ProjectDomain service // --------------------------------------------------------------------------- interface ProjectDomainShape { readonly preparePublicGitSource: typeof preparePublicGitSource; readonly decodePublicGitImportResult: typeof decodePublicGitImportResult; readonly decodePublicTextResult: typeof decodePublicTextResult; readonly makeInitialContext: typeof makeInitialContext; readonly decideContextWrite: typeof decideContextWrite; } export class ProjectDomain extends Context.Service< ProjectDomain, ProjectDomainShape >()("@code/primitives/project/ProjectDomain") { static readonly layer = Layer.succeed( ProjectDomain, ProjectDomain.of({ decideContextWrite, decodePublicGitImportResult, decodePublicTextResult, makeInitialContext, preparePublicGitSource, }) ); } // --------------------------------------------------------------------------- // PublicGit port // --------------------------------------------------------------------------- interface PublicGitShape { readonly inspect: ( source: PreparedPublicGitSource ) => Effect.Effect; readonly fetchText: ( url: string ) => Effect.Effect; } export class PublicGit extends Context.Service()( "@code/primitives/project/PublicGit" ) {} // --------------------------------------------------------------------------- // ProjectStore port // --------------------------------------------------------------------------- interface ProjectStoreShape { readonly getCurrentOrganization: ( userId: string ) => Effect.Effect; readonly listProjects: ( userId: string ) => Effect.Effect; readonly getProject: ( userId: string, projectId: ProjectId ) => Effect.Effect; readonly persistPublicGitImport: (input: { readonly userId: string; readonly source: PreparedPublicGitSource; readonly remote: PublicGitImportResult; }) => Effect.Effect; readonly putContext: (input: { readonly userId: string; readonly projectId: ProjectId; readonly write: ContextWrite; }) => Effect.Effect<{ readonly revision: number }, ProjectStoreError>; } export class ProjectStore extends Context.Service< ProjectStore, ProjectStoreShape >()("@code/primitives/project/ProjectStore") {} // --------------------------------------------------------------------------- // Error mapping // --------------------------------------------------------------------------- const mapStoreError = (error: ProjectStoreError): ProjectApplicationError => { if (error.reason === "NotFound" || error.reason === "Forbidden") { return new ProjectApplicationError({ message: "Project not found", reason: "ProjectNotFound", }); } return new ProjectApplicationError({ message: error.message, reason: "Persistence", }); }; const mapPublicGitError = (error: PublicGitError): ProjectApplicationError => { if (error.reason === "InvalidUrl") { return new ProjectApplicationError({ message: error.message, reason: "InvalidPublicGitUrl", }); } if (error.reason === "Unreachable" || error.reason === "Cancelled") { return new ProjectApplicationError({ message: error.message, reason: "PublicGitUnreachable", }); } return new ProjectApplicationError({ message: error.message, reason: "InvalidRemoteResult", }); }; // --------------------------------------------------------------------------- // ProjectApplication service // --------------------------------------------------------------------------- interface ProjectApplicationShape { readonly importPublicGit: (input: { readonly userId: string; readonly repositoryUrl: unknown; }) => Effect.Effect; readonly importPublicText: (input: { readonly userId: string; readonly projectId: ProjectId; readonly kind: ContextKind; readonly url: string; }) => Effect.Effect<{ readonly revision: number }, ProjectApplicationError>; readonly putContext: (input: { readonly userId: string; readonly projectId: ProjectId; readonly kind: ContextKind; readonly content: string; readonly origin: "paste" | "upload"; }) => Effect.Effect<{ readonly revision: number }, ProjectApplicationError>; readonly listProjects: (input: { readonly userId: string; }) => Effect.Effect; readonly getProject: (input: { readonly userId: string; readonly projectId: ProjectId; }) => Effect.Effect; } export class ProjectApplication extends Context.Service< ProjectApplication, ProjectApplicationShape >()("@code/primitives/project/ProjectApplication") { static readonly layer = Layer.effect( ProjectApplication, Effect.gen(function* layer() { const domain = yield* ProjectDomain; const publicGit = yield* PublicGit; const store = yield* ProjectStore; return ProjectApplication.of({ getProject: Effect.fn("ProjectApplication.getProject")( function* getProject({ userId, projectId, }: { readonly userId: string; readonly projectId: ProjectId; }) { return yield* store .getProject(userId, projectId) .pipe(Effect.mapError(mapStoreError)); } ), importPublicGit: Effect.fn("ProjectApplication.importPublicGit")( function* importPublicGit({ userId, repositoryUrl, }: { readonly userId: string; readonly repositoryUrl: unknown; }) { if (typeof repositoryUrl !== "string") { return yield* Effect.fail(INVALID_GIT_URL); } const source = yield* domain.preparePublicGitSource(repositoryUrl); const rawRemote = yield* publicGit .inspect(source) .pipe(Effect.mapError(mapPublicGitError)); const remote = yield* domain.decodePublicGitImportResult(rawRemote); return yield* store .persistPublicGitImport({ remote, source, userId }) .pipe(Effect.mapError(mapStoreError)); } ), importPublicText: Effect.fn("ProjectApplication.importPublicText")( function* importPublicText({ userId, projectId, kind, url, }: { readonly userId: string; readonly projectId: ProjectId; readonly kind: ContextKind; readonly url: string; }) { if (isWhitespaceOnly(url)) { return yield* Effect.fail( new ProjectApplicationError({ message: "Use a public text URL", reason: "InvalidContextInput", }) ); } const rawText = yield* publicGit .fetchText(url) .pipe(Effect.mapError(mapPublicGitError)); const text = yield* domain.decodePublicTextResult(rawText); const write: ContextWrite = { _tag: "PublicTextUrl", content: text.content, kind, sourceUrl: text.finalUrl, }; return yield* store .putContext({ projectId, userId, write }) .pipe(Effect.mapError(mapStoreError)); } ), listProjects: Effect.fn("ProjectApplication.listProjects")( function* listProjects({ userId }: { readonly userId: string }) { return yield* store .listProjects(userId) .pipe(Effect.mapError(mapStoreError)); } ), putContext: Effect.fn("ProjectApplication.putContext")( function* putContext({ userId, projectId, kind, content, origin, }: { readonly userId: string; readonly projectId: ProjectId; readonly kind: ContextKind; readonly content: string; readonly origin: "paste" | "upload"; }) { const write: ContextWrite = { _tag: "UserText", content, kind, origin, }; return yield* store .putContext({ projectId, userId, write }) .pipe(Effect.mapError(mapStoreError)); } ), }); }) ); }