feat: Projects backend slice — auth, primitives, backend, UI (#3)
This commit is contained in:
430
packages/backend/convex/projectStore.ts
Normal file
430
packages/backend/convex/projectStore.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user