Canvas: Use GraphSchema to validate state

Change-Id: I342c8959c97f3486c4a7cb2aff92fb930a2b3146
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index 38122f8..9456d17 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -24,6 +24,7 @@
 } from "config";
 import { Instant, DateTimeFormatter, ZoneId } from "@js-joda/core";
 import LogStore from "./log.js";
+import { GraphOrConfigSchema, GraphSchema } from "config/dist/graph.js";
 
 async function generateKey(root: string): Promise<[string, string]> {
 	const privKeyPath = path.join(root, "key");
@@ -42,6 +43,13 @@
 
 const projectMonitors = new Map<number, ProjectMonitor>();
 
+function parseGraph(data: string | null | undefined) {
+	if (data == null) {
+		return null;
+	}
+	return GraphSchema.safeParse(JSON.parse(data));
+}
+
 const handleProjectCreate: express.Handler = async (req, resp) => {
 	try {
 		const tmpDir = tmp.dirSync().name;
@@ -94,26 +102,6 @@
 	}
 };
 
-const handleSave: express.Handler = async (req, resp) => {
-	try {
-		await db.project.update({
-			where: {
-				id: Number(req.params["projectId"]),
-				userId: resp.locals.userId,
-			},
-			data: {
-				draft: JSON.stringify(req.body),
-			},
-		});
-		resp.status(200);
-	} catch (e) {
-		console.log(e);
-		resp.status(500);
-	} finally {
-		resp.end();
-	}
-};
-
 async function getState(projectId: number, userId: string, state: "deploy" | "draft"): Promise<Graph | null> {
 	const r = await db.project.findUnique({
 		where: {
@@ -131,7 +119,7 @@
 	let currentState: Graph | null = null;
 	if (state === "deploy") {
 		if (r.state != null) {
-			currentState = JSON.parse(Buffer.from(r.state).toString("utf8"));
+			currentState = parseGraph(r.state)!.data!;
 		}
 	} else {
 		if (r.draft == null) {
@@ -142,10 +130,10 @@
 					viewport: { x: 0, y: 0, zoom: 1 },
 				};
 			} else {
-				currentState = JSON.parse(Buffer.from(r.state).toString("utf8"));
+				currentState = parseGraph(r.state)!.data!;
 			}
 		} else {
-			currentState = JSON.parse(Buffer.from(r.draft).toString("utf8"));
+			currentState = parseGraph(r.draft)!.data!;
 		}
 	}
 	return currentState;
@@ -350,7 +338,7 @@
 						config.data,
 						getNetworks(resp.locals.username),
 						repos,
-						p.state ? JSON.parse(Buffer.from(p.state).toString("utf8")) : null,
+						p.state ? parseGraph(p.state)!.data! : undefined,
 					),
 				);
 		await db.project.update({
@@ -431,13 +419,13 @@
 	}
 };
 
-const handleSaveFromConfig: express.Handler = async (req, resp) => {
+const handleSave: express.Handler = async (req, resp) => {
 	try {
 		const projectId = Number(req.params["projectId"]);
 		const p = await db.project.findUnique({
 			where: {
 				id: projectId,
-				// userId: resp.locals.userId, TODO(gio): validate
+				userId: resp.locals.userId,
 			},
 			select: {
 				instanceId: true,
@@ -453,10 +441,18 @@
 			resp.status(404);
 			return;
 		}
-		const config = ConfigSchema.safeParse(req.body.config);
-		if (!config.success) {
+		const gc = GraphOrConfigSchema.safeParse(req.body);
+		if (!gc.success) {
 			resp.status(400);
-			resp.write(JSON.stringify({ error: "Invalid configuration", issues: config.error.format() }));
+			resp.write(JSON.stringify({ error: "Invalid configuration", issues: gc.error.format() }));
+			return;
+		}
+		if (gc.data.type === "graph") {
+			await db.project.update({
+				where: { id: projectId },
+				data: { draft: JSON.stringify(gc.data.graph) },
+			});
+			resp.status(200);
 			return;
 		}
 		let repos: GithubRepository[] = [];
@@ -466,10 +462,10 @@
 		}
 		const state = JSON.stringify(
 			configToGraph(
-				config.data,
+				gc.data.config,
 				getNetworks(resp.locals.username),
 				repos,
-				p.state ? JSON.parse(Buffer.from(p.state).toString("utf8")) : null,
+				p.state ? parseGraph(p.state)!.data! : undefined,
 			),
 		);
 		await db.project.update({
@@ -538,7 +534,7 @@
 			return;
 		}
 
-		const state = JSON.parse(Buffer.from(project.state).toString("utf8"));
+		const state = parseGraph(project.state)!.data!;
 		const env = await getEnv(projectId, resp.locals.userId, resp.locals.username);
 		const config = generateDodoConfig(projectId.toString(), state.nodes, env);
 
@@ -1204,7 +1200,6 @@
 	const projectRouter = express.Router();
 	projectRouter.use(auth);
 	projectRouter.post("/:projectId/analyze", handleAnalyzeRepo);
-	projectRouter.post("/:projectId/saved/config", handleSaveFromConfig);
 	projectRouter.post("/:projectId/saved", handleSave);
 	projectRouter.get("/:projectId/state/stream/deploy", handleStateGetStream("deploy"));
 	projectRouter.get("/:projectId/state/stream/draft", handleStateGetStream("draft"));
@@ -1235,6 +1230,7 @@
 	internalApi.use(express.json());
 	internalApi.post("/api/project/:projectId/workers", handleRegisterWorker);
 	internalApi.get("/api/project/:projectId/config", handleConfigGet);
+	internalApi.post("/api/project/:projectId/saved", handleSave);
 	internalApi.post("/api/project/:projectId/deploy", handleDeploy);
 	internalApi.post("/api/validate-config", handleValidateConfig);
 
diff --git a/apps/canvas/config/src/config.ts b/apps/canvas/config/src/config.ts
index dcc4318..d91b535 100644
--- a/apps/canvas/config/src/config.ts
+++ b/apps/canvas/config/src/config.ts
@@ -2,6 +2,7 @@
 	AppNode,
 	BoundEnvVar,
 	Env,
+	Edge,
 	GatewayHttpsNode,
 	GatewayTCPNode,
 	GithubNode,
@@ -12,8 +13,8 @@
 	PostgreSQLNode,
 	ServiceNode,
 	VolumeNode,
+	Graph,
 } from "./graph.js";
-import { Edge } from "@xyflow/react";
 import { v4 as uuidv4 } from "uuid";
 import { Ingress, Service, Volume, PostgreSQL, MongoDB, Config, PortDomain, isAgent } from "./types.js";
 import { GithubRepository } from "./github.js";
@@ -186,12 +187,6 @@
 	}
 }
 
-export type Graph = {
-	nodes: AppNode[];
-	edges: Edge[];
-	viewport?: { x: number; y: number; zoom: number };
-};
-
 export function configToGraph(config: Config, networks: Network[], repos: GithubRepository[], current?: Graph): Graph {
 	if (current == null) {
 		current = { nodes: [], edges: [] };
@@ -264,7 +259,6 @@
 			data: {
 				label: s.name,
 				type: s.type,
-				env: [],
 				repository:
 					s.source != null
 						? {
@@ -454,7 +448,6 @@
 			type: "postgresql",
 			data: {
 				label: p.name,
-				volumeId: "", // TODO(gio): volume
 				envVars: [],
 				ports: [
 					{
@@ -518,7 +511,6 @@
 			type: "mongodb",
 			data: {
 				label: m.name,
-				volumeId: "", // TODO(gio): volume
 				envVars: [],
 				ports: [
 					{
diff --git a/apps/canvas/config/src/graph.ts b/apps/canvas/config/src/graph.ts
index c36dbcf..e309926 100644
--- a/apps/canvas/config/src/graph.ts
+++ b/apps/canvas/config/src/graph.ts
@@ -1,6 +1,14 @@
 import { z } from "zod";
 import { Node } from "@xyflow/react";
-import { Domain, ServiceType, VolumeType } from "./types.js";
+import {
+	ConfigSchema,
+	Domain,
+	DomainSchema,
+	ServiceType,
+	ServiceTypeSchema,
+	VolumeType,
+	VolumeTypeSchema,
+} from "./types.js";
 
 export const serviceAnalyzisSchema = z.object({
 	name: z.string(),
@@ -37,6 +45,41 @@
 	),
 });
 
+export const BoundEnvVarSchema = z.union([
+	z.object({
+		id: z.string(),
+		source: z.null(),
+		name: z.string(),
+		value: z.string(),
+		isEditting: z.boolean().optional(),
+	}),
+	z.object({
+		id: z.string(),
+		source: z.string().nullable(),
+		portId: z.string(),
+		name: z.string(),
+		alias: z.string(),
+		isEditting: z.boolean(),
+	}),
+	z.object({
+		id: z.string(),
+		source: z.string().nullable(),
+		name: z.string(),
+		alias: z.string(),
+		isEditting: z.boolean(),
+	}),
+	z.object({
+		id: z.string(),
+		source: z.string().nullable(),
+		name: z.string(),
+		isEditting: z.boolean(),
+	}),
+	z.object({
+		id: z.string(),
+		source: z.string().nullable(),
+	}),
+]);
+
 export type BoundEnvVar =
 	| {
 			id: string;
@@ -131,11 +174,13 @@
 	type: "gateway-tcp";
 };
 
-export type Port = {
-	id: string;
-	name: string;
-	value: number;
-};
+export const PortSchema = z.object({
+	id: z.string(),
+	name: z.string(),
+	value: z.number(),
+});
+
+export type Port = z.infer<typeof PortSchema>;
 
 export type ServiceData = NodeData & {
 	type: ServiceType;
@@ -155,10 +200,9 @@
 				branch: string;
 				rootDir: string;
 		  };
-	env: string[];
-	volume: string[];
-	preBuildCommands: string;
-	isChoosingPortToConnect: boolean;
+	volume?: string[];
+	preBuildCommands?: string;
+	isChoosingPortToConnect?: boolean;
 	dev?:
 		| {
 				enabled: false;
@@ -191,17 +235,13 @@
 	type: "volume";
 };
 
-export type PostgreSQLData = NodeData & {
-	volumeId: string;
-};
+export type PostgreSQLData = NodeData & {};
 
 export type PostgreSQLNode = Node<PostgreSQLData> & {
 	type: "postgresql";
 };
 
-export type MongoDBData = NodeData & {
-	volumeId: string;
-};
+export type MongoDBData = NodeData & {};
 
 export type MongoDBNode = Node<MongoDBData> & {
 	type: "mongodb";
@@ -236,13 +276,13 @@
 
 export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
 
-export const networkSchema = z.object({
+export const NetworkSchema = z.object({
 	name: z.string().min(1),
 	domain: z.string().min(1),
 	hasAuth: z.boolean(),
 });
 
-export type Network = z.infer<typeof networkSchema>;
+export type Network = z.infer<typeof NetworkSchema>;
 
 export const accessSchema = z.discriminatedUnion("type", [
 	z.object({
@@ -340,3 +380,225 @@
 export type Env = z.infer<typeof envSchema>;
 export type Access = z.infer<typeof accessSchema>;
 export type AgentAccess = Required<Extract<Access, { type: "https" }>>;
+
+const NodeBaseSchema = z.object({
+	id: z.string(),
+	position: z.object({
+		x: z.number(),
+		y: z.number(),
+	}),
+});
+
+export const NodeBaseDataSchema = z.object({
+	label: z.string(),
+	envVars: z.array(BoundEnvVarSchema),
+	ports: z.array(PortSchema),
+});
+
+export const NetworkNodeSchema = z
+	.object({
+		type: z.literal("network"),
+		data: z
+			.object({
+				domain: z.string(),
+			})
+			.extend(NodeBaseDataSchema.shape),
+	})
+	.extend(NodeBaseSchema.shape);
+
+export const GithubNodeSchema = z
+	.object({
+		type: z.literal("github"),
+		data: z
+			.object({
+				repository: z
+					.object({
+						id: z.number(),
+						sshURL: z.string(),
+						fullName: z.string(),
+					})
+					.optional(),
+			})
+			.extend(NodeBaseDataSchema.shape),
+	})
+	.extend(NodeBaseSchema.shape);
+
+export const GatewayHttpsNodeSchema = z
+	.object({
+		type: z.literal("gateway-https"),
+		data: z
+			.object({
+				readonly: z.boolean().optional(), // TODO: remove this
+				network: z.string().optional(),
+				subdomain: z.string().optional(),
+				https: z
+					.object({
+						serviceId: z.string(),
+						portId: z.string(),
+					})
+					.optional(),
+			})
+			.extend(NodeBaseDataSchema.shape),
+	})
+	.extend(NodeBaseSchema.shape);
+
+export const GatewayTCPNodeSchema = z
+	.object({
+		type: z.literal("gateway-tcp"),
+		data: z
+			.object({
+				readonly: z.boolean().optional(),
+				network: z.string().optional(),
+				subdomain: z.string().optional(),
+				exposed: z.array(
+					z.object({
+						serviceId: z.string(),
+						portId: z.string(),
+					}),
+				),
+				selected: z
+					.object({
+						serviceId: z.string().optional(),
+						portId: z.string().optional(),
+					})
+					.optional(),
+			})
+			.extend(NodeBaseDataSchema.shape),
+	})
+	.extend(NodeBaseSchema.shape);
+
+export const ServiceNodeSchema = z
+	.object({
+		type: z.literal("app"),
+		data: z
+			.object({
+				type: ServiceTypeSchema,
+				repository: z
+					.union([
+						z.object({
+							id: z.number(),
+							repoNodeId: z.string(),
+							branch: z.string(),
+							rootDir: z.string(),
+						}),
+						z.object({
+							id: z.number(),
+							repoNodeId: z.string(),
+							branch: z.string(),
+						}),
+						z.object({
+							id: z.number(),
+							repoNodeId: z.string(),
+						}),
+					])
+					.optional(),
+				volume: z.array(z.string()).optional(),
+				preBuildCommands: z.string().optional(),
+				isChoosingPortToConnect: z.boolean().optional(),
+				dev: z
+					.discriminatedUnion("enabled", [
+						z.object({
+							enabled: z.literal(false),
+							expose: DomainSchema.optional(),
+						}),
+						z.object({
+							enabled: z.literal(true),
+							expose: DomainSchema.optional(),
+							codeServerNodeId: z.string(),
+							sshNodeId: z.string(),
+						}),
+					])
+					.optional(),
+				model: z
+					.object({
+						name: z.enum(["gemini", "claude"]),
+						apiKey: z.string().optional(),
+					})
+					.optional(),
+				info: serviceAnalyzisSchema.optional(),
+
+				ports: z.array(
+					z.object({
+						id: z.string(),
+						name: z.string(),
+						value: z.number(),
+					}),
+				),
+				activeField: z.string().optional(),
+				state: z.string().nullable().optional(),
+			})
+			.extend(NodeBaseDataSchema.shape),
+	})
+	.extend(NodeBaseSchema.shape);
+
+export const VolumeNodeSchema = z
+	.object({
+		type: z.literal("volume"),
+		data: z
+			.object({
+				size: z.string(),
+				type: VolumeTypeSchema,
+				attachedTo: z.array(z.string()),
+			})
+			.extend(NodeBaseDataSchema.shape),
+	})
+	.extend(NodeBaseSchema.shape);
+
+export const PostgreSQLNodeSchema = z
+	.object({
+		type: z.literal("postgresql"),
+		data: z.object({}).extend(NodeBaseDataSchema.shape),
+	})
+	.extend(NodeBaseSchema.shape);
+
+export const MongoDBNodeSchema = z
+	.object({
+		type: z.literal("mongodb"),
+		data: z.object({}).extend(NodeBaseDataSchema.shape),
+	})
+	.extend(NodeBaseSchema.shape);
+
+export const NodeSchema = z.discriminatedUnion("type", [
+	NetworkNodeSchema,
+	GithubNodeSchema,
+	GatewayHttpsNodeSchema,
+	GatewayTCPNodeSchema,
+	ServiceNodeSchema,
+	VolumeNodeSchema,
+	PostgreSQLNodeSchema,
+	MongoDBNodeSchema,
+]);
+
+export const EdgeSchema = z.object({
+	id: z.string(),
+	source: z.string(),
+	sourceHandle: z.string().optional(),
+	target: z.string(),
+	targetHandle: z.string().optional(),
+});
+
+export const GraphSchema = z.object({
+	nodes: z.array(NodeSchema),
+	edges: z.array(EdgeSchema),
+	viewport: z
+		.object({
+			x: z.number(),
+			y: z.number(),
+			zoom: z.number(),
+		})
+		.optional(),
+});
+
+export const GraphOrConfigSchema = z.discriminatedUnion("type", [
+	z.object({
+		type: z.literal("graph"),
+		graph: GraphSchema,
+	}),
+	z.object({
+		type: z.literal("config"),
+		config: ConfigSchema,
+	}),
+]);
+
+export type Edge = z.infer<typeof EdgeSchema>;
+export type Graph = z.infer<typeof GraphSchema>;
diff --git a/apps/canvas/config/src/index.ts b/apps/canvas/config/src/index.ts
index 569d347..4a6121e 100644
--- a/apps/canvas/config/src/index.ts
+++ b/apps/canvas/config/src/index.ts
@@ -50,9 +50,12 @@
 	accessSchema,
 	Access,
 	AgentAccess,
+	Graph,
+	Edge,
+	GraphOrConfigSchema,
 } from "./graph.js";
 
-export { generateDodoConfig, configToGraph, Graph } from "./config.js";
+export { generateDodoConfig, configToGraph } from "./config.js";
 
 export {
 	GithubRepository,
diff --git a/apps/canvas/config/src/types.ts b/apps/canvas/config/src/types.ts
index 15bf22d..bbf851b 100644
--- a/apps/canvas/config/src/types.ts
+++ b/apps/canvas/config/src/types.ts
@@ -20,7 +20,7 @@
 	auth: AuthSchema,
 });
 
-const DomainSchema = z.object({
+export const DomainSchema = z.object({
 	nodeId: z.string().optional(),
 	network: z.string(),
 	subdomain: z.string(),
@@ -52,7 +52,7 @@
 	"sketch:latest",
 ] as const;
 
-const ServiceTypeSchema = z.enum(ServiceTypes);
+export const ServiceTypeSchema = z.enum(ServiceTypes);
 
 const ModelSchema = z.discriminatedUnion("name", [
 	z.object({
@@ -114,7 +114,7 @@
 	model: ModelSchema,
 });
 
-const VolumeTypeSchema = z.enum(["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany", "ReadWriteOncePod"]);
+export const VolumeTypeSchema = z.enum(["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany", "ReadWriteOncePod"]);
 
 const VolumeSchema = z.object({
 	nodeId: z.string().optional(),
diff --git a/apps/canvas/front/src/components/actions.tsx b/apps/canvas/front/src/components/actions.tsx
index 5ea2920..f283ec8 100644
--- a/apps/canvas/front/src/components/actions.tsx
+++ b/apps/canvas/front/src/components/actions.tsx
@@ -139,7 +139,10 @@
 			headers: {
 				"Content-Type": "application/json",
 			},
-			body: JSON.stringify(instance.toObject()),
+			body: JSON.stringify({
+				type: "graph",
+				graph: instance.toObject(),
+			}),
 		});
 		if (resp.ok) {
 			info("Save succeeded");
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index 0132721..5e9315b 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -539,13 +539,6 @@
 				attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
 			});
 		}
-		if (c.targetHandle === "volume") {
-			if (tn.type === "postgresql" || tn.type === "mongodb") {
-				updateNodeData(c.target, {
-					volumeId: c.source,
-				});
-			}
-		}
 		if (c.targetHandle === "https") {
 			if ((sn.data.ports || []).length === 1) {
 				updateNodeData<"gateway-https">(c.target, {