Files
zopu-code/packages/backend/convex/projectStore.ts

431 lines
12 KiB
TypeScript

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<ProjectView> => {
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<ProjectImportOutcome> => {
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<ProjectView[]> => {
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<ProjectView | null> => {
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<ProjectImportOutcome> => {
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<ProjectImportOutcome> => {
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 };