
TREK 的 trek/shared用 Zod 契约包统一前后端 API 类型的单一事实源实战【免费下载链接】TREKA self-hosted travel/trip planner with real-time collaboration, interactive maps, PWA support, SSO, budgets, packing lists, and more.项目地址: https://gitcode.com/GitHub_Trending/nomad22/TREKtrek/shared 是 TREK自托管旅行规划应用中承担“API 契约单一事实源”职责的共享 TypeScript 包所有接口契约都以 Zod schema 定义服务端用它做请求校验并推导 DTO 类型客户端用它获得类型安全的请求/响应。本文基于仓库中 shared/README.md 展开结合包内真实 schema、测试与前后端构建配置讲解这套“契约驱动迁移”方案的目录治理、通用积木、领域契约实例、双端接入链路与打包发布方式帮助读者理解如何在渐进式架构改造褐地重写中用一份 schema 同时约束 Express 旧服务与 React 新客户端。为什么需要共享契约双端类型漂移与“褐地重写”TREK 正在经历一次增量式的技术栈迁移服务端向 NestJS、客户端向 React 19 演进而存量 Express 路由仍然在线。在这种“旧路由与迁移后路由并存”的过渡期最容易出现的问题是同一接口在服务端 DTO 与客户端请求/响应类型之间各自维护、逐渐失同步——服务端改了字段客户端还按旧字段编译通过直到运行时才报错。trek/shared正是为根治这个问题而设计把 API 契约收敛到一个包中服务端与客户端都从这份 Zod schema 推导类型z.infertypeof schema而不是手工复制两份。按照 shared/README.md 的表述该包属于“Brownfield Rewrite褐地重写”计划的一部分当前刻意保持dormant休眠状态在模块开始真正 import 它之前它对用户没有任何行为影响生产构建也完全不受扰动——这保证了迁移可以小步、安全、随时可回退地进行。目录治理规则一域一契约领域无关的进 commontrek/shared的目录约定非常明确README 中给出了四条硬规则每个领域一个文件夹src/domain/domain.schema.ts并配套.spec.ts测试领域无关的通用构件放在src/common/只有契约进入本包路由才算真正“迁移完成”Schema 是事实源服务端 DTO 与客户端类型一律由z.infertypeof schema推导绝不手写重复。从 shared/src/index.ts 的 barrel 导出可以看到这套规则的落地全貌commonprimitives pagination在最前随后是 33 个领域契约——weather、airport、config、system-notice、maps、category、tag、notification、atlas、vacay、packing、todo、budget、reservation含 ki-reservation、airtrail、day、assignment、place、collection、trip、collab、file、journey、share、settings、appearance、backup、auth、oidc、oauth、admin外加 sanitize 清洗工具与 i18n 语言注册表。领域目录与仓库功能模块一一对应如 trip 对应行程、budget 对应预算、collab 对应协作、packing 对应行李清单这为“按模块逐个迁移、逐个验收”提供了清晰的落地边界想迁移哪个路由就把它的契约搬进对应领域文件夹。领域无关的通用积木src/common任何契约都离不开基础类型。src/common/提供了一套领域无关的“积木”被所有领域 schema 复用primitivesID、字符串与时间戳的规范形态shared/src/common/primitives.schema.ts 定义了四类最基础的构件/** TREK uses auto-increment integer primary keys. */ export const idSchema z.number().int().positive(); export type Id z.infertypeof idSchema; /** Numeric id coming from a URL param / query string. */ export const idParamSchema z.coerce.number().int().positive(); /** Non-empty, trimmed string. */ export const nonEmptyString z.string().trim().min(1); /** ISO-8601 timestamp string (the shape TREK serialises dates as in JSON). */ export const isoDateTime z.string().datetime({ offset: true });四个构件的分工值得细品idSchema数据库自增主键的规范形态必须是正整数idParamSchemaURL 参数 / 查询串里传来的 ID 是字符串Express 侧拿到的就是这样因此用z.coerce.number()先强制转数、再校验正整数——这是契约层主动适配 HTTP 语义而非 DB 语义的典型例子nonEmptyStringtrim()后非空避免“全空白字符串”混入数据isoDateTimeTREK 在 JSON 中序列化日期采用带时区偏移的 ISO-8601 字符串datetime({ offset: true })保证这一点在类型层面被钉死。配套测试 shared/src/common/primitives.schema.spec.ts 直接验证了这些边界行为idSchema拒绝0、负数与1.5idParamSchema能把42解析为42、拒绝abcnonEmptyString把 hi 修剪为hiisoDateTime接受2026-05-25T08:38:14Z而拒绝not-a-date。pagination按需启用而非全局强制的分页契约shared/src/common/pagination.schema.ts 提供了一个通用分页查询契约但设计上非常克制export const paginationQuerySchema z.object({ page: z.coerce.number().int().min(1).default(1), perPage: z.coerce.number().int().min(1).max(200).default(50), }); export type PaginationQuery z.infertypeof paginationQuerySchema;关键约束写在注释里它不会被全局套用——TREK 大量列表接口本就返回全集只有那些原本就分页的路由才“按需 opt-in 继承”。默认值也刻意保守page从 1 开始perPage默认 50、上限 200。测试同样覆盖了默认值填充parse({})得到{ page: 1, perPage: 50 }、字符串强转2/10以及越界拒绝perPage: 0与perPage: 999均失败。领域契约实例从 aggregate root 到独立端点“通用积木 一域一契约”的组合在各领域文件中体现得淋漓尽致。以下挑选几个最具代表性的契约展开。trip聚合根路由与 Strangler 前缀策略shared/src/trip/trip.schema.ts 是tripSchema的所在对应/api/trips聚合根端点列表/创建/获取/更新/删除、封面上传、复制、成员管理、离线包、ICS 导出。其中有几处信息量很大的设计Strangler 模式的前缀策略。trip 聚合根与 day、place、collab、file 等子领域共享同一路径前缀/api/trips因此在迁移路由时采用精确前缀/api/trips|、/api/trips/:tripId|加上各子路由的精确前缀绝不用宽泛的/api/trips——否则会吞掉尚未迁移的嵌套挂载点。这是“渐进式替换”在路由注册层的关键工程细节。实体字段与计算字段的区分。tripSchema反映trips表列同时带上TRIP_SELECT计算出的列表字段day_count、place_count、is_owner以 0/1 整数表示、owner_username、shared_count。注释明确is_archived是原始 SQLite INTEGER——即契约忠实反映后端真实数据形态而不是前端期望的理想形态。guest 成员机制#1362。tripMemberSchema中有is_guest字段配套tripCreateGuestRequestSchema/tripRenameGuestRequestSchema描述“无账号参与者”的创建与重命名guest 可被分配任务但永远无法登录。日期平移模式#1288。更新行程时date_shift_mode是枚举[keep_bookings, shift_all]keep_bookings默认下天数计划跟随日期移动而定档的预订与住宿停留在绝对日期上只要仍在行程范围内shift_all则让整个行程含预订整体平移。契约注释把两种语义写得明明白白前端不必再猜后端行为。所有权转移#973。tripTransferOwnershipRequestSchema用newOwnerId: z.number().int().positive()描述“把行程移交给既有成员”。此外创建行程的tripCreateRequestSchema校验title非空而更新契约tripUpdateRequestSchema全部字段可选——配合注释“路由对实际出现的字段做逐字段权限检查”体现了“部分更新 细粒度鉴权”的 REST 实践。auth让契约适配认证服务的真实行为shared/src/auth/auth.schema.ts 覆盖/api/auth下的注册、登录、忘记/重置密码、修改密码、MFA 校验/启用与 MCP token 创建。几个值得注意的点remember_me语义登录请求中的remember_me: z.boolean().optional()决定服务端签发SESSION_DURATION_REMEMBER长时 JWT 持久 cookie还是默认SESSION_DURATION的会话 cookie。MFA 二次校验请求mfaVerifyLoginRequestSchema同样携带remember_me因为会话 token 只有在 MFA 通过后才铸造必须把登录表单的选择传递到第二步。字段命名纠错重置密码请求里客户端发送的是new_password而旧契约误写成password导致客户端类型失效——注释直言这是“misnamed”被修正展示了契约作为事实源如何反过来暴露并修复历史命名问题。校验权归属密码强度、凭证校验由 auth 服务内部完成返回自己的{error, status}因此 schema 层对密码字段保持宽松只钉死“请求体结构”不越权重复实现业务规则。weather忠实镜像遗留 Express 行为的契约shared/src/weather/weather.schema.ts 是一个“契约如何兼容旧实现”的绝佳案例遗留 Express 路由把lat/lng当作不透明字符串服务内部用parseFloat解析且只检查存在性因此查询 schema 刻意用z.string().min(1)而非强转数字——避免在契约层引入旧服务没有的严格性lang默认de与 Express 默认值一致详细的逐小时天气必须带date旧路由缺 date 会直接 400所以detailedWeatherQuerySchema用extend把 date 从可选变成必填响应 DTO 中大量字段可选因为 Express 服务会按请求类型当前/预报/气候/详细甚至按错误{ ..., error: no_forecast }输出不同字段子集——契约选择“超集 可选”而不是不现实地统一成一种形态。注释还特别说明旧路由里“X is required”的定制 400 消息是在 controller 中逐字复刻的而不是从 schema 推导——目的就是让错误响应体与 Express 逐字节一致与 F5 卡片要求的错误信封对齐是同一原则。day内嵌 assignments 与笔记长度上限shared/src/day/day.schema.ts 覆盖/api/trips/:tripId/days与/api/trips/:tripId/days/:dayId/notes受day_edit权限门控。daySchema内嵌assignments数组复用 shared/src/assignment/assignment.schema.ts与notes_items。笔记相关的契约体现了与遗留校验中间件validateStringLengths的对齐text上限 500 字符、time上限 250 字符创建用.min(1).max(500)更新则是.max(500).optional()——一增一改边界严格一致。天数的重排用orderedIds: z.array(z.number())表达“整段序列全量替换”语义。budget固定费用分类与结算汇率冻结shared/src/budget/budget.schema.ts 是字段最丰富的领域契约之一对应/api/trips/:tripId/budget支出条目、人均分摊、已付切换、结算。亮点有三固定费用分类COST_CATEGORIES是as const的 12 个固定 keyaccommodation、food、groceries、transport、flights、activities、sightseeing、shopping、fees、health、tips、other用户不能自建分类key 的文案/图标/颜色在客户端服务端只存 key。typeToCostCategory()再把预订类型flight、train、hotel……映射到费用分类让“从预订生成支出”自动落入正确桶位未知类型回退other。多付款人分摊budgetItemSchema同时内嵌members等分参与人与payers实际付款人及各自金额total_price是currency下各 payer 金额之和exchange_rate负责折算到行程基础货币NULL currency rate 1即基础货币。结算汇率冻结#1445budgetSettlementSchema的exchange_rate是结算时刻冻结的实时汇率单位外币兑 1 单位行程货币使已结算的账目不随实时汇率波动而失衡历史行currency null / exchange_rate 1用实时汇率换算。创建结算会把建议流程标记为已付删除撤销则恢复建议——契约与状态机行为一一对应。类型推导z.infer 如何消灭手写 DTO整套方案的技术核心是“一份 schema两端推导”每个 schema 旁边都用export type X z.infertypeof xSchema导出对应类型。例如 shared/src/place/place.schema.ts 中placeSchema定义后立即export type Place z.infertypeof placeSchema。这样做有三重收益零手工同步成本服务端响应字段变化只需改 schema客户端类型自动跟随编译期即可捕获破坏性变更schema 即文档字段的可空性nullable().optional()、来源“computed in TRIP_SELECT”“raw SQLite INTEGER”直接写在类型系统里注释进一步说明每个字段出自哪个 service 函数校验与类型不分离同一个 schema 既能parse运行时数据服务端入口校验又能作为静态类型客户端编译期保障。shared包自身在strict: truenoUncheckedIndexedAccess下编译见 shared/tsconfig.json对契约本身的类型严谨性要求高于两端应用。消费端接入开发期的双端解析链路README 明确了两端在开发期的接入方式仓库中能找到对应的真实配置客户端viteresolve.alias type-checker 的paths双重配置。实际配置在 client/tsconfig.jsonpaths: { trek/shared: [../shared/src/index.ts], trek/shared/*: [../shared/src/*] }vite 侧则按 README 描述通过client/vite.config.js的resolve.alias把trek/shared指向本包源码无需预构建即可热更新。最直接的验证证据是冒烟测试 client/tests/unit/shared-contract.test.ts——它直接import { idParamSchema, paginationQuerySchema } from trek/shared并断言idParamSchema.parse(7) 7、paginationQuerySchema.parse({})等于默认分页注释点明其目的就是“证明客户端工具链vite / vitest能解析 trek/shared”。服务端tsxREADME 规划通过server/tsconfig.json的paths解析。需要说明的是当前 server/tsconfig.json 中实际存在的 paths 仅包含 MCP SDK 的 CJS 重定向尚无trek/shared条目——这与包仍处于 dormant 状态完全一致还没有模块在运行时 import 它自然不需要解析配置。README 明确生产打包Docker / workspace 接线在卡片 F2 引入即服务端首次在运行时依赖本包时在那之前生产构建完全不受影响。打包与发布tsdown 双格式产物与按语言分包shared/package.json 揭示了包的工程化细节构建工具 tsdown构建脚本为tsdown见 shared/tsdown.config.ts同时产出 CJS 与 ESM 双格式 类型声明format: [cjs, esm]、dts: trueentry 设计入口除了根 barrelsrc/index.ts还包括 i18n 元数据 barrelsrc/i18n/index.ts和每个语言一个入口src/i18n/*/index.ts——即语言包被打成独立 chunk支持按需懒加载避免客户端一次性拉取 20 种语言deps 策略neverBundle: [zod]zod 作为外部依赖由消费方解析避免重复实例化导致类型不兼容exports 映射根入口与./i18n、./i18n/*子路径都有独立的 import/require/types 三通道声明并配套typesVersions兼容旧解析器依赖面极窄仅zod^4.3.6与isomorphic-dompurify^3.15.0两个运行时依赖开发依赖覆盖 eslint/prettier/typescript-eslint/tsdown/vitest。版本 3.4.1与本仓库主版本保持一致包标记private: true说明它作为 monorepo 内部包消费不对外发布。附带的 i18n 注册表与 HTML 清洗工具trek/shared不只装契约还附带两个被两端共享的纯逻辑模块i18n 语言注册表shared/src/i18n/languages.tsSUPPORTED_LANGUAGES以as const定义 23 种语言value/label/locale 三元组含中文简繁体、阿拉伯语等并提供三个纯函数——getLocaleForLanguage映射 BCP-47 locale如br → pt-BR、getIntlLanguage供 Intl API 使用的 BCP-47 标签同样特殊处理br、isRtlLanguage当前仅ar为 RTL。类型SupportedLanguageCode由数组字面量推导保证新增语言时类型随之收窄。各语言目录如 shared/src/i18n/zh/index.ts将 40 个领域翻译模块展开合并为单个 locale 对象。配套的i18n parity 脚本shared/scripts/i18n-parity.mjs用正则扫描每个非 en locale 的顶层翻译 key检查“文件集合一致 顶层 key 集合完全一致”--strict模式在出现漂移时以退出码 1 阻断 CI——这是把“每个语言与 en 同文件同 key”的质量门槛自动化。此外 shared/src/i18n/i18n-parity.spec.ts 与i18n-placeholders.spec.ts在测试层做双重保障。HTML 清洗工具shared/src/sanitize/sanitize.ts基于isomorphic-dompurify浏览器走 DOMPurify、Node 走 DOMPurify jsdom且可正确 tree-shake 避免客户端引入 jsdom。TREK 目前没有富文本编辑器、也没有用户 HTML 入库该模块只用于保护少数把用户可控字符串插进标记模板的客户端场景当前是 Journey 建议横幅并作为未来 TipTap/Markdown 支持时清洗逻辑的归宿。模块定义了INLINE_TAGS行内白名单与更全的FULL_TAGS两组标签集合按场景选用。测试与质量保障契约也有自己的测试网领域 schema 全部配套.spec.ts单测例如 shared/src/trip/trip.schema.spec.ts、shared/src/auth/auth.schema.spec.ts 等见shared/src各领域目录。测试主要验证三类行为边界值ID 的正整数约束、字符串 trim 与非空、时间戳格式强转与默认值URL 字符串参数强转、分页默认值填充、lang默认de枚举与语义date_shift_mode、COST_CATEGORIES等枚举的合法取值。客户端侧还有跨包冒烟测试 client/tests/unit/shared-contract.test.ts 守护“解析链路本身不坏”。包内package.json提供testvitest run、typechecktsc --noEmit、lint、format等脚本CI 中i18n:parity:strict可作为强制门槛。迁移路线F1 到 F5 的边界纪律README 的最后一部分“Not yet here”透露了迁移路线上的边界纪律规范的错误信封error envelope尚未定型它被安排在卡片F5且必须与 TREK 当前 Express 错误响应逐字节一致因此在 F1 阶段刻意不凭空发明。这个细节恰恰是整套方案的方法论缩影——契约不是从零设计出来的理想模型而是对现有行为的事实性捕获天气查询镜像 Express 的不透明字符串语义、错误消息在 controller 复刻以保持字节一致、分页仅按需启用……每一步迁移都以“不改变线上行为”为前提。小结trek/shared为 TREK 的褐地重写提供了一份可渐进落地的契约基础设施一域一契约的目录治理、src/common的通用积木、z.infer的双端类型推导、精确前缀的 Strangler 路由策略、tsdown 双格式打包与按语言分包再加上对遗留 Express 行为的忠实镜像和逐字节兼容的纪律让“迁移一个路由”变成“先落地其契约”这一可测试、可验收的原子操作。对任何正在做服务端/客户端分离改造的团队这套“单一事实源 类型推导 休眠期零副作用”的组合都值得借鉴先让契约成为事实源再让两端跟随它演化最终用编译期类型替代运行期联调。【免费下载链接】TREKA self-hosted travel/trip planner with real-time collaboration, interactive maps, PWA support, SSO, budgets, packing lists, and more.项目地址: https://gitcode.com/GitHub_Trending/nomad22/TREK创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考