Files
zopu-code/packages/primitives/src/project.ts

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));
}),
});
})
);
}