Litestore · Developer guide

CRUD

Learn how BaseCRUD in lib/crud/ gives every admin module its write path — hooks, archive versus permanent delete, optimistic concurrency, activity logging and getRelatedCacheTags invalidation.

lib/crud/ is the shared persistence layer behind every admin module's crud.ts. A module declares what it is; the base class supplies the write path.

What a declaration buys:

  • Reads
  • Mutations
  • Archive and restore
  • Activity rows
  • Cache invalidation
  • The zsa server actions that wrap them

It generates no interface. Admin tables, forms and detail views are written per module under components/admin/, and nothing in lib/crud/ renders or describes a screen.

The split is deliberate. Persistence is the part every module does identically, and screens are the part every module does differently. Sharing the first without sharing the second is what keeps a declaration to a handful of lines.

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 singleton instance, which its actions.ts and queries.ts import.

Two type parameters keep input and storage apart. TEntity is the full Prisma row; TInput is the validated write shape. They are not the same thing, and treating them as one is how a field that nothing stores ends up in a write schema.

The class is split across four files that each add a layer:

  • base-crud-shared.ts holds the declared fields, the hooks, 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 are thin passes to the delegate. They fold in getDefaultInclude() unless the caller supplied its own include or select.

findWithMeta is the paginated read. It normalizes page and per-page through normalizePaginationParams with a default of 25 and a ceiling of 200, runs the page query and the count in parallel, and returns:

{ items, total, pageCount, meta }

The meta object carries isFirstRun and hasFilters. That is what lets an empty table tell "nothing exists yet" apart from "your filters matched nothing" — without a second query.

lib/crud/pagination.ts holds the arithmetic on its own:

  • getPaginationOffsets
  • getPageCount
  • normalizePaginationParams (defaults 20 per page, ceiling 100)

Modules import that file directly for their hand-written table queries.

Writes and hooks

create and update run the same sequence:

  1. Log any submitted key that is not in the write schema.
  2. Run the before hook.
  3. Write through getDelegate(ctx?.tx), so a caller's transaction is honoured.
  4. Run the after hook.
  5. Write an activity row.
  6. Invalidate.

The hooks default to no-ops on BaseCRUDShared:

beforeCreate
afterCreate
beforeUpdate
afterUpdate
beforeDelete
afterDelete
afterRestore
beforeDuplicate

CategoryCRUD uses afterCreate and afterUpdate to recompute the fullPath breadcrumb for a category and its descendants.

CollectionCRUD uses beforeCreate to reject a handle that collides with a product slug.

Three behaviours are worth knowing because they change what a write means.

Keys outside the schema are named, not silently dropped

warnOnKeysOutsideSchema compares the submitted object against this.schema.shape and logs the extras through the crud-write-guard logger.

This is the runtime half of the phantom-field guard described below. It does not stop the write; it makes the discrepancy visible in logs.

Optimistic concurrency is opt-in

When ctx.expectedUpdatedAt is set, update gates the write on:

where: { id, updatedAt }

A stale form matches no row, Prisma raises P2025, and the caller gets:

RECORD_CHANGED: This record was changed by someone else.

Omit the token and the write is last-write-wins. There is no default protection here — a module that wants it has to pass the token.

Slug renames keep their history

For models in lib/slug-redirects.ts, update folds the old slug into the row's URL history in the same write.

Old storefront URLs then redirect instead of 404ing. Doing it in the same write is what makes the two facts impossible to separate.

Activity entries say what changed

update snapshots the stored row before writing when the model is logged or redirectable, then diffs it.

The activity entry therefore names which field groups changed, rather than repeating every field the form submitted. A form posts everything; only a real before/after comparison is honest about what moved.

Archive, restore, permanent delete

SOFT_DELETE_MODELS in lib/soft-delete-models.ts is derived from the Prisma DMMF: every model carrying a deletedAt column.

delete(), restore() and findDeleted() assert against that list and throw <model> does not support delete when the model has no deletedAt. They never fall through to permanent destruction.

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, then runs afterDelete for every id.

Those hooks run cascades — S3 cleanup, related-record updates — on the global client, and cannot join the soft-delete's transaction.

So if any of them throws, delete() clears deletedAt again and rethrows. That is a compensating rollback, rather than a record flagged deleted whose cascades failed.

permanentDelete() is the irreversible path, and the only base method that uses this.primaryKey. delete() and restore() are hardcoded to id.

A CRUD that overrides primaryKey, as TagCRUD does with slug, therefore only has a working removal through permanentDelete.

When mediaOwnerType is set — Product, Variant, Campaign, Collection, Category — permanentDelete also clears the polymorphic MediaAssociation rows, which have no FK cascade to clean them up.

permanentDelete does not archive

permanentDelete() calls deleteMany and skips the deletedAt check entirely, so it removes rows on archivable and non-archivable models alike and nothing restores them. Archive is delete(); permanentDelete() is the operation an admin screen should name for what it does.

Cache invalidation

Every mutation ends in autoInvalidateCache, which fires three groups of tags:

  • the module's static cacheTag
  • the entity tag ${modelName}-${entityId}
  • whatever getRelatedCacheTags(entity) returns

The base implementation of getRelatedCacheTags returns an empty array. A module overrides it to name the storefront surfaces its writes touch:

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 calls it on create, update, duplicate, delete, restore and permanent delete. So a module that names its tags once cannot forget one on a specific write — which is the failure the arrangement exists to prevent.

getRelatedCacheTags is currently overridden on the product, category, collection, page, order, coupon, customer and ad CRUDs.

When the caller passes ctx.tx, create and update skip invalidation deliberately. Firing it mid-transaction would stale the cache for a write that has not committed. Invalidation is then the caller's job after commit.

Generated server actions

The base builds zsa server actions from the same declarations.

Each one is created through modulePermission(this.permissionModule)(action), so the permission string is <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 }
    })
}

There are six:

createAction
updateAction
duplicateAction
deleteAction
restoreAction
permanentDeleteAction

A module re-exports the ones it wants and writes the rest by hand:

export const permanentDeleteTags = tagCrud.permanentDeleteAction()

batchIdsSchema caps a batch at MAX_BATCH_SIZE, which is 500.

The schema factory

createCRUDSchemas(entitySchema) in schema-factory.ts derives the operation input schemas 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 partial is rebuilt from the raw shape rather than produced by .partial(), and every field's .default() is stripped first, recursively through .optional() and .nullable() wrappers.

Why strip the defaults?

Keeping them would make an omitted field mean "reset me".

That is not hypothetical. Saving a block edit reset status to "draft" and unpublished a live block, and an order edit that omitted shippingCost zeroed it.

Defaults belong to create.

Phantom-field guards

A write schema field that maps to no database column is validated, then dropped before the insert:

write schema field

validated

no matching database column

dropped before the insert

form reports success

That is the bug that lost channel.region, link.notes and the ad sync ids. Nothing throws, and the operator sees a save.

BaseCRUD cannot catch it at runtime because delegate is typed any. So lib/crud/model-columns.ts binds the two 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 offending keys. Assert<T extends true> turns that union into a type error naming the field.

lib/crud/write-schema-columns.assert.ts is one line per admin write schema, checked against Prisma.<Model>UncheckedCreateInput — scalar columns and FK ids, no relation objects.

Fields that legitimately are not columns are declared explicitly in the TExtra union, which doubles as documentation:

  • products and imageMediaId on categories are relation setters
  • updatedAt on coupons is the concurrency token
  • status on collabs and returns is a transition verb the handler maps to approvedAt/rejectedAt timestamps

The file is pure types with no runtime code, verified by bun run typecheck.

The write-schema-columns-coverage discipline rule fails CI when a new admin schema module is not listed there or allowlisted.

What to declare and what to write by hand

Declare the five fields when the module's writes are ordinary persistence. That is where the generated actions, activity rows and invalidation come for free.

Override a hook when a write has a consequence elsewhere in the database — a recomputed path, a uniqueness rule that spans two models.

Override getRelatedCacheTags when a write changes something the storefront reads. That is the one override a module cannot skip without shipping stale pages.

Write an action by hand when the operation is not create, read, update, archive, restore or delete. A bulk import is not a create.

And when a module overrides primaryKey, check its removal path before shipping the screen: delete() and restore() are still on id.

The question to ask of a new module is not "does this have a CRUD?" It is:

Which parts of this write path are the same as every other module's, and which parts are genuinely this module's own?

On this page