From fdf930714738ef3799011231b6576d585a06f7c4 Mon Sep 17 00:00:00 2001 From: Zen0space Date: Wed, 8 Apr 2026 11:56:31 +0800 Subject: [PATCH] fix(db): use partialFilterExpression for stripeCustomerId unique index The original migration used { sparse: true, unique: true } which still fails with E11000 when multiple users have stripeCustomerId: null (sparse only skips missing fields, not explicit nulls). - Fix 000001 in place: replace sparse with partialFilterExpression so only real string Stripe IDs are included in the unique index - Add 000004 safety net: drops and recreates the index properly for environments where 000001 partially applied --- .../20260407000001_add_stripe_fields.js | 11 +++-- ...0407000004_fix_stripe_customer_id_index.js | 48 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 packages/kal-db/migrations/20260407000004_fix_stripe_customer_id_index.js diff --git a/packages/kal-db/migrations/20260407000001_add_stripe_fields.js b/packages/kal-db/migrations/20260407000001_add_stripe_fields.js index 8deb3cf..490b1f2 100644 --- a/packages/kal-db/migrations/20260407000001_add_stripe_fields.js +++ b/packages/kal-db/migrations/20260407000001_add_stripe_fields.js @@ -19,9 +19,14 @@ export const up = async (db, client) => { ); // Create index on stripeCustomerId for webhook lookups - await db - .collection("users") - .createIndex({ stripeCustomerId: 1 }, { sparse: true, unique: true }); + // Use partialFilterExpression so null values are excluded from uniqueness + await db.collection("users").createIndex( + { stripeCustomerId: 1 }, + { + unique: true, + partialFilterExpression: { stripeCustomerId: { $type: "string" } }, + } + ); }; export const down = async (db, client) => { diff --git a/packages/kal-db/migrations/20260407000004_fix_stripe_customer_id_index.js b/packages/kal-db/migrations/20260407000004_fix_stripe_customer_id_index.js new file mode 100644 index 0000000..16414b7 --- /dev/null +++ b/packages/kal-db/migrations/20260407000004_fix_stripe_customer_id_index.js @@ -0,0 +1,48 @@ +/** + * Migration: Fix stripeCustomerId index using partialFilterExpression + * + * Safety net for environments where migration 000001 partially applied — + * the updateMany set stripeCustomerId: null on all users, but the + * createIndex with { sparse: true, unique: true } failed because sparse + * indexes still include documents where the field is explicitly null. + * + * This drops any existing stripeCustomerId index and recreates it with + * a partialFilterExpression that only indexes documents where + * stripeCustomerId is a string — completely excluding null/missing values + * from the uniqueness constraint. + */ + +export const up = async (db, client) => { + // Drop the existing index if it exists (may be sparse or broken) + await db + .collection("users") + .dropIndex("stripeCustomerId_1") + .catch(() => {}); + + // Recreate with partialFilterExpression — only enforce uniqueness on real Stripe IDs + await db.collection("users").createIndex( + { stripeCustomerId: 1 }, + { + unique: true, + partialFilterExpression: { stripeCustomerId: { $type: "string" } }, + } + ); + + console.log( + "✅ Recreated stripeCustomerId index with partialFilterExpression (string only)" + ); +}; + +export const down = async (db, client) => { + // Revert to the sparse unique index from the original migration + await db + .collection("users") + .dropIndex("stripeCustomerId_1") + .catch(() => {}); + + await db + .collection("users") + .createIndex({ stripeCustomerId: 1 }, { sparse: true, unique: true }); + + console.log("✅ Reverted stripeCustomerId index to sparse unique"); +};