Canvas: Use GraphSchema to validate state

Change-Id: I342c8959c97f3486c4a7cb2aff92fb930a2b3146
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>;