Documentation menu
Documentation / Commerce engine / catalog
Catalog
npm install @wabbit/tome-catalogFirst install from our registry?
Get a registry token from your credentials page. You need a purchase that includes this package, or a Craft Library membership.
Add the registry and your token to the
.npmrcat the root of your project, with your token in place ofYOUR_TOKEN:@wabbit:registry=https://npm.wabbit.com/ //npm.wabbit.com/:_authToken=YOUR_TOKEN
Tome catalog layer — products, attributes, categories, vendor scoping, ProductTypeRegistry.
Overview
Tome catalog layer — products, attributes, categories, vendor scoping, ProductTypeRegistry. Description copied verbatim from package.json.
Layer: domain (per the repo's ARCHITECTURE.md § The Layer Model). Consumed (as an optional peer in each case) by @wabbit/tome-lms (course-as-product integration), @wabbit/tome-deals, @wabbit/tome-intake, and @wabbit/tome-blocks-catalog-pack (live-data hydration).
Peer dependencies
Peer ranges, copied from package.json:
| Peer | Range | Optional? |
|---|---|---|
payload | >=3.67.0 | no |
@payloadcms/richtext-lexical | >=3.67.0 | no |
@wabbit/tome-core | >=1.17.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, src/initCatalog.ts and the collection factories import unconditionally from @wabbit/tome-core subpaths (utilities/layerRegistry, utilities/typedSlug, utilities/layerFactoryConfig, access, access/checkRole, access/vendorScoped) — 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. lucide-react is genuinely optional: the layer's Package sidebar icon is loaded lazily, and without the package the admin sidebar shows the nav domain's default icon instead.
60-second quickstart
The current API is createCatalogLayer(config?) — the canonical top-level layer entry. 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.
Three things the quickstart assumes about your config:
- A `media` upload collection exists. Products and categories relate their images to
mediaCollection, default'media'; passmediaCollectionif yours has another slug. - Order matters for vendor scoping.
vendorScoped: trueonly adds thevendorfield if@wabbit/tome-orgis already in the layer registry when the factory runs — create the org layer before callingcreateCatalogLayer, or the flag is silently a no-op (see below). - Product types are read at factory call-time. The Products
typeselect is built fromproductTypeRegistrywhencreateProductCollection/createCatalogLayerruns; a type registered afterwards never reaches the dropdown. Built-in types:physical(default),digital,service,subscription.
Default slugs are in CATALOG_DEFAULT_SLUGS (catalog-products, catalog-attributes, catalog-attribute-values, catalog-categories, catalog-media, catalog-product-variants); the opt-in entitlements collection defaults to entitlements. New products default to status: 'draft', and anonymous visitors can only read active ones.
Access and behavior defaults worth knowing:
- Entitlements read is owner-or-admin. Without
vendorScoped, an admin reads every entitlement, any other signed-in user reads only rows whoseuserIdis theirs, and anonymous requests read nothing. Rows owned only throughaccountIdare not visible to the account's other members by default; passaccess.readif your site resolves entitlements by account. - Entitlements keep a revocation audit. Six nullable fields (
revokedAt,revokedReason=refund/admin/chargeback/policy,revokedBy,revokedByOrder,revokedByRefundId,revokedNote) record who withdrew access, when and why. They are the revocation contract: economy's refund access policy writes them by slug alongsidestatus: 'revoked', so the names are frozen. The built-instampRevocationAuditbeforeChangefillsrevokedAton the transition intorevoked, plusrevokedByand a defaultrevokedReason: 'admin'when a signed-in user makes the change. It runs ahead of anyhooks.beforeChangeyou pass. Reactivating a row clears nothing, so history stays readable.getEntitlementsis unchanged (revoked rows were already excluded). - Category `maxDepth` is enforced.
createCategoryCollection({ maxDepth: 2 })rejects a write that would nest a category below depth 2 (root = 0). Re-parenting a category does not re-check its existing descendants. - Renamed products slugs keep working in product cards. The default
physical/digital/service/subscriptioncard hooks find the products collection through a markercreateProductCollectionputs on the collection'scustomblock, so a non-defaultproductsSlugstill resolves. A products collection you build by hand without the factory is looked up ascatalog-products. - `upsertEntitlementForSubscription` tolerates concurrent deliveries. Two simultaneous deliveries of the same webhook fold back to one row (the oldest survives), and a duplicate-key error from a unique index you add on the key is resolved by updating the existing row.
API surface
Single export subpath (. only — no @wabbit/tome-catalog/server, ./test, etc.).
| Group | Exports |
|---|---|
| Layer entry | createCatalogLayer(config?), CatalogLayerConfig — canonical top-level entry; returns CollectionConfig[], matching the createOrgLayer/createAccountsLayer bare-array shape. Accepts every CatalogConfig slug/flag plus entitlements and variants opt-ins and core's LayerFactoryConfig options |
| 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) — register(descriptor), get(value), has(value), getAll(), reset() (test-only) |
| Access / vendor scoping | isOrgLayerPresent, shouldAddVendorField, buildVendorField, publicReadActiveOnly, vendorOwnerAccess, vendorCreateAccess, vendorAutoAssignHook |
| Hooks | autoSlugHook, slugify, categoryDepthHook, attributeValidationHook, variantOptionUniquenessHook, canonicalizeVariantOptions (+ VariantOptionInput type) |
| 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 color 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. A custommembersSlugis only accepted bycreateProductCollectioncalled directly —createCatalogLayerdoes not forward one, so through the layer entry thevendorfield always relates tomembers.
Condition 1 is evaluated when the factory runs, so the org layer must be registered before createCatalogLayer / createProductCollection is called.
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 (anonymous visitors see only status: 'active' products; any logged-in user can read every product, drafts included); 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
- Gotchas: Foreign-layer relations must be conditionally spread, never
?? 'foreign-default-slug'(the repo'sdocs/claude-gotchas.md) — directly relevant to the vendor-field pattern above CHANGELOG.md
Extending this package
New product types register into productTypeRegistry (see src/registry/ProductTypeRegistry.ts); the Products factory reads the registry when it is called, so any layer that registers a type before createCatalogLayer / createProductCollection runs automatically appears in the Products type dropdown, no catalog-side change needed. A type registered after the factory has run does not appear. One-off types can still be passed via additionalTypes on createProductCollection.
import { productTypeRegistry, createCatalogLayer } from '@wabbit/tome-catalog'
productTypeRegistry.register({ value: 'ticket', label: 'Event Ticket', source: 'my-site' })
const catalogCollections = createCatalogLayer() // 'ticket' is now a Products type optionTesting
pnpm --filter @wabbit/tome-catalog test runs the Vitest suite. Collection factories are pure functions, so most tests assert on the returned CollectionConfig without booting Payload. The layer and product-type registries are module-level state: call productTypeRegistry.reset() (then re-register the four built-ins if you need them) between tests that register types, and register or omit the org layer explicitly in tests that exercise vendorScoped.
Exports
| Import path |
|---|
@wabbit/tome-catalog |
Changelog
1.10.xMinor
Update your code first: the commerce writers now store the American values.
New rows hold
canceledandcanceling, so a site that compares to'cancelled'or'cancelling'must accept both spellings before it takes this release. This is the second of three steps toward American spelling in stored values.Canceled orders (cart and checkout failures, Stripe session expiry and the stale order sweep) store
canceled. Entitlements canceling at period end storecanceling, and the order overview's access status reports it.Restock requests store
canceled, and a canceled shipment preferscanceledwhen the shipments collection offers it.Reads still accept both spellings, and rows that hold the old values are unchanged until a site runs its backfill. The old options stay until the third step.
On a SQL adapter the enum already gained the new value in the accept-both release, so make sure that migration has run.
1.9.xMinor
Canceled statuses now accept both spellings.
This is the first of three steps toward American spelling in stored values, and nothing that gets written changes yet.
Entitlement access
canceling, order, payout and shipmentcanceled, and restock requestcanceledjoin the existingcancellingandcancelledoptions. Every read, query and display treats the two spellings as one meaning: access checks, refund access, the order cancel event, the order overview, restock links and cleanup, the shipment close on refund, and the order confirmation view.Documents that hold the old value keep validating and behave as before.
The admin shows both options, because Payload cannot hide one select option. The old one is labeled "Canceled (legacy spelling)".
Writers still store the old values; a later release switches them and a backfill moves the stored data.
If you read these statuses in your own code or queries, accept both spellings now. Postgres sites need a migration for the added enum values.
1.8.xMinor
BREAKING: Entitlements now record who revoked access, when and why, through new fields that need a database migration on SQL adapters.
Migration: On SQL adapters generate and run a migration for the new nullable entitlement fields; MongoDB needs none. Otherwise the change is additive.
- New fields:
revokedAt,revokedReason(refund / admin / chargeback / policy),revokedBy,revokedByOrder,revokedByRefundIdandrevokedNote. - A built-in
stampRevocationAuditbeforeChange hook (exported) stampsrevokedAt,revokedByand a default reason on the transition intorevoked, ahead of consumer hooks. - The
statusdescription now reads thatrevokedmeans access withdrawn by an admin or the refund policy. - The new field names are the frozen revocation contract that economy writes by slug.
- New fields:
3 fixes (1.8.1 – 1.8.3)
- 1.8.3
Admin labels, field descriptions, demo content and messages now use American spellings.
- 1.8.2
README: subpath and file mentions are written package-qualified (for example
@wabbit/tome-x/render) or as code text, so crawlers no longer resolve them as site URLs. - 1.8.1
Admins whose admin rights come from RBAC role records (
roles: [<admin role>]) can read every Entitlement again; before, only a legacy flatrolepassed the default read rule.entitlementsOwnerOrAdminReadkeeps the legacy check and adds core's role-awareisAdmin, which resolves populated roles or fetches them by id.- Effect: on RBAC sites the Order Overview's access tiles and the Entitlements admin list were empty for admins. Sites that pass their own
access.readare unaffected.
1.7.xMinor
Products gain an optional
sortOrdernumber (default 0, in the admin sidebar), the manual order a storefront's "Featured" sort follows: lower numbers first, ties by title.It matches the
sortOrderthat categories, variants, media and attributes already carry. Nothing reads it unless a storefront listing asks for thefeaturedsort.A store on SQL adds the column in its next migration; a MongoDB store needs none. Products created before the field existed have no value until it is set.
1.6.xMinor
BREAKING: a default changed — see the Migration note below.
Entitlements are now readable only by their owner or an admin by default, and four catalog defaults now behave as documented.
- Security fix: without
vendorScoped, the Entitlements collection's defaultreadlet any signed-in user read every entitlement. It is now owner-or-admin: admins read all rows, other users read rows whoseuserIdis theirs, anonymous requests read nothing. Migration: a site that relied on the old open read (for example, account members reading an account's grants) passes its ownaccess.read. - The default
physical/digital/service/subscriptionproduct card hooks now find the products collection under a renamed slug instead of always queryingcatalog-products.createProductCollectionmarks its collection incustom.tomeCatalogProductsfor this lookup. upsertEntitlementForSubscriptionno longer leaves duplicate rows when two deliveries of the same webhook run at once; duplicates fold back to the oldest row, and a duplicate-key error from a unique index on the key updates the existing row instead of failing.createCategoryCollection({ maxDepth })now rejects a write that would nest a category deeper thanmaxDepth; it was previously shown in the admin description only.
- Security fix: without
The variant option-uniqueness check now reads a product's sibling variants in bounded pages instead of one unbounded read.
Validation is unchanged.
The layer now loads without
lucide-reactinstalled; the optional peer supplies only the sidebar icon.The
Packageicon is imported lazily when the layer registers. Previously a static import made every entry point that registers the layer throw on an install withoutlucide-react.Without it, the admin sidebar shows the nav domain's default icon.
customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
1 fix (1.6.1)
- 1.6.1
Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps.
What you install: one compiled
.js(ESM) and.cjs(CommonJS) file per source module, its.d.ts/.d.ctsdeclarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license:/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */.The
.mapfiles and thesourceMappingURLcomments that pointed at them are gone, which roughly halves the size of each tarball.Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line.
A
'use client'directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged.No API change, no runtime behavior change, and nothing to do on upgrade.
In
@wabbit/tome-blocks-gallery, the source snapshotsextractGallerySourcewrites from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
1.5.xMinor
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.
3 fixes (1.5.1 – 1.5.2)
- 1.5.2
Local relationship-id helpers are replaced by
@wabbit/tome-core/utilities/relationId.Each call site maps to the core reader with the same return shape (raw vs. stringified id,
nullvs.undefined, polymorphic), so behavior is unchanged. The@wabbit/tome-corepeer floor goes up to>=1.17.0because that is the first core version exportingrelationIdRaw,relationIdsandrelationIdsRaw. - 1.5.1
One
LayerFactoryConfigevery layer factory's config extends, and one factory verb.Fourteen layer packages end in the same one call a consumer writes into
payload.config.ts, and no two agreed on whatconfigmay contain: full seam vocabulary in three (org, lms, ledger), partial in six, NONE in six (2026-09-01 sale-readiness audit §5.3). A site that learnedadminGroupfrom org andhooksfrom sc discovered, package by package, that six factories accept neither — not because the seam had been rejected, but because nothing said it existed.New in core (a NEW exports-map subpath, hence the minor):
@wabbit/tome-core/utilities/layerFactoryConfigexports theLayerFactoryConfiginterface —adminGroup,access(per-collection override map),hooks(appended viamergeHooks, never replacing),extraFields,fieldOverrides,omitFields,fieldOrder,slugs— andapplyLayerFactoryConfig(collections, config), which honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated tofields/fieldShape'sapplyFieldShapeso the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config.It is a separate subpath from
./utilities/layerRegistrydeliberately — that module is in core'ssideEffectsarray, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph.The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed
slugs?: Partial<XSlugs>map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base'sRecord<string, string | undefined>), and named<name>Slug?: stringscalars for relationship targets in OTHER layers (memberSlug,mediaSlug,eventSlug,rolesSlug) — those are pointers out of a layer, not entries in its key set.*Every `createLayer
config now extends it.** Twelve extendLayerFactoryConfigdirectly and APPLY it throughapplyLayerFactoryConfig(accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest,adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted.Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). economy and gamification both declare
@wabbit/tome-coreas an OPTIONAL peer and hold zero runtime imports of it — gamification reachesregisterLayerthrough a lazyrequire()in a try/catch for exactly this reason.applyLayerFactoryConfigis a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred byassert:no-forked-primitives. The trigger is stated: the day core becomes a required peer, delete the note and add one line.Both take the type via
import type, which is erased at runtime.Two packages drop seams EXPLICITLY rather than accept-and-ignore. chrome extends
Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed againstCollectionConfig, and chrome's slugs already have direct per-surface knobs (header.slug,footer.slug) a parallel map could contradict.The one seam it keeps,
adminGroup, IS applied: globals carryadmin.groupexactly as collections do. rpg extendsOmit<LayerFactoryConfig, 'access'>becauseCharacterSheetsConfigis a single collection's config that doubles as the layer factory's config, and its ownaccessalready means "this collection's access object" — one level shallower than the base's slug-keyed map.Two meanings under one name is the confusion this interface exists to end.
Factory-verb convergence. Three verbs were live.
createWorkflowLayer(config?)is new in@wabbit/tome-workflow(a new export — hence the minor) and returns a spreadable, deliberately EMPTYCollectionConfig[]: this layer is an engine, not a collection set, so the empty array is the honest answer and lets...createWorkflowLayer()compose exactly like every sibling.Its
WorkflowLayerConfigomits every seam for the same reason, and exists as the stable place a real option will land.createGamificationLayerandcreateRpgLayerare pure aliases ofregisterGamificationLayer/registerRpgLayer.initWorkflow,registerGamificationLayerandregisterRpgLayerare all@deprecatedwith sunset at each package's next major; none is removed.Forcing function:
scripts/assert-layer-factory-contract.mjs+pnpm assert:layer-factory-contract, wired intoplatform-discipline.ymlafterassert:layer-version(source reading only, pre-build). Every exportedcreate*Layermust take a config parameter whose type resolves toLayerFactoryConfig— throughextends, an intersection, or an explicitOmit<…>— with verb aliases followed to theirregister*/init*target.Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral.
docs/guides/create-a-new-layer-package.mdgains a "The factory contract" section stating the rule and the three permitted responses.Three factories are ALLOWLISTED with a reason each:
createAiLayerreturns credential wiring and owns no collections, so every seam is meaningless to it;createFormsLayerandcreateIntakeLayerare owned by the forms+intake access wave running in parallel, whose changes rewrite the same files.Peer floors: accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise
@wabbit/tome-coreto>=1.14.0 <2.0.0. The new subpaths do not exist below that, and a too-low floor is howERR_PACKAGE_PATH_NOT_EXPORTEDreached crowdfund's consumers once already.These are marked
patchbecause the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead. - 1.5.1
Manifest metadata:
homepage,bugs,engines.All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change.
homepagedeep-links to that package README on GitHub (.../tree/main/packages/<dir>#readme). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want.bugs.urlpoints at the repo issue tracker, so a paying customer has a place to report a defect that is not email.engines.nodeis>=22, matching the rootenginesand.nvmrcset the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use fornode --test, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version.
The forcing function ships with the change:
scripts/assert-manifest-metadata.mjs(rootpnpm assert:manifest-metadata, wired intoplatform-discipline.ymlbesideassert:license-metadata) fails when any publishable manifest lacksdescription,repository.directorymatching its own folder,homepage,bugs,engines.nodeequal to the repo floor,license,filesorsideEffects. It reported 138 violations before this change and 0 after.
1.4.xMinor
Naming convergence (all additive; every old name keeps working as a
@deprecatedalias 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 gaincreate*Collectionnames for their formerdefine*Collectionfactories. Layer entries converge oncreateXLayer(config?) → bundle:createCrmLayer/createDealsLayer/createMarketingLayer/createCatalogLayer/createEconomyLayer/createChromeLayer/createLmsLayer/createAiLayer(+createFormsLayer/createIntakeLayer), returning bareCollectionConfig[]where the layer contributes only collections or an honest named bundle where it hands back more (chrome:{ globals }; lms/ai:{ collections, hooks }); void-returninginitCatalog/initEconomystay as the single registration call sites, delegated to internally.Naming note for forms consumers:
createFormsCollection(singular factory) vscreateFormsCollections(plural composer) vscreateFormsLayer(layer entry) — each docblock states the distinction.createProductCollection({ vendorScoped: true })now actually enforces vendor ownership, closing the gap where the flag rendered thevendorfield but leftcreate/update/deletefully open.When the flag is on AND
@wabbit/tome-orgis registered: create requires an authenticated user, update/delete require vendor ownership (admin bypass via core'scheckRole), and abeforeChangehook force-assignsvendoron create. Explicitconfig.accessstill wins per-operation.Flag absent/false = byte-identical behavior to before (pinned by new tests — catalog gains a vitest suite, 24 tests).
registerLayeris now statically imported (forms/intake pattern) instead of lazilyrequire()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'sadmin-nav/self-register.tsdeliberately keeps its subpathrequire()(documented ESM/CJS dual-cache fix — do not convert).Every package now declares an explicit
sideEffectsfield (38 added; motion/engine/forms already correct).Registration-bearing modules (render files'
registerRenderer,blocks/*/index.tsdefineBlockself-registration, widgetregister.tsfiles, 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 blanketfalseon a package with registration or CSS.DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's
sessionHasCapabilityOrLegacyAdmin; new corebuildOwnershipWhere/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; newformatDisplayDatein blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally;useMediaQuery/useIsMobilepublished from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification'sawardPointsnow uses the authoritativegetPointsBalance(fixes a divergent 1000-row scan cap vs the correct 10000).
1 fix (1.4.1)
- 1.4.1
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).
1.3.xMinor
catalog: account-scope entitlements (v3 Amendment A2).
Adds a nullable
accountIdrelationship to the Entitlements collection (configurable viaaccountsSlug, default'accounts') so an entitlement can be owned by a multi-tenant account (@wabbit/tome-accounts).getEntitlementsnow acceptsaccountIdas an alternative owner filter (provideuserIdORaccountId; exactly one required) andupsertEntitlementForSubscriptiondual-writesaccountIdwhen provided.Fully additive:
userIdstays the required, contract-frozen owner through the transition (grant handlers dual-writeaccountId, then a backfill populates existing rows), and the original v3 frozen fields + the A1 subscription fields are unchanged. Orthogonal to the still-pendinguserId → memberIdmember-identity refactor.
1 fix (1.3.1)
- 1.3.1
Docs-manifest emitter pipeline (W3 ship-readiness).
@wabbit/tome-blocks-corenow ships a standalone Node ESM CLI atscripts/emit-docs-manifests.mjsthat emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free<pkg>/metablock-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
./metapayload-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'sBlockMetawas 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.mdto its publishedfilesarray so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.
1.2.xMinor
Entitlements gain subscription-bounded access fields (
expiresAt,subscriptionId,statusincluding acancellingstate) — the entitlement row is the access gate, billing state lives consumer-local.getEntitlementsnow filters toactive/cancelling+ non-expired rows (rows with nostatusare treated as active for backward compatibility). New idempotentupsertEntitlementForSubscriptionquery keyed on (userId, productId).Additive — existing lifetime grants are unaffected (
expiresAtnull = lifetime).
1.1.x
5 fixes (1.1.2 – 1.1.4)
- 1.1.4
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—formatSlugupgraded to the canonical algorithm (promoted from catalog's strictly-more-robust slugify: collapses whitespace/hyphen runs, trims edge hyphens); newbuildAutoSlugHook(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.
- 1.1.4
Platform-wide peer-range sweep: every
workspace:*/workspace:^entry inpeerDependenciesreplaced 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-crmranges 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.0exactly. devDependencies keepworkspace:*for the local link. (@wabbit/tome-admin-progot 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. - 1.1.3
Updated dependencies [8947ff1]
- @wabbit/tome-core@1.0.12
- 1.1.2
Updated dependencies [36dc023]
- 1.1.2
Updated dependencies [2612799]
- @wabbit/tome-core@1.0.11
1.0.x
1 fix (1.0.0)
- 1.0.0
Updated dependencies
- @wabbit/tome-core@0.2.0