diff --git a/.changeset/sku-owners-index-scope.md b/.changeset/sku-owners-index-scope.md new file mode 100644 index 0000000..c72ab29 --- /dev/null +++ b/.changeset/sku-owners-index-scope.md @@ -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, )` 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. diff --git a/packages/store-emdash/src/identity-documents.ts b/packages/store-emdash/src/identity-documents.ts index b528e76..a588aae 100644 --- a/packages/store-emdash/src/identity-documents.ts +++ b/packages/store-emdash/src/identity-documents.ts @@ -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 @@ -106,7 +111,7 @@ export interface IdentityCollectionIndexDeclaration { */ export const IDENTITY_COLLECTIONS: Readonly> = { [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]: {}, diff --git a/packages/store-emdash/src/product-commerce-documents.ts b/packages/store-emdash/src/product-commerce-documents.ts index d069017..37fbef1 100644 --- a/packages/store-emdash/src/product-commerce-documents.ts +++ b/packages/store-emdash/src/product-commerce-documents.ts @@ -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, )` 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> = { [PRODUCT_COMMERCE_COLLECTION]: { indexes: ["productId", "lifecycle", "publishKey", "productKind", "taxClass", "createdAt"], }, - [SKU_OWNERS_COLLECTION]: { uniqueIndexes: ["sku"] }, + [SKU_OWNERS_COLLECTION]: { indexes: ["sku"] }, }; /**