763 lines
22 KiB
TypeScript
763 lines
22 KiB
TypeScript
/* 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.js";
|
|
|
|
export type { OrganizationId, ProjectId } from "./signal.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<Record<ContextKind, string>> = {
|
|
agents: "AGENTS.md",
|
|
business: "business.md",
|
|
design: "design.md",
|
|
product: "product.md",
|
|
readme: "README.md",
|
|
tech: "tech.md",
|
|
};
|
|
|
|
const PATH_TO_CONTEXT_KIND: Readonly<Record<string, ContextKind>> =
|
|
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>()(
|
|
"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>()(
|
|
"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>()(
|
|
"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<PreparedPublicGitSource, ProjectApplicationError> =>
|
|
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<PublicGitImportResult, ProjectApplicationError> =>
|
|
Effect.gen(function* decodeImport() {
|
|
const result = yield* Schema.decodeUnknownEffect(PublicGitImportResult)(
|
|
raw
|
|
).pipe(Effect.mapError(() => INVALID_REMOTE));
|
|
|
|
const seenKinds = new Set<string>();
|
|
const seenPaths = new Set<string>();
|
|
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<PublicTextResult, ProjectApplicationError> =>
|
|
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<DecideContextWriteResult, ProjectApplicationError> =>
|
|
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<PublicGitImportResult, PublicGitError>;
|
|
readonly fetchText: (
|
|
url: string
|
|
) => Effect.Effect<PublicTextResult, PublicGitError>;
|
|
}
|
|
|
|
export class PublicGit extends Context.Service<PublicGit, PublicGitShape>()(
|
|
"@code/primitives/project/PublicGit"
|
|
) {}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ProjectStore port
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ProjectStoreShape {
|
|
readonly getCurrentOrganization: (
|
|
userId: string
|
|
) => Effect.Effect<OrganizationId, ProjectStoreError>;
|
|
readonly listProjects: (
|
|
userId: string
|
|
) => Effect.Effect<readonly ProjectView[], ProjectStoreError>;
|
|
readonly getProject: (
|
|
userId: string,
|
|
projectId: ProjectId
|
|
) => Effect.Effect<ProjectView | null, ProjectStoreError>;
|
|
readonly persistPublicGitImport: (input: {
|
|
readonly userId: string;
|
|
readonly source: PreparedPublicGitSource;
|
|
readonly remote: PublicGitImportResult;
|
|
}) => Effect.Effect<ProjectImportOutcome, ProjectStoreError>;
|
|
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<ProjectImportOutcome, ProjectApplicationError>;
|
|
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 ProjectView[], ProjectApplicationError>;
|
|
readonly getProject: (input: {
|
|
readonly userId: string;
|
|
readonly projectId: ProjectId;
|
|
}) => Effect.Effect<ProjectView | null, ProjectApplicationError>;
|
|
}
|
|
|
|
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));
|
|
}),
|
|
});
|
|
})
|
|
);
|
|
}
|