Guarantees
Learn which money, refund, stock, identity, visibility and cache promises Postgres enforces, which ones a harness check enforces, and which ones are explicitly not guaranteed.
GUARANTEES.md in the repository names the promises the rest of the system is allowed to rely on.
Most of them are CHECK constraints, triggers and unique indexes installed by a Prisma migration. A few are enforced by a harness check over the source instead. And one section exists to say that a thing is not guaranteed, so that nobody builds on a promise that was never made.
The distinction matters more than the list. A rule enforced by Postgres holds for every writer, including a script, a console session and a future code path nobody has written yet. A rule enforced in application code holds only for the callers that go through that code.
Two scripts keep the list honest:
scripts/check-guarantees-contract.tsreads migration SQL and needs no databasescripts/check-db-guarantees.tsconnects toDATABASE_URLand audits the database that is actually applied
Money
Every Order carries amountBase, a non-nullable Decimal(12,2) holding the order total converted to store-base currency at purchase time.
Revenue code must not fall back from amountBase to amountTotal, and the contract check fails if this reappears in a migration:
COALESCE("amountBase", "amountTotal")The fallback is what turns a multi-currency report into a silently wrong one.
Order money projections are non-negative — total, base total, refund total, discount, shipping, tax and the refund-reversal counters. That is the order_amounts_nonnegative constraint.
Order.amountRefunded and Order.refundStatsAppliedAmount cannot exceed Order.amountTotal (order_amounts_refunded_not_over_total).
When amountSubtotal is present, subtotal minus discount plus tax plus shipping must equal amountTotal (order_breakdown_matches_total_when_present). The money comparisons carry a one-cent tolerance, so a rounded projection is not a false failure.
The payment ledger
Payment ledger rows are append-only. The litestore_payment_transaction_before_write trigger raises on an update or delete:
PaymentTransaction is append-only; write a correction row insteadSo a correction is always a new row, and the ledger keeps its own history rather than being edited into a shape that agrees with the current total.
The same trigger rejects a ledger row whose currency does not match the order currency.
litestore_payment_transaction_refund_cap rejects a refund row once the ledger's refunds would cumulatively exceed the order total.
The drift views
OrderMoneyDrift and CustomerMoneyDrift are views, not constraints.
Any row in them means a cached projection disagrees with the totals the database computes in OrderMoneyTotals and CustomerMoneyTotals.
The audit treats a non-empty drift view as a failure and prints the first rows.
Application-side arithmetic
Money arithmetic goes through utils/money.ts, where Money is a decimal.js-light Decimal paired with a currency.
money() requires the currency argument, and sum() throws on mismatched currencies rather than coercing.
Refunds
- A refund request amount is non-negative (
refund_request_amount_and_attempts_valid) - A refund request can reach at most one terminal outcome — approved, rejected or canceled (
refund_request_terminal_state_exclusive) - An approved refund request must be marked as admin-approved (
refund_request_approved_marks_admin_approval) litestore_refund_request_write_guardrejects an approval once approved refund requests for the order would exceed the order total
One rule in this section is not a constraint. The last one in GUARANTEES.md — an admin approval must not refund more than the stored request amount — is clamped in application code, in server/admin/refunds/crud.ts, before the refund is issued at the provider:
Math.min(parsed.amount ?? requestedAmount, requestedAmount)That one holds for callers that go through that path, and not for the database.
Stock
Stock movements are append-only, enforced by litestore_stock_movement_before_write.
previousQuantity + delta must equal newQuantity, and delta cannot be zero (stock_movement_quantity_math).
The same trigger requires a matching Inventory row, and rejects a movement whose newQuantity disagrees with the current inventory quantity for that location and variant. That is what stops two concurrent receipts from overwriting each other: the second one is describing a starting quantity that is no longer true, and the database refuses it.
InventoryLedgerDrift is an operational alarm view. A row in it means inventory and the latest stock movement disagree.
Identity
Case-insensitive uniqueness is owned by Postgres indexes, not by application code:
user_email_lower_unique
customer_email_lower_unique
product_variant_sku_upper_unique
coupon_code_upper_unique
product_slug_lower_unique
product_group_slug_lower_unique
product_group_handle_lower_unique
category_slug_lower_unique
link_slug_lower_uniqueThere can be only one active default channel (channel_one_default_active_unique).
There can be only one active default email template per email type (email_template_one_active_default_per_type).
The audit also runs duplicate probes over the normalized keys directly, so rows that predate an index show up even when the index is present. An index proves that no new duplicate can be written; it does not prove that none already exists.
Visibility
This section is enforced in code and by harness checks, not by constraints.
The set of models with deletedAt is derived from the Prisma DMMF in lib/soft-delete-models.ts, not from a hand-written list.
The global soft-delete extension in lib/soft-delete-extension.ts injects deletedAt: null into top-level reads only:
findMany
findFirst
findUnique
count
aggregate
groupByPlus their OrThrow variants.
Nested relation reads are not covered, so scripts/check-nested-soft-delete.ts fails the build on a nested to-many include that lacks its own deletedAt filter, unless the line is marked as an intentional archived view.
Nothing injects isHidden. Two checks cover it instead:
scripts/check-nested-hidden.tsguards storefront readsscripts/check-public-feed-visibility.tsguards the sitemap, RSS andllm.txtroutes
Both have a // visibility-ok escape hatch for a read that deliberately includes hidden rows.
Archive means reversible hidden state. Delete means permanent removal. There is no central trash surface.
Cache
Cache tags must come from CACHE_TAGS in lib/cache.ts. Raw tag strings and template literals are rejected at revalidateTag / invalidateTag call sites by scripts/check-cache-tags.ts.
The reason is the failure mode: a typo silently never invalidates and produces no error. There is nothing to catch at runtime, because a tag nobody registered is simply a tag nothing is listening on.
Facets have their own tag. Mutations that change published product membership, option or spec values, or price ranges must invalidate CACHE_TAGS.facets. Plain field edits do not own facet freshness.
scripts/check-cache-invalidation.ts fails a server file that runs direct Prisma mutations without importing an invalidation helper — the bug that shows up as an admin table serving stale rows until the TTL expires.
Events and activity
This section records what is deliberately not guaranteed.
Domain events and activity writes are best-effort post-response side effects. They must not be described as guaranteed delivery.
Saying so would require a transactional outbox written in the same database transaction as the state change, and that is not implemented.
Idempotency keys prevent duplicate side effects when a handler is retried. They do not make delivery guaranteed.
Auth
Admin access checks role and ban state against fresh database user state, not only cookie-cached session state.
The shared fresh-principal read must include every field consumed by auth, permissions and account-stage checks. A partial read is how a stale permission slips through.
Reading the source
GUARANTEES.md is the authoritative list, and it opens with the rule that governs edits: do not replace a hard guarantee with a tolerant fallback. Change the database and type guarantee first, then the application code.
The two commands
bun run lint:guaranteesRuns scripts/check-guarantees-contract.ts. No database required.
bun run db:guarantees:auditRuns scripts/check-db-guarantees.ts. Needs DATABASE_URL.
bun run db:guarantees:audit:strictThe strict form of the audit.
What the contract check verifies
db:migrateanddb:deploystill run Prisma Migrate- No script uses
prisma db push amountBaseis still a non-nullableDecimal(12,2)in both the schema and the migration SQL- The 24 constraint, trigger, view and index names it lists still appear in migration SQL
GUARANTEES.mdstill carries all eight of its headings, the tolerant-fallback rule and the sentence naming a transactional outbox
It also fails any file under lib/, services/, events/ or functions/ that opens with a "use server" directive, and requires lib/activity.ts and lib/checkout/stock-reservation.ts to keep their import "server-only".
What the database audit verifies
Against the live schema:
- 20 CHECK constraints
- 4 triggers
- 11 unique indexes
- 6 views
It then runs nine duplicate probes over the normalized keys and counts the three drift views, printing up to five offending rows.
A CHECK that exists but is not catalog-validated is a warning by default, and a failure under --strict.
When they disagree with the code
When the two scripts disagree with the code, the scripts are right: a migration weakened a guarantee and the audit caught it.
The useful question when adding a rule is not "where do I assert this?" It is:
Can Postgres enforce this for every writer — and if not, which check makes it impossible to forget?
Deletion
Learn which eight read operations the global Prisma extension filters, how deletedAt: undefined opts a read back in, and how check-nested-soft-delete.ts enforces explicit filters on nested to-many reads.
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.