Merge pull request #276 from trails-cool/chore/zod-4-migration

Upgrade zod from 3 to 4
This commit is contained in:
Ullrich Schäfer 2026-04-19 11:48:30 +02:00 committed by GitHub
commit be2baa455f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 57 additions and 37 deletions

View file

@ -51,14 +51,14 @@ const PersonaSchema = z
for (const loc of p.locales) { for (const loc of p.locales) {
if (!p.content.names[loc]) { if (!p.content.names[loc]) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: "custom",
path: ["content", "names", loc], path: ["content", "names", loc],
message: `missing name pool for declared locale '${loc}'`, message: `missing name pool for declared locale '${loc}'`,
}); });
} }
if (!p.content.descriptions[loc]) { if (!p.content.descriptions[loc]) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: "custom",
path: ["content", "descriptions", loc], path: ["content", "descriptions", loc],
message: `missing description pool for declared locale '${loc}'`, message: `missing description pool for declared locale '${loc}'`,
}); });

View file

@ -5,6 +5,7 @@ import {
PaginationQuerySchema, PaginationQuerySchema,
CreateActivityRequestSchema, CreateActivityRequestSchema,
ERROR_CODES, ERROR_CODES,
zodIssuesToFieldErrors,
} from "@trails-cool/api"; } from "@trails-cool/api";
/** GET /api/v1/activities — paginated activity list */ /** GET /api/v1/activities — paginated activity list */
@ -59,7 +60,7 @@ export async function action({ request }: Route.ActionArgs) {
const parsed = CreateActivityRequestSchema.safeParse(body); const parsed = CreateActivityRequestSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", 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, { const id = await createActivity(user.id, {

View file

@ -1,7 +1,7 @@
import type { Route } from "./+types/api.v1.routes.$id"; import type { Route } from "./+types/api.v1.routes.$id";
import { requireApiUser, apiError } from "~/lib/api-guard.server"; import { requireApiUser, apiError } from "~/lib/api-guard.server";
import { getRouteWithVersions, updateRoute, deleteRoute } from "~/lib/routes.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 */ /** GET /api/v1/routes/:id — full route detail */
export async function loader({ request, params }: Route.LoaderArgs) { 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); const parsed = UpdateRouteRequestSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", 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); await updateRoute(params.id, user.id, parsed.data);

View file

@ -5,6 +5,7 @@ import {
PaginationQuerySchema, PaginationQuerySchema,
CreateRouteRequestSchema, CreateRouteRequestSchema,
ERROR_CODES, ERROR_CODES,
zodIssuesToFieldErrors,
} from "@trails-cool/api"; } from "@trails-cool/api";
/** GET /api/v1/routes — paginated route list */ /** GET /api/v1/routes — paginated route list */
@ -58,7 +59,7 @@ export async function action({ request }: Route.ActionArgs) {
const parsed = CreateRouteRequestSchema.safeParse(body); const parsed = CreateRouteRequestSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", 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); const id = await createRoute(user.id, parsed.data);

View file

@ -1,6 +1,6 @@
import type { Route } from "./+types/api.v1.routes.compute"; import type { Route } from "./+types/api.v1.routes.compute";
import { requireApiUser, apiError } from "~/lib/api-guard.server"; 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"; 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); const parsed = ComputeRouteRequestSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", 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 { try {

View file

@ -1,6 +1,6 @@
import type { Route } from "./+types/api.v1.uploads"; import type { Route } from "./+types/api.v1.uploads";
import { requireApiUser, apiError } from "~/lib/api-guard.server"; 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"; import { randomUUID } from "node:crypto";
const S3_ENDPOINT = process.env.S3_ENDPOINT ?? "http://localhost:3902"; 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); const parsed = PresignedUploadRequestSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return apiError(400, ERROR_CODES.VALIDATION_ERROR, "Validation failed", 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; const { filename, resourceType, resourceId } = parsed.data;

View file

@ -36,7 +36,7 @@
"react": "catalog:", "react": "catalog:",
"react-dom": "catalog:", "react-dom": "catalog:",
"react-router": "catalog:", "react-router": "catalog:",
"zod": "^3.25.0" "zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@react-router/dev": "catalog:", "@react-router/dev": "catalog:",

View file

@ -66,7 +66,7 @@
"react-native-safe-area-context": "~5.6.2", "react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.23.0", "react-native-screens": "~4.23.0",
"use-latest-callback": "^0.3.3", "use-latest-callback": "^0.3.3",
"zod": "^3.25.76" "zod": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/react-native": "^13.3.3", "@testing-library/react-native": "^13.3.3",

View file

@ -10,6 +10,6 @@
"typecheck": "tsc" "typecheck": "tsc"
}, },
"dependencies": { "dependencies": {
"zod": "^3.25.0" "zod": "^4.0.0"
} }
} }

View file

@ -2,24 +2,24 @@ import { z } from "zod";
/** Activity summary for list views */ /** Activity summary for list views */
export const ActivitySummarySchema = z.object({ export const ActivitySummarySchema = z.object({
id: z.string().uuid(), id: z.uuid(),
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
routeId: z.string().uuid().nullable(), routeId: z.uuid().nullable(),
routeName: z.string().nullable(), routeName: z.string().nullable(),
distance: z.number().nullable(), distance: z.number().nullable(),
duration: z.number().nullable(), duration: z.number().nullable(),
elevationGain: z.number().nullable(), elevationGain: z.number().nullable(),
elevationLoss: z.number().nullable(), elevationLoss: z.number().nullable(),
startedAt: z.string().datetime().nullable(), startedAt: z.iso.datetime().nullable(),
geojson: z.string().nullable(), geojson: z.string().nullable(),
createdAt: z.string().datetime(), createdAt: z.iso.datetime(),
}); });
/** Full activity detail */ /** Full activity detail */
export const ActivityDetailSchema = ActivitySummarySchema.extend({ export const ActivityDetailSchema = ActivitySummarySchema.extend({
gpx: z.string().nullable(), gpx: z.string().nullable(),
photos: z.array(z.string().url()), photos: z.array(z.url()),
}); });
/** Paginated activity list response */ /** Paginated activity list response */
@ -33,8 +33,8 @@ export const CreateActivityRequestSchema = z.object({
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
description: z.string().max(5000).default(""), description: z.string().max(5000).default(""),
gpx: z.string().optional(), gpx: z.string().optional(),
routeId: z.string().uuid().optional(), routeId: z.uuid().optional(),
startedAt: z.string().datetime().optional(), startedAt: z.iso.datetime().optional(),
duration: z.number().optional(), duration: z.number().optional(),
distance: z.number().optional(), distance: z.number().optional(),
}); });

View file

@ -20,8 +20,8 @@ export const TokenResponseSchema = z.object({
export const DeviceSchema = z.object({ export const DeviceSchema = z.object({
id: z.string(), id: z.string(),
deviceName: z.string().nullable(), deviceName: z.string().nullable(),
lastActiveAt: z.string().datetime(), lastActiveAt: z.iso.datetime(),
createdAt: z.string().datetime(), createdAt: z.iso.datetime(),
isCurrent: z.boolean(), isCurrent: z.boolean(),
}); });

View file

@ -3,8 +3,8 @@ import { z } from "zod";
export const DiscoveryResponseSchema = z.object({ export const DiscoveryResponseSchema = z.object({
apiVersion: z.string(), apiVersion: z.string(),
instanceName: z.string(), instanceName: z.string(),
apiBaseUrl: z.string().url(), apiBaseUrl: z.url(),
tileUrl: z.string().url().optional(), tileUrl: z.url().optional(),
}); });
export type DiscoveryResponse = z.infer<typeof DiscoveryResponseSchema>; export type DiscoveryResponse = z.infer<typeof DiscoveryResponseSchema>;

View file

@ -14,6 +14,18 @@ export const ApiErrorResponseSchema = z.object({
export type FieldError = z.infer<typeof FieldErrorSchema>; export type FieldError = z.infer<typeof FieldErrorSchema>;
export type ApiErrorResponse = z.infer<typeof ApiErrorResponseSchema>; export type ApiErrorResponse = z.infer<typeof ApiErrorResponseSchema>;
/**
* 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 */ /** Standard error codes */
export const ERROR_CODES = { export const ERROR_CODES = {
VALIDATION_ERROR: "VALIDATION_ERROR", VALIDATION_ERROR: "VALIDATION_ERROR",

View file

@ -4,6 +4,7 @@ export { ENDPOINTS } from "./endpoints.ts";
// Error schemas // Error schemas
export { export {
ApiErrorResponseSchema, FieldErrorSchema, ERROR_CODES, ApiErrorResponseSchema, FieldErrorSchema, ERROR_CODES,
zodIssuesToFieldErrors,
type ApiErrorResponse, type FieldError, type ApiErrorResponse, type FieldError,
} from "./errors.ts"; } from "./errors.ts";

View file

@ -2,7 +2,7 @@ import { z } from "zod";
/** Route summary for list views */ /** Route summary for list views */
export const RouteSummarySchema = z.object({ export const RouteSummarySchema = z.object({
id: z.string().uuid(), id: z.uuid(),
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
distance: z.number().nullable(), distance: z.number().nullable(),
@ -11,15 +11,15 @@ export const RouteSummarySchema = z.object({
routingProfile: z.string().nullable(), routingProfile: z.string().nullable(),
dayBreaks: z.array(z.number()), dayBreaks: z.array(z.number()),
geojson: z.string().nullable(), geojson: z.string().nullable(),
createdAt: z.string().datetime(), createdAt: z.iso.datetime(),
updatedAt: z.string().datetime(), updatedAt: z.iso.datetime(),
}); });
/** Route version info */ /** Route version info */
export const RouteVersionSchema = z.object({ export const RouteVersionSchema = z.object({
version: z.number(), version: z.number(),
changeDescription: z.string().nullable(), changeDescription: z.string().nullable(),
createdAt: z.string().datetime(), createdAt: z.iso.datetime(),
}); });
/** Full route detail */ /** Full route detail */

View file

@ -4,13 +4,13 @@ export const PresignedUploadRequestSchema = z.object({
filename: z.string(), filename: z.string(),
contentType: z.string(), contentType: z.string(),
resourceType: z.enum(["route", "activity"]), resourceType: z.enum(["route", "activity"]),
resourceId: z.string().uuid(), resourceId: z.uuid(),
}); });
export const PresignedUploadResponseSchema = z.object({ export const PresignedUploadResponseSchema = z.object({
uploadUrl: z.string().url(), uploadUrl: z.url(),
storageKey: z.string(), storageKey: z.string(),
expiresAt: z.string().datetime(), expiresAt: z.iso.datetime(),
}); });
export type PresignedUploadRequest = z.infer<typeof PresignedUploadRequestSchema>; export type PresignedUploadRequest = z.infer<typeof PresignedUploadRequestSchema>;

17
pnpm-lock.yaml generated
View file

@ -267,8 +267,8 @@ importers:
specifier: 'catalog:' specifier: 'catalog:'
version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) version: 7.14.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
zod: zod:
specifier: ^3.25.0 specifier: ^4.0.0
version: 3.25.76 version: 4.3.6
devDependencies: devDependencies:
'@react-router/dev': '@react-router/dev':
specifier: 'catalog:' specifier: 'catalog:'
@ -418,8 +418,8 @@ importers:
specifier: ^0.3.3 specifier: ^0.3.3
version: 0.3.3(react@19.2.5) version: 0.3.3(react@19.2.5)
zod: zod:
specifier: ^3.25.76 specifier: ^4.0.0
version: 3.25.76 version: 4.3.6
devDependencies: devDependencies:
'@testing-library/react-native': '@testing-library/react-native':
specifier: ^13.3.3 specifier: ^13.3.3
@ -576,8 +576,8 @@ importers:
packages/api: packages/api:
dependencies: dependencies:
zod: zod:
specifier: ^3.25.0 specifier: ^4.0.0
version: 3.25.76 version: 4.3.6
packages/db: packages/db:
dependencies: dependencies:
@ -7671,6 +7671,9 @@ packages:
zod@3.25.76: zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
snapshots: snapshots:
'@adobe/css-tools@4.4.4': {} '@adobe/css-tools@4.4.4': {}
@ -15661,3 +15664,5 @@ snapshots:
yocto-queue@0.1.0: {} yocto-queue@0.1.0: {}
zod@3.25.76: {} zod@3.25.76: {}
zod@4.3.6: {}