431 lines
12 KiB
TypeScript
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 };
|