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[]> => { 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> => { 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 }; }, });