Litestore · Developer guide

Modules

Learn about the PDP module registry, the BaseCRUD persistence layer, the 41 operator task kinds, and the registry shape they share.

Blocks are not the only registry in Litestore. Product pages, admin persistence, operator tasks and storefront signals each have one.

They are not four unrelated systems. They are four instances of the same idea: a map keyed by a union type, declared so that the compiler refuses an incomplete entry.

This guide shows where each one lives, and — more usefully — what the compiler does and does not check for you in each.

Product page modules

A product page is composed from modules, but not the same way a landing page is.

PDP_MODULES in lib/pdp/modules.ts is a Record<PdpModuleId, PdpModuleMeta> declaring twelve modules, each with a name, a description and a defaultSection:

  • gallery
  • details
  • cart
  • share
  • content
  • content-tabs
  • product-details
  • related
  • badges
  • reviews
  • shipping
  • specifications

PdpSectionId names the eight structural slots they can sit in:

gallery
details
before_cart
cart
after_gallery
content
after_content
related

One fixed global template

Which module renders in which slot is not configurable:

lib/pdp/resolve.ts
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() returns exactly this and takes no arguments.

There is no per-product or per-category PDP composition and no admin surface for it. The pdpConfig override column was removed because nothing ever authored it, so resolution always produced the default anyway.

Changing a product page's layout means editing DEFAULT_PDP_TEMPLATE.

That also means the registry is wider than the template. Four registered modules are placed nowhere by the shipped template:

  • share
  • content
  • product-details
  • reviews

Reviews still reach the page — through content-tabs.

Admin modules

An admin screen is not generated.

Tables, forms and detail views are written per module under components/admin/, composed from the shared pieces: admin-card.tsx, detail-sections.tsx, and the forms/ directory.

What is shared is the write path. Each module's crud.ts extends BaseCRUD (lib/crud/) and declares five fields:

server/admin/blocks/crud.ts
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
}

From those five declarations it gets:

  • create
  • update
  • archive — soft delete for models in SOFT_DELETE_MODELS
  • permanent delete
  • restore
  • pagination
  • activity logging
  • cache-tag invalidation

Per-model behaviour

Anything a specific model needs hangs off hooks:

beforeCreate
beforeUpdate
beforeDelete
afterDelete

The block CRUD uses them to strip foreign-type config keys and to refuse deleting a block that is placed on a live page.

This is why the discipline check requires mutations to go through the CRUD layer. Routing every write through one path is what makes activity logging and cache invalidation happen on every screen, rather than on the screens someone remembered.

Task kinds

Litestore ships 41 operator task kinds.

A task kind is a query plus a presentation, not a stored record. The kinds are the variants of the AdminTaskData discriminated union in server/admin/tasks/queries.ts, each carrying the presentation-free payload its detection query produces:

{ kind: "lowStock"; lowCount: number; outCount: number; sample: string[] }

The union feeds two Record<AdminTaskKind, …> maps in lib/tasks/definitions.ts, so a new kind fails typecheck until both are filled in.

presentAdminTask turns { kind, level, data } into the icon, title, description and action label the panel renders. Tone comes from the domain level through alertTonethe server emits no icons, tones or formatted strings.

TASK_RESOLUTIONS classifies how the kind resolves:

  • navigate
  • action — a deterministic one-click fix
  • aiFix
  • decide — inline approve/reject rows that call the real server action
  • assist — the only mode that opens chat

A third map, TASK_TOOL_SCOPE in server/ai/agent-tools.ts, lists the agent tools a task-scoped chat may use. It is server-only, which is why it cannot live beside the other two.

See events for where task kinds sit among the other reactions.

The shared pattern

The canonical shape is a map keyed by a real union type and declared satisfies Record<Kind, Def>, so a missing entry fails typecheck.

Five registries follow it:

  • BLOCK_REGISTRYlib/blocks/registry.ts
  • BLOCK_TYPE_SETTINGSlib/blocks/settings.ts
  • PDP_MODULESlib/pdp/modules.ts
  • TASK_RESOLUTIONSlib/tasks/definitions.ts
  • PRODUCT_SIGNAL_DEFINITIONSlib/product-signals/registry.ts

Read the product-signal registry first

The product-signal registry is the one worth reading before you write a new one, because it shows what a definition can usefully carry beyond a label.

Each of the five computed signals — new, sale, sale-ending, limited-stock, bestseller — declares three things:

  • The surfaces it may render on
  • The merchant setting that tunes its threshold, or a written reason it has none
  • The operator task kind fed by the same underlying condition
lib/product-signals/registry.ts
"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() derives every settings key from that declaration, so the settings schema and the registry cannot drift.

Where a new registry goes

When you need one, copy this pattern rather than inventing a variation. .claude/guides/platform-primitives.md in the repository carries the canonical version alongside the rest of the platform's idioms.

The question worth asking about a map you are about to write is not "does it compile?" It is:

If someone adds a member to the union next month, what tells them this map exists?

A registry keyed by a real union type answers that with a typecheck failure; a registry keyed by string answers it with a bug report.

On this page