CRUD
Build an admin module that saves, archives and deletes records through the shared BaseCRUD class.
Every admin module in Litestore saves its data through one shared base class instead of its own persistence code. Read this page when you add an admin module or change how an existing one writes to the database.
BaseCRUD
platform/crud/base-crud.ts is the shared persistence layer behind each admin module's crud.ts. A module declares which model it works with, and the base class supplies create, update, archive, restore and permanent delete, the activity rows and the cache refresh, along with the zsa server actions for archive, restore and permanent delete.
The base class doesn't build any interface or read path. Each module has its own admin tables, forms and detail views in modules/<area>/admin/, and its reads are plain Prisma queries in that folder's queries.ts. Modules save data in the same way but have very different screens, so we only share the persistence code, which keeps each module's declaration down to a handful of lines.
CRUD declaration
A subclass declares five protected fields.
| Field | What it does |
|---|---|
modelName | It names the Prisma model. |
permissionModule | It names the permission module that guards the generated actions. |
delegate | It points at the Prisma delegate that does the writing. |
schema | It gives the zod write schema. |
cacheTags | It lists the static cache tags that every write refreshes. |
TagCRUD is the smallest one in the repository:
class TagCRUD extends BaseCRUD<Tag, TagSchema> {
protected modelName = "tag" as const
protected permissionModule = AdminModule.tags
protected readonly delegate = db.tag
protected schema = tagSchema
protected cacheTags = [CACHE_TAGS.tags]
protected primaryKey = "slug"
protected getDefaultInclude() {
return {
include: {
_count: {
select: {
products: { where: { deletedAt: null } },
},
},
},
} satisfies Prisma.TagDefaultArgs
}
}
export const tagCrud = new TagCRUD()Each module exports a single instance, and the module's actions.ts imports it. There are 21 CRUD modules across the business areas.
The class takes two type parameters. TEntity is the full Prisma row, and TInput is the validated shape of a write. Keeping them separate matters, because treating them as the same type makes it easy to add a write schema field that isn't stored anywhere.
types.ts defines ActionContext (the acting user, an optional transaction and the concurrency token) and batchIdsSchema. pagination.ts contains the pagination arithmetic (getPaginationOffsets, getPageCount and normalizePaginationParams) that hand-written table queries import.
Writes
create and update follow the same steps.
Unknown keys
BaseCRUD logs any submitted key that isn't in the write schema.
Row write
BaseCRUD writes through getDelegate(ctx?.tx), so the write joins the caller's transaction if there is one.
Activity and cache
BaseCRUD writes an activity row and then refreshes the cache.
There are no lifecycle hooks. When a write has a consequence elsewhere, the module overrides the method and calls super, so the extra step is written where it happens. CollectionCRUD.update checks that a new handle is free and re-derives the slug before calling super.update, then refreshes the collection's own storefront caches. CategoryCRUD.update refreshes the category's caches the same way. Writes that aren't plain persistence, such as createWithProducts or duplicate, are ordinary methods on the module's class.
The next four sections describe write behaviour that's worth learning about when you build a module.
Schema key logging
warnOnKeysOutsideSchema compares the submitted object with this.schema.shape and logs any extra keys, so they aren't dropped without a trace.
This is the runtime half of the phantom-field guard described further down. It doesn't stop the write, but the mismatch shows up in the logs.
Optimistic concurrency
When ctx.expectedUpdatedAt is set, update only writes if this condition matches:
where: { id, updatedAt }If the form is stale, no row matches, Prisma raises P2025, and the caller gets this error:
RECORD_CHANGED: This record was changed by someone else. Reload and try again.If you leave out the token, the last write wins. The check is off by default, so a module that needs the check has to pass the token.
Slug history
For the storefront models that modules/storefront/slug-redirects.ts names, update adds the old slug to the row's URL history in the same write, so old storefront URLs redirect instead of returning a 404. Since both changes happen in one write, the new slug is always saved together with its redirect.
Activity entries
When the model is logged or has redirects, update takes a snapshot of the stored row before writing and compares it with the data. Forms always send every field, so this comparison is how the activity entry can list only the field groups that actually changed, with their old and new values.
When the caller passes ctx.tx, the activity row is written inside that transaction, so it rolls back with the change it describes.
Archive, restore and delete
SOFT_DELETE_MODELS in platform/db/soft-delete-models.ts is derived from the Prisma DMMF and contains every model that has a deletedAt column. delete() and restore() check the model against that list. If the model has no deletedAt, they throw <model> does not support delete instead of falling back to destroying the row.
private assertCanSoftDelete(operation: "delete" | "restore"): void {
if (!SOFT_DELETE_MODELS.includes(this.modelName)) {
throw new Error(`${this.modelName} does not support ${operation}`)
}
}delete() archives: it sets deletedAt on every id in one updateMany, logs one activity row and refreshes the caches. restore() clears deletedAt the same way.
permanentDelete() can't be undone. It's also the only base method that uses this.primaryKey, since delete() and restore() always use id. If a CRUD overrides primaryKey, as TagCRUD does with slug, only permanentDelete can remove its records.
Product, Variant, Collection and Category set mediaOwnerType. For these models, permanentDelete also removes the polymorphic MediaAssociation rows, because there's no foreign key cascade to clean them up. Archiving keeps them, so a restore brings the images back.
permanentDelete does not archive
permanentDelete() calls deleteMany and skips the deletedAt check entirely. It removes rows from archivable and non-archivable models alike, and nothing can restore them. Use delete() to archive. When an admin screen offers permanentDelete(), its label should say plainly what it does.
Cache invalidation
Every write outside a transaction ends by refreshing the module's static cacheTags and the entity tag ${modelName}-${entityId}. Once a module lists its tags, every write clears them.
When a write affects more storefront pages than the static tags cover, the module overrides the method and calls the matching helper from platform/cache.ts after super, as CollectionCRUD.update does with invalidateCollections.
When the caller passes ctx.tx, create and update skip invalidation, because invalidating in the middle of a transaction would clear the cache for a write that hasn't committed yet. In that case, the caller has to invalidate after the commit.
Generated server actions
The base class also builds zsa server actions from the same declarations.
It creates each one through modulePermission(this.permissionModule)(action), so the permission string is <module>:<action> and the handler receives ctx.user:
deleteAction() {
return this.serverAction(Action.delete)
.input(z.object({ ids: batchIdsSchema }))
.handler(async ({ input: { ids }, ctx }) => {
await this.delete(ids, ctx)
return { success: true }
})
}The base class provides three actions:
deleteAction
restoreAction
permanentDeleteActionA module exports the actions it needs and writes create and update actions by hand, so each one shows its own schema and permission:
export const deleteReturns = returnCrud.deleteAction()
export const restoreReturns = returnCrud.restoreAction()
export const permanentDeleteReturns = returnCrud.permanentDeleteAction()batchIdsSchema limits a batch to MAX_BATCH_SIZE, which is 500.
Partial updates
partialUpdateSchema(entitySchema) in platform/crud/partial-update-schema.ts builds the schema for a partial update from a module's entity schema.
It doesn't just call .partial(). It rebuilds the object from the raw shape after removing every field's .default(), including defaults wrapped in .optional() and .nullable(). If the defaults stayed in place, an update that omitted a field would reset it to the default value.
We've had this bug before. An order edit that left out shippingCost would have set it to zero. Defaults should only apply on create.
Phantom-field guards
When a write schema has a field that matches no database column, the value is validated and then thrown away before the insert:
write schema field
↓
validated
↓
no matching database column
↓
dropped before the insert
↓
form reports successThis bug lost channel.region and link.notes. Nothing throws, and the operator sees a successful save.
BaseCRUD can't catch the problem at runtime because delegate is typed any, so platform/crud/model-columns.ts checks the schema against the model at compile time instead. ColumnsClean<TInput, TModel, TExtra> resolves to true when every schema field is a real column, and otherwise to the union of the keys that aren't. Assert<T extends true> turns that union into a type error that names the field.
platform/crud/write-schema-columns.assert.ts has one line for each admin write schema. Each line checks the schema against Prisma.<Model>UncheckedCreateInput, which contains scalar columns and foreign key ids but no relation objects. Fields that legitimately aren't columns go in the TExtra union, which also serves as documentation for them.
| Module | Field | Why it isn't a column |
|---|---|---|
| Categories | products, imageMediaId | They set relations. |
| Coupons | updatedAt | It is the concurrency token. |
| Collabs and returns | status | It is a transition that the handler turns into approvedAt or rejectedAt timestamps. |
The file contains only types and no runtime code, and bun run typecheck verifies it. When you add an admin schema module, add it to that file too.
Declared and custom code
When you plan a new module, work out which parts of its write path are the same as in other modules and which parts are specific to it. The shared parts come from BaseCRUD, and the specific parts go in method overrides or hand-written actions.
Related
Contracts
The shared Zod schemas validate common values, and domain schemas build on them.
Caching
Litestore tags cached reads from CACHE_TAGS, the CRUD layer clears them on writes, and some paths are never cached.
Schema
The core Prisma models follow shared conventions, and the schema changes safely when you follow a few rules.