Litestore · Developer guide
Data access

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.

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.

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:

server/admin/tags/crud.ts
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.ts defines the declared fields, the hooks, and the activity and cache helpers.
  • base-crud-queries.ts adds reads.
  • base-crud-mutations.ts adds create, update and duplicate.
  • base-crud.ts adds delete, restore, permanentDelete and findDeleted.

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:

  • getPaginationOffsets
  • getPageCount
  • normalizePaginationParams (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:

  1. Log any submitted key that isn't in the write schema.
  2. Run the before hook.
  3. Write through getDelegate(ctx?.tx), so the write joins the caller's transaction if there is one.
  4. Run the after hook.
  5. Write an activity row.
  6. Invalidate the cache.

On BaseCRUDShared, these hooks do nothing until a module overrides them:

beforeCreate
afterCreate
beforeUpdate
afterUpdate
beforeDelete
afterDelete
afterRestore
beforeDuplicate

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

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

server/admin/collections/crud.ts
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:

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

A 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
count

updateAction 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 success

This 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, products and imageMediaId set relations.
  • On coupons, updatedAt is the concurrency token.
  • On collabs and returns, status is a transition that the handler turns into approvedAt or rejectedAt timestamps.

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.

On this page