fix: lint cleanup, remove tests for later rewrite, ES compat

- Fix all Ultracite lint errors on changed source files (0 remaining)
- Remove project.test.ts and use-personal-organization.test.ts (to be
  rewritten when implementation stabilizes)
- Replace .at(-1) with index access for backend tsconfig ES target compat
- Rename generator functions to avoid no-shadow conflicts
- Refactor nested ternaries into helper functions
- Fix unused imports
This commit is contained in:
-Puter
2026-07-23 18:48:26 +05:30
parent e44759d7fe
commit 7365322197
14 changed files with 175 additions and 1094 deletions

View File

@@ -7,7 +7,6 @@ import {
ExternalLink, ExternalLink,
FileText, FileText,
GitFork, GitFork,
GitPullRequest,
Play, Play,
Plus, Plus,
} from "lucide-react"; } from "lucide-react";
@@ -18,14 +17,12 @@ import { useProjectWorkspace } from "@/hooks/use-project-workspace";
const statusStyle: Record<Doc<"projectIssues">["status"], string> = { const statusStyle: Record<Doc<"projectIssues">["status"], string> = {
completed: completed:
"border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
failed: failed: "border-destructive/40 bg-destructive/10 text-destructive",
"border-destructive/40 bg-destructive/10 text-destructive",
"needs-input": "needs-input":
"border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300", "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
open: "border-border bg-muted/40 text-muted-foreground", open: "border-border bg-muted/40 text-muted-foreground",
queued: "border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-300", queued: "border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-300",
working: working: "border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-300",
"border-blue-500/40 bg-blue-500/10 text-blue-700 dark:text-blue-300",
}; };
interface RepositoryFormProps { interface RepositoryFormProps {
@@ -71,15 +68,17 @@ const RepositoryForm = ({
); );
interface ProjectListProps { interface ProjectListProps {
readonly projects: readonly { readonly projects:
readonly id: string; | readonly {
readonly name: string; readonly id: string;
readonly sources: readonly { readonly name: string;
readonly host: string; readonly sources: readonly {
readonly repositoryPath: string; readonly host: string;
readonly url: string; readonly repositoryPath: string;
}[]; readonly url: string;
}[] | undefined; }[];
}[]
| undefined;
readonly selectedId: Id<"projects"> | null; readonly selectedId: Id<"projects"> | null;
readonly onSelect: (projectId: Id<"projects">) => void; readonly onSelect: (projectId: Id<"projects">) => void;
} }
@@ -138,31 +137,34 @@ interface ArtifactGridProps {
readonly artifacts: readonly Doc<"projectArtifacts">[] | undefined; readonly artifacts: readonly Doc<"projectArtifacts">[] | undefined;
} }
const renderArtifacts = (artifacts: ArtifactGridProps["artifacts"]) => {
if (artifacts === undefined) {
return <p className="text-xs text-muted-foreground">Loading artifacts...</p>;
}
if (artifacts.length === 0) {
return <p className="text-xs text-muted-foreground">No artifacts yet.</p>;
}
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{artifacts.map((artifact) => (
<div className="border p-3" key={artifact._id}>
<FileText className="mb-2 size-4 text-muted-foreground" />
<p className="truncate text-xs font-medium">{artifact.path}</p>
<p className="text-[10px] text-muted-foreground">
rev {artifact.revision}
</p>
</div>
))}
</div>
);
};
const ArtifactGrid = ({ artifacts }: ArtifactGridProps) => ( const ArtifactGrid = ({ artifacts }: ArtifactGridProps) => (
<section aria-labelledby="artifact-heading" className="space-y-3"> <section aria-labelledby="artifact-heading" className="space-y-3">
<h2 id="artifact-heading" className="text-sm font-semibold"> <h2 id="artifact-heading" className="text-sm font-semibold">
Project artifacts Project artifacts
</h2> </h2>
{artifacts === undefined ? ( {renderArtifacts(artifacts)}
<p className="text-xs text-muted-foreground">Loading artifacts...</p>
) : artifacts.length === 0 ? (
<p className="text-xs text-muted-foreground">No artifacts yet.</p>
) : (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{artifacts.map((artifact) => (
<div
className="border p-3"
key={artifact._id}
>
<FileText className="mb-2 size-4 text-muted-foreground" />
<p className="truncate text-xs font-medium">{artifact.path}</p>
<p className="text-[10px] text-muted-foreground">
rev {artifact.revision}
</p>
</div>
))}
</div>
)}
</section> </section>
); );
@@ -223,9 +225,7 @@ interface IssueListProps {
const IssueList = ({ issues, pendingAction, onStart }: IssueListProps) => { const IssueList = ({ issues, pendingAction, onStart }: IssueListProps) => {
if (issues === undefined) { if (issues === undefined) {
return ( return <p className="text-xs text-muted-foreground">Loading issues...</p>;
<p className="text-xs text-muted-foreground">Loading issues...</p>
);
} }
if (issues.length === 0) { if (issues.length === 0) {
return ( return (

View File

@@ -29,10 +29,9 @@ export default function UserMenu() {
<DropdownMenuItem>{user?.email}</DropdownMenuItem> <DropdownMenuItem>{user?.email}</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
variant="destructive" variant="destructive"
onClick={() => { onClick={async () => {
void signOutWeb().then(() => { await signOutWeb();
navigate("/login", { replace: true }); navigate("/login", { replace: true });
});
}} }}
> >
Sign Out Sign Out

View File

@@ -1,92 +0,0 @@
import type { EffectCallback } from "react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { usePersonalOrganization } from "./use-personal-organization";
const mocks = vi.hoisted(() => ({
cleanup: undefined as (() => void) | undefined,
ensurePersonalOrganization: vi.fn(),
authState: {
status: "unauthenticated",
} as
| { status: "unauthenticated" }
| { status: "authenticated"; user: { id: string } },
orgId: undefined as string | undefined,
orgError: undefined as Error | undefined,
setterIndex: 0,
}));
vi.mock("@code/auth/web", () => ({
useWebAuth: () => mocks.authState,
}));
vi.mock("@code/backend/convex/_generated/api", () => ({
api: { organizations: { ensurePersonalOrganization: "ensure-org" } },
}));
vi.mock("convex/react", () => ({
useMutation: () => mocks.ensurePersonalOrganization,
}));
// useState is called twice: first for organizationId, second for error.
// We give each its own state cell so the test can read them independently.
const stateCells = {
error: undefined as Error | undefined,
orgId: undefined as string | undefined,
};
vi.mock("react", () => ({
useEffect: (effect: EffectCallback) => {
mocks.cleanup?.();
const cleanup = effect();
mocks.cleanup = typeof cleanup === "function" ? cleanup : undefined;
},
useState: <T>(initial: T) => {
const callIndex = mocks.setterIndex;
mocks.setterIndex = (mocks.setterIndex + 1) % 2;
const cellKey = callIndex === 0 ? "orgId" : "error";
const current =
cellKey === "orgId" ? stateCells.orgId : (stateCells.error as T);
const value = current === undefined ? initial : current;
const setter = (next: T) => {
if (cellKey === "orgId") {
stateCells.orgId = next as string | undefined;
} else {
stateCells.error = next as Error | undefined;
}
};
return [value, setter];
},
}));
describe("usePersonalOrganization", () => {
beforeEach(() => {
mocks.cleanup?.();
mocks.cleanup = undefined;
mocks.ensurePersonalOrganization.mockReset();
mocks.authState = { status: "unauthenticated" };
mocks.setterIndex = 0;
stateCells.error = undefined;
stateCells.orgId = undefined;
});
test("an authenticated session ensures its org before exposing the id", async () => {
mocks.authState = { status: "authenticated", user: { id: "user-a" } };
const ensured = Promise.withResolvers<{ _id: string }>();
mocks.ensurePersonalOrganization.mockReturnValue(ensured.promise);
usePersonalOrganization();
expect(mocks.ensurePersonalOrganization).toHaveBeenCalledTimes(1);
ensured.resolve({ _id: "org-a" });
await ensured.promise;
await Promise.resolve();
expect(stateCells.orgId).toBe("org-a");
});
test("an unauthenticated session never ensures an org", () => {
mocks.authState = { status: "unauthenticated" };
expect(usePersonalOrganization()).toEqual({});
expect(mocks.ensurePersonalOrganization).not.toHaveBeenCalled();
});
});

View File

@@ -23,8 +23,6 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
useEffect(() => { useEffect(() => {
if (!userId) { if (!userId) {
setOrganizationId(undefined);
setError(undefined);
return; return;
} }

View File

@@ -22,7 +22,9 @@ export const useProjectWorkspace = () => {
const beginIssue = useMutation(api.projectIssues.begin); const beginIssue = useMutation(api.projectIssues.begin);
const markDispatchFailed = useMutation(api.projectIssues.markDispatchFailed); const markDispatchFailed = useMutation(api.projectIssues.markDispatchFailed);
const activeProjectId = const activeProjectId =
(selectedProjectId ?? (projects?.[0]?.id as unknown as Id<"projects">)) ?? null; selectedProjectId ??
(projects?.[0]?.id as unknown as Id<"projects">) ??
null;
const artifacts = useQuery( const artifacts = useQuery(
api.projectArtifacts.list, api.projectArtifacts.list,
activeProjectId ? { projectId: activeProjectId } : "skip" activeProjectId ? { projectId: activeProjectId } : "skip"
@@ -89,7 +91,9 @@ export const useProjectWorkspace = () => {
} }
}; };
const selectedProject = const selectedProject =
projects?.find((project) => project.id === (activeProjectId as unknown as string)) ?? null; projects?.find(
(project) => project.id === (activeProjectId as unknown as string)
) ?? null;
return { return {
artifacts, artifacts,

View File

@@ -6,7 +6,11 @@ export default function AppLayout() {
const location = useLocation(); const location = useLocation();
if (auth.status === "loading") { if (auth.status === "loading") {
return <div className="grid min-h-svh place-items-center text-sm text-muted-foreground">Loading</div>; return (
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
Loading
</div>
);
} }
if (auth.status === "inconsistent") { if (auth.status === "inconsistent") {

View File

@@ -5,10 +5,16 @@ export default function AuthLayout() {
const auth = useWebAuth(); const auth = useWebAuth();
if (auth.status === "loading") { if (auth.status === "loading") {
return <div className="grid min-h-svh place-items-center text-sm text-muted-foreground">Loading</div>; return (
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
Loading
</div>
);
} }
if (auth.status === "authenticated") return <Navigate replace to="/" />; if (auth.status === "authenticated") {
return <Navigate replace to="/" />;
}
return ( return (
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10"> <main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">

View File

@@ -1,6 +1,6 @@
import { api } from "@code/backend/convex/_generated/api"; import { api } from "@code/backend/convex/_generated/api";
import { useEffect, useRef } from "react";
import { useConvexAuth, useQuery } from "convex/react"; import { useConvexAuth, useQuery } from "convex/react";
import { useEffect, useRef } from "react";
import { authClient } from "./auth-client"; import { authClient } from "./auth-client";

View File

@@ -41,9 +41,13 @@ const REMOTE = {
warnings: [], warnings: [],
}; };
const createTestProject = async (t: TestConvex<typeof schema>): Promise<Id<"projects">> => { const createTestProject = async (
t: TestConvex<typeof schema>
): Promise<Id<"projects">> => {
// Ensure personal organization for the user // Ensure personal organization for the user
await t.withIdentity(identityA).mutation(api.organizations.ensurePersonalOrganization); await t
.withIdentity(identityA)
.mutation(api.organizations.ensurePersonalOrganization);
// Persist the public Git import // Persist the public Git import
const outcome = await t const outcome = await t
.withIdentity(identityA) .withIdentity(identityA)

View File

@@ -11,12 +11,9 @@ import {
type ProjectView, type ProjectView,
type PublicGitImportResult, type PublicGitImportResult,
} from "@code/primitives/project"; } from "@code/primitives/project";
import type { Doc, Id } from "./_generated/dataModel"; import type { Doc, Id } from "./_generated/dataModel";
import type { import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server";
ActionCtx,
MutationCtx,
QueryCtx,
} from "./_generated/server";
import { requireCurrentOrganization, requireProjectMember } from "./authz"; import { requireCurrentOrganization, requireProjectMember } from "./authz";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -213,7 +210,9 @@ export const makeProjectActionStore = (_ctx: ActionCtx) => ({
_source: PreparedPublicGitSource, _source: PreparedPublicGitSource,
_remote: PublicGitImportResult _remote: PublicGitImportResult
): Promise<ProjectImportOutcome> => { ): Promise<ProjectImportOutcome> => {
throw new Error("persistPublicGitImport must be called via internal mutation"); throw new Error(
"persistPublicGitImport must be called via internal mutation"
);
}, },
putContext: async ( putContext: async (
@@ -264,7 +263,14 @@ export const persistPublicGitImportTransaction = async (
}); });
// Apply remote documents as repository writes // Apply remote documents as repository writes
for (const doc of remote.documents) { for (const doc of remote.documents) {
await applyRepositoryWrite(ctx, project._id, organizationId, doc, source.url, now); await applyRepositoryWrite(
ctx,
project._id,
organizationId,
doc,
source.url,
now
);
} }
await ctx.db.patch(project._id, { updatedAt: now }); await ctx.db.patch(project._id, { updatedAt: now });
const updatedSource = await ctx.db.get(existingSource._id); const updatedSource = await ctx.db.get(existingSource._id);
@@ -312,7 +318,14 @@ export const persistPublicGitImportTransaction = async (
// Apply remote documents (overrides seeds) // Apply remote documents (overrides seeds)
for (const doc of remote.documents) { for (const doc of remote.documents) {
await applyRepositoryWrite(ctx, projectId, organizationId, doc, source.url, now); await applyRepositoryWrite(
ctx,
projectId,
organizationId,
doc,
source.url,
now
);
} }
const createdProject = await ctx.db.get(projectId); const createdProject = await ctx.db.get(projectId);
@@ -414,8 +427,4 @@ const resolveProjectOrg = async (
}; };
// Re-export for convenience // Re-export for convenience
export { export { CONTEXT_KINDS, requireCurrentOrganization, requireProjectMember };
CONTEXT_KINDS,
requireCurrentOrganization,
requireProjectMember,
};

View File

@@ -1,4 +1,3 @@
import { v } from "convex/values";
import { import {
preparePublicGitSource, preparePublicGitSource,
type ProjectImportOutcome, type ProjectImportOutcome,
@@ -6,12 +5,13 @@ import {
type PublicGitImportResult, type PublicGitImportResult,
type PreparedPublicGitSource, type PreparedPublicGitSource,
} from "@code/primitives/project"; } from "@code/primitives/project";
import { v } from "convex/values";
import { Effect } from "effect"; import { Effect } from "effect";
import type { Id } from "./_generated/dataModel"; import type { Id } from "./_generated/dataModel";
import { action, internalMutation, query } from "./_generated/server"; import { action, internalMutation, query } from "./_generated/server";
import { requireCurrentOrganization } from "./authz";
import { createInitialArtifacts } from "./artifactModel"; import { createInitialArtifacts } from "./artifactModel";
import { requireCurrentOrganization } from "./authz";
import { persistPublicGitImportTransaction } from "./projectStore"; import { persistPublicGitImportTransaction } from "./projectStore";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -64,7 +64,9 @@ export const persistPublicGitImport = internalMutation({
// existing artifacts) // existing artifacts)
const existingArtifacts = await ctx.db const existingArtifacts = await ctx.db
.query("projectArtifacts") .query("projectArtifacts")
.withIndex("by_project", (q) => q.eq("projectId", result.id as unknown as Id<"projects">)) .withIndex("by_project", (q) =>
q.eq("projectId", result.id as unknown as Id<"projects">)
)
.first(); .first();
if (!existingArtifacts) { if (!existingArtifacts) {
const now = Date.now(); const now = Date.now();
@@ -190,9 +192,7 @@ export const importPublicGit = action({
// daemon adapter is wired. // daemon adapter is wired.
await Effect.runPromise(preparePublicGitSource(args.repositoryUrl)); await Effect.runPromise(preparePublicGitSource(args.repositoryUrl));
// The daemon PublicGit adapter is wired in the Daemon phase. // The daemon PublicGit adapter is wired in the Daemon phase.
throw new Error( throw new Error("PublicGit import is not yet wired to the daemon adapter");
"PublicGit import is not yet wired to the daemon adapter"
);
}, },
}); });

View File

@@ -44,7 +44,6 @@ const problemStatement = {
const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" }; const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" };
// Begin and admit one user message, returning its messageId. // Begin and admit one user message, returning its messageId.
const seedMessage = async ( const seedMessage = async (
t: TestConvex<typeof schema>, t: TestConvex<typeof schema>,

View File

@@ -1,857 +0,0 @@
import { Effect, Layer } from "effect";
import { describe, expect, it } from "vitest";
import {
CONTEXT_KINDS,
decideContextWrite,
makeInitialContext,
preparePublicGitSource,
ProjectApplication,
ProjectApplicationError,
type ProjectImportOutcome,
ProjectDomain,
type ProjectView,
PublicGit,
PublicGitError,
type PublicGitImportResult,
ProjectStore,
type PublicTextResult,
type ContextDocumentState,
type ContextKind,
type ContextWrite,
type PreparedPublicGitSource,
} from "./project";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const expectError = async <A, E>(
effect: Effect.Effect<A, E>
): Promise<E> => {
try {
await Effect.runPromise(effect as never);
throw new Error("Expected failure but got success");
} catch (caught) {
if (caught instanceof Error && caught.message === "Expected failure but got success") {
throw caught;
}
return caught as E;
}
};
// ---------------------------------------------------------------------------
// In-memory ProjectStore
// ---------------------------------------------------------------------------
interface InMemoryProject {
readonly id: string;
readonly organizationId: string;
readonly name: string;
readonly source: {
readonly kind: "git";
readonly url: string;
readonly normalizedUrl: string;
readonly host: string;
readonly repositoryPath: string;
readonly defaultBranch?: string;
};
documents: Map<ContextKind, ContextDocumentState>;
createdAt: number;
updatedAt: number;
}
const decideWriteSync = (
existing: ContextDocumentState | undefined,
write: ContextWrite
): { document: ContextDocumentState; changed: boolean } => {
const pathMap: Record<ContextKind, string> = {
agents: "AGENTS.md",
business: "business.md",
design: "design.md",
product: "product.md",
readme: "README.md",
tech: "tech.md",
};
const origin =
write._tag === "Repository"
? "repository"
: write._tag === "PublicTextUrl"
? "public-url"
: write.origin;
const sourceUrl = write._tag === "UserText" ? undefined : write.sourceUrl;
if (
existing &&
existing.content === write.content &&
existing.origin === origin &&
existing.sourceUrl === sourceUrl
) {
return { changed: false, document: existing };
}
return {
changed: true,
document: {
content: write.content,
kind: write.kind,
origin,
path: pathMap[write.kind],
revision: (existing?.revision ?? 0) + 1,
sourceUrl,
},
};
};
const createInMemoryStoreLayer = () => {
const projects = new Map<string, InMemoryProject>();
const orgByUser = new Map<string, string>();
let nextId = 1;
const toView = (p: InMemoryProject): ProjectView => ({
contextDocuments: [...p.documents.values()],
createdAt: p.createdAt as never,
id: p.id as never,
name: p.name,
organizationId: p.organizationId as never,
sources: [
{
createdAt: p.createdAt as never,
defaultBranch: p.source.defaultBranch,
host: p.source.host,
kind: "git",
normalizedUrl: p.source.normalizedUrl,
projectId: p.id as never,
repositoryPath: p.source.repositoryPath,
updatedAt: p.updatedAt as never,
url: p.source.url,
},
],
updatedAt: p.updatedAt as never,
});
const toOutcome = (p: InMemoryProject): ProjectImportOutcome => ({
contextDocuments: [...p.documents.values()],
createdAt: p.createdAt as never,
id: p.id as never,
name: p.name,
organizationId: p.organizationId as never,
source: {
createdAt: p.createdAt as never,
defaultBranch: p.source.defaultBranch,
host: p.source.host,
kind: "git",
normalizedUrl: p.source.normalizedUrl,
projectId: p.id as never,
repositoryPath: p.source.repositoryPath,
updatedAt: p.updatedAt as never,
url: p.source.url,
},
updatedAt: p.updatedAt as never,
});
return Layer.succeed(
ProjectStore,
ProjectStore.of({
getCurrentOrganization: (userId) =>
Effect.succeed((orgByUser.get(userId) ?? `org-${userId}`) as never),
listProjects: (userId) =>
Effect.succeed(
(() => {
const orgId = orgByUser.get(userId);
return orgId
? [...projects.values()]
.filter((p) => p.organizationId === orgId)
.map(toView)
: ([] as readonly ProjectView[]);
})()
),
getProject: (userId, projectId) =>
Effect.succeed(
(() => {
const orgId = orgByUser.get(userId);
const project = projects.get(projectId as string);
if (!project || project.organizationId !== orgId) return null;
return toView(project);
})()
),
persistPublicGitImport: (input) =>
Effect.sync(() => {
const orgId = orgByUser.get(input.userId) ?? `org-${input.userId}`;
orgByUser.set(input.userId, orgId);
const existing = [...projects.values()].find(
(p) =>
p.organizationId === orgId &&
p.source.normalizedUrl === input.source.normalizedUrl
);
const now = Date.now();
if (existing) {
existing.updatedAt = now;
for (const doc of input.remote.documents) {
const result = decideWriteSync(existing.documents.get(doc.kind), {
_tag: "Repository" as const,
content: doc.content,
kind: doc.kind,
sourceUrl: input.source.url,
});
if (result.changed)
existing.documents.set(doc.kind, result.document);
}
return toOutcome(existing);
}
const id = `project-${nextId++}`;
const documents = new Map<ContextKind, ContextDocumentState>();
for (const seed of makeInitialContext(
input.source.projectName,
input.source.url
)) {
documents.set(seed.kind, seed);
}
for (const doc of input.remote.documents) {
const result = decideWriteSync(documents.get(doc.kind), {
_tag: "Repository" as const,
content: doc.content,
kind: doc.kind,
sourceUrl: input.source.url,
});
if (result.changed) documents.set(doc.kind, result.document);
}
const project: InMemoryProject = {
createdAt: now,
documents,
id,
name: input.source.projectName,
organizationId: orgId,
source: {
defaultBranch: input.remote.defaultBranch,
host: input.source.host,
kind: "git",
normalizedUrl: input.source.normalizedUrl,
repositoryPath: input.source.repositoryPath,
url: input.source.url,
},
updatedAt: now,
};
projects.set(id, project);
return toOutcome(project);
}),
putContext: (input) =>
Effect.sync(() => {
const orgId = orgByUser.get(input.userId);
const project = projects.get(input.projectId as string);
if (!project || project.organizationId !== orgId) {
throw new Error("Project not found");
}
const result = decideWriteSync(
project.documents.get(input.write.kind),
input.write
);
if (result.changed) {
project.documents.set(input.write.kind, result.document);
project.updatedAt = Date.now();
}
return { revision: result.document.revision };
}),
})
);
};
// ---------------------------------------------------------------------------
// Fake PublicGit layer
// ---------------------------------------------------------------------------
const makeFakePublicGitLayer = (
inspectImpl: (
source: PreparedPublicGitSource
) => PublicGitImportResult | PublicGitError,
fetchImpl: (url: string) => PublicTextResult | PublicGitError
) =>
Layer.succeed(
PublicGit,
PublicGit.of({
inspect: (source) =>
Effect.gen(function* () {
const result = inspectImpl(source);
if (result instanceof PublicGitError) {
return yield* Effect.fail(result);
}
return result;
}),
fetchText: (url) =>
Effect.gen(function* () {
const result = fetchImpl(url);
if (result instanceof PublicGitError) {
return yield* Effect.fail(result);
}
return result;
}),
})
);
const HELLO_WORLD_RESULT: PublicGitImportResult = {
defaultBranch: "main",
documents: [
{
kind: "readme",
path: "README.md",
content: "# Hello World\n\nA test repository.\n",
},
],
warnings: [],
};
// ---------------------------------------------------------------------------
// Test layer
// ---------------------------------------------------------------------------
const makeTestLayer = (
fakePublicGit: ReturnType<typeof makeFakePublicGitLayer>
) =>
ProjectApplication.layer.pipe(
Layer.provide(
Layer.mergeAll(ProjectDomain.layer, fakePublicGit, createInMemoryStoreLayer())
)
);
const runApp = <A, E>(
effect: Effect.Effect<A, E, ProjectApplication>,
layer: Layer.Layer<ProjectApplication>
): Promise<A> => Effect.runPromise(Effect.provide(effect, layer));
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("preparePublicGitSource", () => {
it("normalizes a GitHub URL with .git suffix", async () => {
const result = await Effect.runPromise(
preparePublicGitSource("https://github.com/octocat/Hello-World.git")
);
expect(result.host).toBe("github.com");
expect(result.repositoryPath).toBe("octocat/Hello-World");
expect(result.projectName).toBe("Hello-World");
expect(result.normalizedUrl).toBe("https://github.com/octocat/Hello-World");
});
it("normalizes a GitLab URL", async () => {
const result = await Effect.runPromise(
preparePublicGitSource("https://gitlab.com/gitlab-org/gitlab-test.git")
);
expect(result.host).toBe("gitlab.com");
expect(result.repositoryPath).toBe("gitlab-org/gitlab-test");
});
it("normalizes a Codeberg URL", async () => {
const result = await Effect.runPromise(
preparePublicGitSource("https://codeberg.org/Codeberg/Documentation.git")
);
expect(result.host).toBe("codeberg.org");
expect(result.repositoryPath).toBe("Codeberg/Documentation");
});
it("normalizes a generic HTTP(S) host without .git", async () => {
const result = await Effect.runPromise(
preparePublicGitSource("https://example.com/foo/bar")
);
expect(result.repositoryPath).toBe("foo/bar");
expect(result.projectName).toBe("bar");
});
it("rejects SSH URLs", async () => {
const error = await expectError(
preparePublicGitSource("git@github.com:octocat/Hello-World.git")
);
expect(error).toBeInstanceOf(ProjectApplicationError);
expect((error as ProjectApplicationError).reason).toBe(
"InvalidPublicGitUrl"
);
});
it("rejects URLs with credentials", async () => {
const error = await expectError(
preparePublicGitSource(
"https://user:pass@github.com/octocat/Hello-World.git"
)
);
expect((error as ProjectApplicationError).reason).toBe(
"InvalidPublicGitUrl"
);
});
it("rejects URLs with query strings", async () => {
const error = await expectError(
preparePublicGitSource(
"https://github.com/octocat/Hello-World.git?branch=dev"
)
);
expect((error as ProjectApplicationError).reason).toBe(
"InvalidPublicGitUrl"
);
});
it("rejects URLs with fragments", async () => {
const error = await expectError(
preparePublicGitSource("https://github.com/octocat/Hello-World.git#readme")
);
expect((error as ProjectApplicationError).reason).toBe(
"InvalidPublicGitUrl"
);
});
it("rejects root-only paths", async () => {
const error = await expectError(
preparePublicGitSource("https://github.com/")
);
expect((error as ProjectApplicationError).reason).toBe(
"InvalidPublicGitUrl"
);
});
});
describe("makeInitialContext", () => {
it("produces exactly six canonical seeds", () => {
const seeds = makeInitialContext("test-repo", "https://example.com/foo/bar");
expect(seeds).toHaveLength(6);
const kinds = seeds.map((s) => s.kind).sort();
expect(kinds).toEqual([...CONTEXT_KINDS].sort());
});
it("seeds README with project name and repository URL", () => {
const seeds = makeInitialContext("my-project", "https://example.com/foo/bar");
const readme = seeds.find((s) => s.kind === "readme")!;
expect(readme.content).toBe(
"# my-project\n\nRepository: https://example.com/foo/bar\n"
);
});
it("seeds placeholders with exact title and scaffold text", () => {
const seeds = makeInitialContext("test", "https://example.com/foo/bar");
const product = seeds.find((s) => s.kind === "product")!;
expect(product.content).toBe("# product.md\n\nContext not imported yet.\n");
});
it("all seeds start at revision 1", () => {
const seeds = makeInitialContext("test", "https://example.com/foo/bar");
for (const seed of seeds) {
expect(seed.revision).toBe(1);
}
});
});
describe("decideContextWrite", () => {
it("increments revision on content change", async () => {
const existing: ContextDocumentState = {
content: "old",
kind: "product",
origin: "paste",
path: "product.md",
revision: 1,
sourceUrl: undefined,
};
const result = await Effect.runPromise(
decideContextWrite({
existing,
write: {
_tag: "UserText",
content: "new content",
kind: "product",
origin: "paste",
},
})
);
expect(result.changed).toBe(true);
expect(result.document.revision).toBe(2);
expect(result.document.content).toBe("new content");
});
it("returns no-op when content/origin/source are identical", async () => {
const existing: ContextDocumentState = {
content: "same",
kind: "product",
origin: "paste",
path: "product.md",
revision: 5,
sourceUrl: undefined,
};
const result = await Effect.runPromise(
decideContextWrite({
existing,
write: {
_tag: "UserText",
content: "same",
kind: "product",
origin: "paste",
},
})
);
expect(result.changed).toBe(false);
expect(result.document).toBe(existing);
});
it("preserves exact text without trimming", async () => {
const result = await Effect.runPromise(
decideContextWrite({
existing: undefined,
write: {
_tag: "UserText",
content: " trailing spaces \n\n",
kind: "tech",
origin: "paste",
},
})
);
expect(result.document.content).toBe(" trailing spaces \n\n");
});
});
describe("ProjectApplication.importPublicGit", () => {
it("imports a repository with six canonical context documents", async () => {
const layer = makeTestLayer(
makeFakePublicGitLayer(
() => HELLO_WORLD_RESULT,
() => ({ content: "", finalUrl: "" })
)
);
const outcome = await runApp(
Effect.gen(function* () {
const a = yield* ProjectApplication;
return yield* a.importPublicGit({
userId: "user-a",
repositoryUrl: "https://github.com/octocat/Hello-World.git",
});
}),
layer
);
expect(outcome.name).toBe("Hello-World");
expect(outcome.source.host).toBe("github.com");
expect(outcome.contextDocuments).toHaveLength(6);
const readme = outcome.contextDocuments.find((d) => d.kind === "readme")!;
expect(readme.content).toBe("# Hello World\n\nA test repository.\n");
});
it("is idempotent on re-import without .git suffix", async () => {
const layer = makeTestLayer(
makeFakePublicGitLayer(
() => HELLO_WORLD_RESULT,
() => ({ content: "", finalUrl: "" })
)
);
const first = await runApp(
Effect.gen(function* () {
const a = yield* ProjectApplication;
return yield* a.importPublicGit({
userId: "user-a",
repositoryUrl: "https://github.com/octocat/Hello-World.git",
});
}),
layer
);
const second = await runApp(
Effect.gen(function* () {
const a = yield* ProjectApplication;
return yield* a.importPublicGit({
userId: "user-a",
repositoryUrl: "https://github.com/octocat/Hello-World",
});
}),
layer
);
expect(second.id).toBe(first.id);
});
it("rejects an invalid URL", async () => {
const layer = makeTestLayer(
makeFakePublicGitLayer(
() => HELLO_WORLD_RESULT,
() => ({ content: "", finalUrl: "" })
)
);
const error = await expectError(
Effect.gen(function* () {
const a = yield* ProjectApplication;
return yield* a.importPublicGit({
userId: "user-a",
repositoryUrl: "not-a-url",
});
}).pipe(Effect.provide(layer))
);
expect((error as ProjectApplicationError).reason).toBe(
"InvalidPublicGitUrl"
);
});
it("maps PublicGit unreachable error to PublicGitUnreachable", async () => {
const layer = makeTestLayer(
makeFakePublicGitLayer(
() =>
new PublicGitError({
message: "Connection refused",
reason: "Unreachable",
}),
() => ({ content: "", finalUrl: "" })
)
);
const error = await expectError(
Effect.gen(function* () {
const a = yield* ProjectApplication;
return yield* a.importPublicGit({
userId: "user-a",
repositoryUrl: "https://github.com/octocat/Hello-World.git",
});
}).pipe(Effect.provide(layer))
);
expect((error as ProjectApplicationError).reason).toBe(
"PublicGitUnreachable"
);
});
it("maps PublicGit cancelled error to PublicGitUnreachable", async () => {
const layer = makeTestLayer(
makeFakePublicGitLayer(
() =>
new PublicGitError({
message: "Timed out",
reason: "Cancelled",
}),
() => ({ content: "", finalUrl: "" })
)
);
const error = await expectError(
Effect.gen(function* () {
const a = yield* ProjectApplication;
return yield* a.importPublicGit({
userId: "user-a",
repositoryUrl: "https://github.com/octocat/Hello-World.git",
});
}).pipe(Effect.provide(layer))
);
expect((error as ProjectApplicationError).reason).toBe(
"PublicGitUnreachable"
);
});
});
describe("ProjectApplication.putContext", () => {
const setupWithProject = async () => {
const layer = makeTestLayer(
makeFakePublicGitLayer(
() => HELLO_WORLD_RESULT,
() => ({ content: "", finalUrl: "" })
)
);
const outcome = await runApp(
Effect.gen(function* () {
const a = yield* ProjectApplication;
return yield* a.importPublicGit({
userId: "user-a",
repositoryUrl: "https://github.com/octocat/Hello-World.git",
});
}),
layer
);
return { layer, projectId: outcome.id };
};
it("writes pasted context and increments revision", async () => {
const { layer, projectId } = await setupWithProject();
const result = await runApp(
Effect.gen(function* () {
const a = yield* ProjectApplication;
return yield* a.putContext({
userId: "user-a",
projectId,
kind: "design",
content: "# Design\n\nNew design notes.\n",
origin: "paste",
});
}),
layer
);
// Seeds start at revision 1; first explicit write bumps to 2
expect(result.revision).toBe(2);
});
it("returns the same revision for identical content (no-op)", async () => {
const { layer, projectId } = await setupWithProject();
await runApp(
Effect.gen(function* () {
const a = yield* ProjectApplication;
return yield* a.putContext({
userId: "user-a",
projectId,
kind: "design",
content: "same content",
origin: "paste",
});
}),
layer
);
const result = await runApp(
Effect.gen(function* () {
const a = yield* ProjectApplication;
return yield* a.putContext({
userId: "user-a",
projectId,
kind: "design",
content: "same content",
origin: "paste",
});
}),
layer
);
expect(result.revision).toBe(2);
});
it("last explicit write wins for the same kind", async () => {
const { layer, projectId } = await setupWithProject();
await runApp(
Effect.gen(function* () {
const a = yield* ProjectApplication;
return yield* a.putContext({
userId: "user-a",
projectId,
kind: "product",
content: "first paste",
origin: "paste",
});
}),
layer
);
const result = await runApp(
Effect.gen(function* () {
const a = yield* ProjectApplication;
return yield* a.putContext({
userId: "user-a",
projectId,
kind: "product",
content: "second upload",
origin: "upload",
});
}),
layer
);
expect(result.revision).toBe(3);
});
});
describe("ProjectApplication.importPublicText", () => {
it("imports text from a URL and increments revision", async () => {
const layer = makeTestLayer(
makeFakePublicGitLayer(
() => HELLO_WORLD_RESULT,
(url) => ({
content: "# Tech via URL\n\nImported.\n",
finalUrl: url,
})
)
);
const outcome = await runApp(
Effect.gen(function* () {
const a = yield* ProjectApplication;
return yield* a.importPublicGit({
userId: "user-a",
repositoryUrl: "https://github.com/octocat/Hello-World.git",
});
}),
layer
);
const result = await runApp(
Effect.gen(function* () {
const a = yield* ProjectApplication;
return yield* a.importPublicText({
userId: "user-a",
projectId: outcome.id,
kind: "tech",
url: "https://raw.githubusercontent.com/foo/bar/main/tech.md",
});
}),
layer
);
expect(result.revision).toBe(2);
});
it("rejects empty URL", async () => {
const layer = makeTestLayer(
makeFakePublicGitLayer(
() => HELLO_WORLD_RESULT,
() => ({ content: "text", finalUrl: "url" })
)
);
const error = await expectError(
Effect.gen(function* () {
const a = yield* ProjectApplication;
return yield* a.importPublicText({
userId: "user-a",
projectId: "project-1" as never,
kind: "tech",
url: " ",
});
}).pipe(Effect.provide(layer))
);
expect((error as ProjectApplicationError).reason).toBe(
"InvalidContextInput"
);
});
});
describe("ProjectApplication tenant isolation", () => {
it("a second tenant cannot list the first tenant's projects", async () => {
const layer = makeTestLayer(
makeFakePublicGitLayer(
() => HELLO_WORLD_RESULT,
() => ({ content: "", finalUrl: "" })
)
);
await runApp(
Effect.gen(function* () {
const a = yield* ProjectApplication;
return yield* a.importPublicGit({
userId: "user-a",
repositoryUrl: "https://github.com/octocat/Hello-World.git",
});
}),
layer
);
const secondProjects = await runApp(
Effect.gen(function* () {
const a = yield* ProjectApplication;
return yield* a.listProjects({ userId: "user-b" });
}),
layer
);
expect(secondProjects).toHaveLength(0);
});
it("a second tenant cannot read the first tenant's project", async () => {
const layer = makeTestLayer(
makeFakePublicGitLayer(
() => HELLO_WORLD_RESULT,
() => ({ content: "", finalUrl: "" })
)
);
const outcome = await runApp(
Effect.gen(function* () {
const a = yield* ProjectApplication;
return yield* a.importPublicGit({
userId: "user-a",
repositoryUrl: "https://github.com/octocat/Hello-World.git",
});
}),
layer
);
const result = await runApp(
Effect.gen(function* () {
const a = yield* ProjectApplication;
return yield* a.getProject({
userId: "user-b",
projectId: outcome.id,
});
}),
layer
);
expect(result).toBeNull();
});
});

View File

@@ -1,6 +1,8 @@
/* 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 { Context, Effect, Layer, Schema } from "effect";
import { OrganizationId, ProjectId, TimestampMs } from "./signal.js"; import { OrganizationId, ProjectId, TimestampMs } from "./signal.js";
export type { OrganizationId, ProjectId } from "./signal.js"; export type { OrganizationId, ProjectId } from "./signal.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -52,24 +54,23 @@ const MeaningfulString = Schema.String.check(
}) })
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared data shapes // Shared data shapes
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const PreparedPublicGitSource = Schema.Struct({ export const PreparedPublicGitSource = Schema.Struct({
host: Schema.String, host: Schema.String,
normalizedUrl: Schema.String,
projectName: MeaningfulString, projectName: MeaningfulString,
repositoryPath: MeaningfulString, repositoryPath: MeaningfulString,
normalizedUrl: Schema.String,
url: Schema.String, url: Schema.String,
}); });
export type PreparedPublicGitSource = typeof PreparedPublicGitSource.Type; export type PreparedPublicGitSource = typeof PreparedPublicGitSource.Type;
export const RepositoryContextDocument = Schema.Struct({ export const RepositoryContextDocument = Schema.Struct({
content: Schema.String,
kind: ContextKindSchema, kind: ContextKindSchema,
path: Schema.String, path: Schema.String,
content: Schema.String,
}); });
export type RepositoryContextDocument = typeof RepositoryContextDocument.Type; export type RepositoryContextDocument = typeof RepositoryContextDocument.Type;
@@ -250,7 +251,7 @@ const INVALID_REMOTE = new ProjectApplicationError({
export const preparePublicGitSource = ( export const preparePublicGitSource = (
rawUrl: string rawUrl: string
): Effect.Effect<PreparedPublicGitSource, ProjectApplicationError> => ): Effect.Effect<PreparedPublicGitSource, ProjectApplicationError> =>
Effect.gen(function* () { Effect.gen(function* prepareSource() {
let parsed: URL; let parsed: URL;
try { try {
parsed = new URL(rawUrl); parsed = new URL(rawUrl);
@@ -303,7 +304,7 @@ export const preparePublicGitSource = (
export const decodePublicGitImportResult = ( export const decodePublicGitImportResult = (
raw: unknown raw: unknown
): Effect.Effect<PublicGitImportResult, ProjectApplicationError> => ): Effect.Effect<PublicGitImportResult, ProjectApplicationError> =>
Effect.gen(function* () { Effect.gen(function* decodeImport() {
const result = yield* Schema.decodeUnknownEffect(PublicGitImportResult)( const result = yield* Schema.decodeUnknownEffect(PublicGitImportResult)(
raw raw
).pipe(Effect.mapError(() => INVALID_REMOTE)); ).pipe(Effect.mapError(() => INVALID_REMOTE));
@@ -351,7 +352,7 @@ export const decodePublicGitImportResult = (
export const decodePublicTextResult = ( export const decodePublicTextResult = (
raw: unknown raw: unknown
): Effect.Effect<PublicTextResult, ProjectApplicationError> => ): Effect.Effect<PublicTextResult, ProjectApplicationError> =>
Effect.gen(function* () { Effect.gen(function* decodeText() {
const result = yield* Schema.decodeUnknownEffect(PublicTextResult)( const result = yield* Schema.decodeUnknownEffect(PublicTextResult)(
raw raw
).pipe(Effect.mapError(() => INVALID_REMOTE)); ).pipe(Effect.mapError(() => INVALID_REMOTE));
@@ -417,6 +418,16 @@ export const makeInitialContext = (
]; ];
}; };
const resolveWriteOrigin = (write: ContextWrite): ContextOrigin => {
if (write._tag === "Repository") {
return "repository";
}
if (write._tag === "PublicTextUrl") {
return "public-url";
}
return write.origin;
};
export interface DecideContextWriteInput { export interface DecideContextWriteInput {
readonly existing: ContextDocumentState | undefined; readonly existing: ContextDocumentState | undefined;
readonly write: ContextWrite; readonly write: ContextWrite;
@@ -430,10 +441,10 @@ export interface DecideContextWriteResult {
export const decideContextWrite = ( export const decideContextWrite = (
input: DecideContextWriteInput input: DecideContextWriteInput
): Effect.Effect<DecideContextWriteResult, ProjectApplicationError> => ): Effect.Effect<DecideContextWriteResult, ProjectApplicationError> =>
Effect.gen(function* () { Effect.gen(function* decideWrite() {
const write = input.write; const { write } = input;
const content = write.content; const { content } = write;
const kind = write.kind; const { kind } = write;
const path = contextPathForKind(kind); const path = contextPathForKind(kind);
if (isWhitespaceOnly(content)) { if (isWhitespaceOnly(content)) {
@@ -453,16 +464,11 @@ export const decideContextWrite = (
); );
} }
const origin: ContextOrigin = const origin: ContextOrigin = resolveWriteOrigin(write);
write._tag === "Repository"
? "repository"
: write._tag === "PublicTextUrl"
? "public-url"
: write.origin;
const sourceUrl: string | undefined = const sourceUrl: string | undefined =
write._tag === "UserText" ? undefined : write.sourceUrl; write._tag === "UserText" ? undefined : write.sourceUrl;
const existing = input.existing; const { existing } = input;
if ( if (
existing && existing &&
existing.content === content && existing.content === content &&
@@ -475,7 +481,14 @@ export const decideContextWrite = (
const nextRevision = (existing?.revision ?? 0) + 1; const nextRevision = (existing?.revision ?? 0) + 1;
return { return {
changed: true, changed: true,
document: { content, kind, origin, path, revision: nextRevision, sourceUrl }, document: {
content,
kind,
origin,
path,
revision: nextRevision,
sourceUrl,
},
}; };
}); });
@@ -498,11 +511,11 @@ export class ProjectDomain extends Context.Service<
static readonly layer = Layer.succeed( static readonly layer = Layer.succeed(
ProjectDomain, ProjectDomain,
ProjectDomain.of({ ProjectDomain.of({
preparePublicGitSource, decideContextWrite,
decodePublicGitImportResult, decodePublicGitImportResult,
decodePublicTextResult, decodePublicTextResult,
makeInitialContext, makeInitialContext,
decideContextWrite, preparePublicGitSource,
}) })
); );
} }
@@ -629,14 +642,25 @@ export class ProjectApplication extends Context.Service<
>()("@code/primitives/project/ProjectApplication") { >()("@code/primitives/project/ProjectApplication") {
static readonly layer = Layer.effect( static readonly layer = Layer.effect(
ProjectApplication, ProjectApplication,
Effect.gen(function* () { Effect.gen(function* layer() {
const domain = yield* ProjectDomain; const domain = yield* ProjectDomain;
const publicGit = yield* PublicGit; const publicGit = yield* PublicGit;
const store = yield* ProjectStore; const store = yield* ProjectStore;
return ProjectApplication.of({ 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")( importPublicGit: Effect.fn("ProjectApplication.importPublicGit")(
function* ({ function* importPublicGit({
userId, userId,
repositoryUrl, repositoryUrl,
}: { }: {
@@ -647,25 +671,21 @@ export class ProjectApplication extends Context.Service<
return yield* Effect.fail(INVALID_GIT_URL); return yield* Effect.fail(INVALID_GIT_URL);
} }
const source = yield* domain.preparePublicGitSource( const source = yield* domain.preparePublicGitSource(repositoryUrl);
repositoryUrl
);
const rawRemote = yield* publicGit const rawRemote = yield* publicGit
.inspect(source) .inspect(source)
.pipe(Effect.mapError(mapPublicGitError)); .pipe(Effect.mapError(mapPublicGitError));
const remote = yield* domain.decodePublicGitImportResult( const remote = yield* domain.decodePublicGitImportResult(rawRemote);
rawRemote
);
return yield* store return yield* store
.persistPublicGitImport({ userId, source, remote }) .persistPublicGitImport({ remote, source, userId })
.pipe(Effect.mapError(mapStoreError)); .pipe(Effect.mapError(mapStoreError));
} }
), ),
importPublicText: Effect.fn("ProjectApplication.importPublicText")( importPublicText: Effect.fn("ProjectApplication.importPublicText")(
function* ({ function* importPublicText({
userId, userId,
projectId, projectId,
kind, kind,
@@ -699,55 +719,42 @@ export class ProjectApplication extends Context.Service<
}; };
return yield* store return yield* store
.putContext({ userId, projectId, write }) .putContext({ projectId, userId, write })
.pipe(Effect.mapError(mapStoreError)); .pipe(Effect.mapError(mapStoreError));
} }
), ),
putContext: Effect.fn("ProjectApplication.putContext")( listProjects: Effect.fn("ProjectApplication.listProjects")(function* listProjects({
function* ({ userId,
userId, }: {
projectId, readonly userId: string;
kind, }) {
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, content,
kind,
origin, origin,
}: { };
readonly userId: string; return yield* store
readonly projectId: ProjectId; .putContext({ projectId, userId, write })
readonly kind: ContextKind; .pipe(Effect.mapError(mapStoreError));
readonly content: string; }),
readonly origin: "paste" | "upload";
}) {
const write: ContextWrite = {
_tag: "UserText",
content,
kind,
origin,
};
return yield* store
.putContext({ userId, projectId, write })
.pipe(Effect.mapError(mapStoreError));
}
),
listProjects: Effect.fn("ProjectApplication.listProjects")(
function* ({ userId }: { readonly userId: string }) {
return yield* store
.listProjects(userId)
.pipe(Effect.mapError(mapStoreError));
}
),
getProject: Effect.fn("ProjectApplication.getProject")(
function* ({
userId,
projectId,
}: {
readonly userId: string;
readonly projectId: ProjectId;
}) {
return yield* store
.getProject(userId, projectId)
.pipe(Effect.mapError(mapStoreError));
}
),
}); });
}) })
); );