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

@classytic/catalog

v0.1.1

Published

Commerce catalog kernel for MongoDB — products, variants, offers, pricing layers, modifiers, attributes, categories, offer execution modules, channels, bridges

Downloads

261

Readme

@classytic/catalog

Commerce catalog kernel for MongoDB. Products, variants, offers, pricing layers, modifiers, attributes, categories, offer execution modules, channels, and integration bridges.

Framework-agnostic by design. Catalog ships pure primitives (models, repositories, services, Zod schemas, events) that compose into any HTTP framework. There are no Arc, Fastify, Express, or Next.js imports anywhere in the package. Host apps wire catalog into their framework of choice with ~5 lines of glue code — see the integration recipes below.

Design principles

  1. Zero config to startcreateCatalog({ connection }) works.
  2. Progressive disclosure — four API levels: zero config, simple knobs, module opt-in, full config. Each is a natural step up, never a rewrite.
  3. Composition over configuration — modules opt in via modules: { offers: true, ... } rather than monolithic presets.
  4. Bounded contexts — Catalog Definition, Offers, Offer Execution, Channels, Projections, Integration. Each is an internal subdomain with a clear responsibility boundary.
  5. MongoDB-native via Mongoose + @classytic/mongokit as peer deps. Not DB-agnostic — that's a deliberate simplification for v1.
  6. Framework-agnostic — no framework imports. Host apps write their own integration using buildRestHandlers() or by calling services directly.
  7. Better Auth aligned — storage field is always organizationId.
  8. Zod as source of truth — every schema is a Zod schema, types derive via z.infer<>, runtime validation at service boundaries.
  9. No breaking changes post-1.0.

Package structure

src/
  catalog-core/          Product, Variant, Attribute, Category, Exclusion,
                         Modifier, Compliance, Relationship, RankingSignals
  offers/                Offer, PricingLayers, OfferAvailability,
                         CompositionPolicy, OfferBoost
  offer-execution/       OfferExecutionModule interface + 11 built-in modules
                         (standard, affiliate, preorder, reservation, voucher,
                          auction, rental, subscription, course_offering,
                          dropship, pod)
  channels/              ChannelPublication value object
  projections/           SearchProjection + flat denorm builder/plugin
  integration/           13 bridge interfaces (inventory, pricing, tax,
                         media, currency, recommendation, review, promotion,
                         loyalty, ranking, composition, compliance, uom)
  product-types/         ProductTypeHandler interface + physical / service
  value-objects/         Money, Identifiers, ProductRef, Translatable
  events/                41 typed event contracts
  engine/                createCatalog factory + CatalogEngine
  validators/            Zod schemas (exported via @classytic/catalog/schemas)
  http/                  buildRestHandlers — framework-agnostic route map

Peer dependencies

  • mongoose >= 9.4.1 (required)
  • @classytic/mongokit >= 3.5.6 (required)

That's it. No framework peer deps. No optional plugins in the peer list.

Quick start

import mongoose from 'mongoose';
import { createCatalog } from '@classytic/catalog';

await mongoose.connect(process.env.MONGO_URI!);

const catalog = await createCatalog({
  connection: mongoose.connection,
  scope: true, // enable multi-tenant scoping via organizationId
  defaultCurrency: 'USD',
  modules: {
    offers: true, // enable marketplace-style offers + buy-box
  },
});

// Use services directly in any runtime (Node, Fastify, Express, Next.js, CLI)
const ctx = { organizationId: 'org_123', actorId: 'user_456' };
const product = await catalog.services.product.create(
  { name: 'T-Shirt', productType: 'physical' },
  ctx,
);

Integration recipes

Catalog exposes services and a framework-agnostic route handler map via buildRestHandlers(catalog). The handler map is a plain object keyed by "METHOD /path" where each value is a pure async function:

type RestHandler = (
  ctx: CatalogContext,
  input: { params?: Record<string, string>; body?: unknown; query?: Record<string, unknown> },
) => Promise<unknown>;

Pick the recipe for your framework below. Each is ~15 lines. Catalog does not know or care which framework you use.

Arc (@classytic/arc)

import { defineResource } from '@classytic/arc';
import { buildRestHandlers } from '@classytic/catalog';
import { catalog } from './catalog.js';

const handlers = buildRestHandlers(catalog);

export default defineResource({
  name: 'product',
  tag: 'Products',
  disableDefaultRoutes: true,
  routes: [
    {
      method: 'GET', path: '/products', raw: true,
      handler: async (req, reply) => {
        const ctx = { organizationId: req.scope?.organizationId, actorId: req.user?.id };
        return reply.send({ data: await handlers['GET /products'](ctx, { query: req.query as Record<string, unknown> }) });
      },
    },
    {
      method: 'GET', path: '/products/:id', raw: true,
      handler: async (req, reply) => {
        const ctx = { organizationId: req.scope?.organizationId, actorId: req.user?.id };
        return reply.send({ data: await handlers['GET /products/:id'](ctx, { params: req.params as Record<string, string> }) });
      },
    },
    {
      method: 'POST', path: '/products', raw: true,
      handler: async (req, reply) => {
        const ctx = { organizationId: req.scope?.organizationId, actorId: req.user?.id };
        return reply.send({ data: await handlers['POST /products'](ctx, { body: req.body }) });
      },
    },
    // ... PATCH, DELETE similarly
  ],
});

Fastify (plain, no Arc)

import Fastify from 'fastify';
import { buildRestHandlers, listRestRoutes } from '@classytic/catalog';
import { catalog } from './catalog.js';

const app = Fastify();
const handlers = buildRestHandlers(catalog);

function toCtx(req: { user?: { orgId?: string; id?: string } }) {
  return { organizationId: req.user?.orgId, actorId: req.user?.id };
}

// Register every catalog route on your Fastify app.
for (const { method, path } of listRestRoutes(handlers)) {
  app.route({
    method: method as 'GET' | 'POST' | 'PATCH' | 'DELETE',
    url: path,
    handler: async (req) => {
      const ctx = toCtx(req as unknown as { user?: { orgId?: string; id?: string } });
      return handlers[`${method} ${path}`]!(ctx, {
        params: req.params as Record<string, string>,
        body: req.body,
        query: req.query as Record<string, unknown>,
      });
    },
  });
}

Express

import express from 'express';
import { buildRestHandlers } from '@classytic/catalog';
import { catalog } from './catalog.js';

const app = express();
app.use(express.json());
const handlers = buildRestHandlers(catalog);

function toCtx(req: express.Request) {
  const user = (req as unknown as { user?: { orgId?: string; id?: string } }).user;
  return { organizationId: user?.orgId, actorId: user?.id };
}

app.get('/products', async (req, res, next) => {
  try {
    const result = await handlers['GET /products'](toCtx(req), { query: req.query as Record<string, unknown> });
    res.json(result);
  } catch (err) { next(err); }
});

app.get('/products/:id', async (req, res, next) => {
  try {
    const result = await handlers['GET /products/:id'](toCtx(req), { params: req.params });
    res.json(result);
  } catch (err) { next(err); }
});

app.post('/products', async (req, res, next) => {
  try {
    const result = await handlers['POST /products'](toCtx(req), { body: req.body });
    res.status(201).json(result);
  } catch (err) { next(err); }
});

Next.js (App Router)

// app/lib/catalog.ts
import mongoose from 'mongoose';
import { createCatalog, buildRestHandlers } from '@classytic/catalog';

await mongoose.connect(process.env.MONGO_URI!);
export const catalog = await createCatalog({
  connection: mongoose.connection,
  scope: true,
  modules: { offers: true },
});
export const handlers = buildRestHandlers(catalog);
// app/api/products/route.ts
import { auth } from '@/lib/auth';
import { handlers } from '@/lib/catalog';

async function toCtx() {
  const session = await auth();
  return { organizationId: session?.user?.orgId, actorId: session?.user?.id };
}

export async function GET(req: Request) {
  const url = new URL(req.url);
  const query = Object.fromEntries(url.searchParams);
  const result = await handlers['GET /products'](await toCtx(), { query });
  return Response.json(result);
}

export async function POST(req: Request) {
  const body = await req.json();
  const result = await handlers['POST /products'](await toCtx(), { body });
  return Response.json(result, { status: 201 });
}
// app/api/products/[id]/route.ts
import { handlers } from '@/lib/catalog';
import { toCtx } from '@/lib/catalog-ctx';

export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const result = await handlers['GET /products/:id'](await toCtx(), { params: { id } });
  return result ? Response.json(result) : new Response('Not Found', { status: 404 });
}

Hono

import { Hono } from 'hono';
import { buildRestHandlers } from '@classytic/catalog';
import { catalog } from './catalog.js';

const app = new Hono();
const handlers = buildRestHandlers(catalog);

function toCtx(c: { get: (key: string) => unknown }) {
  const user = c.get('user') as { orgId?: string; id?: string } | undefined;
  return { organizationId: user?.orgId, actorId: user?.id };
}

app.get('/products', async (c) => {
  return c.json(await handlers['GET /products'](toCtx(c), { query: c.req.query() }));
});

app.get('/products/:id', async (c) => {
  const result = await handlers['GET /products/:id'](toCtx(c), { params: { id: c.req.param('id') } });
  return result ? c.json(result) : c.notFound();
});

app.post('/products', async (c) => {
  return c.json(await handlers['POST /products'](toCtx(c), { body: await c.req.json() }), 201);
});

Calling services directly (no HTTP)

For CLI tools, cron jobs, background workers, RPC servers, and any non-HTTP use case, skip buildRestHandlers entirely and call services:

import { catalog } from './catalog.js';

const ctx = { organizationId: 'org_123', actorId: 'system' };

// Direct service call — same API the HTTP handlers wrap.
const product = await catalog.services.product.create(
  { name: 'Widget', productType: 'physical' },
  ctx,
);

// Offer commit with idempotency.
const result = await catalog.services.offer.commit(
  { offerId: 'offer_abc', quantity: 1, idempotencyKey: 'job_run_xyz' },
  ctx,
);

Zod schemas for frontends and SDKs

Catalog exports every Zod schema via the @classytic/catalog/schemas subpath. Frontends, SDKs, and test code can import them to get runtime validation + TypeScript types without pulling in the rest of the package:

import {
  productCreateSchema,
  offerCreateSchema,
  type OfferCreateInput,
} from '@classytic/catalog/schemas';

// Client-side form validation — same Zod schema the backend uses.
const result = productCreateSchema.safeParse(formValues);
if (!result.success) {
  // handle validation errors
}

Testing

# Run the fast unit suite (no DB)
npm run test:unit

# Run integration tests (MongoDB memory server)
npm run test:integration

# Run both in CI
npm test

# Typecheck only
npm run typecheck

License

MIT