import { CONTEXT_KINDS, contextPathForKind, decideContextWrite, makeInitialContext, type ContextDocumentState, type ContextKind, type ContextWrite, type PreparedPublicGitSource, type ProjectImportOutcome, type ProjectView, type PublicGitImportResult, } from "@code/primitives/project"; import type { Doc, Id } from "./_generated/dataModel"; import type { ActionCtx, MutationCtx, QueryCtx } from "./_generated/server"; import { requireCurrentOrganization, requireProjectMember } from "./authz"; // --------------------------------------------------------------------------- // View mappers // --------------------------------------------------------------------------- interface SourceRow extends Doc<"projectSources"> {} const toContextDocumentState = ( doc: Doc<"projectContextDocuments"> ): ContextDocumentState => ({ content: doc.content, kind: doc.kind, origin: doc.origin, path: doc.path, revision: doc.revision, sourceUrl: doc.sourceUrl ?? undefined, }); const toProjectView = async ( ctx: QueryCtx, project: Doc<"projects"> ): Promise => { const [sources, contextDocs] = await Promise.all([ ctx.db .query("projectSources") .withIndex("by_project", (q) => q.eq("projectId", project._id)) .collect(), ctx.db .query("projectContextDocuments") .withIndex("by_project", (q) => q.eq("projectId", project._id)) .collect(), ]); return { contextDocuments: contextDocs.map(toContextDocumentState), createdAt: project.createdAt as never, id: project._id as never, name: project.name, organizationId: project.organizationId as never, sources: sources.map((s) => ({ createdAt: s.createdAt as never, defaultBranch: s.defaultBranch, host: s.host, kind: "git" as const, normalizedUrl: s.normalizedUrl, projectId: s.projectId as never, repositoryPath: s.repositoryPath, updatedAt: s.updatedAt as never, url: s.url, })), updatedAt: project.updatedAt as never, }; }; const toImportOutcome = async ( ctx: QueryCtx, project: Doc<"projects">, source: SourceRow ): Promise => { const contextDocs = await ctx.db .query("projectContextDocuments") .withIndex("by_project", (q) => q.eq("projectId", project._id)) .collect(); return { contextDocuments: contextDocs.map(toContextDocumentState), createdAt: project.createdAt as never, id: project._id as never, name: project.name, organizationId: project.organizationId as never, source: { createdAt: source.createdAt as never, defaultBranch: source.defaultBranch, host: source.host, kind: "git" as const, normalizedUrl: source.normalizedUrl, projectId: source.projectId as never, repositoryPath: source.repositoryPath, updatedAt: source.updatedAt as never, url: source.url, }, updatedAt: project.updatedAt as never, }; }; // --------------------------------------------------------------------------- // Query store — read-only ctx.db access // --------------------------------------------------------------------------- export const makeProjectQueryStore = (ctx: QueryCtx) => ({ listProjects: async (userId: string): Promise => { const { organizationId } = await resolveOrgForUser(ctx, userId); const projects = await ctx.db .query("projects") .withIndex("by_organization_and_createdAt", (q) => q.eq("organizationId", organizationId) ) .order("desc") .take(50); return Promise.all(projects.map((p) => toProjectView(ctx, p))); }, getProject: async ( userId: string, projectId: Id<"projects"> ): Promise => { const { organizationId } = await resolveOrgForUser(ctx, userId); const project = await ctx.db.get(projectId); if (!project || project.organizationId !== organizationId) { return null; } return toProjectView(ctx, project); }, }); // --------------------------------------------------------------------------- // Mutation store — transactional ctx.db writes // --------------------------------------------------------------------------- export const makeProjectMutationStore = (ctx: MutationCtx) => ({ putContext: async ( userId: string, projectId: Id<"projects">, write: ContextWrite ): Promise<{ revision: number }> => { const { organizationId } = await resolveProjectOrg(ctx, projectId, userId); const path = contextPathForKind(write.kind); const existing = await ctx.db .query("projectContextDocuments") .withIndex("by_project_and_path", (q) => q.eq("projectId", projectId).eq("path", path) ) .unique(); const result = await Effect.runPromise( decideContextWrite({ existing: existing ? toContextDocumentState(existing) : undefined, write, }) ); const now = Date.now(); if (!result.changed) { return { revision: result.document.revision }; } if (existing) { await ctx.db.patch(existing._id, { content: result.document.content, origin: result.document.origin, revision: result.document.revision, sourceUrl: result.document.sourceUrl, updatedAt: now, }); } else { await ctx.db.insert("projectContextDocuments", { content: result.document.content, createdAt: now, kind: result.document.kind, organizationId, origin: result.document.origin, path: result.document.path, projectId, revision: result.document.revision, sourceUrl: result.document.sourceUrl, updatedAt: now, }); } return { revision: result.document.revision }; }, }); // --------------------------------------------------------------------------- // Action store — calls narrowly scoped internal mutations/queries // --------------------------------------------------------------------------- export const makeProjectActionStore = (_ctx: ActionCtx) => ({ getCurrentOrganization: async (userId: string) => { return userId; }, listProjects: async (userId: string) => { return userId; }, getProject: async (userId: string, _projectId: Id<"projects">) => { return userId; }, persistPublicGitImport: async ( _userId: string, _source: PreparedPublicGitSource, _remote: PublicGitImportResult ): Promise => { throw new Error( "persistPublicGitImport must be called via internal mutation" ); }, putContext: async ( _userId: string, _projectId: Id<"projects">, _write: ContextWrite ): Promise<{ revision: number }> => { throw new Error("putContext must be called via internal mutation"); }, }); // We need Effect for decideContextWrite at runtime. import { Effect } from "effect"; // --------------------------------------------------------------------------- // Internal mutation store — the actual transactional persistence // --------------------------------------------------------------------------- export const persistPublicGitImportTransaction = async ( ctx: MutationCtx, userId: string, source: PreparedPublicGitSource, remote: PublicGitImportResult ): Promise => { const { organizationId } = await resolveOrgForUser(ctx, userId); // Idempotency: lookup by normalizedUrl within the organization const existingSource = await ctx.db .query("projectSources") .withIndex("by_organization_and_normalizedUrl", (q) => q .eq("organizationId", organizationId) .eq("normalizedUrl", source.normalizedUrl) ) .unique(); const now = Date.now(); if (existingSource) { const project = await ctx.db.get(existingSource.projectId); if (!project) { throw new Error("Project not found for existing source"); } // Update source metadata await ctx.db.patch(existingSource._id, { defaultBranch: remote.defaultBranch, updatedAt: now, }); // Apply remote documents as repository writes for (const doc of remote.documents) { await applyRepositoryWrite( ctx, project._id, organizationId, doc, source.url, now ); } await ctx.db.patch(project._id, { updatedAt: now }); const updatedSource = await ctx.db.get(existingSource._id); return toImportOutcome(ctx, project, updatedSource ?? existingSource); } // Create new project const projectId = await ctx.db.insert("projects", { createdAt: now, name: source.projectName, organizationId, updatedAt: now, }); // Create source const sourceId = await ctx.db.insert("projectSources", { createdAt: now, defaultBranch: remote.defaultBranch, host: source.host, kind: "git", normalizedUrl: source.normalizedUrl, organizationId, projectId, repositoryPath: source.repositoryPath, updatedAt: now, url: source.url, }); // Seed six canonical context documents const seeds = makeInitialContext(source.projectName, source.url); for (const seed of seeds) { await ctx.db.insert("projectContextDocuments", { content: seed.content, createdAt: now, kind: seed.kind, organizationId, origin: seed.origin, path: seed.path, projectId, revision: seed.revision, sourceUrl: seed.sourceUrl, updatedAt: now, }); } // Apply remote documents (overrides seeds) for (const doc of remote.documents) { await applyRepositoryWrite( ctx, projectId, organizationId, doc, source.url, now ); } const createdProject = await ctx.db.get(projectId); const createdSource = await ctx.db.get(sourceId); if (!createdProject || !createdSource) { throw new Error("Failed to read created project"); } return toImportOutcome(ctx, createdProject, createdSource); }; const applyRepositoryWrite = async ( ctx: MutationCtx, projectId: Id<"projects">, organizationId: Id<"organizations">, doc: { kind: ContextKind; path: string; content: string }, sourceUrl: string, now: number ) => { const path = contextPathForKind(doc.kind); const existing = await ctx.db .query("projectContextDocuments") .withIndex("by_project_and_path", (q) => q.eq("projectId", projectId).eq("path", path) ) .unique(); const result = await Effect.runPromise( decideContextWrite({ existing: existing ? toContextDocumentState(existing) : undefined, write: { _tag: "Repository" as const, content: doc.content, kind: doc.kind, sourceUrl, }, }) ); if (!result.changed) return; if (existing) { await ctx.db.patch(existing._id, { content: result.document.content, origin: result.document.origin, revision: result.document.revision, sourceUrl: result.document.sourceUrl, updatedAt: now, }); } else { await ctx.db.insert("projectContextDocuments", { content: result.document.content, createdAt: now, kind: result.document.kind, organizationId, origin: result.document.origin, path: result.document.path, projectId, revision: result.document.revision, sourceUrl: result.document.sourceUrl, updatedAt: now, }); } }; // --------------------------------------------------------------------------- // Organization resolvers // --------------------------------------------------------------------------- const resolveOrgForUser = async ( ctx: QueryCtx | MutationCtx, userId: string ): Promise<{ organizationId: Id<"organizations"> }> => { const organization = await ctx.db .query("organizations") .withIndex("by_createdBy_and_kind", (q) => q.eq("createdBy", userId).eq("kind", "personal") ) .unique(); if (!organization) { throw new Error("Organization not found"); } return { organizationId: organization._id }; }; const resolveProjectOrg = async ( ctx: QueryCtx | MutationCtx, projectId: Id<"projects">, userId: string ): Promise<{ organizationId: Id<"organizations"> }> => { const project = await ctx.db.get(projectId); if (!project) { throw new Error("Project not found"); } const { organizationId } = await resolveOrgForUser(ctx, userId); if (project.organizationId !== organizationId) { throw new Error("Project not found"); } return { organizationId }; }; // Re-export for convenience export { CONTEXT_KINDS, requireCurrentOrganization, requireProjectMember };