Litestore · Developer docs
Database

Guarantees

The database enforces some of Litestore's guarantees about money, stock and data, application code enforces others, and some aren't made at all.

Litestore provides a set of guarantees about the store's data that the rest of the code relies on. Each guarantee below names the part of the code or database that enforces it, and the last section covers the guarantees Litestore doesn't provide.

Order #1042Paid $120.00
Refunded $80.00Requested $60.00
Write rejectedTotal refunded can't exceed the order total. Nothing was saved.
The app tries to write a refund larger than what is left on the order, and Postgres rejects it.

Enforcement

The authoritative list is GUARANTEES.md in the repository.

A Prisma migration installs most guarantees in Postgres as CHECK constraints, triggers and unique indexes. A few are enforced by application code instead. One section lists things that are not guaranteed, so that nobody writes code that depends on them.

It's worth paying attention to what enforces each rule. When Postgres enforces a rule, it applies to every writer, including scripts, console sessions and code that hasn't been written yet. When application code enforces a rule, it only applies to callers that go through that code.

test/integration/db-guarantees.test.ts checks that the database really enforces the list. It runs against a real Postgres database and tries to write data that the guarantees forbid.

Money

Every Order has an amountBase column, 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, so don't write this expression in a query or a migration:

COALESCE("amountBase", "amountTotal")

That fallback mixes currencies in a multi-currency report without producing any error.

ConstraintWhat Postgres does
order_amounts_nonnegativeIt keeps the order's money columns at zero or above. That covers the total, the base total, the refund total, discount, shipping, tax and the refund-reversal counters.
order_amounts_refunded_not_over_totalIt stops Order.amountRefunded and Order.refundStatsAppliedAmount from going above Order.amountTotal.
order_breakdown_matches_total_when_presentWhen amountSubtotal is present, it checks that subtotal minus discount plus tax plus shipping equals amountTotal.

The money comparisons allow a one-cent tolerance, so rounded values don't cause false failures.

Payment ledger

The payment ledger only accepts new rows. The litestore_payment_transaction_before_write trigger raises an error on any update or delete:

PaymentTransaction is append-only; write a correction row instead

To correct the ledger, you write a new row, so the ledger keeps its full history. The same trigger also 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.

Drift views

OrderMoneyDrift and CustomerMoneyDrift are views rather than constraints. Postgres computes the true totals in OrderMoneyTotals and CustomerMoneyTotals, and a row in a drift view means a cached value on an order or customer doesn't match those totals. Any row in a drift view is a problem you should investigate.

Money arithmetic

The app does money arithmetic through platform/utils/money.ts, where a Money value is a decimal.js-light Decimal paired with a currency. You must pass a currency to money(), and if you call sum() on values in different currencies, it throws instead of converting them.

Refunds

The database enforces these rules on refund requests.

RuleWhat Postgres does
refund_request_amount_and_attempts_validIt keeps the requested amount at zero or above.
refund_request_terminal_state_exclusiveIt lets a request reach at most one final outcome: approved, rejected or canceled.
refund_request_approved_marks_admin_approvalIt requires an approved request to be marked as approved by an admin.
litestore_refund_request_write_guardThis trigger rejects an approval once the approved requests for the order would add up to more than the order total.
Return requestedAva sends back 1 of 2 items
Refund raisedBy support, $48.00
ApprovedBy someone with refund approval
RefundedBack to the original card
Refunded of order total$0.00 / $96.00
An admin approves a refund request, and Litestore clamps the amount before it asks the payment provider to refund it.

One rule in this section isn't a database 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 modules/orders/refunds/refund-requests.ts by clamping the amount before it issues the refund at the payment provider:

Math.min(input.amount ?? requestedAmount, requestedAmount)

This rule only applies to callers that go through that code path, because the database doesn't enforce it.

Stock

Stock movements can be added but not edited, which the litestore_stock_movement_before_write trigger enforces. The stock_movement_quantity_math constraint requires previousQuantity + delta to equal newQuantity and doesn't allow a delta of zero.

The same trigger requires a matching Inventory row and rejects a movement whose newQuantity doesn't match the current inventory quantity for that location and variant. This prevents two receipts that arrive at the same time from overwriting each other: the second one starts from a quantity that's no longer current, so the database rejects it.

InventoryLedgerDrift is a view that works as an operational alarm. A row in it means the inventory and the latest stock movement don't match.

Identity

Postgres indexes 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_unique

The channel_one_default_active_unique index allows only one active default channel.

An index prevents new duplicates. Postgres can't create a unique index while duplicates exist, so a migration that adds one fails until you resolve the existing duplicates.

Visibility

The rules in this section are enforced by application code rather than database constraints.

Litestore derives the list of models that have deletedAt from the Prisma DMMF in platform/db/soft-delete-models.ts. The global soft-delete extension in platform/db/soft-delete-extension.ts adds deletedAt: null to top-level reads only. It covers findMany, findFirst, findUnique, count, aggregate and groupBy, and their OrThrow variants.

The extension doesn't reach nested relation reads, so a nested to-many include of a soft-deletable relation needs a deletedAt: null filter of its own, unless you mean to show archived rows.

Nothing adds an isHidden filter automatically, so every storefront read has to filter hidden rows itself. That includes the sitemap, RSS and llm.txt routes.

Archiving hides a row and can be undone, while deleting removes the row permanently. There's no central trash for deleted items.

Cache

Cache tags must come from CACHE_TAGS. Don't pass a raw tag string or a template literal to revalidateTag or invalidateTag.

A typo in a tag doesn't invalidate anything and doesn't produce an error, so there's no way to catch it at runtime. Using the constants lets TypeScript catch it instead.

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 doesn't need to.

A server file that runs Prisma mutations directly must also invalidate the affected tags. Without that, an admin table could keep showing stale rows until the cache expires.

Events and activity

This section describes what Litestore does not guarantee.

Litestore runs domain events and activity writes as side effects after the response, on a best-effort basis. Don't describe them as guaranteed delivery. Guaranteed delivery would need a transactional outbox written in the same database transaction as the state change, and Litestore doesn't implement one. Idempotency keys prevent a retried handler from causing the same side effect twice, but they don't guarantee that delivery happens.

Auth

When someone opens the admin, Litestore checks their role and ban state against the current user row in the database instead of relying 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. If a field is missing from the read, a check could run against a stale value.

Verification

GUARANTEES.md is the authoritative list. It starts with the rule for editing it, which says you shouldn't replace a hard guarantee with a tolerant fallback. Change the database and the type guarantee first, and then change the application code.

To check that the database enforces the guarantees, run the integration tests:

bun run test:integration

test/integration/db-guarantees.test.ts creates a paid order and then tries to write data the database must reject, such as a refund larger than the order total, an edit to a payment ledger row or a stock movement whose numbers don't add up. The test passes only when Postgres rejects each write. The integration tests need a local litestore_test database, so CI doesn't run them, and you should run them yourself after any migration that touches money, refunds or stock.

The unit tests test/unit/money-laws.test.ts (order reconciliation, refund ≤ payment, the cash ledger projection) and test/unit/invariants.test.ts (the runtime invariant guards) cover the application side of the money rules, and they run in verify and CI.

On this page