npm.io
0.2.2 • Published 5h ago

@classytic/catalog

Licence
MIT
Version
0.2.2
Deps
0
Size
665 kB
Vulns
0
Weekly
0

@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 — Mongoose models, mongokit repositories, pure-domain utilities, Zod schemas, and a typed event transport. There are no Arc / Fastify / Express / Next.js imports anywhere in the package, and no ./http adapter ships. Hosts wire catalog into their framework of choice with ~5 lines of glue code, calling catalog.repositories.* directly — 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, no HTTP adapter shipped. Host apps wire their own routes against catalog.repositories.*.
  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 + tenant resolver
  validators/            Zod schemas (exported via @classytic/catalog/schemas)

Catalog ships only the pure primitives. There is no src/http/ and no @classytic/catalog/http subpath — HTTP integration is host-owned, see the recipes below.

Peer dependencies

  • mongoose >= 9.4.1 (required)
  • @classytic/mongokit >= 3.16.0 (required)
  • @classytic/repo-core >= 0.6.0 (required)
  • @classytic/primitives >= 0.5.0 (required)
  • zod >= 4.0.0 (required)

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,
  multiTenant: true, // enable per-tenant scoping (default field: organizationId)
  defaultCurrency: 'USD',
  modules: {
    offers: true, // enable marketplace-style offers + buy-box
  },
});

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

Integration recipes

Catalog has no HTTP adapter — the engine returns repositories that hosts call directly from their framework's request handler. Each recipe below is ~15 lines of host glue: parse the request → call catalog.repositories.<entity>.<verb>(...) → serialize the response.

The pattern (any framework)

The recipe is the same in every framework — read the request, build a CatalogContext, call a repository / utility method, return the result. No catalog-specific adapter is involved.

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

// Inside any HTTP handler — Arc, Fastify, Express, Hono, Next.js Route
// Handler, tRPC, etc.
async function handlePostProduct(req, reply) {
  const ctx = {
    organizationId: req.user?.orgId,
    actorId: req.user?.id,
  };
  const product = await catalog.repositories.product.create(req.body, ctx);
  reply.send({ data: product });
}

async function handleGetProduct(req, reply) {
  const ctx = { organizationId: req.user?.orgId, actorId: req.user?.id };
  const doc = await catalog.repositories.product.getById(req.params.id, ctx);
  if (!doc) return reply.code(404).send({ error: 'Not Found' });
  reply.send({ data: doc });
}

The repositories surface is whatever mongokit gives you (getAll, getById, create, update, delete, count, aggregate) plus the domain methods catalog adds (product.delete(id, ctx, { cascade }), offer.commit(input, ctx), etc.). Pick the recipe for your framework's auth + req/res wiring; the catalog call is always the same.

CLI / cron / workers (no HTTP)
import { catalog } from './catalog.js';

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

const product = await catalog.repositories.product.create(
  { name: 'Widget', productType: 'physical' },
  ctx,
);

const result = await catalog.repositories.offer.commit(
  { offerId: 'offer_abc', quantity: 1, idempotencyKey: 'job_run_xyz' },
  ctx,
);

Operational helpers

All on the repositories — no extra service surface. Full runbooks live in AGENTS.md.

import { SkuCollisionError } from '@classytic/catalog/catalog-core';

// Typed SKU/barcode collision contract on create — pre-flight check plus
// E11000 mapping; the unique index stays the authoritative guard.
try {
  await catalog.repositories.product.create(input, ctx);
} catch (err) {
  if (err instanceof SkuCollisionError) {
    // err.field: 'sku' | 'barcode', err.values, err.conflictingProductId?
  }
}

// Denormalized category counts — failures emit catalog:category.count_desync.
await catalog.repositories.category.validateCategoryCounts(ctx); // report drift
await catalog.repositories.category.repairCategoryCounts(ctx);   // re-aggregate + fix

// Remove variants that are both user-disabled AND auto-disabled (frees SKUs).
await catalog.repositories.product.compactVariants(productId, ctx);

// Batched create-or-update keyed by per-tenant-unique slug.
await catalog.repositories.product.bulkUpsertProducts(inputs, ctx, { errorMode: 'continue' });

// Scheduled publishing — call from the host's cron (no scheduler ships).
await catalog.repositories.product.runScheduledPublishing(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

Keywords