Skip to content

Add Zod-based type safety to bundle search route#38

Closed
shwetank-dev wants to merge 8 commits intomainfrom
implement-type-safety-in-registry
Closed

Add Zod-based type safety to bundle search route#38
shwetank-dev wants to merge 8 commits intomainfrom
implement-type-safety-in-registry

Conversation

@shwetank-dev
Copy link
Collaborator

Summary

  • Add BundleSearchQuerySchema in schemas/query.ts using Zod with proper enum constraints (type: node/python/binary, sort: downloads/recent/name) and min/max validation for limit (1-100) and offset (>=0)
  • Replace hand-written JSON Schema querystring with toJsonSchema(BundleSearchQuerySchema) in the /v1/bundles/search route
  • Make the handler type-safe via Fastify <{ Querystring: BundleSearchQuery }> generic — removes the manual as { ... } cast
  • Use PackageSearchFilters instead of Record<string, unknown> for filters
  • Replace if/else sort chain with explicit sort map lookup — all sort options are now visible in one place, no hidden default in a let initializer
  • Remove runtime clamping (Math.max/Math.min) — validation is now enforced at the schema level, invalid values return 400 instead of being silently corrected
  • Update test to verify 400 rejection for invalid pagination instead of clamping behavior

Design decisions & tradeoffs discussed

optional() + default() ordering in Zod

  • z.optional(z.string().default(...)) wraps default inside optional → output type includes undefined
  • z.string().optional().default(...) puts default on the outside → output type is always concrete
  • We use the latter so the handler sees sort: "downloads" | "recent" | "name" (never undefined), while the JSON Schema still marks the field as not required (user can omit it)

Schema validation vs silent clamping

  • Previously invalid values like limit=0 were silently clamped to 1 — the client got unexpected results with no indication their input was wrong
  • Now Zod's min(1).max(100) rejects bad values with a 400, which is more correct API behavior

Response type safety — current limitations

  • Runtime: Fastify's response schema (toJsonSchema(BundleSearchResponseSchema)) does serialization/stripping of extra fields but does not validate the response shape — if the handler returns the wrong structure, it won't throw
  • Compile-time: Fastify's Reply generic can type reply.send(), but does not enforce return types from async handlers — so there's no compile-time check that the handler returns the correct shape
  • Recommendation: Use Zod response schemas in tests to verify handler output shape rather than adding runtime validation overhead in production

Where should the abstraction between DB fields and API fields live?

  • Currently the handler manually maps between query params → Prisma filters and Prisma results → API response shape
  • Two approaches discussed:
    1. Repository layer: Add methods like searchBundles() that accept query params directly and return API-shaped types (e.g. BundleSearchItem[]). Keeps handlers thin, but couples the repo to the API shape
    2. Separate helpers/mappers: Keep the repo returning Prisma types, add a mapping layer between repo and handler. More separation of concerns but more files
  • This PR does not change the repo layer — left for follow-up discussion

Where should query schemas live? (open question)

  • Currently in apps/registry/src/schemas/query.ts — co-located with the registry app
  • Could move to packages/schemas/ alongside the shared response schemas, then generate/export from there — this would allow the SDK and CLI to share the same query types
  • Needs discussion on whether query schemas are app-specific or shared across consumers

Test plan

  • pnpm --filter @nimblebrain/mpak-registry test — all 80 tests pass
  • Manual test: curl 'http://localhost:3200/v1/bundles/search?q=echo' returns results with defaults applied
  • Manual test: curl 'http://localhost:3200/v1/bundles/search?limit=0' returns 400
  • Manual test: curl 'http://localhost:3200/v1/bundles/search?type=rust' returns 400
  • Manual test: curl 'http://localhost:3200/v1/bundles/search?sort=popularity' returns 400

🤖 Generated with Claude Code

shwetank-dev and others added 2 commits March 10, 2026 18:46
Replace hand-written JSON Schema with BundleSearchQuerySchema in query.ts,
wire it into the /v1/bundles/search route via toJsonSchema(), and make the
handler type-safe using Fastify generics instead of manual type casts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add BundleSearchQuerySchema in schemas/query.ts with proper enum
  constraints for type (node/python/binary) and sort, plus min/max
  validation for limit and offset
- Replace hand-written JSON Schema with toJsonSchema(BundleSearchQuerySchema)
- Make handler type-safe via Fastify generics, removing manual type cast
- Use PackageSearchFilters instead of Record<string, unknown> for filters
- Replace if/else sort chain with explicit sort map lookup
- Remove runtime clamping (now enforced at schema validation level)
- Update test to verify 400 rejection instead of silent clamping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be in packages/shcmeas

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some confusion on how schemas are copy over to generated

import { z } from 'zod';

export const BundleSearchQuerySchema = z.object({
q: z.optional(z.string()),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add limit to the elngth

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

shwetank-dev and others added 5 commits March 12, 2026 11:04
…parks

Seed the local dev database with realistic bundle data matching production.
Includes all 9 echo versions (with prereleases) and 6 nationalparks versions,
with real manifests, provenance SHAs, download counts, and timestamps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Define BundleSearchParamsSchema in packages/schemas as the single source
of truth for bundle search query validation (q max 200 chars, type,
sort defaulting to downloads, limit 1-100, offset min 0). Copy to
registry generated schemas and update bundles route to import from
there instead of the ad-hoc query.ts, which is now deleted.

Also fix type mismatches in the search handler: explicitly type tools
as PackageTool[], coerce verified to boolean, and convert provenance
schema_version from number to string to match the API response schema.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tring

Replace inline JSON Schema definitions in the download route with Zod
schemas, matching the pattern established for the search route:

- Add BundleDownloadParamsSchema to validate os/arch query params
  against known enum values (darwin/linux/win32, x64/arm64), rejecting
  invalid platform values at the Fastify validation layer (422)
- Add BundleVersionPathParamsSchema for scope/package/version path params
- Wire both schemas via Fastify generics for type-safe request access
  without manual `as` casts
- Register the production error handler in tests so validation errors
  return 422 (matching prod behavior) instead of raw Fastify 400s
- Add tests for invalid os/arch rejection and search schema validation
  (defaults, type filter, enum/boundary validation, pagination)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…'t match

When os/arch query params were provided but didn't match any artifact,
the download route silently fell back to artifacts[0] instead of
returning a 404. This meant requests like ?os=foo&arch=bar would
succeed and return an unrelated platform's artifact.

Clear the artifact to undefined when neither an exact platform match
nor a universal (any/any) fallback is found, so the existing !artifact
guard correctly returns 404.

Fixes #28

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
const { os: queryOs, arch: queryArch } = request.query;
const name = `@${scope}/${packageName}`;

const pkg = await packageRepo.findByName(name);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: we should refactor this to be .findPackage(name, os, arch)

Then check for pkg and/or artifacts === undefined

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants