From f78ce548f28de574f81a9fa1b7818061947da52d Mon Sep 17 00:00:00 2001 From: Vedanshu Date: Tue, 22 Sep 2026 11:11:05 +0000 Subject: [PATCH] [Adapters] Stop sku_owners/customer_emails declaring a unique index that leaks onto sibling collections The host materializes a declared uniqueIndexes entry as one physical index per plugin, scoped to (plugin_id, collection, field) with no per-collection WHERE clause. reservation_keys and reservation_index also carry a top-level sku field (one row per reserve attempt, many rows legitimately sharing a sku), so they collided on sku_owners' declared unique index the moment a second reserve happened for any SKU: the first add-to-cart for a product worked, every later one threw a raw D1 UNIQUE constraint error. Both collections' own create-if-absent CAS against the sku/email-as-id was always the real enforcement (the module comments already said so, wrongly adding "no physical index exists in any tier" as the reason it was safe to declare unique anyway). Switched both to a plain (non-unique) indexes entry and corrected the comments. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HPywdmMJ9B4V1ME6GrxuDu --- .changeset/sku-owners-index-scope.md | 9 +++++++++ packages/store-emdash/src/identity-documents.ts | 13 +++++++++---- .../src/product-commerce-documents.ts | 17 ++++++++++++++--- 3 files changed, 32 insertions(+), 7 deletions(-) create mode 100644 .changeset/sku-owners-index-scope.md 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"] }, }; /**