Modules
See how Litestore's registries for product pages, admin writes, operator tasks and product signals work, so you can extend one or write a new one safely.
Litestore uses registries to describe product pages, admin writes, operator tasks and storefront signals. Read this page before you add a module, a task kind or a signal, or before you write a registry of your own.
Blocks are not the only registry. These four are not separate systems either. Each one is a map keyed by a union type, declared so that the compiler rejects a missing entry.
This guide shows where each registry is and, more usefully, what the compiler checks for you in each one and what it doesn't.
Product page modules
A product page is built from modules, but in a different way from a landing page.
PDP_MODULES in lib/pdp/modules.ts is a Record<PdpModuleId, PdpModuleMeta>. It declares twelve modules, and each has a name, a description and a defaultSection:
gallerydetailscartsharecontentcontent-tabsproduct-detailsrelatedbadgesreviewsshippingspecifications
PdpSectionId names the eight slots on the page where a module can go:
gallery
details
before_cart
cart
after_gallery
content
after_content
relatedOne fixed template for every product
You can't configure which module goes in which slot. The template is fixed in code:
export const DEFAULT_PDP_TEMPLATE: ResolvedPdpTemplate = {
gallery: ["gallery"],
details: ["details"],
before_cart: ["badges"],
cart: ["cart"],
after_gallery: [],
content: ["content-tabs", "specifications", "shipping"],
after_content: [],
related: ["related"],
}resolvePdpTemplate() takes no arguments and returns exactly this template.
Litestore has no way to compose a product page per product or per category, and the admin has no screen for it. There used to be a pdpConfig override column, but nothing ever wrote to it, so every page resolved to the default anyway. The column was removed.
To change the layout of product pages, edit DEFAULT_PDP_TEMPLATE.
It follows that the registry has more modules than the template uses. The shipped template places four registered modules nowhere:
sharecontentproduct-detailsreviews
Reviews still appear on the page, because content-tabs includes them.
Admin modules
Litestore does not generate admin screens.
Each module has its own tables, forms and detail views under components/admin/. They are built from shared pieces: admin-card.tsx, detail-sections.tsx and the forms/ directory.
What the modules share is the way they write data. Each module's crud.ts extends BaseCRUD from lib/crud/ and declares five fields:
export class BlockCRUD extends BaseCRUD<Block, BlockSchema> {
protected modelName = "block" as const
protected permissionModule = AdminModule.blocks
protected readonly delegate = db.block
protected cacheTag = CACHE_TAGS.blocks
protected schema = blockSchema
}With those five declarations, the module can create, update, archive, permanently delete and restore records, and it can paginate. Archive is a soft delete for models listed in SOFT_DELETE_MODELS. Every write is also logged to the activity history and clears the module's cache tag.
Behaviour for one model
When a model needs something specific, you add it in hooks:
beforeCreate
beforeUpdate
beforeDelete
afterDeleteThe block CRUD uses these hooks to remove config keys that belong to other block types. It also uses them to refuse deleting a block that is placed on a live page.
This is why the discipline check requires every mutation to go through the CRUD layer. When every write takes the same path, activity logging and cache invalidation happen on every screen, not just on the screens someone remembered.
Task kinds
Litestore ships 41 kinds of operator task.
A task kind is not a stored record. It is a query that detects a condition, plus a way to present the result. The kinds are the variants of the AdminTaskData discriminated union in server/admin/tasks/queries.ts. Each variant contains the data its detection query returns, with no presentation in it:
{ kind: "lowStock"; lowCount: number; outCount: number; sample: string[] }Two Record<AdminTaskKind, …> maps in lib/tasks/definitions.ts are keyed by this union. If you add a kind, typecheck fails until you fill in both maps.
presentAdminTask takes { kind, level, data } and returns the icon, title, description and action label that the task panel shows. alertTone picks the colour from the domain level. The server sends no icons, colours or formatted text.
TASK_RESOLUTIONS records how the operator resolves each kind. It uses one of these modes:
navigateaction, a fixed one-click fixaiFixdecide, which shows approve and reject rows inline that call the real server actionassist, the only mode that opens chat
A third map, TASK_TOOL_SCOPE in server/ai/agent-tools.ts, lists the agent tools that a chat about one task may use. It is server-only, so it can't sit next to the other two maps.
See events to learn how task kinds relate to the other ways Litestore reacts to changes.
The shared pattern
Every registry here has the same shape. It is a map keyed by a real union type and declared with satisfies Record<Kind, Def>, so a missing entry fails typecheck.
These five registries follow it:
BLOCK_REGISTRYinlib/blocks/registry.tsBLOCK_TYPE_SETTINGSinlib/blocks/settings.tsPDP_MODULESinlib/pdp/modules.tsTASK_RESOLUTIONSinlib/tasks/definitions.tsPRODUCT_SIGNAL_DEFINITIONSinlib/product-signals/registry.ts
Read the product-signal registry first
Read the product-signal registry before you write a new registry. It shows how much useful information a definition can hold beyond a label.
Litestore computes five signals: new, sale, sale-ending, limited-stock and bestseller. Each signal declares three things:
- The
surfaceswhere it may appear. - The merchant setting that adjusts its threshold, or a written reason why it has none.
- The operator task kind that the same condition triggers.
"sale-ending": {
slug: "sale-ending",
label: "Sale ends soon",
icon: "lucide/timer",
variant: "danger",
priority: 45,
surfaces: ["pdp"],
setting: { key: "sale_ends_soon_hours" },
taskKind: "saleEnding",
},allSignalSettingKeys() reads every settings key from these declarations. Because of that, the settings schema can't fall out of step with the registry.
Writing a new registry
When you need a new registry, copy this pattern instead of inventing a variation. .claude/guides/platform-primitives.md in the repository has the canonical version, together with the rest of the platform's patterns.
Before you write the map, don't just ask whether it compiles. Ask this:
If someone adds a member to the union next month, what tells them this map exists?
If the registry is keyed by a real union type, a typecheck failure tells them. If it is keyed by string, a bug report tells them.