134 lines
4.1 KiB
TypeScript
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 };
|
|
},
|
|
});
|