Litestore · Developer guide

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/operations

Primitives

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

Email
OptionalEmail
NullableEmail

Defined in contracts/primitives/email.ts. Trimmed, lowercased, then RFC-checked.

Phone

Phone
OptionalPhone
NullablePhone

Defined in contracts/primitives/phone.ts. Max 20 chars, digits/spaces/dashes/parens/leading +, and at least 7 actual digits.

Url

Url
OptionalUrl
NullableUrl
UrlWithDefault

Defined in contracts/primitives/url.ts. A parseable URL. NullableUrl also accepts "" and null.

Ids and slugs

StringId
CuidId
BatchIds
OptionalId
IdOrNew
Slug
OptionalSlug

Defined 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
LookbackDays

Defined 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
PaginatedResponse

Defined 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:

contracts/primitives/email.ts
// 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 "----":

contracts/primitives/phone.ts
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:

server/admin/links/schema.ts
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.ts files that validate a single argument
  • one client component, components/admin/emails/compose/compose-form.tsx, which reuses Email for 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
FindOneInput

It 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
all

BooleanFilter 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:

  • createCRUDSchemas in lib/crud/schema-factory.ts derives 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?

On this page