From 0eea6f2047cab2ce9807ee6b61bf98b78434e155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 19 Apr 2026 11:35:39 +0200 Subject: [PATCH 1/3] Upgrade zod from 3 to 4 Mechanical bump across the three packages that pull zod directly: - packages/api (8 schema files, ~300 lines) - apps/journal (persona schema in demo-bot.server.ts, 5 API route handlers that introspect parsed.error.issues) - apps/mobile (declared dep; no active imports yet) No code changes required. The legacy methods we use (.url(), .uuid(), .datetime() on strings) still parse identically in v4 via the compat shim, and z.ZodIssueCode.custom retains its runtime value. Error-issue introspection (parsed.error.issues.map(i => ...)) keeps the same shape at the fields we read (path, message, code). All 147 tests across journal + api + packages pass against zod 4.3.6. Follow-ups out of scope for this PR: - Modernise to top-level validator helpers (z.url(), z.iso.datetime()). The legacy string methods are deprecated but not removed. - Consider migrating error introspection to the richer v4 shape (.path is now branded, .input is new) if we ever want structured error surfaces. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/journal/package.json | 2 +- apps/mobile/package.json | 2 +- packages/api/package.json | 2 +- pnpm-lock.yaml | 17 +++++++++++------ 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/journal/package.json b/apps/journal/package.json index 62c0ca7..b036f5b 100644 --- a/apps/journal/package.json +++ b/apps/journal/package.json @@ -36,7 +36,7 @@ "react": "catalog:", "react-dom": "catalog:", "react-router": "catalog:", - "zod": "^3.25.0" + "zod": "^4.0.0" }, "devDependencies": { "@react-router/dev": "catalog:", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a3b13dc..4bd2597 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -66,7 +66,7 @@ "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.23.0", "use-latest-callback": "^0.3.3", - "zod": "^3.25.76" + "zod": "^4.0.0" }, "devDependencies": { "@testing-library/react-native": "^13.3.3", diff --git a/packages/api/package.json b/packages/api/package.json index 2e8b628..1c5432e 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -10,6 +10,6 @@ "typecheck": "tsc" }, "dependencies": { - "zod": "^3.25.0" + "zod": "^4.0.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c61e98a..3cd855d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,8 +267,8 @@ importers: specifier: 'catalog:' version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) zod: - specifier: ^3.25.0 - version: 3.25.76 + specifier: ^4.0.0 + version: 4.3.6 devDependencies: '@react-router/dev': specifier: 'catalog:' @@ -418,8 +418,8 @@ importers: specifier: ^0.3.3 version: 0.3.3(react@19.2.5) zod: - specifier: ^3.25.76 - version: 3.25.76 + specifier: ^4.0.0 + version: 4.3.6 devDependencies: '@testing-library/react-native': specifier: ^13.3.3 @@ -576,8 +576,8 @@ importers: packages/api: dependencies: zod: - specifier: ^3.25.0 - version: 3.25.76 + specifier: ^4.0.0 + version: 4.3.6 packages/db: dependencies: @@ -7671,6 +7671,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + snapshots: '@adobe/css-tools@4.4.4': {} @@ -15661,3 +15664,5 @@ snapshots: yocto-queue@0.1.0: {} zod@3.25.76: {} + + zod@4.3.6: {} From 079c3de90c53f6394aeb2351850ab424dc7e7714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 19 Apr 2026 11:41:03 +0200 Subject: [PATCH 2/3] Modernise zod idioms to v4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the deprecated v3 string-method forms with the top-level validator helpers zod 4 prefers: - z.string().url() → z.url() (3 sites) - z.string().uuid() → z.uuid() (5 sites) - z.string().datetime() → z.iso.datetime() (9 sites) Also swap the ZodIssueCode.custom compat-shim reference in demo-bot.server.ts's superRefine for the bare string literal "custom", which is v4's idiomatic form. No runtime change. All 147 tests still pass. The `parsed.error.issues.map(i => ({field, message}))` pattern in the five journal API route handlers stays as-is — the fields we read are unchanged in v4, and zod's new error helpers (z.treeifyError, z.prettifyError) produce different shapes that would change our public API error contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/journal/app/lib/demo-bot.server.ts | 4 ++-- packages/api/src/activities.ts | 14 +++++++------- packages/api/src/auth.ts | 4 ++-- packages/api/src/discovery.ts | 4 ++-- packages/api/src/routes.ts | 8 ++++---- packages/api/src/uploads.ts | 6 +++--- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/apps/journal/app/lib/demo-bot.server.ts b/apps/journal/app/lib/demo-bot.server.ts index 9b22296..8fa1cf6 100644 --- a/apps/journal/app/lib/demo-bot.server.ts +++ b/apps/journal/app/lib/demo-bot.server.ts @@ -51,14 +51,14 @@ const PersonaSchema = z for (const loc of p.locales) { if (!p.content.names[loc]) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", path: ["content", "names", loc], message: `missing name pool for declared locale '${loc}'`, }); } if (!p.content.descriptions[loc]) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", path: ["content", "descriptions", loc], message: `missing description pool for declared locale '${loc}'`, }); diff --git a/packages/api/src/activities.ts b/packages/api/src/activities.ts index 479fbc6..e4f2420 100644 --- a/packages/api/src/activities.ts +++ b/packages/api/src/activities.ts @@ -2,24 +2,24 @@ import { z } from "zod"; /** Activity summary for list views */ export const ActivitySummarySchema = z.object({ - id: z.string().uuid(), + id: z.uuid(), name: z.string(), description: z.string(), - routeId: z.string().uuid().nullable(), + routeId: z.uuid().nullable(), routeName: z.string().nullable(), distance: z.number().nullable(), duration: z.number().nullable(), elevationGain: z.number().nullable(), elevationLoss: z.number().nullable(), - startedAt: z.string().datetime().nullable(), + startedAt: z.iso.datetime().nullable(), geojson: z.string().nullable(), - createdAt: z.string().datetime(), + createdAt: z.iso.datetime(), }); /** Full activity detail */ export const ActivityDetailSchema = ActivitySummarySchema.extend({ gpx: z.string().nullable(), - photos: z.array(z.string().url()), + photos: z.array(z.url()), }); /** Paginated activity list response */ @@ -33,8 +33,8 @@ export const CreateActivityRequestSchema = z.object({ name: z.string().min(1).max(200), description: z.string().max(5000).default(""), gpx: z.string().optional(), - routeId: z.string().uuid().optional(), - startedAt: z.string().datetime().optional(), + routeId: z.uuid().optional(), + startedAt: z.iso.datetime().optional(), duration: z.number().optional(), distance: z.number().optional(), }); diff --git a/packages/api/src/auth.ts b/packages/api/src/auth.ts index 8440738..3f556f1 100644 --- a/packages/api/src/auth.ts +++ b/packages/api/src/auth.ts @@ -20,8 +20,8 @@ export const TokenResponseSchema = z.object({ export const DeviceSchema = z.object({ id: z.string(), deviceName: z.string().nullable(), - lastActiveAt: z.string().datetime(), - createdAt: z.string().datetime(), + lastActiveAt: z.iso.datetime(), + createdAt: z.iso.datetime(), isCurrent: z.boolean(), }); diff --git a/packages/api/src/discovery.ts b/packages/api/src/discovery.ts index 9680a9a..2f1ff2a 100644 --- a/packages/api/src/discovery.ts +++ b/packages/api/src/discovery.ts @@ -3,8 +3,8 @@ import { z } from "zod"; export const DiscoveryResponseSchema = z.object({ apiVersion: z.string(), instanceName: z.string(), - apiBaseUrl: z.string().url(), - tileUrl: z.string().url().optional(), + apiBaseUrl: z.url(), + tileUrl: z.url().optional(), }); export type DiscoveryResponse = z.infer; diff --git a/packages/api/src/routes.ts b/packages/api/src/routes.ts index b9e64e5..dc83071 100644 --- a/packages/api/src/routes.ts +++ b/packages/api/src/routes.ts @@ -2,7 +2,7 @@ import { z } from "zod"; /** Route summary for list views */ export const RouteSummarySchema = z.object({ - id: z.string().uuid(), + id: z.uuid(), name: z.string(), description: z.string(), distance: z.number().nullable(), @@ -11,15 +11,15 @@ export const RouteSummarySchema = z.object({ routingProfile: z.string().nullable(), dayBreaks: z.array(z.number()), geojson: z.string().nullable(), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), }); /** Route version info */ export const RouteVersionSchema = z.object({ version: z.number(), changeDescription: z.string().nullable(), - createdAt: z.string().datetime(), + createdAt: z.iso.datetime(), }); /** Full route detail */ diff --git a/packages/api/src/uploads.ts b/packages/api/src/uploads.ts index 98f8927..37da2f1 100644 --- a/packages/api/src/uploads.ts +++ b/packages/api/src/uploads.ts @@ -4,13 +4,13 @@ export const PresignedUploadRequestSchema = z.object({ filename: z.string(), contentType: z.string(), resourceType: z.enum(["route", "activity"]), - resourceId: z.string().uuid(), + resourceId: z.uuid(), }); export const PresignedUploadResponseSchema = z.object({ - uploadUrl: z.string().url(), + uploadUrl: z.url(), storageKey: z.string(), - expiresAt: z.string().datetime(), + expiresAt: z.iso.datetime(), }); export type PresignedUploadRequest = z.infer; From edd2cabf123d1cde0a155589784202a2fa8f4b0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sun, 19 Apr 2026 11:45:09 +0200 Subject: [PATCH 3/3] =?UTF-8?q?DRY=20up=20ZodError=20=E2=86=92=20FieldErro?= =?UTF-8?q?r=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five journal API route handlers had the same inline lambda: parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message, })) Pull it into `zodIssuesToFieldErrors(error)` next to FieldError in `@trails-cool/api/errors.ts` and re-export it so all five handlers share the same implementation. The path-flattening rule now lives in one place, and new routes get it for free. Public error-response shape is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/journal/app/routes/api.v1.activities._index.ts | 3 ++- apps/journal/app/routes/api.v1.routes.$id.ts | 4 ++-- apps/journal/app/routes/api.v1.routes._index.ts | 3 ++- apps/journal/app/routes/api.v1.routes.compute.ts | 4 ++-- apps/journal/app/routes/api.v1.uploads.ts | 4 ++-- packages/api/src/errors.ts | 12 ++++++++++++ packages/api/src/index.ts | 1 + 7 files changed, 23 insertions(+), 8 deletions(-) diff --git a/apps/journal/app/routes/api.v1.activities._index.ts b/apps/journal/app/routes/api.v1.activities._index.ts index d9545b1..a323e88 100644 --- a/apps/journal/app/routes/api.v1.activities._index.ts +++ b/apps/journal/app/routes/api.v1.activities._index.ts @@ -5,6 +5,7 @@ import { PaginationQuerySchema, CreateActivityRequestSchema, ERROR_CODES, + zodIssuesToFieldErrors, } from "@trails-cool/api"; /** GET /api/v1/activities — paginated activity list */ @@ -59,7 +60,7 @@ export async function action({ request }: Route.ActionArgs) { const parsed = CreateActivityRequestSchema.safeParse(body); if (!parsed.success) { return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", - parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message }))); + zodIssuesToFieldErrors(parsed.error)); } const id = await createActivity(user.id, { diff --git a/apps/journal/app/routes/api.v1.routes.$id.ts b/apps/journal/app/routes/api.v1.routes.$id.ts index 8458312..86e3690 100644 --- a/apps/journal/app/routes/api.v1.routes.$id.ts +++ b/apps/journal/app/routes/api.v1.routes.$id.ts @@ -1,7 +1,7 @@ import type { Route } from "./+types/api.v1.routes.$id"; import { requireApiUser, apiError } from "~/lib/api-guard.server"; import { getRouteWithVersions, updateRoute, deleteRoute } from "~/lib/routes.server"; -import { UpdateRouteRequestSchema, ERROR_CODES } from "@trails-cool/api"; +import { UpdateRouteRequestSchema, ERROR_CODES, zodIssuesToFieldErrors } from "@trails-cool/api"; /** GET /api/v1/routes/:id — full route detail */ export async function loader({ request, params }: Route.LoaderArgs) { @@ -45,7 +45,7 @@ export async function action({ request, params }: Route.ActionArgs) { const parsed = UpdateRouteRequestSchema.safeParse(body); if (!parsed.success) { return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", - parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message }))); + zodIssuesToFieldErrors(parsed.error)); } await updateRoute(params.id, user.id, parsed.data); diff --git a/apps/journal/app/routes/api.v1.routes._index.ts b/apps/journal/app/routes/api.v1.routes._index.ts index 8ce9821..5f72767 100644 --- a/apps/journal/app/routes/api.v1.routes._index.ts +++ b/apps/journal/app/routes/api.v1.routes._index.ts @@ -5,6 +5,7 @@ import { PaginationQuerySchema, CreateRouteRequestSchema, ERROR_CODES, + zodIssuesToFieldErrors, } from "@trails-cool/api"; /** GET /api/v1/routes — paginated route list */ @@ -58,7 +59,7 @@ export async function action({ request }: Route.ActionArgs) { const parsed = CreateRouteRequestSchema.safeParse(body); if (!parsed.success) { return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", - parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message }))); + zodIssuesToFieldErrors(parsed.error)); } const id = await createRoute(user.id, parsed.data); diff --git a/apps/journal/app/routes/api.v1.routes.compute.ts b/apps/journal/app/routes/api.v1.routes.compute.ts index 077d733..cd111ff 100644 --- a/apps/journal/app/routes/api.v1.routes.compute.ts +++ b/apps/journal/app/routes/api.v1.routes.compute.ts @@ -1,6 +1,6 @@ import type { Route } from "./+types/api.v1.routes.compute"; import { requireApiUser, apiError } from "~/lib/api-guard.server"; -import { ComputeRouteRequestSchema, ERROR_CODES } from "@trails-cool/api"; +import { ComputeRouteRequestSchema, ERROR_CODES, zodIssuesToFieldErrors } from "@trails-cool/api"; const PLANNER_URL = process.env.PLANNER_URL ?? "http://localhost:3001"; @@ -13,7 +13,7 @@ export async function action({ request }: Route.ActionArgs) { const parsed = ComputeRouteRequestSchema.safeParse(body); if (!parsed.success) { return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", - parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message }))); + zodIssuesToFieldErrors(parsed.error)); } try { diff --git a/apps/journal/app/routes/api.v1.uploads.ts b/apps/journal/app/routes/api.v1.uploads.ts index 83a18da..4998bdc 100644 --- a/apps/journal/app/routes/api.v1.uploads.ts +++ b/apps/journal/app/routes/api.v1.uploads.ts @@ -1,6 +1,6 @@ import type { Route } from "./+types/api.v1.uploads"; import { requireApiUser, apiError } from "~/lib/api-guard.server"; -import { PresignedUploadRequestSchema, ERROR_CODES } from "@trails-cool/api"; +import { PresignedUploadRequestSchema, ERROR_CODES, zodIssuesToFieldErrors } from "@trails-cool/api"; import { randomUUID } from "node:crypto"; const S3_ENDPOINT = process.env.S3_ENDPOINT ?? "http://localhost:3902"; @@ -16,7 +16,7 @@ export async function action({ request }: Route.ActionArgs) { const parsed = PresignedUploadRequestSchema.safeParse(body); if (!parsed.success) { return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", - parsed.error.issues.map((i) => ({ field: i.path.join("."), message: i.message }))); + zodIssuesToFieldErrors(parsed.error)); } const { filename, resourceType, resourceId } = parsed.data; diff --git a/packages/api/src/errors.ts b/packages/api/src/errors.ts index 7d7fb88..381dd95 100644 --- a/packages/api/src/errors.ts +++ b/packages/api/src/errors.ts @@ -14,6 +14,18 @@ export const ApiErrorResponseSchema = z.object({ export type FieldError = z.infer; export type ApiErrorResponse = z.infer; +/** + * Map a ZodError's issues to the FieldError[] shape used in API + * validation responses. Keeps the path-flattening rule in one place so + * every route returns validation errors in the same shape. + */ +export function zodIssuesToFieldErrors(error: z.ZodError): FieldError[] { + return error.issues.map((i) => ({ + field: i.path.join("."), + message: i.message, + })); +} + /** Standard error codes */ export const ERROR_CODES = { VALIDATION_ERROR: "VALIDATION_ERROR", diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 8f7e100..a6e14a6 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -4,6 +4,7 @@ export { ENDPOINTS } from "./endpoints.ts"; // Error schemas export { ApiErrorResponseSchema, FieldErrorSchema, ERROR_CODES, + zodIssuesToFieldErrors, type ApiErrorResponse, type FieldError, } from "./errors.ts";