Contracts
This page shows you which shared Zod schemas Litestore uses to validate common values and how module schemas build on them.
Litestore keeps one shared Zod schema for each common kind of value, such as an email address or a slug, so every form checks it the same way. Read this page when you write a schema for a new admin or storefront module.
The contracts/ folder contains Zod schemas that you reuse instead of typing out again.
It has two subfolders. primitives/ validates single field types, and operations/ describes generic input and response shapes for CRUD operations.
There is no contracts/index.ts at the top level, so your imports name the subfolder:
~/contracts/primitives
~/contracts/operationsPrimitives
Each primitive file exports the required version of a field plus optional and nullable versions.
This lets a schema pick the nullability it needs without writing its own version of the rule. A field can be optional in one module and required in another and still use the same validation rules.
Email
OptionalEmail
NullableEmailThese are defined in contracts/primitives/email.ts. The value is trimmed and lowercased, then checked against the RFC format.
Phone
Phone
OptionalPhone
NullablePhoneThese are defined in contracts/primitives/phone.ts. A phone number can be at most 20 characters long. It may contain digits, spaces, dashes, parentheses and a leading +, and it must contain at least 7 actual digits.
Url
Url
OptionalUrl
NullableUrl
UrlWithDefaultThese are defined in contracts/primitives/url.ts. The value has to be a URL that can be parsed. NullableUrl also accepts "" and null.
Ids and slugs
StringId
CuidId
BatchIds
OptionalId
IdOrNew
Slug
OptionalSlugThese are defined in contracts/primitives/id.ts. They cover non-empty ids, cuids and non-empty arrays of ids. A slug has to match this pattern:
^[a-z0-9]+(?:-[a-z0-9]+)*$A slug can be up to 250 characters long.
Dates and ranges
ISODate
DateInput
OptionalDate
DateRange
RequiredDateRange
LookbackDaysThese are defined in contracts/primitives/date-range.ts. They accept ISO datetime strings or Date objects. The range schemas check that from <= to. LookbackDays accepts 1 to 365 days and defaults to 30.
Pagination
PaginationInput
CursorPaginationInput
PaginationMeta
CursorPaginationMeta
PaginatedResponseThese are defined in contracts/primitives/pagination.ts. They cover page and page size (default 25, maximum 100), cursor and limit, and the metadata that goes with each kind of response.
Email and Phone do more than check the shape
Email normalizes the address before it validates it:
// Every email is trimmed + lowercased BEFORE validation, so the whole app stores
// one canonical form. Without this, a signup `John@X.com` and an admin-created
// `john@x.com` are two rows Postgres's case-sensitive unique index never dedupes.
const normalizedEmail = z.string().trim().toLowerCase()
export const Email = normalizedEmail.email("Please enter a valid email address")The order matters. If the schema validated first and normalized later, two spellings of one address could still reach the database as two rows.
Phone counts the digits instead of relying only on its character regex. On its own, that regex would accept "+++", "((((" and "----".
export const Phone = z
.string()
.trim()
.max(20, "Phone number is too long")
.regex(/^[+]?[\d\s\-().]+$/, "Please enter a valid phone number")
// 7 is the shortest real subscriber number (E.164 national minimum).
.refine(value => (value.match(/\d/g)?.length ?? 0) >= 7, "Please enter a valid phone number")Import the primitive, don't retype it
A local z.string().email() skips the trim-and-lowercase step. The row it writes becomes a second identity that Postgres's case-sensitive unique index won't merge with the first. The email and URL contracts were created because the codebase had seven different versions of the email check and five of the URL check.
How module schemas use them
A module's entity schema imports the primitives it needs and builds everything else with plain Zod.
The primitive isn't wrapped in anything. You use it as a field type, and you refine it further when the column is narrower:
import { z } from "zod"
import { Slug, Url } from "~/contracts/primitives"
export const linkSchema = z.object({
slug: Slug,
destination: Url.max(2000),
utmSource: z.string().max(100).optional().nullable(),
utmMedium: z.string().max(100).optional().nullable(),
utmCampaign: z.string().max(100).optional().nullable(),
campaignId: z.string().cuid().optional().nullable(),
productId: z.string().cuid().optional().nullable(),
isActive: z.boolean().default(true),
expiresAt: z.coerce.date().optional().nullable(),
})The module then sets this schema as the schema field on its BaseCRUD subclass. That step, not the import, makes the primitive's rules run on every create and update.
Around twenty-five files import from ~/contracts/primitives. Most are admin and storefront schemas. A handful of actions.ts files use a primitive to validate a single argument. One client component, components/admin/emails/compose/compose-form.tsx, reuses Email to validate the form in the browser.
Operations
The operations/ folder describes the generic shapes that a CRUD API would use.
operations/crud.ts
This file defines the CreateInput and UpdateInput builders, which take an entity schema. It also defines these fixed shapes:
DeleteInput ({ ids })
DuplicateInput
UpdateFieldInput
FindManyInput
FindOneInputIt also contains the SuccessResponse/ErrorResponse/MutationResult union and DeleteResult. A ListResponse(itemSchema) builder pairs a list of items with PaginationMeta.
operations/filters.ts
StatusFilter accepts these values:
active
inactive
draft
published
archived
allBooleanFilter accepts true, false or "all". ProductFilters, OrderFilters and CustomerFilters extend SearchFilter and BaseFilters.
Money ranges use PriceRange, which takes non-negative amounts in major units to match the Decimal(12,2) columns.
operations/sorting.ts
This file defines SortDirection, SortField, SortFields and a CommonSortFields enum. Its SortInput(allowedFields) builder only allows sorting by fields from a list you pass in.
Nothing outside contracts/ imports them
No file outside contracts/ imports ~/contracts/operations.
The shapes that the running app uses are built elsewhere:
createCRUDSchemasinlib/crud/schema-factory.tsbuilds the update, delete, duplicate and find inputs that the generated server actions use.- Table filtering and sorting go through
lib/table-params.ts. - Each admin module defines its own filter parameters in its
schema.ts.
Treat operations/ as a set of conventions that new code can use. The shipped admin doesn't run on these definitions.
Which one to use
When you validate an email, phone number, URL, id, slug, date range or pagination input, import the primitive. If you write the rule again locally, you bring back the problem of one address stored under two spellings.
When the primitive is nearly right but the column is narrower, refine it, as in Url.max(2000), instead of replacing it. The shared rules still run.
When you need the operation input shapes that the admin actually uses, look in lib/crud/schema-factory.ts, not operations/.
When you add a filter or sort option to a module, define it in that module's schema.ts, next to the existing ones.
Before you add a new schema, check that each rule will still have exactly one place to change if it changes later.
Related
CRUD
See how an admin module saves, archives and deletes records through the shared BaseCRUD class.
Overview
Learn which of Litestore's three transports to use — zsa server actions for admin and storefront writes, oRPC at /api/rpc for service-key consumers, and the REST endpoints described by /api/openapi.json.
Schema
Learn the core Prisma models in schema.prisma, the conventions every model follows, and how to change the schema safely.