Catalog
CapabilitiesTome catalog layer — products, attributes, categories, vendor scoping, ProductTypeRegistry.
npm install @wabbit/tome-catalogOverview
@wabbit/tome-catalog
Tome catalog layer — products, attributes, categories, vendor scoping, ProductTypeRegistry. Description copied verbatim from package.json.
Layer: domain (per ARCHITECTURE.md). Consumed by @wabbit/tome-lms (course-as-product integration), @wabbit/tome-deals, @wabbit/tome-intake, and @wabbit/tome-sc.
Install
pnpm add @wabbit/tome-catalogPeer ranges, copied from package.json (v1.3.1):
| Peer | Range | Optional? | |---|---|---| | payload | >=3.67.0 | no | | @payloadcms/richtext-lexical | >=3.67.0 | no | | @wabbit/tome-core | >=1.0.0 <2.0.0 | marked yes in peerDependenciesMeta | | lucide-react | >=0.460.0 | yes |
Drift, verified in source: @wabbit/tome-core is declared optional, but src/access/vendor-scoping.ts and src/initCatalog.ts import unconditionally from @wabbit/tome-core/utilities/layerRegistry and @wabbit/tome-core/utilities/typedSlug — initCatalog.ts's own comment says so explicitly ("the package is not actually usable without it"). Treat @wabbit/tome-core as effectively required despite the manifest flag.
60-second quickstart
The current API is createCatalogLayer(config?) — the canonical top-level layer entry (R4 ruling #3, 2026-07). It returns a bare CollectionConfig[]: the five core collections (Products, ProductAttributes, ProductAttributeValues, Categories, ProductMedia) built from one shared CatalogConfig, plus an opt-in sixth (Entitlements, via entitlements: true | EntitlementsCollectionConfig) — and it delegates sidebar registration to initCatalog() internally, so you don't call that separately:
import { buildConfig } from 'payload'
import { createCatalogLayer } from '@wabbit/tome-catalog'
export default buildConfig({
collections: [
...createCatalogLayer({ vendorScoped: true }),
// ...your other collections
],
})Catalog has always also been consumable via its per-collection factories called directly (the pattern that predates createCatalogLayer) — that path still works and is unaffected:
import { buildConfig } from 'payload'
import {
initCatalog,
createProductCollection,
createCategoryCollection,
createProductAttributeCollection,
createProductAttributeValueCollection,
createProductMediaCollection,
createEntitlementsCollection,
} from '@wabbit/tome-catalog'
initCatalog() // optional — sidebar grouping only; safe to omit; superseded by createCatalogLayer for most consumers
export default buildConfig({
collections: [
createProductCollection({ vendorScoped: true }),
createCategoryCollection(),
createProductAttributeCollection(),
createProductAttributeValueCollection(),
createProductMediaCollection(),
createEntitlementsCollection(),
],
})initCatalog() itself did not change shape — it still returns void and only registers the admin-sidebar manifest; createCatalogLayer calls it internally rather than the other way around, so there remains exactly one place that owns the registerLayer call.
API surface
Single export subpath (. only — no ./server, ./test, etc.).
| Group | Exports | |---|---| | Layer entry | createCatalogLayer(config?), CatalogLayerConfig — canonical (R4 ruling #3, 2026-07); returns CollectionConfig[], matching the createOrgLayer/createAccountsLayer bare-array shape | | Collection factories | createProductCollection, createProductAttributeCollection, createProductAttributeValueCollection, createCategoryCollection, createProductMediaCollection, createEntitlementsCollection, createProductVariantCollection (+ each factory's *CollectionConfig/*Seed type) | | Sidebar-only registration | initCatalog(config?), InitCatalogConfig — void-returning, registration-only; unchanged contract, now called internally by createCatalogLayer too | | Registry | ProductTypeRegistry (class), productTypeRegistry (singleton) | | Access / vendor scoping | isOrgLayerPresent, shouldAddVendorField, buildVendorField, publicReadActiveOnly, vendorOwnerAccess, vendorCreateAccess, vendorAutoAssignHook | | Hooks | autoSlugHook, slugify, categoryDepthHook, attributeValidationHook, variantOptionUniquenessHook, canonicalizeVariantOptions | | Queries | getProductsByCategory, getProductsByVendor, getCategoryTree, getFilterableAttributes, getEntitlements, upsertEntitlementForSubscription (+ each query's Args/result type) | | Types | AttributeType, ProductStatus, ProductMediaType, CatalogConfig, ProductTypeDescriptor, Category, ProductAttribute, ProductAttributeValue, Product, ProductMedia, ProductVariant, ProductVariantStatus, VariantWeightUnit, VariantDimensionUnit, CATALOG_DEFAULT_SLUGS |
Product variants (opt-in, default OFF)
catalog-product-variants models one sellable configuration of a product — the size x colour cell of a merch matrix. It is not part of createCatalogLayer()'s default bundle:
createCatalogLayer({ variants: true }) // defaults
createCatalogLayer({ variants: { slug: 'merch-variants' } }) // overridesDefault-off is a compatibility requirement, not a preference: consumers track this package on a caret range across all of 1.x, so an always-on new collection would be an unrequested schema migration on their next dependency update.
Two guards, doing different jobs: sku is unique at the database level, and a beforeValidate hook rejects a second variant of the same product that declares the same axis/value set (order- and case-independent) — unique identifiers do not prevent a duplicate of the thing being identified.
Stock is not here. On-hand quantity is warehouse state and lives in @wabbit/tome-fulfillment as fulfillment-stock rows, keyed on this same sku string — as are fulfillment-shipments line items and the optional variantSku on economy prices. Matching is by SKU string, not relationship, precisely because this collection is opt-in.
The vendorScoped flag — real, post-T1 behavior
Vendor scoping is all three conditions or nothing (verified in src/collections/Products.ts + src/access/vendor-scoping.ts):
@wabbit/tome-orgis registered in the layer registry (isOrgLayerPresent()checkshasLayer('org')andhasLayer('@wabbit/tome-org')), and- the factory is called with
vendorScoped: true, and - (implicitly) the site has an org collection at the
'members'slug (or a custommembersSlug).
When any condition is false, the vendor field is absent from the schema entirely — not hidden in the admin UI, not present at all — and every product is treated as platform-owned. update/delete access gating is computed from the same vendorScopingActive boolean as the field, so they never drift out of lockstep with each other. read access is not touched by vendor scoping — publicReadActiveOnly always governs read; catalog deliberately does not reuse @wabbit/tome-core's vendorScoped() wrapper because that wrapper force-overwrites read to owner-only, which would break public catalog browsing (documented inline in Products.ts). Any site-supplied config.access is spread last and wins over both the vendor-scoped defaults and the always-on read default.
Server / client posture
Fully server-side: Payload collection factories and query helpers, no React. sideEffects: ["./dist/registry/**"] (an array, not false) — the ProductTypeRegistry module has import-side-effect registration (other layers, e.g. @wabbit/tome-lms's catalog integration, register product types by importing the registry module), so it must survive bundler tree-shaking.
Links
- Design spec:
docs/superpowers/specs/2026-04-07-tome-catalog-layer-design.md - Variants scope:
docs/superpowers/specs/2026-04-17-tome-catalog-variants-scope.md - Cross-layer: LMS↔catalog integration
docs/superpowers/specs/2026-04-26-tome-lms-catalog-integration-design.md - Gotchas: Foreign-layer relations must be conditionally spread, never `?? 'foreign-default-slug'` — directly relevant to the vendor-field pattern above
- CHANGELOG
Extending this package
New product types register into productTypeRegistry (see src/registry/ProductTypeRegistry.ts) at factory call-time — any layer that registers a type before payload.config.ts finishes building automatically appears in the Products type dropdown, no catalog-side change needed. One-off types can still be passed via additionalTypes on createProductCollection.
Exports
@wabbit/tome-catalog
Changelog
Track C I3: opt-in catalog-product-variants collection (default OFF — bare createCatalogLayer() is unchanged; enable via variants: true|{slug}, the entitlements-style knob) with SKU-axis options, semantic-duplicate hook, weight/dimensions. Economy Prices gain an optional variantSku TEXT field (advisory by design, non-unique); the checkout price query is untouched and now test-pinned.
- Track C I3: opt-in catalog-product-variants collection (default OFF — bare createCatalogLayer() is unchanged; enable via variants: true|{slug}, the entitlements-style knob) with SKU-axis options, semantic-duplicate hook, weight/dimensions. Economy Prices gain an optional variantSku TEXT field (advisory by design, non-unique); the checkout price query is untouched and now test-pinned.
1df8cf0: Track C I0 hygiene: dist ships extensioned specifiers (fix-dist-extensions --strict wired into build; assert-node-loadable preflight added — both dists now raw-Node loadable, PASS 2/2). Stale registerLayer versions corrected (catalog said 1.1.1 at 1.4.0; economy said 0.2.3 at 0.5.0) and test-pinned to package.json so future bumps can't silently drift. Economy gains its vitest harness (first tests in the package — the settlement logic landing in I1 requires it).
- 1df8cf0: Track C I0 hygiene: dist ships extensioned specifiers (fix-dist-extensions --strict wired into build; assert-node-loadable preflight added — both dists now raw-Node loadable, PASS 2/2). Stale registerLayer versions corrected (catalog said 1.1.1 at 1.4.0; economy said 0.2.3 at 0.5.0) and test-pinned to package.json so future bumps can't silently drift. Economy gains its vitest harness (first tests in the package — the settlement logic landing in I1 requires it).
6bc419c: R4 rulings #2 + #3 (all additive; every old name keeps working as a `@deprecated` alias until that package's next major). `create*` is canonical for collection/layer factories (`define*` stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain `create*Collection` names for their former `define*Collection` factories. Layer entries converge on `createXLayer(config?) → bundle`: `createCrmLayer`/`createDealsLayer`/`createMarketingLayer`/`createCatalogLayer`/`createEconomyLayer`/`createChromeLayer`/`createLmsLayer`/`createAiLayer` (+ `createFormsLayer`/`createIntakeLayer`), returning bare `CollectionConfig[]` where the layer contributes only collections or an honest named bundle where it hands back more (chrome: `{ globals }`; lms/ai: `{ collections, hooks }`); void-returning `initCatalog`/`initEconomy` stay as the single registration call sites, delegated to internally. Naming note for forms consumers: `createFormsCollection` (singular factory) vs `createFormsCollections` (plural composer) vs `createFormsLayer` (layer entry) — each docblock states the distinction.
- 6bc419c: R4 rulings #2 + #3 (all additive; every old name keeps working as a `@deprecated` alias until that package's next major). `create*` is canonical for collection/layer factories (`define*` stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain `create*Collection` names for their former `define*Collection` factories. Layer entries converge on `createXLayer(config?) → bundle`: `createCrmLayer`/`createDealsLayer`/`createMarketingLayer`/`createCatalogLayer`/`createEconomyLayer`/`createChromeLayer`/`createLmsLayer`/`createAiLayer` (+ `createFormsLayer`/`createIntakeLayer`), returning bare `CollectionConfig[]` where the layer contributes only collections or an honest named bundle where it hands back more (chrome: `{ globals }`; lms/ai: `{ collections, hooks }`); void-returning `initCatalog`/`initEconomy` stay as the single registration call sites, delegated to internally. Naming note for forms consumers: `createFormsCollection` (singular factory) vs `createFormsCollections` (plural composer) vs `createFormsLayer` (layer entry) — each docblock states the distinction.
- 36e537a: `createProductCollection({ vendorScoped: true })` now actually enforces vendor ownership, closing the gap where the flag rendered the `vendor` field but left `create`/`update`/`delete` fully open. When the flag is on AND `@wabbit/tome-org` is registered: create requires an authenticated user, update/delete require vendor ownership (admin bypass via core's `checkRole`), and a `beforeChange` hook force-assigns `vendor` on create. Explicit `config.access` still wins per-operation. Flag absent/false = byte-identical behavior to before (pinned by new tests — catalog gains a vitest suite, 24 tests).
- 36e537a: `registerLayer` is now statically imported (forms/intake pattern) instead of lazily `require()`d in ten layer packages' init/register paths. The lazy pattern silently no-ops under Payload's native-ESM CLI (`generate:types` / `generate:importmap`), so layer registration could vanish without error. Packages whose tome-core peer is genuinely optional (economy, ai, gamification) deliberately keep the guarded lazy path; tome-core's `admin-nav/self-register.ts` deliberately keeps its subpath `require()` (documented ESM/CJS dual-cache fix — do not convert).
- 36e537a: Every package now declares an explicit `sideEffects` field (38 added; motion/engine/forms already correct). Registration-bearing modules (render files' `registerRenderer`, `blocks/*/index.ts` `defineBlock` self-registration, widget `register.ts` files, productHooks, permission self-registrations, print templates, chrome built-in variants) are listed so bundlers can tree-shake everything else WITHOUT dropping import-time registrations — previously the field was unset, which blocked cross-module tree-shaking through the barrels entirely. Never blanket `false` on a package with registration or CSS.
- aef2725: DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's `sessionHasCapabilityOrLegacyAdmin`; new core `buildOwnershipWhere`/`ownershipOrBypass` (via `./access`) adopted by core's vendorScoped, catalog's vendor-scoping, and org's ownOrScoped (public APIs unchanged); `slugField()` adopted at 7 sites where semantics matched exactly (core lms collections + createMemberCollection — replacing a third independent slugify), with ~25 sites honestly skipped for named semantic divergences (auto-regenerate-on-clear vs allow-empty, collection-level hook pattern) now listed as core-enhancement candidates; new `formatDisplayDate` in blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally; `useMediaQuery`/`useIsMobile` published from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification's `awardPoints` now uses the authoritative `getPointsBalance` (fixes a divergent 1000-row scan cap vs the correct 10000).
bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.
- bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.
b4e5625: catalog: account-scope entitlements (v3 Amendment A2). Adds a nullable `accountId` relationship to the Entitlements collection (configurable via `accountsSlug`, default `'accounts'`) so an entitlement can be owned by a multi-tenant account (`@wabbit/tome-accounts`). `getEntitlements` now accepts `accountId` as an alternative owner filter (provide `userId` OR `accountId`; exactly one required) and `upsertEntitlementForSubscription` dual-writes `accountId` when provided. Fully additive: `userId` stays the required, contract-frozen owner through the transition (grant handlers dual-write `accountId`, then a backfill populates existing rows), and the original v3 frozen fields + the A1 subscription fields are unchanged. Orthogonal to the still-pending `userId → memberId` member-identity refactor.
- b4e5625: catalog: account-scope entitlements (v3 Amendment A2). Adds a nullable `accountId` relationship to the Entitlements collection (configurable via `accountsSlug`, default `'accounts'`) so an entitlement can be owned by a multi-tenant account (`@wabbit/tome-accounts`). `getEntitlements` now accepts `accountId` as an alternative owner filter (provide `userId` OR `accountId`; exactly one required) and `upsertEntitlementForSubscription` dual-writes `accountId` when provided. Fully additive: `userId` stays the required, contract-frozen owner through the transition (grant handlers dual-write `accountId`, then a backfill populates existing rows), and the original v3 frozen fields + the A1 subscription fields are unchanged. Orthogonal to the still-pending `userId → memberId` member-identity refactor.
61af0ea: Entitlements gain subscription-bounded access fields (`expiresAt`, `subscriptionId`, `status` including a `cancelling` state) per the B2 subscription-extension spec §6 — the entitlement row is the access gate, billing state lives consumer-local. `getEntitlements` now filters to active/cancelling + non-expired rows (rows with no `status` are treated as active for backward compatibility). New idempotent `upsertEntitlementForSubscription` query keyed on (userId, productId). Additive — existing lifetime grants are unaffected (`expiresAt` null = lifetime).
- 61af0ea: Entitlements gain subscription-bounded access fields (`expiresAt`, `subscriptionId`, `status` including a `cancelling` state) per the B2 subscription-extension spec §6 — the entitlement row is the access gate, billing state lives consumer-local. `getEntitlements` now filters to active/cancelling + non-expired rows (rows with no `status` are treated as active for backward compatibility). New idempotent `upsertEntitlementForSubscription` query keyed on (userId, productId). Additive — existing lifetime grants are unaffected (`expiresAt` null = lifetime).
a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.
- a9801fe: Consolidation pass (2026-06-10 audit dialect-drift findings) — the platform stops forking its own conventions: **tome-core (minor — new public APIs):** - `./auth/repScoping` — `buildRepWhereClause({ adminCapability, repField })` + `buildCapabilityScopedRead({ readCapability, adminCapability, repField })` + `sessionHasCapabilityOrLegacyAdmin` + `DENY_ALL_WHERE`. The canonical "rows I own" access primitive, promoted from crm/deals' ~90%-identical copies (266 LOC → one parameterized implementation). - `./utilities/normalize` — `normalizeEmail` (trim + lowercase). Email is the cross-layer join key; one normalizer, everywhere. - `./fields/slug` — `formatSlug` upgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); new `buildAutoSlugHook(sourceField, slugField)` collection-level variant. Stored slugs untouched; only future generations on irregular-whitespace inputs differ. **catalog / org / crm / deals (patch):** local copies replaced with delegations to the core primitives. Public names and signatures unchanged (`slugify`, `autoSlugHook`, `buildNormalizeEmailHook`, `normalizeDealEmail`, `repWhereClause`, `accountRepWhereClause`, `dealsRepWhereClause`, `dealsRepOrAdminWhereClause`). Notably, org's auto-slug header had _claimed_ to wrap core's slugifier while carrying a divergent local copy — now it actually does.
- 4b2f368: Platform-wide peer-range sweep: every `workspace:*`/`workspace:^` entry in `peerDependencies` replaced with an explicit semver range (`@wabbit/tome-core >=1.0.0 <2.0.0`, `tome-ui >=0.9.0 <1.0.0`, `tome-motion >=0.2.0 <1.0.0`, `tome-catalog >=1.1.0 <2.0.0`, `tome-admin >=0.5.0 <1.0.0`; `tome-crm` ranges standardized to `>=0.2.0 <1.0.0`). The workspace protocol publishes as an **exact-version pin**, so every substrate bump stranded installed dependents — the breakage class proven by marketing@0.1.0/deals@0.1.1 requiring `tome-crm@0.2.0` exactly. devDependencies keep `workspace:*` for the local link. (`@wabbit/tome-admin-pro` got the same source fix but is rc-versioned; it carries the change on its next intentional release.) tome-crm additionally gains a once-per-process **production warning when the capability-registry fallback grants access** — the bootstrap heuristic (any authenticated user passes `crm:read`) now announces itself instead of running silently on sites that forgot to seed capability grants (2026-06-10 audit hardening item). Graph-truth additions (same hygiene wave): tome-deals declares its lazy print integration as an optional peer (`@wabbit/tome-print >=0.1.0 <1.0.0`); tome-intake declares its lazy catalog routing strategy (`@wabbit/tome-catalog >=1.1.0 <2.0.0`, optional). These were undeclared dynamic imports — invisible to consumers and to pnpm's build topology.
Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12
- Updated dependencies [8947ff1] - @wabbit/tome-core@1.0.12
Updated dependencies [36dc023]
- Updated dependencies [36dc023]
- Updated dependencies [2612799] - @wabbit/tome-core@1.0.11
Updated dependencies - @wabbit/tome-core@0.2.0
- Updated dependencies - @wabbit/tome-core@0.2.0