Litestore · Developer docs
Data access

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.

CatalogAdd products, sort them and keep track of stock.
Products
Categories
Tags
Collections
Discounts
Options
Inventory
Each area of the admin, such as products or collections, writes its data through its own subclass of BaseCRUD.

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.

FieldWhat it does
modelNameIt names the Prisma model.
permissionModuleIt names the permission module that guards the generated actions.
delegateIt points at the Prisma delegate that does the writing.
schemaIt gives the zod write schema.
cacheTagsIt lists the static cache tags that every write refreshes.

TagCRUD is the smallest one in the repository:

modules/products/admin/tag-crud.ts
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.

base-crud.ts
types.ts
pagination.ts
partial-update-schema.ts
model-columns.ts
write-schema-columns.assert.ts

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.

platform/crud/base-crud.ts
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:

platform/crud/base-crud.ts
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
permanentDeleteAction

A module exports the actions it needs and writes create and update actions by hand, so each one shows its own schema and permission:

modules/orders/admin/returns/actions.ts
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 success

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

ModuleFieldWhy it isn't a column
Categoriesproducts, imageMediaIdThey set relations.
CouponsupdatedAtIt is the concurrency token.
Collabs and returnsstatusIt 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.

On this page