Files
zopu-code/packages/backend/convex/artifacts.ts
2026-08-04 01:38:48 +05:30

115 lines
3.9 KiB
TypeScript

import { ConvexError, v } from "convex/values";
import type { Doc } from "./_generated/dataModel";
import { internalMutation, query } from "./_generated/server";
import { requireArtifactMember, requireWorkMember } from "./authz";
export const get = query({
args: { artifactId: v.id("artifacts") },
handler: async (ctx, args): Promise<Doc<"artifacts"> | null> => {
await requireArtifactMember(ctx, args.artifactId);
return await ctx.db.get(args.artifactId);
},
});
export const listForWork = query({
args: { limit: v.optional(v.number()), workId: v.id("works") },
handler: async (ctx, args): Promise<readonly Doc<"artifacts">[]> => {
const { organizationId } = await requireWorkMember(ctx, args.workId);
const limit = Math.min(Math.max(Math.floor(args.limit ?? 50), 1), 100);
return await ctx.db
.query("artifacts")
.withIndex("by_workId_and_createdAt", (q) => q.eq("workId", args.workId))
.filter((q) => q.eq(q.field("organizationId"), organizationId))
.order("desc")
.take(limit);
},
});
/**
* Publish (or supersede) a typed artifact revision for a project/work/thread.
*
* Versioning: the new artifact's `version` is one greater than the latest
* existing artifact sharing the same `logicalKey` within the org. The previous
* latest artifact (if any) is marked `superseded` and linked via
* `supersedesArtifactId` on the new row.
*
* The optional `storageId` links the rendered static HTML (or other binary) in
* Convex Storage to the artifact row, keeping the card/manifest inline for the
* timeline.
*
* Idempotent by `logicalKey` + `version`: a re-publish of the same version
* returns the existing artifact. `createdByEventId` ties the artifact to the
* event that produced it (exact-once via the event's idempotency key).
*/
export const publishRevision = internalMutation({
args: {
card: v.any(),
content: v.any(),
createdByEventId: v.id("events"),
kind: v.union(
v.literal("project_setup"),
v.literal("summary"),
v.literal("plan"),
v.literal("preview"),
v.literal("blocker"),
v.literal("report")
),
logicalKey: v.string(),
organizationId: v.id("organizations"),
projectId: v.optional(v.id("projects")),
status: v.union(
v.literal("working"),
v.literal("ready"),
v.literal("blocked"),
v.literal("failed"),
v.literal("superseded")
),
storageId: v.optional(v.id("_storage")),
summary: v.string(),
threadId: v.optional(v.id("threads")),
title: v.string(),
workId: v.optional(v.id("works")),
},
handler: async (ctx, args): Promise<Doc<"artifacts">> => {
const previous = await ctx.db
.query("artifacts")
.withIndex("by_organizationId_and_logicalKey_and_version", (q) =>
q
.eq("organizationId", args.organizationId)
.eq("logicalKey", args.logicalKey)
)
.order("desc")
.first();
const version = (previous?.version ?? 0) + 1;
const now = Date.now();
const artifactId = await ctx.db.insert("artifacts", {
card: args.card,
content: args.content,
createdAt: now,
createdByEventId: args.createdByEventId,
kind: args.kind,
logicalKey: args.logicalKey,
organizationId: args.organizationId,
projectId: args.projectId,
status: args.status,
storageId: args.storageId,
summary: args.summary,
supersedesArtifactId: previous?._id,
threadId: args.threadId,
title: args.title,
version,
workId: args.workId,
});
// Mark the prior revision superseded so only the latest is "ready".
if (previous && previous.status !== "superseded") {
await ctx.db.patch(previous._id, { status: "superseded" });
}
const artifact = await ctx.db.get(artifactId);
if (!artifact) {
throw new ConvexError("Artifact could not be read after publish");
}
return artifact;
},
});