Contracts
Learn what the shared Zod schemas in contracts/ validate — Email, Phone, Url, Slug, DateRange, PaginationInput — and how domain schemas compose them.
contracts/ holds Zod schemas meant to be reused rather than retyped.
It has two directories. primitives/ validates single field types. operations/ describes generic CRUD input and response shapes.
There is no top-level contracts/index.ts, so imports name the directory:
~/contracts/primitives
~/contracts/operationsPrimitives
Each primitive file exports the required form plus its optional and nullable variants.
That is the point of the arrangement: a schema picks the nullability it needs instead of re-deriving one. A field that is optional in one module and required in another still shares the same validation rules.
Email
OptionalEmail
NullableEmailDefined in contracts/primitives/email.ts. Trimmed, lowercased, then RFC-checked.
Phone
Phone
OptionalPhone
NullablePhoneDefined in contracts/primitives/phone.ts. Max 20 chars, digits/spaces/dashes/parens/leading +, and at least 7 actual digits.
Url
Url
OptionalUrl
NullableUrl
UrlWithDefaultDefined in contracts/primitives/url.ts. A parseable URL. NullableUrl also accepts "" and null.
Ids and slugs
StringId
CuidId
BatchIds
OptionalId
IdOrNew
Slug
OptionalSlugDefined in contracts/primitives/id.ts. Non-empty ids, cuids, non-empty id arrays, and slugs matching:
^[a-z0-9]+(?:-[a-z0-9]+)*$up to 250 characters.
Dates and ranges
ISODate
DateInput
OptionalDate
DateRange
RequiredDateRange
LookbackDaysDefined in contracts/primitives/date-range.ts. ISO datetime strings or Date. The range schemas refine that from <= to. LookbackDays is 1–365 days, defaulting to 30.
Pagination
PaginationInput
CursorPaginationInput
PaginationMeta
CursorPaginationMeta
PaginatedResponseDefined in contracts/primitives/pagination.ts. Page and per-page (default 25, max 100), cursor and limit, and the matching response metadata.
Two of them do more than shape-check
Email normalizes before it validates:
// 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. Validating first and normalizing later would still let two spellings of one address reach the database as two rows.
Phone counts digits rather than trusting its own character-class regex, which on its own accepts "+++", "((((" 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, and the row it
writes becomes a second identity Postgres's case-sensitive unique index will not
merge with the first. The email and URL contracts exist because that validation
had been written seven and five ways.
How domain schemas compose them
A module's entity schema imports the primitives it needs and builds the rest with plain Zod.
Nothing wraps or decorates the primitive. It is used as a field type, and refined further where 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(),
})That schema is then the schema field on the module's BaseCRUD subclass. That is what makes the primitive's rules run on every create and update — not the import itself.
Around twenty-five files import from ~/contracts/primitives:
- admin and storefront schemas
- a handful of
actions.tsfiles that validate a single argument - one client component,
components/admin/emails/compose/compose-form.tsx, which reusesEmailfor form-side validation
Operations
operations/ describes the generic shapes a CRUD API would take.
operations/crud.ts
The CreateInput and UpdateInput builders that take an entity schema, plus the fixed shapes:
DeleteInput ({ ids })
DuplicateInput
UpdateFieldInput
FindManyInput
FindOneInputIt also holds the SuccessResponse/ErrorResponse/MutationResult union, DeleteResult, and a ListResponse(itemSchema) builder pairing items with PaginationMeta.
operations/filters.ts
StatusFilter covers:
active
inactive
draft
published
archived
allBooleanFilter is true/false/"all". SearchFilter and BaseFilters are extended into ProductFilters, OrderFilters and CustomerFilters.
Money ranges use a non-negative major-unit PriceRange, matching the Decimal(12,2) columns.
operations/sorting.ts
SortDirection, SortField, SortFields, a CommonSortFields enum, and a SortInput(allowedFields) builder that constrains sorting to a named list.
Nothing outside contracts/ imports them
No file outside contracts/ imports ~/contracts/operations.
The shapes that actually run are built elsewhere:
createCRUDSchemasinlib/crud/schema-factory.tsderives the update/delete/duplicate/find inputs the generated server actions use- Table filtering and sorting go through
lib/table-params.ts - Each admin module defines its own filter params in its
schema.ts
Treat operations/ as a set of conventions available to new code, not as the definitions the shipped admin runs on.
Which one to reach for
If you are validating an email, phone, URL, id, slug, date range or pagination input, import the primitive. Writing the rule again locally is how the two spellings problem comes back.
If the primitive is nearly right but the column is narrower, refine it — Url.max(2000) — rather than replacing it. The shared rules still run.
If you need the operation input shapes the admin actually uses, go to lib/crud/schema-factory.ts, not operations/.
And if you are adding a filter or sort to a module, define it in that module's schema.ts, which is where the shipped ones live.
The test for a new schema is not "does something like this already exist?" It is:
If this rule changes later, is there exactly one place it changes?
Related
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.
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.
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.
Caching
Learn how Litestore tags cached reads from CACHE_TAGS, how the CRUD layer invalidates them, and which paths are never cached.