Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/sku-owners-index-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@otta-sh/store-emdash": patch
---

Fix add-to-cart failing with a D1 unique-constraint error on the second request for any given SKU.

`sku_owners` and `customer_emails` declared their natural key as a `uniqueIndexes` entry, documented as "a lookup plan, never the enforcement — no physical index exists in any tier." On the host's current release that is no longer true: a declared unique index materializes as one physical index per plugin, keyed on `(plugin_id, collection, <field>)` with no per-collection scoping. Any other collection under the same plugin that also carries a same-named top-level field collides on that same index — `reservation_keys` and `reservation_index` both carry `sku` (one row per reserve attempt, many rows legitimately sharing a sku), so the first reserve for a SKU succeeded and every later one for that SKU threw a raw `SQLITE_CONSTRAINT_UNIQUE`, surfacing to shoppers as "Something went wrong" on Add to cart.

Both collections now declare their natural key as a plain (non-unique) `indexes` entry. The create-if-absent compare-and-set against the sku/email-as-document-id was always the real enforcement; the index was redundant for its own collection and actively unsafe for its neighbors.
13 changes: 9 additions & 4 deletions packages/store-emdash/src/identity-documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,14 @@ export interface IdentityCollectionIndexDeclaration {
* cross-cutting rule (b)). Nothing filters on the embedded addresses — every
* address method is given its owning customer id, so the document is reached by
* id and the address is found inside it.
* - `customer_emails` declares its own doc id as a unique index, exactly as
* `sku_owners` does. It enforces nothing (no physical index exists in any tier);
* the claim document and its create-if-absent write are the enforcement.
* - `customer_emails` declares its own doc id as a lookup index, exactly as
* `sku_owners` does — see that collection's declaration
* (`product-commerce-documents.ts`) for why it is a plain `indexes` entry,
* never `uniqueIndexes`: the host materializes a unique index as one
* physical, plugin-wide index with no per-collection `WHERE` clause, so a
* `uniqueIndexes` entry here would equally risk colliding with `customers`'
* own declared `emailLower` index above. The claim document and its
* create-if-absent write are the real enforcement.
* - `sessions` declares `customerId` alone — the only filter the port asks for.
* The history's `createdAt DESC, id DESC` ordering is applied **in code** after a
* bounded paged read, so no ordering index is declared: a session's `id` is a
Expand All @@ -106,7 +111,7 @@ export interface IdentityCollectionIndexDeclaration {
*/
export const IDENTITY_COLLECTIONS: Readonly<Record<string, IdentityCollectionIndexDeclaration>> = {
[CUSTOMERS_COLLECTION]: { indexes: ["emailLower"] },
[CUSTOMER_EMAILS_COLLECTION]: { uniqueIndexes: ["emailLower"] },
[CUSTOMER_EMAILS_COLLECTION]: { indexes: ["emailLower"] },
[SESSIONS_COLLECTION]: { indexes: ["customerId"] },
[LOGIN_CHALLENGES_COLLECTION]: { indexes: ["consumed", "expiresAt"] },
[LOGIN_CHALLENGE_CLAIMS_COLLECTION]: {},
Expand Down
17 changes: 14 additions & 3 deletions packages/store-emdash/src/product-commerce-documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,25 @@ export interface CollectionIndexDeclaration {
* `EmdashProductCommerceStore.listProducts`.
*
* `sku_owners` declares its natural key, which IS its document id — a lookup
* plan, never the enforcement (ADR-0019 §4's `uniqueIndexes` rule). The store
* reaches it by id alone.
* plan, never the enforcement. The store reaches it by id alone.
*
* Declared as a plain (non-unique) `indexes` entry, NOT `uniqueIndexes`: the
* host materializes a declared unique index as ONE PHYSICAL SQLITE INDEX per
* plugin, keyed on `(plugin_id, collection, <field>)` with no `WHERE`
* clause — it is not scoped to this collection alone. Any OTHER collection
* under this plugin that also carries a top-level `sku` field (`inventory`'s
* `reservation_keys` and `reservation_index` both do, one row per reserve
* attempt, many rows legitimately sharing one sku) collides on that same
* physical index the moment a second such row exists — a production
* incident (2026-09-22), not a hypothetical. The create-if-absent CAS
* against the sku-as-id is the real enforcement; the index is a lookup plan
* only and must stay non-unique.
*/
export const PRODUCT_COMMERCE_COLLECTIONS: Readonly<Record<string, CollectionIndexDeclaration>> = {
[PRODUCT_COMMERCE_COLLECTION]: {
indexes: ["productId", "lifecycle", "publishKey", "productKind", "taxClass", "createdAt"],
},
[SKU_OWNERS_COLLECTION]: { uniqueIndexes: ["sku"] },
[SKU_OWNERS_COLLECTION]: { indexes: ["sku"] },
};

/**
Expand Down
Loading