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