Documentation menu

Documentation / Commerce engine / catalog

Catalog

Package
@wabbit/tome-catalog
Version
v1.10.0
npm install @wabbit/tome-catalog
First install from our registry?
  1. Get a registry token from your credentials page. You need a purchase that includes this package, or a Craft Library membership.

  2. Add the registry and your token to the .npmrc at the root of your project, with your token in place of YOUR_TOKEN:

    @wabbit:registry=https://npm.wabbit.com/
    //npm.wabbit.com/:_authToken=YOUR_TOKEN
Commerce engineStable

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:

PeerRangeOptional?
payload>=3.67.0no
@payloadcms/richtext-lexical>=3.67.0no
@wabbit/tome-core>=1.17.0 <2.0.0marked yes in peerDependenciesMeta
lucide-react>=0.460.0yes

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'; pass mediaCollection if yours has another slug.
  • Order matters for vendor scoping. vendorScoped: true only adds the vendor field if @wabbit/tome-org is already in the layer registry when the factory runs — create the org layer before calling createCatalogLayer, or the flag is silently a no-op (see below).
  • Product types are read at factory call-time. The Products type select is built from productTypeRegistry when createProductCollection / createCatalogLayer runs; 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 whose userId is theirs, and anonymous requests read nothing. Rows owned only through accountId are not visible to the account's other members by default; pass access.read if 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 alongside status: 'revoked', so the names are frozen. The built-in stampRevocationAudit beforeChange fills revokedAt on the transition into revoked, plus revokedBy and a default revokedReason: 'admin' when a signed-in user makes the change. It runs ahead of any hooks.beforeChange you pass. Reactivating a row clears nothing, so history stays readable. getEntitlements is 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/subscription card hooks find the products collection through a marker createProductCollection puts on the collection's custom block, so a non-default productsSlug still resolves. A products collection you build by hand without the factory is looked up as catalog-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.).

GroupExports
Layer entrycreateCatalogLayer(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 factoriescreateProductCollection, createProductAttributeCollection, createProductAttributeValueCollection, createCategoryCollection, createProductMediaCollection, createEntitlementsCollection, createProductVariantCollection (+ each factory's *CollectionConfig/*Seed type)
Sidebar-only registrationinitCatalog(config?), InitCatalogConfig — void-returning, registration-only; unchanged contract, now called internally by createCatalogLayer too
RegistryProductTypeRegistry (class), productTypeRegistry (singleton) — register(descriptor), get(value), has(value), getAll(), reset() (test-only)
Access / vendor scopingisOrgLayerPresent, shouldAddVendorField, buildVendorField, publicReadActiveOnly, vendorOwnerAccess, vendorCreateAccess, vendorAutoAssignHook
HooksautoSlugHook, slugify, categoryDepthHook, attributeValidationHook, variantOptionUniquenessHook, canonicalizeVariantOptions (+ VariantOptionInput type)
QueriesgetProductsByCategory, getProductsByVendor, getCategoryTree, getFilterableAttributes, getEntitlements, upsertEntitlementForSubscription (+ each query's Args/result type)
TypesAttributeType, 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' } })  // overrides

Default-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):

  1. @wabbit/tome-org is registered in the layer registry (isOrgLayerPresent() checks hasLayer('org') and hasLayer('@wabbit/tome-org')), and
  2. the factory is called with vendorScoped: true, and
  3. (implicitly) the site has an org collection at the 'members' slug. A custom membersSlug is only accepted by createProductCollection called directly — createCatalogLayer does not forward one, so through the layer entry the vendor field always relates to members.

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.

  • Gotchas: Foreign-layer relations must be conditionally spread, never ?? 'foreign-default-slug' (the repo's docs/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 option

Testing

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 canceled and canceling, 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 store canceling, and the order overview's access status reports it.

    Restock requests store canceled, and a canceled shipment prefers canceled when 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 shipment canceled, and restock request canceled join the existing cancelling and cancelled options. 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, revokedByRefundId and revokedNote.
    • A built-in stampRevocationAudit beforeChange hook (exported) stamps revokedAt, revokedBy and a default reason on the transition into revoked, ahead of consumer hooks.
    • The status description now reads that revoked means access withdrawn by an admin or the refund policy.
    • The new field names are the frozen revocation contract that economy writes by slug.
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 flat role passed the default read rule.
    • entitlementsOwnerOrAdminRead keeps the legacy check and adds core's role-aware isAdmin, 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.read are unaffected.

1.7.xMinor

  • Products gain an optional sortOrder number (default 0, in the admin sidebar), the manual order a storefront's "Featured" sort follows: lower numbers first, ties by title.

    It matches the sortOrder that categories, variants, media and attributes already carry. Nothing reads it unless a storefront listing asks for the featured sort.

    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 default read let any signed-in user read every entitlement. It is now owner-or-admin: admins read all rows, other users read rows whose userId is 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 own access.read.
    • The default physical/digital/service/subscription product card hooks now find the products collection under a renamed slug instead of always querying catalog-products. createProductCollection marks its collection in custom.tomeCatalogProducts for this lookup.
    • upsertEntitlementForSubscription no 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 than maxDepth; it was previously shown in the admin description only.
  • 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-react installed; the optional peer supplies only the sidebar icon.

    The Package icon is imported lazily when the layer registers. Previously a static import made every entry point that registers the layer throw on an install without lucide-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.cts declarations, 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 .map files and the sourceMappingURL comments 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 snapshots extractGallerySource writes 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, null vs. undefined, polymorphic), so behavior is unchanged. The @wabbit/tome-core peer floor goes up to >=1.17.0 because that is the first core version exporting relationIdRaw, relationIds and relationIdsRaw.

  • 1.5.1
    One LayerFactoryConfig every 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 what config may 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 learned adminGroup from org and hooks from 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/layerFactoryConfig exports the LayerFactoryConfig interface — adminGroup, access (per-collection override map), hooks (appended via mergeHooks, never replacing), extraFields, fieldOverrides, omitFields, fieldOrder, slugs — and applyLayerFactoryConfig(collections, config), which honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to fields/fieldShape's applyFieldShape so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config.

    It is a separate subpath from ./utilities/layerRegistry deliberately — that module is in core's sideEffects array, 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's Record<string, string | undefined>), and named <name>Slug?: string scalars 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 extend LayerFactoryConfig directly and APPLY it through applyLayerFactoryConfig (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-core as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches registerLayer through a lazy require() in a try/catch for exactly this reason.

    applyLayerFactoryConfig is 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 by assert: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 against CollectionConfig, 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 carry admin.group exactly as collections do. rpg extends Omit<LayerFactoryConfig, 'access'> because CharacterSheetsConfig is a single collection's config that doubles as the layer factory's config, and its own access already 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 EMPTY CollectionConfig[]: 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 WorkflowLayerConfig omits every seam for the same reason, and exists as the stable place a real option will land. createGamificationLayer and createRpgLayer are pure aliases of registerGamificationLayer / registerRpgLayer.

    initWorkflow, registerGamificationLayer and registerRpgLayer are all @deprecated with sunset at each package's next major; none is removed.

    Forcing function: scripts/assert-layer-factory-contract.mjs + pnpm assert:layer-factory-contract, wired into platform-discipline.yml after assert:layer-version (source reading only, pre-build). Every exported create*Layer must take a config parameter whose type resolves to LayerFactoryConfig — through extends, an intersection, or an explicit Omit<…> — with verb aliases followed to their register*/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.md gains a "The factory contract" section stating the rule and the three permitted responses.

    Three factories are ALLOWLISTED with a reason each: createAiLayer returns credential wiring and owns no collections, so every seam is meaningless to it; createFormsLayer and createIntakeLayer are 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-core to >=1.14.0 <2.0.0. The new subpaths do not exist below that, and a too-low floor is how ERR_PACKAGE_PATH_NOT_EXPORTED reached crowdfund's consumers once already.

    These are marked patch because 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.

    • homepage deep-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.url points at the repo issue tracker, so a paying customer has a place to report a defect that is not email.
    • engines.node is >=22, matching the root engines and .nvmrc set the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use for node --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 (root pnpm assert:manifest-metadata, wired into platform-discipline.yml beside assert:license-metadata) fails when any publishable manifest lacks description, repository.directory matching its own folder, homepage, bugs, engines.node equal to the repo floor, license, files or sideEffects. It reported 138 violations before this change and 0 after.

1.4.xMinor

  • Naming convergence (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.

  • 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).

  • 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).

  • 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.

  • 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).

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 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.

1 fix (1.3.1)
  • 1.3.1
    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.

1.2.xMinor

  • Entitlements gain subscription-bounded access fields (expiresAt, subscriptionId, status including a cancelling state) — 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).

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 — 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.

  • 1.1.4
    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.

  • 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

Full changelog across all packages