Tables, cascades and what a delete really removes

The eleven E-commerce tables, the columns that carry money and delivery, which deletes are soft, and the two cascade paths that destroy paid orders without warning.

10 min readUpdated 6 August 2026schema, tables, cascade, delete, backup

E-commerce adds eleven tables to the same MySQL schema core already uses. No second database, no separate connection, no migration files — the tables are synced from the model definitions on boot.

This page is for the three jobs that need the schema rather than the screens: writing a report against the database, planning a backup or a migration, and working out what a Permanent delete button is actually about to remove.

API and data carries two claims about these tables that are wrong, and both matter to anyone writing SQL:

  • Stock does not decrement "when an order is PAID". It decrements inside the checkout transaction, at the moment the order row is written, under a conditional update. A PENDING physical order has taken its stock.
  • The money columns are not DECIMAL. Every money column on ecommerce_order is DOUBLE, and the driver returns those as JavaScript numbers, not strings. The DECIMAL-as-string trap is real elsewhere on the platform — core's wallet and transaction tables — but not here.

Both are corrected in full below.

The eleven tables

Model Table Soft delete Timestamps What it holds
ecommerceCategory ecommerce_category Yes Yes Catalogue categories
ecommerceProduct ecommerce_product Yes Yes Products — price, type, currency, stock
ecommerceOrder ecommerce_order Yes Yes One order per product per checkout, with its money
ecommerceOrderItem ecommerce_order_item No No The line: quantity, and the digital delivery
ecommerceReview ecommerce_review Yes Yes One review per customer per product
ecommerceDiscount ecommerce_discount Yes Yes Discount codes, each scoped to one product
ecommerceUserDiscount ecommerce_user_discount No No Who has used which code — the one-use record
ecommerceShipping ecommerce_shipping Yes Yes Your consignment records
ecommerceShippingAddress ecommerce_shipping_address Yes Yes One address per order
ecommerceWishlist ecommerce_wishlist Yes Yes One wishlist per customer
ecommerceWishlistItem ecommerce_wishlist_item Yes Yes Products on a wishlist

Every primary key is a CHAR(36) UUID with a v4 default. Nine of the eleven are paranoid — a delete stamps deletedAt and the row stays. The two that are not (ecommerce_order_item, ecommerce_user_discount) have no deletedAt column at all, so any delete that reaches them is permanent.

ecommerce_order_item, ecommerce_user_discount, ecommerce_shipping_address and ecommerce_wishlist_item are not tables you can browse. They are reached only through the order screen, the discount screen or the wishlist screen, and ecommerce_user_discount has no admin surface whatsoever — it is written by checkout and read by checkout. If you need to see one, you are querying the database.

The order carries the money; the order item carries the goods

This split is the single most important thing on the page, because the two halves have different delete rules.

ecommerce_order

Column Type Notes
id UUID This is the order number. It is what the confirmation email prints
userId UUID The buyer. FK to user
status ENUM PENDING, COMPLETED, CANCELLED, REJECTED. Default PENDING
subtotal DOUBLE price × quantity, before discount
discount DOUBLE Amount taken off. Default 0
shippingCost DOUBLE Charged once per checkout, so it lands on one order of a multi-product cart. Default 0
tax DOUBLE Applied to the discounted subtotal. Default 0
total DOUBLE What the buyer was actually debited
currency VARCHAR(191) Copied from the product at sale time
walletType VARCHAR(50) FIAT, SPOT or ECO — the wallet that paid
shippingId UUID, nullable The shipment assigned to it. FK to ecommerce_shipping
createdAt / updatedAt / deletedAt DATETIME

There is no orderNumber, no reference, no paymentId and no product column on the order. A cart of three products produces three independent order rows with three unrelated UUIDs and no parent record joining them.

Checkout writes subtotal, discount, shippingCost, tax and total from the settings and prices in force at that moment, and nothing ever recalculates them. That is deliberate: an earlier build re-derived receipts from current settings and emailed coupon users the full price.

So a report must read these columns, not multiply the current product price by the quantity. Refunds quote the row. Receipts quote the row. Changing the tax rate on Store settings does not move a single historic figure — see Orders and fulfilment.

ecommerce_order_item

Column Type Notes
id UUID
orderId UUID FK to ecommerce_order
productId UUID FK to ecommerce_product
quantity INTEGER Minimum 1
key VARCHAR(191), nullable The licence key handed to the buyer
filePath VARCHAR(191), nullable The download — either an https:// URL or a path under the uploads root
instructions TEXT, nullable Delivery notes shown with the download

Unique on (orderId, productId). No timestamps, no deletedAt.

key, filePath and instructions are written per order item by PUT /api/admin/ecommerce/order/{id}/download — they are what an operator attaches after a digital sale. Nothing equivalent exists on the product row.

That is why a permanent product delete destroys a purchase somebody has already paid for: the cascade takes the order item, and the licence key and the download path go with it. The order row survives, holding the money, with nothing attached to it. There is no undo — the order item table has no deletedAt to restore from.

See Digital delivery.

The one-use-per-customer record

ecommerce_user_discount is four columns and easy to miss:

Column Type Notes
id UUID
userId UUID FK to user
discountId UUID FK to ecommerce_discount
status BOOLEAN Default true

Unique on (userId, discountId). Written as an upsert inside the checkout transaction, immediately after the wallet debit, whenever a discount was applied. It is the only place a code's usage is recorded.

Checkout reads it twice:

  • SELECT COUNT(*) WHERE discountId = ? AND status = true against the discount's maxUses, and
  • SELECT ... WHERE userId = ? AND discountId = ? AND status = true for "You have already used this discount".

Deleting the row — or flipping status to false — makes the customer eligible for that code again and gives back one of its maxUses slots. There is no admin screen for this table, so it can only be done in SQL, and nothing logs it.

That is occasionally what you want (a customer whose order you cancelled and who should not lose their coupon), and it is otherwise a hole in your promotion budget. Treat a DELETE here as a money operation.

Shipment records: your figures, not the buyer's

ecommerce_shipping is a log of a consignment. It is not connected to what anyone was charged.

Column Type Required Notes
loadId VARCHAR Yes Your reference or the carrier's. Free text, not unique
loadStatus ENUM Yes PENDING, TRANSIT, DELIVERED, CANCELLED
shipper VARCHAR Yes Free text
transporter VARCHAR Yes Free text — no carrier integration exists
vehicle VARCHAR Yes Free text
goodsType VARCHAR Yes Free text
description VARCHAR Yes Free text
weight FLOAT Yes Unitless
volume FLOAT Yes Unitless
cost FLOAT No What the shipment costs you
tax FLOAT No Tax on that cost
deliveryDate DATETIME No Expected or actual

cost and tax here are your own carrier figures — what the consignment cost you. They are not what the buyer was charged: that is ecommerce_order.shippingCost and ecommerce_order.tax, written by checkout from ecommerceDefaultShippingCost and the tax rate in force at sale time.

They are not private, though. GET /api/ecommerce/order/{id} eager-loads the whole shipment row, and the customer's own order page renders shipping.cost as its Shipping line and shipping.tax as its Tax line, then adds both into the Total it displays. The admin order page prints the same two values with a $ in front of them, whatever currency the carrier invoiced you in. So whatever you type into a shipment's cost and tax is shown to every buyer whose order is assigned to that shipment, and the total that page displays will not match ecommerce_order.total — that column is what was actually debited, and it is the only figure a report, a receipt or a refund should use.

Note the precision difference: shipment money is FLOAT, order money is DOUBLE. Do not sum the two together and expect them to agree.

Orders point at a shipment through ecommerce_order.shippingId, so the relationship is many orders to one shipment. Operational detail is on Shipping and fulfilment records.

The rest, briefly

Table Worth knowing
ecommerce_category name, slug, description (all NOT NULL), image, status. The slug is generated from the name if left blank, but there is no unique index on it
ecommerce_product slug is unique across the whole catalogue. type is DOWNLOADABLE or PHYSICAL, price is DOUBLE, walletType is FIAT/SPOT/ECO, inventoryQuantity is an INTEGER with a floor of 0. gallery is a JSON list of upload paths, capped at 12 and filtered to /uploads/ or /img/
ecommerce_review rating 1–5, comment capped at 191 characters, status is the moderation flag. Unique on (productId, userId)
ecommerce_discount code is unique. type is PERCENTAGE/FIXED/FREE_SHIPPING, percentage is an INTEGER 0–100, amount is DOUBLE, maxUses is nullable, validUntil is NOT NULL and validated as a future date on write
ecommerce_shipping_address One per order. phone is validated against E.164 (+ then 7–15 digits) and normalised on both write paths, so whatever the buyer typed is accepted
ecommerce_wishlist / ecommerce_wishlist_item One wishlist row per customer, created on first use; items unique on (wishlistId, productId)

Stock, correctly

Both order doors — POST /api/ecommerce/cart/checkout and POST /api/ecommerce/order — decrement stock inside the transaction that creates the order, with a conditional update:

UPDATE ecommerce_product
   SET inventoryQuantity = inventoryQuantity - :quantity
 WHERE id = :productId
   AND inventoryQuantity >= :quantity

If that update touches zero rows, the handler throws Product inventory changed during checkout and the whole transaction rolls back — no order, no debit, no stock movement. It is what makes two simultaneous buyers of the last unit safe.

Three consequences for anyone reading the table:

  • A PENDING physical order has already taken its stock. Reserved and sold are the same state here.
  • Only PHYSICAL products are touched. inventoryQuantity on a DOWNLOADABLE product is inert — it is stored and ignored.
  • Cancelling or rejecting an order puts the stock back, in the same transaction as the refund.

The cart is not a table

There is no cart table and no cart endpoint that stores anything. The cart lives in the browser's localStorage, under the key ecommerce-storage, and only cart and wishlist are persisted from that store.

  • Nothing is reserved when a customer adds to the cart.
  • Clearing site data empties it, and it does not follow the customer to another device.
  • The wishlist is the one thing that lives in both places: the browser copy is a mirror, and ecommerce_wishlist / ecommerce_wishlist_item are the server-side record.
  • A cart persisted by an older build can hold product entries missing walletType; the store repairs those on load rather than failing checkout.

So "how many abandoned carts do we have" is not a question this schema can answer. There is no row until checkout succeeds.

Soft delete, permanent delete and restore

Every store table with a deletedAt is wired the same way. The admin tables expose all three doors:

Action Request Effect
Delete DELETE .../{id} UPDATE … SET deletedAt = NOW(). The row stays, and no cascade fires
Restore DELETE .../{id}?restore=true Clears deletedAt. Not gated behind a confirmation — it is additive
Permanent delete DELETE .../{id}?force=true A real SQL DELETE. Every cascade fires

On the screens: the Show deleted button in the table toolbar reveals soft-deleted rows and turns the row menu into Restore and Permanent delete. Permanent delete raises a confirmation dialog; restore does not.

The button renders on every paranoid table, which is all seven store screens. Permissions decide whether it works, not whether it appears: a role missing either the view. or the delete. key sees it greyed out, with a tooltip saying they need both. "The Show deleted button does nothing" is a permissions answer, not a bug.

deletedAt does not release a unique index. Two cases bite:

  • Product slugs. slug is unique on ecommerce_product, and the slug generator only looks at live rows. Soft-delete "Blue Widget", create it again, and the generator hands back blue-widget unchanged — straight into the unique index the deleted row still holds. Restore it, rename the new one, or permanently delete the old one.
  • Discount codes. code is unique on ecommerce_discount. A soft-deleted SUMMER20 keeps the string reserved. The same applies to a customer's soft-deleted review of a product, which is unique on (productId, userId).

Category slugs are the exception — that table has no unique index on slug, so duplicates are possible there and the storefront resolves whichever row is live.

The cascade map

Every foreign key in this addon is declared ON DELETE CASCADE. A soft delete never reaches them; a permanent delete reaches all of them, in the database, below every application guard.

user ─┬─> ecommerce_order ─┬─> ecommerce_order_item
      │                    └─> ecommerce_shipping_address
      ├─> ecommerce_review
      ├─> ecommerce_wishlist ─> ecommerce_wishlist_item
      └─> ecommerce_user_discount

ecommerce_category ─> ecommerce_product ─┬─> ecommerce_review
                                         ├─> ecommerce_discount ─> ecommerce_user_discount
                                         ├─> ecommerce_order_item
                                         └─> ecommerce_wishlist_item

ecommerce_shipping ─> ecommerce_order ─┬─> ecommerce_order_item
                                       └─> ecommerce_shipping_address

Two of those paths are data-loss events, and neither warns you beyond the generic confirmation dialog.

Permanently deleting a category

ecommerce_category → ecommerce_product is a cascade, so one permanent category delete removes every product in it, and each product then removes its reviews, its discount codes (and their usage records), its wishlist entries and its order items.

The order rows survive. Their items do not. Afterwards you hold orders that recorded a charge for a product that no longer exists, with the buyer's licence key and download path gone, and no soft-delete copy of either.

Setting status = false takes it off the storefront and leaves everything intact. A soft delete is also safe — it stamps deletedAt and cascades nothing. Only ?force=true is destructive, and it is destructive several tables deep.

Permanently deleting a shipment

This is the one nobody expects. ecommerce_shipping.id is the parent of ecommerce_order.shippingId, and that FK is ON DELETE CASCADE too. So a permanent delete of a shipment record deletes every order assigned to it — and, through those orders, their items and shipping addresses.

The orders table refuses to delete a paid, unrefunded order:

Order <id> has been paid for and not refunded. Cancel or reject it first — that refunds the buyer and restores the stock — then delete it.

That guard lives in the order delete handler. A cascade from the shipping table never calls it. Since a shipment is deliberately shared across a courier run, one permanent delete can take out a whole batch of paid orders.

Soft-delete it, or set its loadStatus to CANCELLED. Those are the only safe options, because nothing in the addon can unassign an order from a shipment.

The one writer of ecommerce_order.shippingId is PUT /api/admin/ecommerce/order/{id}/shipment, and it only ever sets the column: shipmentId is required on the request and the handler 404s Shipment not found if it does not resolve, so it cannot be used to clear the field. The order screen's shipment picker is rendered only while the order has no shipment, so once assigned there is no control to change or remove it, and PUT /api/admin/ecommerce/order/{id} accepts nothing but status and its allowRefundFromCompleted override. Clearing shippingId is an UPDATE … SET shippingId = NULL in SQL, or nothing.

Deleting a customer

Deleting a user cascades into their orders, reviews, wishlist, shipping addresses and discount usage records. The store keeps no anonymised copy of a sale. If you delete a customer for a data-erasure request, your revenue history loses those orders — export first.

Checking your own database

The cascades come from the model definitions, which are what created the tables. To confirm what your install actually has:

SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, CONSTRAINT_NAME
  FROM information_schema.KEY_COLUMN_USAGE
 WHERE TABLE_SCHEMA = DATABASE()
   AND REFERENCED_TABLE_NAME LIKE 'ecommerce\_%';
SELECT CONSTRAINT_NAME, TABLE_NAME, REFERENCED_TABLE_NAME, DELETE_RULE
  FROM information_schema.REFERENTIAL_CONSTRAINTS
 WHERE CONSTRAINT_SCHEMA = DATABASE()
   AND TABLE_NAME LIKE 'ecommerce\_%';

Reporting and backup notes

  • Exclude soft-deleted rows. Nine of the eleven tables have deletedAt, and the admin screens hide those rows by default. A raw SELECT does not — add WHERE deletedAt IS NULL or your revenue figures will include deleted orders.
  • Sum total, in one currency at a time. Orders carry their own currency and walletType. Adding a USD order to a USDT order produces a number that means nothing.
  • Join on the order item for product-level reporting. The order row does not name a product; ecommerce_order_item.productId does.
  • ecommerce_order.status is the entitlement. A digital download is served only for a COMPLETED order. Do not infer it from the transaction ledger.
  • Back up the uploads directory as well as the database. Self-hosted download files and every product and category image live on disk; the tables hold paths only. Core's Backup and restore covers the commands.
  • Nothing in this schema needs a manual migration. Tables are synced from the models at boot, so an extension update restarts into the new shape.

Related: API and data for the endpoints, Permissions and roles for who can reach the delete doors, Categories and products and Orders and fulfilment for the screens that drive all of the above.