Inventory
Litestore counts stock per variant and location, records every change in a ledger and stops two checkouts from selling the same unit.
Litestore tracks how many units of each variant are in each location, and it records every change to those numbers. Read this page before you write code that changes stock.
A variant has no stored stock number. When Litestore needs the stock, it adds up the quantities in the active locations that hold the variant. What Litestore stores is the history, and every change to stock is a StockMovement row.
The count and the history work together. With only a number, nobody could explain it, and with only a history, nobody could rely on the total. Litestore computes the count and only ever appends to the history, so every quantity has a chain of rows behind it that adds up to exactly that quantity.
Stock movements
A movement records more than the change. Each row stores the quantity before, the change, the quantity after and who made it:
variantId
locationId
delta signed: negative = out, positive = in
previousQuantity
newQuantity
reason
actorType system | user | api | webhook
actorId
actorName
referenceType order | transfer | return | variant | …
referenceId
notesWith referenceType and referenceId, the ledger answers questions directly. To find out why a variant is down three units, you query its movements, and you don't have to guess from timestamps.
Movement constraint
The database rejects a row whose three quantities don't add up:
CHECK ("previousQuantity" + "delta" = "newQuantity" AND "delta" <> 0)The delta <> 0 part matters as much as the sum. A movement that changes nothing points to a bug, so the database rejects it instead of recording it.
The trigger litestore_guard_stock_movement_write enforces the rest. It runs before every insert, update and delete on StockMovement, and it raises an error in three cases.
| Case | Why the database rejects it |
|---|---|
| Any UPDATE or DELETE | The ledger only grows, so you correct a mistake by adding a new movement. |
No Inventory row exists for that location and variant | The movement has nothing to describe. |
newQuantity differs from the current Inventory.quantity for that location and variant | The movement would claim a quantity the stock doesn't have. |
The last check is the strictest. A movement may not claim an ending quantity that the inventory row doesn't actually have. If code lowers the stock and then writes a different number to the ledger, the database rejects the write on the spot. The mismatch never turns up later as a gap nobody can explain.
Concurrency
When two shoppers check out the last unit at the same moment, adjustInventoryInTransaction in modules/inventory/adjust-inventory.ts makes them take turns. It lowers stock with one conditional UPDATE … SET quantity = quantity + delta WHERE quantity >= n RETURNING quantity, not a read followed by a write. The row lock that the update takes serializes the two checkouts, and the loser of the race for the last unit matches no row and is rejected.
The check and the decrement are the same statement, so the check can never use an outdated count.
Litestore writes the ledger entry in the same transaction as the change. It takes the new quantity from the write itself instead of reading the row again. Nothing can change the row between the update and the ledger entry, so the before and after quantities on a movement are always exact.
test/integration/adjust-inventory.test.ts tests this against a real database, because the behaviour comes from the database, not from the application. See Integration Tests.
Absolute edits
When an operator corrects a count, that is a different operation from a sale. It needs the same lock, for a different reason.
When the admin sets a quantity to a fixed number, modules/inventory/location-stock.ts locks the row with SELECT … FOR UPDATE before it reads the current quantity. It then writes the difference through the same path that orders, refunds and returns use.
Without the lock, an operator's "set to 120" would quietly become a relative change, added on top of whatever happened in between:
read 100
↓
concurrent sale −5
↓
apply +20
↓
115, not 120The tool operators use to fix wrong counts would then create wrong counts itself.
Because the edit goes through the same path as every other change, a manual correction shows up in the ledger like everything else. Manual edits and imports leave a record too.
Reservations
Checkout lowers the stock when the checkout session starts. It holds the units for as long as the customer can pay, plus a grace period. Checkout explains the timing and the overselling it prevents.
Negative stock
The guarded decrement rejects any attempt to take more units than are in stock. Exactly one path skips that guard, for a specific reason.
Once a payment has been captured, Litestore must create the order. If it rejected the order because stock ran out in the meantime, the customer would be charged and have no order, which is worse than a negative count.
So when the customer has already paid, Litestore lets the location's stock go negative instead of throwing an error. It flags the order with needsReview so a person can sort it out. The oversell becomes visible on the order, not a rejected order or a lost payment.
Drift is surfaced, never silently corrected
The InventoryLedgerDrift view shows any row where the inventory and the latest stock movement don't match. Litestore leaves any difference for a person to look at, because quietly rewriting a stock count would destroy the evidence of what caused it.
Locations
Each location has a priority and two separate switches. One says whether it ships online orders, and the other says whether customers can pick up there.
| Setting | What it does |
|---|---|
| Ships online orders | Lets the storefront fulfil online orders from this location. |
| Offers pickup | Lets customers pick up orders here. |
| Priority | Sorts the locations. A higher priority wins. |
| Active | Sets whether Litestore counts this location's stock. |
The two switches are independent. A shop might offer pickup without shipping online orders, and a warehouse might do the opposite. A location can also do both, or neither.
Litestore only counts stock in active locations. When you deactivate a location, the storefront can no longer sell its units, but the movement history stays. Taking a location out of service is not the same as deleting it. Its units stop being sellable at once, and the record of everything that moved through it stays intact.
When nothing else has chosen a location, Litestore uses the active location with the highest priority. It creates a new location only if none exist at all.
Adding stock changes
Before any stock write, don't only ask whether it leaves the right number. Ask this: if someone sums the ledger for this variant tomorrow, will it explain the number they see?
Related
Checkout
A cart becomes a Stripe Checkout Session, Litestore reserves stock for a fixed time, and the reservation window must outlast the payment window.
Guarantees
Postgres enforces some guarantees about money, refunds, stock, identity, visibility and caching, application code enforces others, and Litestore doesn't make the rest.
Integration
Integration tests run against a real database, and a new test belongs there when stubs can't check its behavior.