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

134 lines
4.1 KiB
TypeScript

import { ConvexError, v } from "convex/values";
import type { Doc, Id } from "./_generated/dataModel";
import { internalMutation, mutation, query } from "./_generated/server";
import { requireProjectMember } from "./authz";
const category = v.union(
v.literal("required"),
v.literal("integration"),
v.literal("optional")
);
const status = v.union(
v.literal("missing"),
v.literal("configured"),
v.literal("invalid")
);
const source = v.union(
v.literal("detected"),
v.literal("user"),
v.literal("system")
);
export const list = query({
args: { projectId: v.id("projects") },
handler: async (
ctx,
args
): Promise<readonly Doc<"projectEnvironmentVariables">[]> => {
const { organizationId } = await requireProjectMember(ctx, args.projectId);
return await ctx.db
.query("projectEnvironmentVariables")
.withIndex("by_projectId", (q) => q.eq("projectId", args.projectId))
.filter((q) => q.eq(q.field("organizationId"), organizationId))
.order("asc")
.collect();
},
});
export const upsert = mutation({
args: {
category,
description: v.optional(v.string()),
name: v.string(),
projectId: v.id("projects"),
secretReference: v.optional(v.string()),
source,
status,
},
handler: async (ctx, args): Promise<Doc<"projectEnvironmentVariables">> => {
const { organizationId } = await requireProjectMember(ctx, args.projectId);
const name = args.name.trim();
if (!name) {
throw new ConvexError("Environment variable name is required");
}
const existing = await ctx.db
.query("projectEnvironmentVariables")
.withIndex("by_projectId_and_name", (q) =>
q.eq("projectId", args.projectId).eq("name", name)
)
.unique();
const value = {
category: args.category,
description: args.description,
name,
organizationId,
projectId: args.projectId,
secretReference: args.secretReference,
source: args.source,
status: args.status,
updatedAt: Date.now(),
};
const id: Id<"projectEnvironmentVariables"> = existing
? existing._id
: await ctx.db.insert("projectEnvironmentVariables", value);
if (existing) {
await ctx.db.patch(id, value);
}
const saved = await ctx.db.get(id);
if (!saved) {
throw new ConvexError("Environment variable could not be saved");
}
return saved;
},
});
/**
* Replace the entire detected environment-variable manifest for a Project in a
* single idempotent transaction. Called by the setup coordinator after it
* scans the root `.env.example` (names only — example/default text is never
* treated as a secret, stored, or displayed).
*
* Semantics:
* - All previously `detected` rows for the project are removed.
* - One new `detected` row is inserted per scanned name, category `optional`,
* status `missing` (configuration is not part of the readiness predicate).
* - `user`/`system` rows are untouched.
*
* Idempotency: re-running the coordinator with the same names produces the
* same final set of detected rows. No values are persisted.
*/
export const replaceDetectedManifest = internalMutation({
args: {
names: v.array(v.string()),
organizationId: v.id("organizations"),
projectId: v.id("projects"),
},
handler: async (ctx, args): Promise<{ count: number }> => {
const detected = await ctx.db
.query("projectEnvironmentVariables")
.withIndex("by_projectId", (q) => q.eq("projectId", args.projectId))
.filter((q) => q.eq(q.field("source"), "detected"))
.collect();
for (const row of detected) {
await ctx.db.delete(row._id);
}
const timestamp = Date.now();
const deduped = [...new Set(args.names.map((name) => name.trim())).values()]
.filter((name) => name.length > 0)
.sort();
for (const name of deduped) {
await ctx.db.insert("projectEnvironmentVariables", {
category: "optional",
name,
organizationId: args.organizationId,
projectId: args.projectId,
source: "detected",
status: "missing",
updatedAt: timestamp,
});
}
return { count: deduped.length };
},
});