Guarantees
See which promises about money, stock and data the database enforces, which ones a check script enforces, and which ones Litestore does not make.
Litestore makes a set of promises about your data, and the rest of the code is allowed to rely on them. This page tells you which promises exist, what enforces each one, and which ones Litestore deliberately does not make.
The authoritative list is GUARANTEES.md in the repository.
Who enforces a promise
Most guarantees are CHECK constraints, triggers and unique indexes that a Prisma migration installs in Postgres. A few are enforced by a harness check that reads the source code instead. One section exists only to say that something is not guaranteed, so that nobody builds on a promise that was never made.
The difference between these matters more than the list itself. When Postgres enforces a rule, the rule holds for every writer. That includes a script, a console session and code nobody has written yet. When application code enforces a rule, it holds only for callers that go through that code.
Two scripts keep the list honest:
scripts/check-guarantees-contract.tsreads the migration SQL. It needs no database.scripts/check-db-guarantees.tsconnects toDATABASE_URLand audits the database you have actually applied.
Money
Every Order has an amountBase column. It is a non-nullable Decimal(12,2) that stores the order total converted to the store's base currency at the time of purchase.
Revenue code must not fall back from amountBase to amountTotal. The contract check fails if this expression shows up in a migration again:
COALESCE("amountBase", "amountTotal")That fallback is what makes a multi-currency report silently wrong.
The order_amounts_nonnegative constraint keeps the order's money columns at zero or above. It covers the total, the base total, the refund total, discount, shipping, tax and the refund-reversal counters.
The order_amounts_refunded_not_over_total constraint stops Order.amountRefunded and Order.refundStatsAppliedAmount from going above Order.amountTotal.
When amountSubtotal is present, the order_breakdown_matches_total_when_present constraint checks the arithmetic. Subtotal minus discount plus tax plus shipping must equal amountTotal. The money comparisons allow a one-cent tolerance, so a rounded value does not cause a false failure.
The payment ledger
You can only add rows to the payment ledger. The litestore_payment_transaction_before_write trigger raises an error on any update or delete:
PaymentTransaction is append-only; write a correction row insteadTo correct the ledger, you always write a new row. The ledger keeps its full history instead of being edited until it agrees with the current total.
The same trigger rejects a ledger row whose currency differs from the order's currency.
The litestore_payment_transaction_refund_cap trigger rejects a refund row once the ledger's refunds would add up to more than the order total.
The drift views
OrderMoneyDrift and CustomerMoneyDrift are views, not constraints.
Postgres computes the true totals in OrderMoneyTotals and CustomerMoneyTotals. A row in a drift view means a cached value on an order or customer disagrees with those totals.
The audit treats a non-empty drift view as a failure and prints the first rows.
Arithmetic in the app
The app does money arithmetic through utils/money.ts. There, a Money value is a decimal.js-light Decimal paired with a currency.
You must pass a currency to money(). If you call sum() on values in different currencies, it throws instead of converting them.
Refunds
The database enforces these rules on refund requests:
- The
refund_request_amount_and_attempts_validconstraint keeps the requested amount at zero or above. - The
refund_request_terminal_state_exclusiveconstraint lets a request reach at most one final outcome: approved, rejected or canceled. - The
refund_request_approved_marks_admin_approvalconstraint requires an approved request to be marked as approved by an admin. - The
litestore_refund_request_write_guardtrigger rejects an approval once the approved requests for the order would add up to more than the order total.
One rule in this section is not a constraint. The last rule in GUARANTEES.md says an admin approval must not refund more than the amount stored on the request. Application code enforces it in server/admin/refunds/crud.ts by clamping the amount before it issues the refund at the payment provider:
Math.min(parsed.amount ?? requestedAmount, requestedAmount)This rule holds only for callers that go through that code path. The database does not enforce it.
Stock
You can only add stock movements, never edit them. The litestore_stock_movement_before_write trigger enforces this.
The stock_movement_quantity_math constraint requires previousQuantity + delta to equal newQuantity, and it does not allow a delta of zero.
The same trigger requires a matching Inventory row. It also rejects a movement whose newQuantity disagrees with the current inventory quantity for that location and variant.
This is what stops two receipts that arrive at the same time from overwriting each other. The second receipt starts from a quantity that is no longer current, so the database refuses it.
InventoryLedgerDrift is a view that works as an operational alarm. A row in it means the inventory and the latest stock movement disagree.
Identity
Postgres indexes, not application code, make these values unique regardless of letter case:
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_uniqueThe channel_one_default_active_unique index allows only one active default channel.
The email_template_one_active_default_per_type index allows only one active default email template for each email type.
An index proves that nobody can write a new duplicate. It does not prove that no duplicate already exists. For that reason, the audit also searches the normalized keys for duplicates directly. Rows written before an index was added show up even when the index is in place.
Visibility
Code and harness checks enforce this section. No constraints are involved.
Litestore works out which models have deletedAt from the Prisma DMMF in lib/soft-delete-models.ts. Nobody maintains that list by hand.
The global soft-delete extension in lib/soft-delete-extension.ts adds deletedAt: null to top-level reads only. It covers these operations and their OrThrow variants:
findMany
findFirst
findUnique
count
aggregate
groupByThe extension does not reach nested relation reads. To cover them, scripts/check-nested-soft-delete.ts fails the build when a nested to-many include has no deletedAt filter of its own. You can mark a line as an intentional archived view to let it through.
Nothing adds an isHidden filter automatically. Two checks cover it instead:
scripts/check-nested-hidden.tsguards storefront reads.scripts/check-public-feed-visibility.tsguards the sitemap, RSS andllm.txtroutes.
Both checks accept a // visibility-ok comment for a read that includes hidden rows on purpose.
Archiving hides a row, and you can undo it. Deleting removes a row permanently. There is no central trash where deleted items collect.
Cache
Cache tags must come from CACHE_TAGS in lib/cache.ts. The scripts/check-cache-tags.ts check rejects raw tag strings and template literals wherever code calls revalidateTag or invalidateTag.
The reason is how this bug fails. A typo in a tag never invalidates anything, and it produces no error. Nothing can catch it at runtime, because nothing is listening on a tag that nobody registered.
Facets have their own tag. A mutation must invalidate CACHE_TAGS.facets when it changes which products are published, changes option or spec values, or changes price ranges. A plain field edit is not responsible for keeping facets fresh.
The scripts/check-cache-invalidation.ts check fails a server file that runs Prisma mutations directly without importing an invalidation helper. Without this check, you get an admin table that shows stale rows until the cache expires.
Events and activity
This section records what Litestore deliberately does not guarantee.
Domain events and activity writes run as side effects after the response, on a best-effort basis. Do not describe them as guaranteed delivery.
Guaranteed delivery would need a transactional outbox, written in the same database transaction as the state change. Litestore does not implement one.
Idempotency keys stop a retried handler from causing the same side effect twice. They do not make delivery guaranteed.
Auth
When someone opens the admin, Litestore checks their role and ban state against the current user row in the database. It does not rely only on the session cached in the cookie.
The shared read of that user must include every field that the auth, permission and account-stage checks use. A read that leaves a field out is how a stale permission slips through.
Reading the source
GUARANTEES.md is the authoritative list. It opens with the rule for editing it: do not replace a hard guarantee with a tolerant fallback. Change the database and the type guarantee first, then change the application code.
The two commands
bun run lint:guaranteesThis runs scripts/check-guarantees-contract.ts. It does not need a database.
bun run db:guarantees:auditThis runs scripts/check-db-guarantees.ts. It needs DATABASE_URL.
bun run db:guarantees:audit:strictThis runs the audit in strict mode.
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.- All 24 constraint, trigger, view and index names on its list still appear in the migration SQL.
GUARANTEES.mdstill has all eight of its headings, the tolerant-fallback rule and the sentence that names a transactional outbox.
The check also fails any file under lib/, services/, events/ or functions/ that starts with a "use server" directive. It requires lib/activity.ts and lib/checkout/stock-reservation.ts to keep their import "server-only".
What the database audit verifies
The audit checks that the live schema has 20 CHECK constraints, 4 triggers, 11 unique indexes and 6 views.
It then runs nine duplicate searches over the normalized keys and counts the rows in the three drift views. It prints up to five offending rows.
If a CHECK constraint exists but Postgres has not validated it, the audit reports a warning. Under --strict, that becomes a failure.
When the scripts disagree with the code
If the two scripts disagree with the code, trust the scripts. It means a migration weakened a guarantee and the audit caught it.
When you add a rule, don't start by asking where to put the assertion. Ask this instead:
Can Postgres enforce this for every writer? If not, which check makes it impossible to forget?