mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
Phase 0 — NestJS + Zod foundation harness (F1–F8) (#1050)
Co-hosted NestJS app behind the existing Express server via a strangler-fig dispatcher, sharing the same better-sqlite3 connection and JWT httpOnly cookie. Additive and dormant: default routing stays on Express, Nest only serves its own /api/_nest diagnostics until a module opts in. F1 @trek/shared Zod contract package; F2 Nest bootstrap co-hosted (fall-through, single Dockerfile/port); F3 shared better-sqlite3 provider; F4 JWT cookie auth guard (+ @CurrentUser, admin guard); F5 Zod validation pipe + error-envelope parity; F6 Nest test + coverage gates; F7 per-prefix strangler toggle (env, default Express); F8 CI build/typecheck/test/coverage. Remaining F4/F6/F8 checklist items (trip-access + permission levels + MFA policy, e2e harness/seed + 80% gate, Nest↔Express parity test, Playwright PR-comment workflow) are tracked on the first consuming module cards (L1/A1/C1).
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
# @trek/shared
|
||||
|
||||
Single source of truth for TREK's API contracts, expressed as [Zod](https://zod.dev) schemas
|
||||
and consumed by **both** the server (request validation + inferred DTO types) and the client
|
||||
(typed requests/responses).
|
||||
|
||||
This package is part of the incremental NestJS + React 19 migration
|
||||
(see the "Brownfield Rewrite" board). It is intentionally **dormant** until modules start
|
||||
importing it — adding it changes nothing for users.
|
||||
|
||||
## Rules
|
||||
|
||||
- **One folder per domain**: `src/<domain>/<domain>.schema.ts` (+ `.spec.ts`).
|
||||
- Domain-agnostic building blocks live in `src/common/`.
|
||||
- A route is only considered **migrated** once its contract lives here.
|
||||
- Schemas are the source of truth; server DTOs and client types are *inferred* from them
|
||||
(`z.infer<typeof schema>`), never hand-duplicated.
|
||||
|
||||
## Consumption (dev)
|
||||
|
||||
Both apps resolve `@trek/shared` to this package's TypeScript source:
|
||||
|
||||
- **Server** (`tsx`): via `paths` in `server/tsconfig.json`.
|
||||
- **Client** (`vite`): via `resolve.alias` in `client/vite.config.ts` (+ `paths` for the type-checker).
|
||||
|
||||
> Production packaging (Docker / workspace wiring) is introduced in card **F2**, when the
|
||||
> server first depends on this package at runtime. Until then prod builds are untouched.
|
||||
|
||||
## Not yet here
|
||||
|
||||
The canonical **error envelope** is finalised in card **F5** (it must match TREK's current
|
||||
Express error responses byte-for-byte), so it is deliberately not invented in F1.
|
||||
Generated
+1619
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@trek/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Shared API contracts (Zod schemas) — single source of truth for TREK server and client.",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Generic pagination query helper. Individual endpoints opt in by extending
|
||||
* this; it is NOT applied globally (many TREK list endpoints return full sets).
|
||||
* Defaults are conservative and only used where a route already paginates.
|
||||
*/
|
||||
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.infer<typeof paginationQuerySchema>;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { idSchema, idParamSchema, nonEmptyString, isoDateTime } from './primitives.schema';
|
||||
import { paginationQuerySchema } from './pagination.schema';
|
||||
|
||||
describe('@trek/shared primitives', () => {
|
||||
it('idSchema accepts positive integers, rejects others', () => {
|
||||
expect(idSchema.parse(1)).toBe(1);
|
||||
expect(idSchema.safeParse(0).success).toBe(false);
|
||||
expect(idSchema.safeParse(-3).success).toBe(false);
|
||||
expect(idSchema.safeParse(1.5).success).toBe(false);
|
||||
});
|
||||
|
||||
it('idParamSchema coerces string params to a positive int', () => {
|
||||
expect(idParamSchema.parse('42')).toBe(42);
|
||||
expect(idParamSchema.safeParse('abc').success).toBe(false);
|
||||
});
|
||||
|
||||
it('nonEmptyString trims and rejects empty', () => {
|
||||
expect(nonEmptyString.parse(' hi ')).toBe('hi');
|
||||
expect(nonEmptyString.safeParse(' ').success).toBe(false);
|
||||
});
|
||||
|
||||
it('isoDateTime accepts an ISO timestamp', () => {
|
||||
expect(isoDateTime.safeParse('2026-05-25T08:38:14Z').success).toBe(true);
|
||||
expect(isoDateTime.safeParse('not-a-date').success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('@trek/shared pagination', () => {
|
||||
it('applies defaults and coerces', () => {
|
||||
expect(paginationQuerySchema.parse({})).toEqual({ page: 1, perPage: 50 });
|
||||
expect(paginationQuerySchema.parse({ page: '2', perPage: '10' })).toEqual({ page: 2, perPage: 10 });
|
||||
});
|
||||
|
||||
it('enforces bounds', () => {
|
||||
expect(paginationQuerySchema.safeParse({ perPage: 0 }).success).toBe(false);
|
||||
expect(paginationQuerySchema.safeParse({ perPage: 999 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Primitive, domain-agnostic building blocks shared by every contract.
|
||||
* Domain schemas (trips, places, ...) live in their own folders and reuse these.
|
||||
*/
|
||||
|
||||
/** TREK uses auto-increment integer primary keys. */
|
||||
export const idSchema = z.number().int().positive();
|
||||
export type Id = z.infer<typeof idSchema>;
|
||||
|
||||
/**
|
||||
* Numeric id coming from a URL param / query string. Express hands these over
|
||||
* as strings, so we coerce, then enforce a positive integer.
|
||||
*/
|
||||
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 });
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* @trek/shared — single source of truth for TREK's API contracts.
|
||||
*
|
||||
* Zod schemas defined here are consumed by BOTH the server (validation +
|
||||
* inferred DTO types) and the client (typed requests/responses). A route is
|
||||
* only considered "migrated" once its contract lives in this package.
|
||||
*
|
||||
* Layout: one folder per domain (e.g. src/trip/trip.schema.ts), plus the
|
||||
* domain-agnostic primitives below. See the board card "Module blueprint".
|
||||
*/
|
||||
export * from './common/primitives.schema';
|
||||
export * from './common/pagination.schema';
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user