npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

zod-op

v0.1.0

Published

Zod-Oriented Programming — turn one Zod schema into a complete REST resource: validation, OpenAPI, ETag, Link pagination, idempotency, locks, soft delete, and more.

Readme

ZodOP — Zod-Oriented Programming

One Zod schema → seven REST endpoints + OpenAPI + ETag + Link pagination + idempotency + locks + soft delete. Zero CRUD boilerplate. Pure ESM. TypeScript-first. Built on Fastify.

npm version License: MIT Node ≥ 20 TypeScript Coverage ≥ 99% lines / 95% branches

npm install zod-op zod fastify
# or
yarn add zod-op zod fastify
import { z } from 'zod';
import { zodop, Resource, MemoryStorePlugin, SwaggerPlugin } from 'zod-op';

const Todo = z.object({
  id: z.string().uuid(),
  title: z.string().min(1).max(200),
  done: z.boolean().default(false),
  createdAt: z.date(),
});

const { listen } = await zodop({
  title: 'Todo API',
  plugins: [new SwaggerPlugin()],
  resources: [
    new Resource({
      name: 'todo',
      schema: Todo,
      store: new MemoryStorePlugin(),
      sortDefault: '-createdAt',
    }),
  ],
});

await listen({ port: 3000 });
// → POST/GET/PATCH/PUT/DELETE /todos[/:id], POST /todos/batch,
//   GET /openapi.json, GET /docs

That's it. The seven CRUD endpoints, the OpenAPI 3.1 spec, the Swagger UI, the validated query string with sort and filter allowlists, ETag-based optimistic concurrency, RFC 5988 Link headers on lists — all from the schema above.


Why ZodOP

ZodOP rejects misconfiguration at compile time — the schema is the source of truth.

| When you write… | …you get back | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------- | | softDelete: true on a schema without deletedAt | TS error at the call site | | sortableFields: ['titel'] (typo) against a schema with title | TS error at the call site | | A schema that omits id | TS error pointing at schema: with the rule it broke | | endpoints: { create: { disabled: true }, bulk: { disabled: false } } | Construction-time throw — bulk is built on create | | ?filter[priority]=banana on a z.number() field | 400 with the field name | | If-Match: "stale-etag" on a PATCH | 412 precondition_failed | | Two concurrent PATCHes on the same id | Serialised through the lock plugin | | A POST retried with the same Idempotency-Key | The original response, replayed verbatim with Idempotent-Replayed: true |

Plugin-composed REST APIs from Zod schemas. The schema is the source of truth — configuration that's inconsistent with the schema is rejected at compile time by TypeScript, and everything else at construction time by Zod refinements. No duplicated if/else guards, no "works for you but throws in prod" surprises.

const Post = z.object({
  id: z.string().uuid(),
  title: z.string().min(1).max(200),
  deletedAt: z.date().nullable().default(null),
});

new Resource({
  name: 'post',
  schema: Post,
  store,
  softDelete: true, // ✅ OK — the schema declares deletedAt
});

const Comment = z.object({
  id: z.string().uuid(),
  body: z.string(),
});

new Resource({
  name: 'comment',
  schema: Comment,
  store,
  softDelete: true,
  // ^^^^^^^^^^^^^^ ❌ TS2322  Type 'true' is not assignable to type 'false'.
});

Everything the schema (and Zod) decides for you

Compile-time (TypeScript)

| rule | how | | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Resource schema must declare an id field | conditional type resolves the arg to a descriptive string literal error when id is missing | | softDelete: true only valid when schema declares deletedAt | conditional type narrows to false \| undefined otherwise | | Hook signatures must match z.infer<TSchema> | generic flow through CrudHooks<z.infer<TSchema>> | | Endpoint overrides key in endpoints must be a real CRUD op | Partial<Record<CrudOperation, …>> | | Plugin-instance fields (store, locks, redis) must be correct instances | z.instanceof(StorePlugin) etc. on the plugin's configSchema | | Plugin placement scope | static scopes is checked against the scope it's placed in |

Construction-time (Zod refinements)

Every plugin owns a static configSchema. The constructor parses the caller's config through it, so defaults are applied and invalid inputs throw before any route mounts. A few examples:

| plugin | refined by | | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Resource options | name / plural are slug-safe (/^[a-z][a-z0-9_-]*$/i), bulkMax ∈ [1, 10_000], tags[] non-empty, description ≤ 2000 chars | | Resource.endpoints | bulk cannot be enabled when create.disabled === true (refinement) | | HealthPlugin | paths must start with / and be URL-safe; livenessPath / readinessPath / fullPath must differ | | MemoryIdempotencyPlugin / RedisIdempotencyPlugin | methods are z.enum(['GET','POST',…]), header is a lowercase HTTP token, onConflict.status and onInFlight.status are bounded to [400, 599], ttlMs > 0 | | RedisIdempotencyPlugin.keyPrefix | no whitespace | | RedisPlugin.client | z.custom<RedisClient> with a duck-type check for set/get/del | | PostgresStorePlugin.pool | z.custom<PgPool> with a duck-type check for .query() | | MongoStorePlugin.db | z.custom<MongoDb> with a duck-type check for .collection() |

Compose-time (zodop())

| rule | how | | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | | Plugin name unique within its scope | Registry keeps a name map per scope | | Plugin multiple: false → at most one per scope | Registry rejects on a second insert | | Plugin placed in unsupported scope | Registry checks each instance's allowedScopes | | dependsOn satisfied in declaration order | composeFlat walks names + kinds left-to-right | | Resource base paths don't collide with each other | zodop() builds a seenBases map | | Resource base paths don't clash with /openapi.json, /docs, /livez, /readyz, /health, /metrics | RESERVED_PATHS check in zodop() |

All errors are thrown before any Fastify route mounts, with messages that name the resource / plugin / field that failed.


The shape

const Post = z.object({
  id: z.string().uuid(),
  title: z.string().min(1).max(200),
  content: z.string(),
  authorId: z.string().uuid(),
  tags: z.array(z.string()).default([]),
  publishedAt: z.coerce.date().nullable().default(null),
  createdAt: z.date(),
  updatedAt: z.date(),
  deletedAt: z.date().nullable().default(null),
});

const store = new PostgresStorePlugin({ pool });
const redis = new RedisPlugin({ client });
const locks = new RedisLockPlugin({ redis });

await zodop({
  title: 'My API',
  version: '1.0.0',

  plugins: [
    // API-scope
    new SwaggerPlugin(),
    new RedisIdempotencyPlugin({ redis }),
    new HealthPlugin({
      checks: [
        /* ... */
      ],
    }),
  ],

  resources: [
    new Resource({
      name: 'post',
      schema: Post,
      store,
      locks,
      softDelete: true, // schema has deletedAt ⇒ TS allows it

      plugins: [
        /* resource-scope */
      ],
      endpoints: {
        // endpoint-scope
        delete: {
          plugins: [
            /* ... */
          ],
        },
        bulk: { disabled: true },
      },

      hooks: { beforeCreate: data => /* ... */ data },
    }),
  ],
});

Plugin hierarchy

Plugin                             (abstract root — static kind / multiple / scopes / configSchema)
├── StorePlugin                    ['store',        multiple: true ]
│     MemoryStorePlugin, SqliteStorePlugin, PostgresStorePlugin, MongoStorePlugin
├── LockPlugin                     ['lock',         multiple: true ]
│     MemoryLockPlugin, RedisLockPlugin
├── IdempotencyPlugin              ['idempotency',  multiple: false]
│     MemoryIdempotencyPlugin, RedisIdempotencyPlugin
├── RedisPlugin                    ['redis',        multiple: true ]
├── SwaggerPlugin                  ['swagger',      multiple: false, scopes: ['api']]
└── HealthPlugin                   ['health',       multiple: false, scopes: ['api']]

A user-written plugin is a subclass with its own static configSchema:

class SqlServerStorePlugin extends StorePlugin {
    static override readonly configSchema = z.object({
        name: z.string().regex(/^[a-z][a-z0-9_-]*$/i).default('sqlserver'),
        pool: z.custom<SqlPool>(v => /* duck-type check */),
        schemaName: z.string().default('dbo'),
    });
    // …
}

Drop new SqlServerStorePlugin({ pool }) into Resource({ store }) — the rest of the app doesn't know or care which concrete class it is.

Capability plugins vs middleware plugins

Plugins fall into two roles, even though they all extend Plugin:

| role | examples | how they're consumed | | -------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | capability | StorePlugin, LockPlugin, RedisPlugin | referenced by name or by reference from a Resource or another plugin. They provide a service (persistence, mutual exclusion, a client connection). | | middleware | SwaggerPlugin, HealthPlugin, IdempotencyPlugin, user-written auth / rate-limit | placed into one of the three scopes (API / resource / endpoint) so their hooks run on every request inside that scope. |

Capability plugins don't need to be listed in zodop({ plugins }) — pass them directly to the Resource (store, locks) or to another plugin (redis), and zodop() deduplicates and registers each one exactly once at the API scope. That means you can share a single MemoryStorePlugin between two resources, or pass the same RedisPlugin to both RedisLockPlugin and RedisIdempotencyPlugin, without registering it twice or causing a name collision.

Middleware plugins are always placed explicitly, because where they run is what they do.

Query allowlists

?sort=-createdAt,title and ?filter[field]=value are validated against two allowlists on ResourceOptions, both typed against the schema keys so unknown fields are a compile-time error:

new Resource({
  name: 'post',
  schema: Post,
  store,
  sortableFields: ['createdAt', 'title'], // default: all keys
  filterableFields: ['authorId', 'publishedAt'], // default: all keys
});

Requests that reference an unlisted field get a 400 bad_request with the allowed set echoed in details. Default is permissive (every schema key); tighten by listing only the fields you really want to expose to indexed queries.

Filter values are coerced through the matching Zod field schema, so ?filter[priority]=5 against a z.number() field compares as 5, not "5". Repeat a filter key to get IN semantics — ?filter[priority]=1&filter[priority]=3 selects rows with priority 1 or 3. An uncoercible value (?filter[priority]=banana) returns 400.

sortDefault is applied when the client omits ?sort=; pair it with defaultPageSize / maxPageSize to cap the list surface:

new Resource({
  name: 'post',
  schema: Post,
  store,
  sortableFields: ['createdAt', 'title'],
  sortDefault: '-createdAt', // newest first by default
  defaultPageSize: 25, // default pageSize
  maxPageSize: 100, // upper bound; requests over → 400
});

Custom id generation

By default each new row gets crypto.randomUUID(). Supply idGenerator to plug in ULID, nanoid, a sequence, or anything else — synchronous or Promise-returning:

import { ulid } from 'ulid';

new Resource({
  name: 'post',
  schema: Post,
  store,
  idGenerator: () => ulid(),
});

Precedence is beforeCreate-supplied id → idGeneratorcrypto.randomUUID(), so a hook can still force an id for a specific insert.

View-only and internal resources

readOnly: true disables every mutation endpoint (create, bulk, update, replace, delete) in one line — useful when the resource is populated from an external source or a read-replica:

new Resource({
  name: 'exchange-rate',
  schema: ExchangeRate,
  store,
  readOnly: true, // only GET /exchange-rates + GET /exchange-rates/:id
});

hidden: true keeps every route working but removes them from the generated OpenAPI document — for internal or admin resources whose existence shouldn't be advertised:

new Resource({
  name: 'admin-user',
  plural: 'admin-users',
  schema: AdminUser,
  store,
  hidden: true,
});

Combine them (readOnly: true, hidden: true) for a fully internal, read-only surface.

Endpoint-level plugins and bulk

Endpoint-level plugins apply to a single operation. A guard placed on endpoints.create does not run on POST /:plural/batchbulk is a separate endpoint with its own scope.

To guard both operations, either list the plugin on each endpoint or — the usual fix — place it at the resource scope:

new Resource({
  name: 'post',
  schema: Post,
  store,

  // ✅ Resource-level: runs for every operation on /posts*, including /batch.
  plugins: [new BearerGuardPlugin({ accept: ['admin'] })],

  // ❌ Endpoint-level create-only — bulk/batch stays unguarded.
  // endpoints: { create: { plugins: [new BearerGuardPlugin(...)] } },
});

Rule of thumb: endpoint scope is for things that differ between endpoints (e.g. "only delete requires an admin token"). Anything you want uniformly on mutations belongs at the resource scope.

Testing

zod-op/testing exposes a thin Fastify-inject harness so tests can exercise the full HTTP surface without opening a socket:

import { createTestApp } from 'zod-op/testing';

const harness = await createTestApp({
  resources: [
    /* … */
  ],
});
const res = await harness.request({
  method: 'POST',
  url: '/todos',
  body: { title: 'x' },
});
expect(res.statusCode).toBe(201);
await harness.close();

Three scopes, like Express middleware

| scope | Fastify mechanics | placement | | ---------- | ------------------------------------ | ------------------------------------------------------------- | | api | root app | zodop({ plugins: [ … ] }) | | resource | per-resource app.register() scope | new Resource({ plugins: [ … ] }) | | endpoint | per-operation app.register() scope | new Resource({ endpoints: { create: { plugins: [ … ] } } }) |

Plugins whose static scopes excludes a placement scope throw at construction time — placement bugs fail before the app runs.

File layout

src/
  app/             zodop() factory, error handler, OpenAPI helpers, HttpError hierarchy
  plugin/          Plugin hierarchy (base, store, lock, idempotency), context types, Registry, composeFlat
  resource/        Resource class, schema-driven conditional types, schema derivation, endpoint map
  crud/            internal CRUD wiring — ctx, hooks, etag, filter/sort, one route file per operation
    routes/        list.ts, create.ts, read.ts, update.ts, replace.ts, delete.ts, bulk.ts
  store/           Store<T> contract, sort parsing, Zod reflection helpers
  plugins/         concrete plugin implementations, grouped by kind
    stores/        MemoryStorePlugin, SqliteStorePlugin, PostgresStorePlugin, MongoStorePlugin
    locks/         MemoryLockPlugin, RedisLockPlugin
    idempotency/   MemoryIdempotencyPlugin, RedisIdempotencyPlugin, fingerprint, shared config fields
    health.ts, redis.ts, swagger.ts   (single-plugin files stay flat)
  testing/         Fastify-inject harness for integration tests

Subpath imports mirror this layout — zod-op/plugins/stores/memory, zod-op/plugins/locks/redis, zod-op/plugins/idempotency/memory, and so on.

Anything else is your Fastify

Auth, CORS, rate-limit, helmet, logging, metrics, tracing — after zodop() returns, app is a plain Fastify instance. Register your own Fastify plugins via app.register(...), or wrap behaviour as a Plugin subclass and place it at the API / resource / endpoint scope of your choosing.

How does it compare?

| Feature | ZodOP | Hand-rolled Fastify | NestJS | Hasura/PostgREST | | --------------------------------------- | ----- | ------------------- | ----------- | ---------------- | | Auto CRUD from schema | ✅ | ❌ | ⚠️ via libs | ✅ | | OpenAPI 3.1 from schema | ✅ | ⚠️ manual | ⚠️ manual | ✅ | | Strict TS narrowing on misconfig | ✅ | ❌ | ⚠️ | ❌ | | Idempotency-Key replay | ✅ | ❌ | ❌ | ❌ | | ETag + If-Match | ✅ | ❌ | ❌ | ❌ | | Per-id locking | ✅ | ❌ | ❌ | ❌ | | RFC 5988 Link headers | ✅ | ❌ | ❌ | ❌ | | Storage choice (Memory/SQLite/PG/Mongo) | ✅ | ✅ | ✅ | ❌ (one DB) | | You own every Fastify hook & plugin | ✅ | ✅ | ⚠️ | ❌ | | Pure ESM, no decorators, no DI | ✅ | ✅ | ❌ | n/a |

ZodOP is intentionally narrow: first-table CRUD scaffolding. Relations, transactions, GraphQL, server-side rendering, multi-tenant sharding — out of scope. For those, reach for a heavier framework.

FAQ

Is this production-ready? The framework's surface is — every CRUD path, every plugin, every config branch is unit- + integration-tested at ≥ 99% line coverage. The version is 0.1.0 because the API is stabilising; minor bumps may add narrow features (cursor pagination, transaction hooks) and patch bumps fix bugs.

Does it support Postgres / Mongo / SQLite? Yes — see the plugins/stores/{postgres,mongo,sqlite} adapters. They duck-type the driver so any drop-in replacement (@databases/pg, postgres, etc.) works as long as it exposes the expected verbs.

Does it support relations / joins / nested resources? No. The Store contract is single-table. For relations, use afterRead hooks or compose in a route handler using app.register() after zodop() returns. A future minor version may add eager-load helpers.

Does it support cursor pagination? Not yet — only offset (?page= / ?pageSize=) with RFC 5988 Link headers and X-Total-Count. Cursor support is on the roadmap.

Can I use this with non-Fastify frameworks? No. The framework is built on Fastify and uses its encapsulation model for plugin scoping. The Zod schemas, Store<T> contract, and helper modules are framework-agnostic if you want to lift them.

How does idempotency interact with bulk? Identical — POST /:plural/batch is just another POST that the idempotency plugin will replay. Each item still flows through beforeCreate/afterCreate hooks once on the original request.

Where do I put auth? Any of the three scopes. Simplest pattern: a Plugin subclass with a preHandler hook that throws UnauthorizedError. Place at the API scope to guard everything, the resource scope to guard one resource, or the endpoint scope to guard one operation. See the Endpoint-level plugins and bulk section for the common gotcha.

Can I disable a single endpoint? endpoints: { delete: { disabled: true } } for one. readOnly: true disables all five mutations in one line.

Does it open a connection to my database? No — you create the connection (a pg.Pool, MongoClient.db(), a better-sqlite3 Database, an ioredis client) and hand it to the plugin. ZodOP never connects, disconnects, or holds connections beyond what your driver does.

Contributing

Pull requests welcome. See CONTRIBUTING.md for the development setup, the test/coverage requirements, and the code style.

Security

If you find a security issue, please follow the process in SECURITY.md. Do not open a public issue.

License

MIT © Pedro Zigante