Canvas: Use GraphSchema to validate state

Change-Id: I342c8959c97f3486c4a7cb2aff92fb930a2b3146
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(),