Schémas Zod
Les schémas Zod sont la source unique de vérité pour tous les types de l'application. Définis dans packages/shared, ils sont importés par le serveur, l'agent et le frontend via l'inférence de types tRPC.
Localisation
packages/shared/src/schemas/
├── service.schema.ts # ServiceConfig, ServiceVariable
├── environment.schema.ts # Environment, Variable
├── agent.schema.ts # Agent
├── project.schema.ts # Project
├── settings.schema.ts # Settings, JSLogParser
├── log.schema.ts # LogLine, HealthMessage
└── image.schema.ts # ImagePart (ajouté — image support)Règles de définition
Contraintes obligatoires
// ✅ Toujours .max() sur les strings
z.string().min(1).max(128) // IDs, noms
z.string().max(2048) // commandes, chemins, URLs
z.string().url().max(2048) // URLs (url() ET max())
z.string().email().max(320) // emails
// ✅ Toujours .max() sur les arrays
z.array(ItemSchema).max(100)
// ✅ .refine() pour limiter les records
z.record(z.string(), VariableSchema).refine(
r => Object.keys(r).length <= 1000,
'Too many variables'
)Inférence de types
export const ServiceConfigSchema = z.object({ ... })
// Type inféré — pas de duplication
export type ServiceConfig = z.infer<typeof ServiceConfigSchema>Schémas partiels pour update
// Pour les mutations update, utiliser .partial() ou .pick()
export const ServiceUpdateSchema = ServiceConfigSchema
.omit({ id: true, createdAt: true })
.partial()Rebuild obligatoire
Après modification de packages/shared :
yarn workspace @runeya/packages-shared buildSans cette étape, tsup bundle l'ancienne version du dist et Zod strip silencieusement les nouveaux champs.
Schémas inline dans les routers
Les routers tRPC doivent référencer les schémas partagés — jamais dupliquer inline :
// ✅ Référence au schéma partagé
service.create: protectedProcedure
.input(ServiceCreateSchema)
.mutation(...)
// ❌ Schéma inline qui peut dériver
service.create: protectedProcedure
.input(z.object({ name: z.string(), ... })) // copie qui diverge
.mutation(...)ImagePart schema
Introduit dans packages/shared/src/schemas/image.schema.ts pour le support des images dans le chat.
export const ImagePartSchema = z.object({
type: z.literal('image'),
imageId: z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i),
url: z.string().min(1),
mimeType: z.enum(['image/png', 'image/jpeg', 'image/webp', 'image/gif']),
altText: z.string().max(500).optional(),
})
export type ImagePart = z.infer<typeof ImagePartSchema>Ce schéma est intégré dans l'union discriminée MessagePartSchema dans apps/server/src/trpc/routers/chat.ts. Les messages sans images (parts ne contenant pas d'image) continuent de valider sans modification.
LogLine schema
export const LogLineSchema = z.object({
raw: z.string().max(16384),
stream: z.enum(['stdout', 'stderr']),
level: z.enum(['error', 'warn', 'info', 'debug']),
message: z.string().max(16384),
hidden: z.boolean(),
metadata: z.record(z.string(), z.unknown()).refine(
r => Object.keys(r).length <= 100
),
json: z.record(z.string(), z.unknown()).optional(),
timestamp: z.string().datetime(),
})HealthMessage schema
export const HealthMessageSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('ping') }),
z.object({ type: z.literal('pong') }),
z.object({ type: z.literal('status'), data: AgentStatusSchema }),
])Les messages WebSocket de health sont validés avec ce schéma — messages invalides ignorés (le timer d'alive n'est reset qu'après validation réussie).