Skip to content

💡 [REQUEST] - Migrer apps/server-nestjs vers Zod 4 (catalog isolé, RC @ts-rest 3.53) #2558

Open

Description

@shikanime

Description

Migrer le dépôt cloud-pi-native/console vers Zod 4 (actuellement zod@^3.25.76 via le catalog runtime), cible prioritaire apps/server-nestjs.

Problème : ce n'est pas un correctif de sécurité (Zod 3 reste maintenu, séries 3.25.x). Modernisation optionnelle, basse urgence. Le vrai verrou est la topologie des dépendances : server-nestjs compose des schémas Zod issus de @cpn-console/shared (zod 3) et @cpn-console/hooks (zod 3) — pas seulement il les valide.

Spécifications techniques

Constats vérifiés (commentaires de l'issue + expérimentation en workspace isolé zod4) :

  • zod = catalog partagé (catalogs.runtime, pnpm-workspace.yaml:118, catalogMode: strict). apps/server-nestjs compose des schémas Zod de @cpn-console/shared (ex. ProjectSchemaV2.omit().extend() dans project.utils.ts) et de @cpn-console/hooks (ex. editStrippersGenerator dans project-services.utils.ts), pas seulement il les passe au ZodValidationPipe.
  • ZodValidationPipe (zod-validation.pipe.ts) n'importe zod qu'en import type { ZodSchema } (effacé au runtime) et appelle schema.safeParse(...). → Le pipe seul est agnostique de version. Mais ce n'est pas le seul point de contact : les sites de composition (.extend(), .merge()) croisent réellement les deux majorités.
  • @ts-rest : core/fastify en RC 3.53.0-rc.1 ont supprimé le peer zod, mais @ts-rest/open-api@3.53.0-rc.1 garde peerDependencies.zod: "^3.22.3". open-api importé uniquement par apps/server/src (figé). → @ts-rest a été retiré de server-nestjs (0 import source) lors de l'expérimentation ; ce n'était pas le vrai verrou.

Résultat de l'expérimentation (workspace zod4, catalog zod4: { zod: ^4.4.3 } isolé sur server-nestjs)

  • Build pnpm build : VERT (0 erreur) après corrections mécaniques (catalog isolé, drop @ts-rest, pipe version-agnostique, retrait de ZodTypeDef, base.config .default(0), csv en .transform()).
  • Tests pnpm test : 572 passés / 2 échoués → en isolation 1 fail : project-services.service.spec.ts > servicesService > update stores project configuration through the real utility.
  • Cause racine (runtime) : @cpn-console/hooks est en zod 3.25.76. buildProjectEditStrippers (project-services.utils.ts:39-41) fait global = global.merge(z.object({ [service.name]: editZod.global.default({}) })) où editZod provient de editStrippersGenerator.parse(service.config) (hooks, zod-3). Zod 4 normalizeDef rejette le schéma zod-3 embarqué → Error: Invalid element at key "...": expected a Zod schema.

→ Path A REFUTÉ empiriquement : le build compile (l'alias ZodType de zod 4 est assez laxiste pour le typecheck), mais la frontière dual-major se manifeste AU RUNTIME. Isoler zod sur server-nestjs ne suffit pas : les schémas de shared/hooks (zod 3) y sont composés, pas seulement validés.

Chemins évalués

  • Path A — catalog isolé server-nestjs (zod 4) : ❌ NON viable. Build vert mais crash runtime via buildProjectEditStrippers / editStrippersGenerator (@cpn-console/hooks zod 3). Le pipe étant version-agnostique ne suffit pas ; les sites de composition croisent les deux majorités.
  • Path B — bump workspace-wide (shared + hooks + server-nestjs, figer apps/server) : seul chemin viable si migration, mais blast-radius maximal (doit migrer shared + hooks + server-nestjs ensemble, et résoudre le peer zod@^3 de @ts-rest/open-api consommé par apps/server figé).
  • Path C — rester sur Zod 3 : aucun coût (série 3.25.x maintenue). Recommandé tant que la migration workspace-wide n'est pas planifiée.

Définition du fini

  • Recenser la douleur (commentaire PAIN) : basse urgence, contrat client + zod-validation-error@5 (si partagé) + audit enum
  • Recenser le chemin (commentaire PATH) + rectification initiale : Path A cru viable (pipe version-agnostique)
  • REFUTATION Path A (empirique) : build vert + 1 fail runtime — dual-major au runtime via buildProjectEditStrippers / editStrippersGenerator (@cpn-console/hooks zod 3.25.76)
  • Décider : Path B (migration workspace-wide shared+hooks+server-nestjs, coordonnée) OU Path C (rester Zod 3)
  • Si Path B : planifier la migration coordonnée shared→zod4, hooks→zod4, puis server-nestjs ; résoudre le peer @ts-rest/open-api@^3 (figer apps/server ou attendre @ts-rest stable sans peer zod-3)
  • Si Path B : tsc/pnpm build verts + pnpm test verts sur l'ensemble des paquets migrés

Activity

  1. shikanime commented on Aug 24, 2026

    @shikanime
    MemberAuthor

    Recherche — chemin de migration (Path A écarté)

    Fait vérifié décisif (non couvert par l'RC @ts-rest)

    apps/server-nestjs importe des schémas zod depuis @cpn-console/shared et les valide via son propre ZodValidationPipe :

    apps/server-nestjs/src/modules/repository/repository.controller.ts:6
      import { CreateRepositorySchema, SyncRepositorySchema, UpdateRepositorySchema } from '@cpn-console/shared'
    apps/server-nestjs/src/modules/repository/repository.controller.ts:52
      @Body(new ZodValidationPipe(CreateRepositorySchema)) repositoryToCreate: CreateRepository
    

    CreateRepositorySchema est donc compilé en zod 3 (dans packages/shared) puis validé par un pipe zod 4 (dans apps/server-nestjs).
    Conséquence : un catalog isolé apps/server-nestjs (Path A) mettrait deux versions majeures de zod dans un même runtime → incompatibilité de type à la compilation (le ZodSchema zod-4 n'accepte pas un export zod-3) et frontière d'instances zod dupliquées. Path A est écarté.

    pnpm multi-catalog : confirmé mais insuffisant ici

    pnpm-workspace.yaml supporte bien plusieurs catalogs nommés (catalog:name), et la doc cite explicitement le cas « repo multi-package migrant une dépendance par morceaux ».
    Mais comme shared et server-nestjs partagent des schémas zod à l'exécution, ils doivent monter ensemble en zod 4. Un catalog isolé ne résout pas la dépendance croisée.

    Chemin retenu : workspace-wide (Path B), bloqué sur @ts-rest/open-api

    1. Bump zod -> ^4 et zod-validation-error -> ^5.0.0 dans le catalog runtime (les deux ensemble, car packages/shared utilise fromZodError).
    2. @ts-rest/core + @ts-rest/fastify -> 3.53.0-rc.1 (peer zod supprimé, Standard Schema).
    3. Blocage : @ts-rest/open-api@3.53.0-rc.1 garde peerDependencies.zod: "^3.22.3". Il est importé uniquement par apps/server/src (serveur Fastify historique, figé) et déclaré-mais-inutilisé dans apps/server-nestjs. Un bump workspace-wide viole ce peer tant que apps/server reste sur la branche zod-3.

    Sources officielles

    Décision proposée

    La migration n'est faisable proprement que quand @ts-rest/open-api (ou la stable 3.53) supprime son peer zod-3, OU si apps/server est retiré du workspace zod-4. Jusque-là : blocker documenté, pas de bump.

  2. shikanime commented on Aug 24, 2026

    @shikanime
    MemberAuthor

    Recherche — la douleur (pain) du passage Zod 3 → 4

    Sources : guide de migration officiel zod https://zod.dev/v4/changelog (extrait et vérifié). Tous les points ci-dessous sont des breaking changes réels de zod 4, projetés sur les sites d'appel du repo.

    1. Coût de RESTER en Zod 3 : aucun (point crucial)

    Zod 3 est toujours maintenu (séries 3.25.x actives). Le passage en 4 n'est pas un correctif de sécurité : c'est une modernisation optionnelle (parsing ~15x plus rapide, ~100x moins d'instantiations tsc, bundle réduit). Aucune urgence à imposer au backlog.

    2. Breaking changes qui touchent CE repo (si on migre)

    • ZodError.message a changé : avant = JSON.stringify(issues), maintenant = message lisible. Impact direct sur packages/shared/src/utils/schemas.ts (parseZodError = fromZodError(zodError).toString()). Nécessite zod-validation-error@5 (compatible zod 4) — à bump synchrone avec zod.
    • error.flatten() : forme de sortie modifiée (zod-validation.pipe.ts:12 → throw new BadRequestException(result.error.flatten())). Le contrat d'erreur de validation envoyé au client change ; le client (apps/client) doit être vérifié pour ne pas casser l'affichage des erreurs de champ.
    • transform() renvoie une instance ZodPipe (et non plus ZodEffects). apps/server-nestjs/src/config/config.utils.ts (flag(), csv()) chaîne .transform/.pipe — fonctionnellement OK, mais tout instanceof ZodEffects (absent ici) casserait.
    • superRefine + z.ZodIssueCode.custom : conservé (config.utils.ts:62,66). Pas de changement requis sur nos ctx.addIssue.
    • errorMap retiré au profit d'un callback error ; invalid_type_error / required_error supprimés. À vérifier (non utilisé directement a priori).
    • z.nativeEnum déprécié → préférer z.enum. À auditer dans packages/shared (schémas d'enums).

    3. Effort de migration (réel)

    • Bump couplé zod@^4 + zod-validation-error@^5 (sinon fromZodError casse).
    • Mise à jour du contrat d'erreur côté client.
    • Audit z.nativeEnum / instanceof ZodEffects / errorMap.
    • Tests : packages/shared/src/utils/schemas.spec.ts:39 (safeParse(undefined).error instanceof ZodError) et :222 (parseZodError) doivent être revalidés sous zod 4.

    Conclusion douleur

    La migration est basse-urgence, coût moyen, confinement maîtrisable (essentiellement zod-validation-error + contrat client + audit enum). Le vrai verrou n'est pas la douleur zod, mais le peer @ts-rest/open-api@zod@^3 (voir commentaire précédent) qui impose un bump workspace-wide.

    Sources officielles

  3. shikanime commented on Aug 24, 2026

    @shikanime
    MemberAuthor

    Rectification — Path A n'est PAS mort (correction du commentaire précédent)

    Erreur dans mon commentaire PATH : j'écrivais qu'un catalog isolé server-nestjs créerait une « frontière d'instances zod dupliquées » et casserait à l'exécution. Faux. Réanalyse de apps/server-nestjs/src/modules/infrastructure/pipe/zod-validation.pipe.ts :

    import type { ZodSchema } from 'zod'   // type-only, effacé au runtime
    export class ZodValidationPipe implements PipeTransform {
      constructor(private readonly schema: ZodSchema) {}
      transform(value: unknown) {
        const result = this.schema.safeParse(value)   // .safeParse appelé SUR le schéma reçu
        if (!result.success) throw new BadRequestException(result.error.flatten())
        return result.data
      }
    }
    • CreateRepositorySchema (zod 3, compilé dans packages/shared) est passé tel quel au pipe. Le pipe invoque le .safeParse propre au schéma → exécution 100% zod 3, sans jamais charger le zod 4 de server-nestjs (qui n'est qu'un import type).
    • Donc aucun double-instance au runtime. Le pipe est agnostique de la version.

    Risque résiduel réel (beaucoup plus étroit)

    1. Compatibilité de TYPE TS : ZodSchema importé en zod 4 (server-nestjs) vs schéma exporté typé zod 3 (shared). Si structurellement incompatible, tsc échoue à la compilation du pipe. À vérifier par un build, pas un crash runtime.
    2. Pin RC : 3.53.0-rc.1 est prérelease ; catalogMode: strict + minimumReleaseAge: 1440 peuvent gêner — pin explicite ou overrides.

    Précision sur Standard Schema

    Le point du commentaire PATH (« @ts-rest/core@3.53 valide les schémas des deux majors via Standard Schema ») est vrai mais orthogonal : il concerne la validation des contrats FAITE PAR @ts-rest, pas notre ZodValidationPipe (qui appelle .safeParse directement). Ne pas le citer comme justification du pipe.

    Verdict corrigé

    • Path A (catalog isolé server-nestjs) = viable, risque réel = compat type TS + pin RC. C'est le chemin de migration à plus faible blast-radius (laisse shared et apps/server intacts).
    • Path B (workspace-wide) = blast-radius maximal (doit migrer shared + apps/server figé qui consomme @ts-rest/open-api zod-3).
    • Le vrai verrou n'est plus « Path A impossible » mais « faut-il accepter un RC prérelease en backend ».

    Action de vérification restante

    Build server-nestjs avec zod 4 catalog isolé + schémas zod-3 de shared passés au pipe → confirmer que tsc accepte le type ZodSchema inter-major. C'est le seul point non résolu.

  4. changed the title [-]💡 [REQUEST] - Migrer vers Zod 4 (blocage @ts-rest peer zod@^3)[/-] [+]💡 [REQUEST] - Migrer apps/server-nestjs vers Zod 4 (catalog isolé, RC @ts-rest 3.53)[/+] on Aug 24, 2026
  5. shikanime commented on Aug 24, 2026

    @shikanime
    MemberAuthor

    🔁 Rectification — Path A REFUTÉ (preuve empirique)

    L'affirmation précédente (« Path A viable, risque = type TS + pin RC ») est infirmée par l'expérimentation en workspace isolé zod4.

    Ce qui a été fait

    • Catalog isolé zod4: { zod: ^4.4.3 } appliqué uniquement à apps/server-nestjs (@cpn-console/shared + apps/server restent zod 3.25.76).
    • Retrait de @ts-rest/* de server-nestjs (0 import source).
    • Corrections mécaniques : pipe version-agnostique, retrait ZodTypeDef, base.config .default(0), csv en .transform().

    Résultat

    • pnpm build → VERT (0 erreur). L'alias ZodType de zod 4 est assez laxiste pour compiler.
    • pnpm test → 572 passés / 1 fail en isolation : project-services.service.spec.ts > servicesService > update stores project configuration through the real utility.

    Cause racine (runtime, pas typecheck)

    Error: Invalid element at key "...": expected a Zod schema
      at normalizeDef (zod@4.4.3 .../core/schemas.js)
      at ProjectServicesService.update (project-services.service.ts:97)
    

    @cpn-console/hooks est en zod 3.25.76. buildProjectEditStrippers (project-services.utils.ts:39-41) compose editStrippersGenerator.parse(service.config) (zod-3) dans z.object({ [service.name]: ... }).merge(...) local zod-4. Zod 4 rejette le schéma zod-3 embarqué au runtime.

    Conclusion

    Isoler zod sur server-nestjs ne suffit pas : le pipe est version-agnostique, mais les sites de composition (.extend(), .merge()) croisent réellement les deux majorités. La frontière dual-major se manifeste au runtime, pas au build.

    Path A ❌ NON viable. Seuls restent :

    • Path B — migration workspace-wide coordonnée (shared+hooks+server-nestjs → zod 4), blast-radius maximal ;
    • Path C — rester sur Zod 3 (série 3.25.x maintenue, aucun coût). Recommandé.

    Le travail d'expérimentation (catalog isolé + corrections mécaniques) est conservé en workspace zod4 mais ne doit pas être mergé tant que la décision Path B / C n'est pas prise.

  6. self-assigned this
    on Aug 26, 2026
  7. added this to the 9.25.0 milestone on Aug 26, 2026
  8. modified the milestones: 9.25.0, 10.0.0 on Sep 1, 2026
  9. added theissue type on Sep 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions