CRUD
This page shows you how an admin module 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.
lib/crud/ is the shared persistence layer behind each admin module's crud.ts. A module declares what it is, and the base class supplies the code that writes it.
From that declaration, the module gets reads, mutations, archive and restore, activity rows and cache invalidation. It also gets the zsa server actions that wrap all of these.
The base class doesn't build any interface. Each module has its own admin tables, forms and detail views under components/admin/, and nothing in lib/crud/ renders or describes a screen.
This split is deliberate. Every module saves data in the same way, but every module's screens look different. Because only the saving is shared, a module's declaration stays a handful of lines long.
Declaring a CRUD
A subclass declares five protected fields:
- the Prisma model name
- the permission module
- the Prisma delegate
- the zod write schema
- the static cache tag
TagCRUD is the smallest one in the repository:
export class TagCRUD extends BaseCRUD<Tag, TagSchema> {
protected modelName = "tag" as const
protected permissionModule = AdminModule.tags
protected readonly delegate = db.tag
protected schema = tagSchema
protected cacheTag = 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 and queries.ts import it.
The class takes two type parameters to keep input and storage apart. TEntity is the full Prisma row, and TInput is the validated shape of a write. They are different things. If you treat them as one, a field that nothing stores can end up in a write schema.
The class is built up across four files, and each file adds a layer:
base-crud-shared.tsdefines the declared fields, the hooks, and the activity and cache helpers.base-crud-queries.tsadds reads.base-crud-mutations.tsaddscreate,updateandduplicate.base-crud.tsaddsdelete,restore,permanentDeleteandfindDeleted.
lib/crud/index.ts re-exports BaseCRUD, the schema factory and the shared types.
Reads
findMany, findOne, findFirst and count pass straight through to the delegate. They add getDefaultInclude() unless the caller supplies its own include or select.
findWithMeta is the paginated read. It normalizes the page and page size through normalizePaginationParams, with a default of 25 per page and a maximum of 200. It runs the page query and the count in parallel and returns this:
{ items, total, pageCount, meta }The meta object contains isFirstRun and hasFilters. With those, an empty table can tell "nothing exists yet" apart from "your filters matched nothing" without running a second query.
lib/crud/pagination.ts contains the pagination arithmetic as separate functions:
getPaginationOffsetsgetPageCountnormalizePaginationParams(defaults to 20 per page, with a maximum of 100)
Modules import that file directly for the table queries they write by hand.
Writes and hooks
create and update follow the same steps:
- Log any submitted key that isn't in the write schema.
- Run the
beforehook. - Write through
getDelegate(ctx?.tx), so the write joins the caller's transaction if there is one. - Run the
afterhook. - Write an activity row.
- Invalidate the cache.
On BaseCRUDShared, these hooks do nothing until a module overrides them:
beforeCreate
afterCreate
beforeUpdate
afterUpdate
beforeDelete
afterDelete
afterRestore
beforeDuplicateCategoryCRUD uses afterCreate and afterUpdate to recompute the fullPath breadcrumb for a category and all categories below it.
CollectionCRUD uses beforeCreate to reject a handle that is already used as a product slug.
The next four sections describe behaviour that changes what a write means.
Keys outside the schema are logged
warnOnKeysOutsideSchema compares the submitted object with this.schema.shape. It logs any extra keys through the crud-write-guard logger instead of dropping them silently.
This is the runtime half of the phantom-field guard described further down. It doesn't stop the write. It only makes the mismatch visible in the logs.
Optimistic concurrency is opt-in
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.If you leave out the token, the last write wins. Nothing protects you by default. A module that wants this check has to pass the token.
Slug renames keep their history
For models listed in lib/slug-redirects.ts, update adds the old slug to the row's URL history in the same write.
Old storefront URLs then redirect instead of returning a 404. Because both changes happen in one write, the new slug can never be saved without its redirect.
Activity entries say what changed
When the model is logged or has redirects, update takes a snapshot of the stored row before writing and compares it with the result.
The activity entry then lists the field groups that actually changed instead of every field the form sent. A form always sends every field, so only a before-and-after comparison shows what really changed.
Archive, restore, permanent delete
SOFT_DELETE_MODELS in lib/soft-delete-models.ts comes from the Prisma DMMF. It contains every model that has a deletedAt column.
delete(), restore() and findDeleted() check the model against that list. If the model has no deletedAt, they throw <model> does not support delete and never fall back to destroying the row.
private assertCanSoftDelete(operation: "delete" | "restore" | "find archived"): void {
if (!this.canSoftDelete) {
throw new Error(`${this.modelName} does not support ${operation}`)
}
}delete() sets deletedAt and then runs afterDelete for every id.
Those hooks run cascades, such as S3 cleanup and updates to related records. They use the global client, so they can't join the soft delete's transaction.
If any of them throws, delete() clears deletedAt again and rethrows the error. This compensating rollback means you never end up with a record marked as deleted whose cascades failed.
permanentDelete() is the irreversible path. It is also the only base method that uses this.primaryKey, because delete() and restore() always use id.
If a CRUD overrides primaryKey, as TagCRUD does with slug, only permanentDelete can remove its records.
Some CRUDs set mediaOwnerType: Product, Variant, Campaign, Collection and Category. For these, permanentDelete also removes the polymorphic MediaAssociation rows, because no foreign key cascade would clean them up.
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 mutation ends by calling autoInvalidateCache, which invalidates three groups of tags:
- the module's static
cacheTag - the entity tag
${modelName}-${entityId} - any tags that
getRelatedCacheTags(entity)returns
In the base class, getRelatedCacheTags returns an empty array. A module overrides it to list the storefront pages that its writes affect:
protected getRelatedCacheTags(entity?: Collection | null): string[] {
const tags: string[] = [CACHE_TAGS.relatedCollections]
if (entity?.id) {
tags.push(CACHE_TAGS.collectionById(entity.id), CACHE_TAGS.collection(entity.id))
}
if (entity?.slug) tags.push(CACHE_TAGS.collectionBySlug(entity.slug))
return tags
}The base class calls it on create, update, duplicate, delete, restore and permanent delete. Once a module lists its tags, it can't forget them on any single write. Preventing that mistake is the reason for this design.
These CRUDs currently override getRelatedCacheTags: product, category, collection, page, order, coupon, customer and ad.
When the caller passes ctx.tx, create and update deliberately skip invalidation. 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). The permission string is therefore <module>:<action>, and the handler receives ctx.user:
deleteAction() {
return this.createServerAction(Action.delete)
.input(z.object({ ids: batchIdsSchema }))
.handler(async ({ input: { ids }, ctx }) => {
await this.delete(ids, ctx)
return { success: true }
})
}The base class provides six actions:
createAction
updateAction
duplicateAction
deleteAction
restoreAction
permanentDeleteActionA module re-exports the actions it wants and writes any others by hand:
export const permanentDeleteTags = tagCrud.permanentDeleteAction()batchIdsSchema limits a batch to MAX_BATCH_SIZE, which is 500.
The schema factory
createCRUDSchemas(entitySchema) in schema-factory.ts builds the input schemas for each operation from a module's entity schema:
update
delete
duplicate
findMany
findOne
findFirst
countupdateAction uses the update shape:
{ id, data: Partial<Entity>, updatedAt?: string }The factory doesn't use .partial() to build that partial schema. It rebuilds it from the raw shape and first removes every field's .default(), including defaults wrapped in .optional() and .nullable().
The defaults are removed because keeping them would turn an omitted field into a request to reset that field.
This has really happened. Saving a block edit reset status to "draft" and unpublished a live block. An order edit that left out shippingCost set it to zero.
Defaults only belong in 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, link.notes and the ad sync ids. Nothing throws, and the operator sees a successful save.
BaseCRUD can't catch the problem at runtime because delegate is typed any. Instead, lib/crud/model-columns.ts ties the schema and the model together at compile time.
ColumnsClean<TInput, TModel, TExtra> resolves to true when every schema field is a real column. Otherwise it resolves to the union of the keys that aren't. Assert<T extends true> turns that union into a type error that names the field.
lib/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.
You declare fields that really aren't columns in the TExtra union, which also documents them:
- On categories,
productsandimageMediaIdset relations. - On coupons,
updatedAtis the concurrency token. - On collabs and returns,
statusis a transition that the handler turns intoapprovedAtorrejectedAttimestamps.
The file contains only types and no runtime code. bun run typecheck verifies it.
The write-schema-columns-coverage discipline rule fails CI when a new admin schema module is neither listed in that file nor allowlisted.
What to declare and what to write by hand
Declare the five fields when the module's writes are ordinary persistence. You then get the generated actions, activity rows and invalidation without writing them.
Override a hook when a write has a consequence elsewhere in the database, such as a path that needs recomputing or a uniqueness rule that spans two models.
Override getRelatedCacheTags when a write changes something the storefront reads. If you skip this override, the storefront shows stale pages.
Write an action by hand when the operation isn't create, read, update, archive, restore or delete. For example, a bulk import is not a create.
If a module overrides primaryKey, check how its records get removed before you ship the screen, because delete() and restore() still use id.
For a new module, the useful question isn't whether it has a CRUD. Ask which parts of its write path match every other module, and which parts belong only to this module.
Related
Contracts
See which shared Zod schemas Litestore uses to validate common values, and how domain schemas build on them.
Caching
Learn how Litestore tags cached reads from CACHE_TAGS, how the CRUD layer invalidates them, and which paths are never cached.
Schema
Learn the core Prisma models in schema.prisma, the conventions every model follows, and how to change the schema safely.