feat: consolidate web auth and add Effect v4 Project primitives
Auth:
- Add useWebAuth hook as the single web-facing auth interface with four
states (loading/unauthenticated/authenticated/inconsistent)
- Normalize getCurrentUser to return {id: tokenIdentifier, name, email}
- Rename requireOwnerId to requireAuthUserId across all backend callers
- Set expectAuth:true on ConvexReactClient singleton
- Migrate _app, _auth, user-menu, use-personal-organization to useWebAuth
- Remove unused @code/auth/server export (zero callers confirmed)
Primitives:
- Add packages/primitives/src/project.ts with Effect v4 Project domain:
URL normalization, six canonical context kinds, strict remote decoding,
context write decisioning with revision tracking, and no-op detection
- Define PublicGit and ProjectStore replaceable ports
- Define ProjectApplication service orchestrating domain+ports with
deterministic error mapping
- Add comprehensive test suite (28 tests) with in-memory store and fake
PublicGit covering normalization, seeds, idempotency, tenant isolation,
last-write-wins, and typed error mapping
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { authClient } from "@code/auth/web";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import { signOutWeb, useWebAuth } from "@code/auth/web";
|
||||
import { Button } from "@code/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -10,12 +9,13 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@code/ui/components/dropdown-menu";
|
||||
import { useQuery } from "convex/react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export default function UserMenu() {
|
||||
const navigate = useNavigate();
|
||||
const user = useQuery(api.auth.getCurrentUser);
|
||||
const auth = useWebAuth();
|
||||
|
||||
const user = auth.status === "authenticated" ? auth.user : null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -30,12 +30,8 @@ export default function UserMenu() {
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
navigate("/login", { replace: true });
|
||||
},
|
||||
},
|
||||
void signOutWeb().then(() => {
|
||||
navigate("/login", { replace: true });
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -6,18 +6,18 @@ import { usePersonalOrganization } from "./use-personal-organization";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
cleanup: undefined as (() => void) | undefined,
|
||||
ensurePersonalOrganization: vi.fn(),
|
||||
session: {
|
||||
data: { session: { id: "session-a" } },
|
||||
} as { data: { session: { id: string } } | null },
|
||||
state: null as {
|
||||
error?: Error;
|
||||
organizationId?: string;
|
||||
sessionId: string;
|
||||
} | null,
|
||||
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", () => ({
|
||||
authClient: { useSession: () => mocks.session },
|
||||
useWebAuth: () => mocks.authState,
|
||||
}));
|
||||
vi.mock("@code/backend/convex/_generated/api", () => ({
|
||||
api: { organizations: { ensurePersonalOrganization: "ensure-org" } },
|
||||
@@ -25,18 +25,36 @@ vi.mock("@code/backend/convex/_generated/api", () => ({
|
||||
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: () => [
|
||||
mocks.state,
|
||||
(next: { error?: Error; organizationId?: string; sessionId: string }) => {
|
||||
mocks.state = next;
|
||||
},
|
||||
],
|
||||
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", () => {
|
||||
@@ -44,34 +62,31 @@ describe("usePersonalOrganization", () => {
|
||||
mocks.cleanup?.();
|
||||
mocks.cleanup = undefined;
|
||||
mocks.ensurePersonalOrganization.mockReset();
|
||||
mocks.session = { data: { session: { id: "session-a" } } };
|
||||
mocks.state = null;
|
||||
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);
|
||||
|
||||
expect(usePersonalOrganization()).toEqual({});
|
||||
usePersonalOrganization();
|
||||
expect(mocks.ensurePersonalOrganization).toHaveBeenCalledTimes(1);
|
||||
|
||||
ensured.resolve({ _id: "org-a" });
|
||||
await ensured.promise;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(usePersonalOrganization()).toEqual({
|
||||
error: undefined,
|
||||
organizationId: "org-a",
|
||||
});
|
||||
expect(stateCells.orgId).toBe("org-a");
|
||||
});
|
||||
|
||||
test("logout cannot expose the prior session organization", async () => {
|
||||
mocks.ensurePersonalOrganization.mockResolvedValue({ _id: "org-a" });
|
||||
usePersonalOrganization();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
test("an unauthenticated session never ensures an org", () => {
|
||||
mocks.authState = { status: "unauthenticated" };
|
||||
|
||||
mocks.session = { data: null };
|
||||
expect(usePersonalOrganization()).toEqual({});
|
||||
expect(mocks.ensurePersonalOrganization).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +1,30 @@
|
||||
import { authClient } from "@code/auth/web";
|
||||
import { useWebAuth } from "@code/auth/web";
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import type { Id } from "@code/backend/convex/_generated/dataModel";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface OrganizationBootstrapState {
|
||||
readonly error?: Error;
|
||||
readonly organizationId?: Id<"organizations">;
|
||||
readonly sessionId: string;
|
||||
}
|
||||
|
||||
export interface PersonalOrganizationState {
|
||||
readonly error?: Error;
|
||||
readonly organizationId?: Id<"organizations">;
|
||||
}
|
||||
|
||||
/** Ensure the authenticated session has its personal tenancy boundary. */
|
||||
/** Ensure the authenticated user has its personal tenancy boundary. */
|
||||
export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
const { data: session } = authClient.useSession();
|
||||
const sessionId = session?.session.id;
|
||||
const auth = useWebAuth();
|
||||
const userId = auth.status === "authenticated" ? auth.user.id : null;
|
||||
const ensurePersonalOrganization = useMutation(
|
||||
api.organizations.ensurePersonalOrganization
|
||||
);
|
||||
const [bootstrap, setBootstrap] = useState<OrganizationBootstrapState | null>(
|
||||
null
|
||||
);
|
||||
const [organizationId, setOrganizationId] = useState<
|
||||
Id<"organizations"> | undefined
|
||||
>();
|
||||
const [error, setError] = useState<Error | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
if (!userId) {
|
||||
setOrganizationId(undefined);
|
||||
setError(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -36,20 +33,16 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
try {
|
||||
const organization = await ensurePersonalOrganization();
|
||||
if (active) {
|
||||
setBootstrap({
|
||||
organizationId: organization._id,
|
||||
sessionId,
|
||||
});
|
||||
setOrganizationId(organization._id);
|
||||
setError(undefined);
|
||||
}
|
||||
} catch (caughtError: unknown) {
|
||||
if (active) {
|
||||
setBootstrap({
|
||||
error:
|
||||
caughtError instanceof Error
|
||||
? caughtError
|
||||
: new Error(String(caughtError)),
|
||||
sessionId,
|
||||
});
|
||||
setError(
|
||||
caughtError instanceof Error
|
||||
? caughtError
|
||||
: new Error(String(caughtError))
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -58,13 +51,11 @@ export const usePersonalOrganization = (): PersonalOrganizationState => {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [ensurePersonalOrganization, sessionId]);
|
||||
}, [ensurePersonalOrganization, userId]);
|
||||
|
||||
if (!sessionId || bootstrap?.sessionId !== sessionId) {
|
||||
if (!userId) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
error: bootstrap.error,
|
||||
organizationId: bootstrap.organizationId,
|
||||
};
|
||||
|
||||
return { error, organizationId };
|
||||
};
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { useConvexAuth } from "convex/react";
|
||||
import { useWebAuth } from "@code/auth/web";
|
||||
import { Navigate, Outlet, useLocation } from "react-router";
|
||||
|
||||
export default function AppLayout() {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||
const auth = useWebAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
if (auth.status === "loading") {
|
||||
return <div className="grid min-h-svh place-items-center text-sm text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (auth.status === "inconsistent") {
|
||||
return (
|
||||
<div className="grid min-h-svh place-items-center text-sm text-muted-foreground">
|
||||
Your session could not be verified
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (auth.status === "unauthenticated") {
|
||||
return <Navigate replace state={{ from: location.pathname }} to="/login" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useConvexAuth } from "convex/react";
|
||||
import { useWebAuth } from "@code/auth/web";
|
||||
import { Navigate, Outlet } from "react-router";
|
||||
|
||||
export default function AuthLayout() {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||
const auth = useWebAuth();
|
||||
|
||||
if (isLoading) {
|
||||
if (auth.status === "loading") {
|
||||
return <div className="grid min-h-svh place-items-center text-sm text-muted-foreground">Loading…</div>;
|
||||
}
|
||||
|
||||
if (isAuthenticated) return <Navigate replace to="/" />;
|
||||
if (auth.status === "authenticated") return <Navigate replace to="/" />;
|
||||
|
||||
return (
|
||||
<main className="grid min-h-svh place-items-center bg-muted/30 p-6 md:p-10">
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./native": "./src/native/index.ts",
|
||||
"./server": "./src/server/index.ts",
|
||||
"./web": "./src/web/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { authComponent, createAuth } from "@code/backend/convex/auth";
|
||||
@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
|
||||
|
||||
import { authClient } from "./auth-client";
|
||||
|
||||
const convex = new ConvexReactClient(env.VITE_CONVEX_URL);
|
||||
const convex = new ConvexReactClient(env.VITE_CONVEX_URL, { expectAuth: true });
|
||||
|
||||
export const WebAuthProvider = ({ children }: { children: ReactNode }) => (
|
||||
<ConvexBetterAuthProvider authClient={authClient} client={convex}>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export { authClient } from "./auth-client";
|
||||
export { WebAuthProvider } from "./auth-provider";
|
||||
export { useConvexAccessToken } from "./hooks";
|
||||
export { signOutWeb, useWebAuth } from "./use-web-auth";
|
||||
export type { WebAuthState, WebAuthUser } from "./use-web-auth";
|
||||
export { LoginForm } from "./login-form";
|
||||
export { SignupForm } from "./signup-form";
|
||||
|
||||
81
packages/auth/src/web/use-web-auth.ts
Normal file
81
packages/auth/src/web/use-web-auth.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { api } from "@code/backend/convex/_generated/api";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useConvexAuth, useQuery } from "convex/react";
|
||||
|
||||
import { authClient } from "./auth-client";
|
||||
|
||||
export interface WebAuthUser {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
}
|
||||
|
||||
export type WebAuthState =
|
||||
| { readonly status: "loading" }
|
||||
| { readonly status: "unauthenticated" }
|
||||
| { readonly status: "authenticated"; readonly user: WebAuthUser }
|
||||
| { readonly status: "inconsistent" };
|
||||
|
||||
/**
|
||||
* Sign out of the web session. Clears the Better Auth session, which in turn
|
||||
* invalidates the cached Convex JWT (the session-token resolver keys on the
|
||||
* session id). Protected Convex queries after sign-out are denied.
|
||||
*/
|
||||
export const signOutWeb = async (): Promise<void> => {
|
||||
await authClient.signOut({ fetchOptions: { throw: false } });
|
||||
};
|
||||
|
||||
/**
|
||||
* The single web-facing auth interface. Combines Convex auth state with the
|
||||
* normalized {@link api.auth.getCurrentUser} projection, skipping the user
|
||||
* query until Convex has authenticated. Reports `inconsistent` only when a JWT
|
||||
* is accepted but the Better Auth user row cannot be resolved.
|
||||
*/
|
||||
export const useWebAuth = (): WebAuthState => {
|
||||
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||
|
||||
// Skip the user query until Convex has accepted the JWT. This keeps the
|
||||
// status `loading` during token restoration and `unauthenticated` when no
|
||||
// session exists, without firing a query that would return null.
|
||||
const userQuery = useQuery(
|
||||
api.auth.getCurrentUser,
|
||||
isAuthenticated ? {} : "skip"
|
||||
);
|
||||
|
||||
const signOutWebRef = useRef(signOutWeb);
|
||||
const inconsistent =
|
||||
isAuthenticated && userQuery !== undefined && userQuery === null;
|
||||
|
||||
// If Convex accepted the JWT but the Better Auth user row cannot be resolved,
|
||||
// the session is inconsistent: sign out so the next sign-in starts clean.
|
||||
useEffect(() => {
|
||||
if (inconsistent) {
|
||||
void signOutWebRef.current();
|
||||
}
|
||||
}, [inconsistent]);
|
||||
|
||||
if (isLoading) {
|
||||
return { status: "loading" };
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return { status: "unauthenticated" };
|
||||
}
|
||||
|
||||
if (userQuery === undefined) {
|
||||
return { status: "loading" };
|
||||
}
|
||||
|
||||
if (userQuery === null) {
|
||||
return { status: "inconsistent" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "authenticated",
|
||||
user: {
|
||||
email: userQuery.email,
|
||||
id: userQuery.id,
|
||||
name: userQuery.name,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -36,7 +36,33 @@ const createAuth = (ctx: GenericCtx<DataModel>) =>
|
||||
|
||||
export { createAuth };
|
||||
|
||||
export interface AuthUserView {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the authenticated user to the canonical web view. The returned `id`
|
||||
* is the Convex `identity.tokenIdentifier`, the stable authenticated identity
|
||||
* used across organization/conversation/Signal ownership. Returns null when no
|
||||
* identity is present or the Better Auth user row cannot be resolved.
|
||||
*/
|
||||
export const getCurrentUser = query({
|
||||
args: {},
|
||||
handler: (ctx) => authComponent.safeGetAuthUser(ctx),
|
||||
handler: async (ctx): Promise<AuthUserView | null> => {
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
if (!identity) {
|
||||
return null;
|
||||
}
|
||||
const authUser = await authComponent.safeGetAuthUser(ctx);
|
||||
if (!authUser) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
email: authUser.email,
|
||||
id: identity.tokenIdentifier,
|
||||
name: authUser.name,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface AuthContext {
|
||||
};
|
||||
}
|
||||
|
||||
export const requireOwnerId = async (ctx: AuthContext): Promise<string> => {
|
||||
export const requireAuthUserId = async (ctx: AuthContext): Promise<string> => {
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
if (!identity) {
|
||||
throw new ConvexError("Authentication required");
|
||||
@@ -28,7 +28,7 @@ export const requireOrganizationMember = async (
|
||||
ctx: QueryCtx | MutationCtx,
|
||||
organizationId: Id<"organizations">
|
||||
): Promise<string> => {
|
||||
const userId = await requireOwnerId(ctx);
|
||||
const userId = await requireAuthUserId(ctx);
|
||||
const membership = await ctx.db
|
||||
.query("organizationMembers")
|
||||
.withIndex("by_organizationId_and_userId", (q) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { requireOwnerId } from "./authz";
|
||||
import { requireAuthUserId } from "./authz";
|
||||
|
||||
interface OrganizationView {
|
||||
readonly _id: Id<"organizations">;
|
||||
@@ -31,7 +31,7 @@ const toView = (org: Doc<"organizations">): OrganizationView => ({
|
||||
export const ensurePersonalOrganization = mutation({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<OrganizationView> => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const existing = await ctx.db
|
||||
.query("organizations")
|
||||
.withIndex("by_createdBy_and_kind", (q) =>
|
||||
@@ -70,7 +70,7 @@ export const ensurePersonalOrganization = mutation({
|
||||
export const getCurrent = query({
|
||||
args: {},
|
||||
handler: async (ctx): Promise<OrganizationView | null> => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const existing = await ctx.db
|
||||
.query("organizations")
|
||||
.withIndex("by_createdBy_and_kind", (q) =>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { v } from "convex/values";
|
||||
|
||||
import { query } from "./_generated/server";
|
||||
import { requireOwnerId } from "./authz";
|
||||
import { requireAuthUserId } from "./authz"
|
||||
|
||||
export const list = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const project = await ctx.db.get("projects", args.projectId);
|
||||
if (!project || project.ownerId !== ownerId) {
|
||||
return [];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ConvexError, v } from "convex/values";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
import { requireOwnerId } from "./authz";
|
||||
import { requireAuthUserId } from "./authz"
|
||||
|
||||
const requireProjectOwner = async (
|
||||
ctx: MutationCtx,
|
||||
@@ -24,7 +24,7 @@ export const create = mutation({
|
||||
title: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
await requireProjectOwner(ctx, args.projectId, ownerId);
|
||||
const title = args.title.trim();
|
||||
const body = args.body.trim();
|
||||
@@ -81,7 +81,7 @@ export const create = mutation({
|
||||
export const begin = mutation({
|
||||
args: { issueId: v.id("projectIssues") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
@@ -109,7 +109,7 @@ export const begin = mutation({
|
||||
export const markDispatchFailed = mutation({
|
||||
args: { error: v.string(), issueId: v.id("projectIssues") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const issue = await ctx.db.get("projectIssues", args.issueId);
|
||||
if (!issue) {
|
||||
throw new ConvexError("Issue not found");
|
||||
@@ -133,7 +133,7 @@ export const markDispatchFailed = mutation({
|
||||
export const list = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const project = await ctx.db.get("projects", args.projectId);
|
||||
if (!project || project.ownerId !== ownerId) {
|
||||
return [];
|
||||
|
||||
@@ -5,7 +5,7 @@ import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import { action, internalMutation, query } from "./_generated/server";
|
||||
import { createInitialArtifacts } from "./artifactModel";
|
||||
import { requireOwnerId } from "./authz";
|
||||
import { requireAuthUserId } from "./authz"
|
||||
|
||||
const gitHubRepositorySchema = z.object({
|
||||
default_branch: z.string(),
|
||||
@@ -47,7 +47,7 @@ const readGitHubRepository = (value: unknown) => {
|
||||
export const connectGitHub = action({
|
||||
args: { repository: v.string() },
|
||||
handler: async (ctx, args): Promise<Id<"projects">> => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const { owner, repo } = parseRepository(args.repository);
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/${owner}/${repo}`,
|
||||
@@ -157,7 +157,7 @@ export const storeGitHubProject = internalMutation({
|
||||
export const list = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
return await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_owner_and_createdAt", (q) => q.eq("ownerId", ownerId))
|
||||
@@ -169,7 +169,7 @@ export const list = query({
|
||||
export const get = query({
|
||||
args: { projectId: v.id("projects") },
|
||||
handler: async (ctx, args) => {
|
||||
const ownerId = await requireOwnerId(ctx);
|
||||
const ownerId = await requireAuthUserId(ctx);
|
||||
const project = await ctx.db.get("projects", args.projectId);
|
||||
return project?.ownerId === ownerId ? project : null;
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./agent-os": "./src/agent-os.ts",
|
||||
"./project": "./src/project.ts",
|
||||
"./signal": "./src/signal.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// oxlint-disable-next-line no-barrel-file -- The package root intentionally exposes its public modules.
|
||||
export * from "./agent-os";
|
||||
export * from "./project";
|
||||
export * from "./signal";
|
||||
|
||||
857
packages/primitives/src/project.test.ts
Normal file
857
packages/primitives/src/project.test.ts
Normal file
@@ -0,0 +1,857 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
754
packages/primitives/src/project.ts
Normal file
754
packages/primitives/src/project.ts
Normal file
@@ -0,0 +1,754 @@
|
||||
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,
|
||||
projectName: MeaningfulString,
|
||||
repositoryPath: MeaningfulString,
|
||||
normalizedUrl: Schema.String,
|
||||
url: Schema.String,
|
||||
});
|
||||
export type PreparedPublicGitSource = typeof PreparedPublicGitSource.Type;
|
||||
|
||||
export const RepositoryContextDocument = Schema.Struct({
|
||||
kind: ContextKindSchema,
|
||||
path: Schema.String,
|
||||
content: 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* () {
|
||||
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);
|
||||
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* () {
|
||||
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* () {
|
||||
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"),
|
||||
];
|
||||
};
|
||||
|
||||
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* () {
|
||||
const write = input.write;
|
||||
const content = write.content;
|
||||
const kind = write.kind;
|
||||
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 =
|
||||
write._tag === "Repository"
|
||||
? "repository"
|
||||
: write._tag === "PublicTextUrl"
|
||||
? "public-url"
|
||||
: write.origin;
|
||||
const sourceUrl: string | undefined =
|
||||
write._tag === "UserText" ? undefined : write.sourceUrl;
|
||||
|
||||
const existing = input.existing;
|
||||
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({
|
||||
preparePublicGitSource,
|
||||
decodePublicGitImportResult,
|
||||
decodePublicTextResult,
|
||||
makeInitialContext,
|
||||
decideContextWrite,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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* () {
|
||||
const domain = yield* ProjectDomain;
|
||||
const publicGit = yield* PublicGit;
|
||||
const store = yield* ProjectStore;
|
||||
|
||||
return ProjectApplication.of({
|
||||
importPublicGit: Effect.fn("ProjectApplication.importPublicGit")(
|
||||
function* ({
|
||||
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({ userId, source, remote })
|
||||
.pipe(Effect.mapError(mapStoreError));
|
||||
}
|
||||
),
|
||||
importPublicText: Effect.fn("ProjectApplication.importPublicText")(
|
||||
function* ({
|
||||
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({ userId, projectId, write })
|
||||
.pipe(Effect.mapError(mapStoreError));
|
||||
}
|
||||
),
|
||||
putContext: Effect.fn("ProjectApplication.putContext")(
|
||||
function* ({
|
||||
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({ 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));
|
||||
}
|
||||
),
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user