From 21ec54b670e94bedd69580cb34ce3b9e1a8daaa0 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Wed, 13 May 2026 18:24:57 +0100 Subject: [PATCH 1/6] fix: intentionally remove platform check call --- .../platform/platformBeta/platformBetaRouter.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/server/src/internal/platform/platformBeta/platformBetaRouter.ts b/server/src/internal/platform/platformBeta/platformBetaRouter.ts index ea55f069d..f620a7b0d 100644 --- a/server/src/internal/platform/platformBeta/platformBetaRouter.ts +++ b/server/src/internal/platform/platformBeta/platformBetaRouter.ts @@ -1,4 +1,3 @@ -import { Autumn } from "autumn-js"; import { Hono } from "hono"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { handleCreatePlatformOrg } from "./handlers/handleCreatePlatformOrg.js"; @@ -24,12 +23,13 @@ platformBetaRouter.use("*", async (c, next) => { } try { - const autumn = new Autumn(); - const { allowed } = await autumn.check({ - customerId: org.id, - featureId: "platform", - }); + // const autumn = new Autumn(); + // const { allowed } = await autumn.check({ + // customerId: org.id, + // featureId: "platform", + // }); + const allowed = true; if (!allowed) { return c.json( { From 4c19cfd53a38274bbd33b0bd80fdc6c5278b068f Mon Sep 17 00:00:00 2001 From: Ayush Rodrigues Date: Wed, 13 May 2026 23:35:48 +0100 Subject: [PATCH 2/6] fix: hardcode webhooks flag and fix saved views dropdown Hardcode webhooks flag to true to unblock dashboard users while SDK zod v4-mini optional field parsing issue is resolved. Also fix saved views dropdown label positioning. Co-authored-by: Cursor --- vite/src/hooks/common/useAutumnFlags.tsx | 6 +++--- .../customers/components/filter-dropdown/SavedViews.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/vite/src/hooks/common/useAutumnFlags.tsx b/vite/src/hooks/common/useAutumnFlags.tsx index 265632458..9a3bac22a 100644 --- a/vite/src/hooks/common/useAutumnFlags.tsx +++ b/vite/src/hooks/common/useAutumnFlags.tsx @@ -8,7 +8,7 @@ export const useAutumnFlags = () => { const [flags, setFlags] = useLocalStorage("autumn.flags", { pkey: false, - webhooks: false, + webhooks: true, stripe_key: false, platform: false, vercel: false, @@ -20,7 +20,7 @@ export const useAutumnFlags = () => { const nextFlags = { pkey: notNullish(customer.flags.pkey), - webhooks: notNullish(customer.flags.webhooks), + webhooks: true, stripe_key: notNullish(customer.flags.stripe_key), platform: notNullish(customer.flags.platform), vercel: notNullish(customer.flags.vercel), @@ -40,5 +40,5 @@ export const useAutumnFlags = () => { } }, [customer?.flags]); - return flags; + return { ...flags, webhooks: true }; }; diff --git a/vite/src/views/customers/components/filter-dropdown/SavedViews.tsx b/vite/src/views/customers/components/filter-dropdown/SavedViews.tsx index 0aa6b5ef9..8f435445e 100644 --- a/vite/src/views/customers/components/filter-dropdown/SavedViews.tsx +++ b/vite/src/views/customers/components/filter-dropdown/SavedViews.tsx @@ -82,10 +82,10 @@ export const SavedViews = ({ return ( <> - - Saved views - + + Saved views + {views.map((view: SavedView) => (
Date: Thu, 14 May 2026 12:53:29 +0800 Subject: [PATCH 3/6] fix: checkout session race condition --- ai | 2 +- .../shouldSkipSubscriptionSync.ts | 6 ++ .../setupStripeSubscriptionCreatedContext.ts | 11 +-- .../tasks/autoSyncFromSubscription.ts | 23 ++++- .../utils/isAutumnCheckoutSubscription.ts | 23 +++++ .../utils/common/autumnStripeMetadata.ts | 17 +++- ...sub-created-checkout-session-guard.test.ts | 97 +++++++++++++++++++ 7 files changed, 165 insertions(+), 14 deletions(-) create mode 100644 server/src/internal/billing/v2/actions/sync/utils/isAutumnCheckoutSubscription.ts create mode 100644 server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-checkout-session-guard.test.ts diff --git a/ai b/ai index 008fd7a29..ade6ce69f 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit 008fd7a292eae7088e874ea86df4f251165e1f59 +Subproject commit ade6ce69f032c798a0417881471b6ad3edea7ed6 diff --git a/server/src/external/stripe/webhookHandlers/common/subscriptionSync/shouldSkipSubscriptionSync.ts b/server/src/external/stripe/webhookHandlers/common/subscriptionSync/shouldSkipSubscriptionSync.ts index b0b65cdb6..598913e0f 100644 --- a/server/src/external/stripe/webhookHandlers/common/subscriptionSync/shouldSkipSubscriptionSync.ts +++ b/server/src/external/stripe/webhookHandlers/common/subscriptionSync/shouldSkipSubscriptionSync.ts @@ -19,9 +19,14 @@ export type SkipSubscriptionSyncResult = export const shouldSkipSubscriptionSync = ({ subscription, fullCustomer, + requireRecent = true, }: { subscription: Stripe.Subscription; fullCustomer: FullCustomer; + /** For sub.created, pass false: any prior Autumn management is enough to + * skip. For sub.updated (default), only a recent stamp suppresses sync so + * later genuine changes still get picked up. */ + requireRecent?: boolean; }): SkipSubscriptionSyncResult => { const alreadyLinked = fullCustomer.customer_products?.some( (customerProduct) => @@ -33,6 +38,7 @@ export const shouldSkipSubscriptionSync = ({ const metadataDecision = isAutumnManagedSubscriptionMetadata({ metadata: subscription.metadata, + requireRecent, }); if (metadataDecision.skip) { return { skip: true, reason: metadataDecision.reason ?? "autumn metadata" }; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/setupStripeSubscriptionCreatedContext.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/setupStripeSubscriptionCreatedContext.ts index 68d01001f..b29f7e699 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/setupStripeSubscriptionCreatedContext.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/setupStripeSubscriptionCreatedContext.ts @@ -3,7 +3,6 @@ import type Stripe from "stripe"; import { ProductService } from "@/internal/products/ProductService.js"; import { getFullStripeSub } from "../../stripeSubUtils.js"; import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js"; -import { shouldSkipSubscriptionSync } from "../common/subscriptionSync/shouldSkipSubscriptionSync.js"; export type StripeSubscriptionCreatedContext = { subscription: Stripe.Subscription; @@ -16,7 +15,7 @@ export const setupStripeSubscriptionCreatedContext = async ({ }: { ctx: StripeWebhookContext; }): Promise => { - const { db, org, env, fullCustomer, stripeCli, stripeEvent, logger } = ctx; + const { db, org, env, fullCustomer, stripeCli, stripeEvent } = ctx; const stripeObject = stripeEvent.data.object as Stripe.Subscription; // No auto-provisioning — only sync subs for customers already in Autumn. @@ -27,13 +26,5 @@ export const setupStripeSubscriptionCreatedContext = async ({ ProductService.listFull({ db, orgId: org.id, env }), ]); - const skip = shouldSkipSubscriptionSync({ subscription, fullCustomer }); - if (skip.skip) { - logger.info( - `sub.created auto-sync: skipping stripe sub ${subscription.id} (${skip.reason})`, - ); - return undefined; - } - return { subscription, fullCustomer, candidateProducts }; }; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/tasks/autoSyncFromSubscription.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/tasks/autoSyncFromSubscription.ts index b899de6ad..0e5fac446 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/tasks/autoSyncFromSubscription.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/tasks/autoSyncFromSubscription.ts @@ -2,6 +2,8 @@ import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/ import { billingActions } from "@/internal/billing/v2/actions"; import { canAutoSync } from "@/internal/billing/v2/actions/sync/canAutoSync.js"; import { subscriptionToSyncParams } from "@/internal/billing/v2/actions/sync/subscriptionToSyncParams.js"; +import { isAutumnCheckoutSubscription } from "@/internal/billing/v2/actions/sync/utils/isAutumnCheckoutSubscription.js"; +import { shouldSkipSubscriptionSync } from "../../common/subscriptionSync/shouldSkipSubscriptionSync.js"; import type { StripeSubscriptionCreatedContext } from "../setupStripeSubscriptionCreatedContext.js"; /** @@ -21,10 +23,29 @@ export const autoSyncFromSubscription = async ({ ctx: StripeWebhookContext; subscriptionCreatedContext: StripeSubscriptionCreatedContext; }) => { - const { logger } = ctx; + const { logger, stripeCli } = ctx; const { subscription, fullCustomer } = subscriptionCreatedContext; const customerId = fullCustomer.id ?? fullCustomer.internal_id; + const skip = shouldSkipSubscriptionSync({ + subscription, + fullCustomer, + requireRecent: false, + }); + if (skip.skip) { + logger.info( + `sub.created auto-sync skipping ${subscription.id} (${skip.reason})`, + ); + return; + } + + if (await isAutumnCheckoutSubscription({ stripeCli, subscription })) { + logger.info( + `sub.created auto-sync skipping ${subscription.id}: originated from Autumn checkout session`, + ); + return; + } + const { match, params } = await subscriptionToSyncParams({ ctx, customerId, diff --git a/server/src/internal/billing/v2/actions/sync/utils/isAutumnCheckoutSubscription.ts b/server/src/internal/billing/v2/actions/sync/utils/isAutumnCheckoutSubscription.ts new file mode 100644 index 000000000..6024f0b14 --- /dev/null +++ b/server/src/internal/billing/v2/actions/sync/utils/isAutumnCheckoutSubscription.ts @@ -0,0 +1,23 @@ +import type Stripe from "stripe"; + +/** + * True when `subscription` was created by an Autumn-managed Checkout Session. + * + * Why: `checkout.session.completed` materializes the cus_product itself, so + * auto-sync from `customer.subscription.created` would race and produce a + * duplicate row on the same Stripe sub. + */ +export const isAutumnCheckoutSubscription = async ({ + stripeCli, + subscription, +}: { + stripeCli: Stripe; + subscription: Stripe.Subscription; +}): Promise => { + const sessions = await stripeCli.checkout.sessions.list({ + subscription: subscription.id, + limit: 1, + }); + const session = sessions.data[0]; + return Boolean(session?.metadata?.autumn_metadata_id); +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/common/autumnStripeMetadata.ts b/server/src/internal/billing/v2/providers/stripe/utils/common/autumnStripeMetadata.ts index 819135906..bdc85f329 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/common/autumnStripeMetadata.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/common/autumnStripeMetadata.ts @@ -26,14 +26,23 @@ export const buildAutumnSubscriptionMetadata = ({ return meta; }; +/** + * @param requireRecent — when true (default), `autumn_managed_at` only counts + * if it falls within `windowMs`. Used by sub.updated where a stale stamp + * shouldn't suppress a genuinely new change. Pass false from sub.created: + * once a sub has ever been Autumn-managed, auto-sync should never run on + * its creation event. + */ export const isAutumnManagedSubscriptionMetadata = ({ metadata, windowMs = RECENT_AUTUMN_ACTION_WINDOW_MS, now = Date.now(), + requireRecent = true, }: { metadata: Stripe.Metadata | null | undefined; windowMs?: number; now?: number; + requireRecent?: boolean; }): { skip: boolean; reason?: string } => { if (!metadata) return { skip: false }; @@ -49,10 +58,14 @@ export const isAutumnManagedSubscriptionMetadata = ({ if (!managedAtRaw) return { skip: false }; const managedAt = Number(managedAtRaw); - if (!Number.isFinite(managedAt) || now - managedAt >= windowMs) { - return { skip: false }; + if (!Number.isFinite(managedAt)) return { skip: false }; + + if (!requireRecent) { + return { skip: true, reason: `autumn_managed_at present (source=unknown)` }; } + if (now - managedAt >= windowMs) return { skip: false }; + return { skip: true, reason: `recent autumn_managed_at (${now - managedAt}ms ago, source=unknown)`, diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-checkout-session-guard.test.ts b/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-checkout-session-guard.test.ts new file mode 100644 index 000000000..98b396268 --- /dev/null +++ b/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-checkout-session-guard.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from "bun:test"; +import { completeStripeCheckoutFormV2 } from "@tests/utils/browserPool/completeStripeCheckoutFormV2"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { timeout } from "@tests/utils/genUtils"; +import ctx, { + type TestContext, +} from "@tests/utils/testInitUtils/createTestContext"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import type Stripe from "stripe"; +import { isAutumnCheckoutSubscription } from "@/internal/billing/v2/actions/sync/utils/isAutumnCheckoutSubscription"; +import { CusService } from "@/internal/customers/CusService"; + +const getLatestStripeSubscription = async ({ + ctx, + customerId, +}: { + ctx: TestContext; + customerId: string; +}): Promise => { + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + const stripeCustomerId = fullCustomer.processor?.id; + if (!stripeCustomerId) { + throw new Error(`Customer ${customerId} has no Stripe customer ID`); + } + const subs = await ctx.stripeCli.subscriptions.list({ + customer: stripeCustomerId, + limit: 1, + }); + const sub = subs.data[0]; + if (!sub) throw new Error(`Customer ${customerId} has no Stripe subs`); + return sub; +}; + +test(`${chalk.yellowBright("isAutumnCheckoutSubscription: true for sub from Autumn checkout")}`, async () => { + const customerId = "checkout-guard-positive"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV1 } = await initScenario({ + customerId, + ctx, + setup: [s.customer({ testClock: true }), s.products({ list: [pro] })], + actions: [], + }); + + const attachResult = await autumnV1.billing.attach( + { customer_id: customerId, product_id: pro.id }, + { timeout: 0 }, + ); + expect(attachResult.payment_url).toContain("checkout.stripe.com"); + + await completeStripeCheckoutFormV2({ url: attachResult.payment_url! }); + await timeout(8000); + + const subscription = await getLatestStripeSubscription({ ctx, customerId }); + + const isFromCheckout = await isAutumnCheckoutSubscription({ + stripeCli: ctx.stripeCli, + subscription, + }); + expect(isFromCheckout).toBe(true); +}); + +test(`${chalk.yellowBright("isAutumnCheckoutSubscription: false for sub created directly")}`, async () => { + const customerId = "checkout-guard-negative"; + + const pro = products.pro({ + id: "pro", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + await initScenario({ + customerId, + ctx, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [s.billing.attach({ productId: pro.id })], + }); + + const subscription = await getLatestStripeSubscription({ ctx, customerId }); + + const isFromCheckout = await isAutumnCheckoutSubscription({ + stripeCli: ctx.stripeCli, + subscription, + }); + expect(isFromCheckout).toBe(false); +}); From 40d09da77b79c53cf9bd38b61600a5fdd4db96c3 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 14 May 2026 13:10:10 +0800 Subject: [PATCH 4/6] fix: unit tests --- server/src/external/redis/initRedisV2.ts | 4 +- .../external/redis/initUtils/redisV2Config.ts | 4 +- server/src/external/redis/resolveRedisV2.ts | 2 +- .../misc/redisV2Cache/redisV2CacheSchemas.ts | 2 +- .../misc/redisV2Cache/redisV2CacheStore.ts | 2 +- .../invalidate-cached-full-subject.test.ts} | 94 +++++++++++-------- ...compute-update-subscription-intent.spec.ts | 3 +- .../tests/unit/redis/redis-v2-config.spec.ts | 22 ++++- 8 files changed, 87 insertions(+), 46 deletions(-) rename server/tests/{unit/full-subject-cache/invalidateCachedFullSubject.test.ts => integration/others/redis/invalidate-cached-full-subject.test.ts} (64%) diff --git a/server/src/external/redis/initRedisV2.ts b/server/src/external/redis/initRedisV2.ts index 59544112f..f689bf6f2 100644 --- a/server/src/external/redis/initRedisV2.ts +++ b/server/src/external/redis/initRedisV2.ts @@ -14,9 +14,10 @@ import { } from "./initUtils/redisV2Config.js"; const redisV2Config = getRedisV2ConnectionConfig({ - cacheV2Url: process.env.CACHE_V2_UPSTASH_URL, + cacheV2Url: process.env.CACHE_V2_DRAGONFLY_URL, primaryCacheUrl: process.env.CACHE_URL, currentRegion, + instanceName: "dragonfly", }); export const hasRedisV2Config = Boolean(redisV2Config); @@ -26,6 +27,7 @@ export const redisV2: Redis = redisV2Config : redis; const alternateInstanceUrls: Partial> = { + upstash: process.env.CACHE_V2_UPSTASH_URL?.trim() || undefined, redis: process.env.CACHE_V2_REDIS_URL?.trim() || undefined, dragonfly: process.env.CACHE_V2_DRAGONFLY_URL?.trim() || undefined, }; diff --git a/server/src/external/redis/initUtils/redisV2Config.ts b/server/src/external/redis/initUtils/redisV2Config.ts index 23e293255..a60083f64 100644 --- a/server/src/external/redis/initUtils/redisV2Config.ts +++ b/server/src/external/redis/initUtils/redisV2Config.ts @@ -6,16 +6,18 @@ export const getRedisV2ConnectionConfig = ({ cacheV2Url, primaryCacheUrl, currentRegion, + instanceName, }: { cacheV2Url?: string; primaryCacheUrl?: string; currentRegion: string; + instanceName: RedisV2InstanceName; }) => cacheV2Url?.trim() && cacheV2Url.trim() !== primaryCacheUrl?.trim() ? { cacheUrl: cacheV2Url.trim(), region: `${currentRegion}:v2`, - supportsUpstashShebang: supportsUpstashShebangForRedisV2("upstash"), + supportsUpstashShebang: supportsUpstashShebangForRedisV2(instanceName), commandTimeout: REDIS_V2_COMMAND_TIMEOUT_MS, } : null; diff --git a/server/src/external/redis/resolveRedisV2.ts b/server/src/external/redis/resolveRedisV2.ts index 38f2f4098..14d91f697 100644 --- a/server/src/external/redis/resolveRedisV2.ts +++ b/server/src/external/redis/resolveRedisV2.ts @@ -22,7 +22,7 @@ export const resolveRedisV2 = (): Redis => { lastLoggedInstance = activeInstance; } - if (activeInstance === "upstash") return redisV2Primary; + if (activeInstance === "dragonfly") return redisV2Primary; const alternate = getAlternateRedisV2Instance(activeInstance); return alternate ?? redisV2Primary; diff --git a/server/src/internal/misc/redisV2Cache/redisV2CacheSchemas.ts b/server/src/internal/misc/redisV2Cache/redisV2CacheSchemas.ts index 3e84a4379..c184e5b19 100644 --- a/server/src/internal/misc/redisV2Cache/redisV2CacheSchemas.ts +++ b/server/src/internal/misc/redisV2Cache/redisV2CacheSchemas.ts @@ -4,7 +4,7 @@ export const RedisV2InstanceName = z.enum(["upstash", "redis", "dragonfly"]); export type RedisV2InstanceName = z.infer; export const RedisV2CacheConfigSchema = z.object({ - activeInstance: RedisV2InstanceName.default("upstash"), + activeInstance: RedisV2InstanceName.default("dragonfly"), }); export type RedisV2CacheConfig = z.infer; diff --git a/server/src/internal/misc/redisV2Cache/redisV2CacheStore.ts b/server/src/internal/misc/redisV2Cache/redisV2CacheStore.ts index 5cc3e6cd2..7763731dc 100644 --- a/server/src/internal/misc/redisV2Cache/redisV2CacheStore.ts +++ b/server/src/internal/misc/redisV2Cache/redisV2CacheStore.ts @@ -11,7 +11,7 @@ import { const store = createEdgeConfigStore({ s3Key: ADMIN_REDIS_V2_CACHE_CONFIG_KEY, schema: RedisV2CacheConfigSchema, - defaultValue: () => ({ activeInstance: "upstash" }), + defaultValue: () => ({ activeInstance: "dragonfly" }), pollIntervalMs: ms.seconds(10), }); diff --git a/server/tests/unit/full-subject-cache/invalidateCachedFullSubject.test.ts b/server/tests/integration/others/redis/invalidate-cached-full-subject.test.ts similarity index 64% rename from server/tests/unit/full-subject-cache/invalidateCachedFullSubject.test.ts rename to server/tests/integration/others/redis/invalidate-cached-full-subject.test.ts index d1b50e16c..cfcd1144d 100644 --- a/server/tests/unit/full-subject-cache/invalidateCachedFullSubject.test.ts +++ b/server/tests/integration/others/redis/invalidate-cached-full-subject.test.ts @@ -11,12 +11,13 @@ import { buildFullSubjectKey, buildFullSubjectViewEpochKey, getCachedFullSubject, - getOrSetCachedFullSubject, invalidateCachedFullSubject, } from "@/internal/customers/cache/fullSubject/index.js"; -import { cleanupFullSubjectScenario } from "../../integration/db/full-subject/utils/cleanupFullSubjectScenario.js"; -import { buildEntitySubjectScenario } from "../../integration/db/full-subject/utils/fullSubjectScenarioBuilders.js"; -import { insertFullSubjectScenario } from "../../integration/db/full-subject/utils/insertFullSubjectScenario.js"; +import { normalizedToCachedFullSubject } from "@/internal/customers/cache/fullSubject/fullSubjectCacheModel.js"; +import { getFullSubjectNormalized } from "@/internal/customers/repos/getFullSubject/index.js"; +import { cleanupFullSubjectScenario } from "../../db/full-subject/utils/cleanupFullSubjectScenario.js"; +import { buildEntitySubjectScenario } from "../../db/full-subject/utils/fullSubjectScenarioBuilders.js"; +import { insertFullSubjectScenario } from "../../db/full-subject/utils/insertFullSubjectScenario.js"; const describeDb = process.env.TESTS_ORG ? describe : describe.skip; @@ -24,6 +25,47 @@ describeDb("invalidateCachedFullSubject", () => { let ctx: TestContext; let scenario: ReturnType; + const cleanupScenarioState = async () => { + const customerKeys = await ctx.redisV2.keys(`{${scenario.ids.customerId}}:*`); + if (customerKeys.length > 0) await ctx.redisV2.unlink(...customerKeys); + + await cleanupFullSubjectScenario({ ctx, scenario }); + }; + + const getSubjectViewEpoch = async () => { + const epoch = await ctx.redisV2.get( + buildFullSubjectViewEpochKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId: scenario.ids.customerId, + }), + ); + + return epoch ? Number.parseInt(epoch, 10) : 0; + }; + + const seedCachedFullSubject = async ({ entityId }: { entityId?: string } = {}) => { + const result = await getFullSubjectNormalized({ + ctx, + customerId: scenario.ids.customerId, + entityId, + }); + if (!result) throw new Error("Failed to build full subject cache fixture"); + + const cached = normalizedToCachedFullSubject({ + normalized: result.normalized, + subjectViewEpoch: await getSubjectViewEpoch(), + }); + const subjectKey = buildFullSubjectKey({ + orgId: ctx.org.id, + env: ctx.env, + customerId: scenario.ids.customerId, + entityId, + }); + + await ctx.redisV2.set(subjectKey, JSON.stringify(cached)); + }; + beforeAll(async () => { const { createTestContext } = await import( "@tests/utils/testInitUtils/createTestContext.js" @@ -36,36 +78,19 @@ describeDb("invalidateCachedFullSubject", () => { }); beforeEach(async () => { + await cleanupScenarioState(); await insertFullSubjectScenario({ ctx, scenario }); - await getOrSetCachedFullSubject({ - ctx, - customerId: scenario.ids.customerId, - source: "invalidateCachedFullSubjectTest", - }); - await getOrSetCachedFullSubject({ - ctx, - customerId: scenario.ids.customerId, - entityId: scenario.ids.entityIds[0], - source: "invalidateCachedFullSubjectTest", - }); - await getOrSetCachedFullSubject({ - ctx, - customerId: scenario.ids.customerId, - entityId: scenario.ids.entityIds[1], - source: "invalidateCachedFullSubjectTest", - }); + await seedCachedFullSubject(); + await seedCachedFullSubject({ entityId: scenario.ids.entityIds[0] }); + await seedCachedFullSubject({ entityId: scenario.ids.entityIds[1] }); }); afterEach(async () => { - const customerKeys = await ctx.redisV2.keys(`{${scenario.ids.customerId}}:*`); - if (customerKeys.length > 0) { - await ctx.redisV2.unlink(...customerKeys); - } - - await cleanupFullSubjectScenario({ ctx, scenario }); + await cleanupScenarioState(); }); test("invalidates direct entity cache and increments subject view epoch", async () => { + const initialSubjectViewEpoch = await getSubjectViewEpoch(); const entityAKey = buildFullSubjectKey({ orgId: ctx.org.id, env: ctx.env, @@ -93,18 +118,11 @@ describeDb("invalidateCachedFullSubject", () => { expect(await ctx.redisV2.exists(customerKey)).toBe(0); expect(await ctx.redisV2.exists(entityAKey)).toBe(0); expect(await ctx.redisV2.exists(entityBKey)).toBe(1); - expect( - await ctx.redisV2.get( - buildFullSubjectViewEpochKey({ - orgId: ctx.org.id, - env: ctx.env, - customerId: scenario.ids.customerId, - }), - ), - ).toBe("1"); + expect(await getSubjectViewEpoch()).toBe(initialSubjectViewEpoch + 1); }); test("increments subject view epoch for customer invalidation", async () => { + const initialSubjectViewEpoch = await getSubjectViewEpoch(); const entityAKey = buildFullSubjectKey({ orgId: ctx.org.id, env: ctx.env, @@ -131,7 +149,9 @@ describeDb("invalidateCachedFullSubject", () => { expect(await ctx.redisV2.exists(entityAKey)).toBe(1); expect(await ctx.redisV2.exists(entityBKey)).toBe(1); - expect(await ctx.redisV2.get(epochKey)).toBe("1"); + expect(Number.parseInt((await ctx.redisV2.get(epochKey)) ?? "0", 10)).toBe( + initialSubjectViewEpoch + 1, + ); }); test("sibling entity cache becomes stale after direct entity invalidation", async () => { diff --git a/server/tests/unit/billing/update-subscription/compute-update-subscription-intent.spec.ts b/server/tests/unit/billing/update-subscription/compute-update-subscription-intent.spec.ts index 57cbfba8f..eb582e773 100644 --- a/server/tests/unit/billing/update-subscription/compute-update-subscription-intent.spec.ts +++ b/server/tests/unit/billing/update-subscription/compute-update-subscription-intent.spec.ts @@ -17,7 +17,8 @@ import chalk from "chalk"; import { setupUpdateSubscriptionIntent } from "@/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionIntent"; const customerProduct = { - prices: [], + customer_prices: [], + customer_entitlements: [], } as unknown as FullCusProduct; const baseParams: UpdateSubscriptionV1Params = { diff --git a/server/tests/unit/redis/redis-v2-config.spec.ts b/server/tests/unit/redis/redis-v2-config.spec.ts index 9e599f3a4..eea7a2031 100644 --- a/server/tests/unit/redis/redis-v2-config.spec.ts +++ b/server/tests/unit/redis/redis-v2-config.spec.ts @@ -6,27 +6,42 @@ import { } from "@/external/redis/initUtils/redisV2Config.js"; describe("redis V2 connection config", () => { - test("uses a distinct CACHE_V2_UPSTASH_URL with the Upstash shebang", () => { + test("uses a distinct CACHE_V2_DRAGONFLY_URL without the Upstash shebang", () => { expect( getRedisV2ConnectionConfig({ cacheV2Url: " redis://v2 ", primaryCacheUrl: "redis://primary", currentRegion: "us-west-2", + instanceName: "dragonfly", }), ).toEqual({ cacheUrl: "redis://v2", region: "us-west-2:v2", - supportsUpstashShebang: true, + supportsUpstashShebang: false, commandTimeout: REDIS_V2_COMMAND_TIMEOUT_MS, }); }); - test("falls back to primary Redis when CACHE_V2_UPSTASH_URL is absent or matches primary", () => { + test("uses the Upstash shebang when the upstash instance is selected", () => { + expect( + getRedisV2ConnectionConfig({ + cacheV2Url: " redis://v2 ", + primaryCacheUrl: "redis://primary", + currentRegion: "us-west-2", + instanceName: "upstash", + }), + ).toMatchObject({ + supportsUpstashShebang: true, + }); + }); + + test("falls back to primary Redis when the V2 URL is absent or matches primary", () => { expect( getRedisV2ConnectionConfig({ cacheV2Url: undefined, primaryCacheUrl: "redis://primary", currentRegion: "us-west-2", + instanceName: "dragonfly", }), ).toBeNull(); expect( @@ -34,6 +49,7 @@ describe("redis V2 connection config", () => { cacheV2Url: " redis://primary ", primaryCacheUrl: "redis://primary", currentRegion: "us-west-2", + instanceName: "dragonfly", }), ).toBeNull(); }); From e8e44bf353973cdd484802fce8abe595ae7469bd Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 14 May 2026 13:20:03 +0800 Subject: [PATCH 5/6] fix: disabled upstash shebang by default --- server/src/external/redis/initUtils/createRedisClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/external/redis/initUtils/createRedisClient.ts b/server/src/external/redis/initUtils/createRedisClient.ts index 5858700c5..a8e2682b5 100644 --- a/server/src/external/redis/initUtils/createRedisClient.ts +++ b/server/src/external/redis/initUtils/createRedisClient.ts @@ -13,7 +13,7 @@ const REDIS_COMMAND_TIMEOUT_MS = export const createRedisClient = ({ cacheUrl, region, - supportsUpstashShebang = true, + supportsUpstashShebang = false, commandTimeout = REDIS_COMMAND_TIMEOUT_MS, }: { cacheUrl: string; From 1075dd477c32ff9d7db8215344c54d3af498190f Mon Sep 17 00:00:00 2001 From: John Yeo Date: Thu, 14 May 2026 13:23:51 +0800 Subject: [PATCH 6/6] cleaned up redis V2 --- server/src/external/redis/initRedisV2.ts | 17 ++---- .../redis/initUtils/redisV2Availability.ts | 16 ++---- .../external/redis/initUtils/redisV2Config.ts | 4 +- .../tests/unit/redis/redis-v2-config.spec.ts | 54 +------------------ 4 files changed, 12 insertions(+), 79 deletions(-) diff --git a/server/src/external/redis/initRedisV2.ts b/server/src/external/redis/initRedisV2.ts index f689bf6f2..49fd6f09b 100644 --- a/server/src/external/redis/initRedisV2.ts +++ b/server/src/external/redis/initRedisV2.ts @@ -8,24 +8,17 @@ import { waitForRedisReady, } from "./initRedis.js"; import { - getRedisV2ConnectionConfig, REDIS_V2_COMMAND_TIMEOUT_MS, supportsUpstashShebangForRedisV2, } from "./initUtils/redisV2Config.js"; -const redisV2Config = getRedisV2ConnectionConfig({ - cacheV2Url: process.env.CACHE_V2_DRAGONFLY_URL, - primaryCacheUrl: process.env.CACHE_URL, - currentRegion, - instanceName: "dragonfly", +export const redisV2: Redis = createRedisConnection({ + cacheUrl: process.env.CACHE_V2_DRAGONFLY_URL?.trim() || "", + region: `${currentRegion}:v2`, + supportsUpstashShebang: false, + commandTimeout: REDIS_V2_COMMAND_TIMEOUT_MS, }); -export const hasRedisV2Config = Boolean(redisV2Config); - -export const redisV2: Redis = redisV2Config - ? createRedisConnection(redisV2Config) - : redis; - const alternateInstanceUrls: Partial> = { upstash: process.env.CACHE_V2_UPSTASH_URL?.trim() || undefined, redis: process.env.CACHE_V2_REDIS_URL?.trim() || undefined, diff --git a/server/src/external/redis/initUtils/redisV2Availability.ts b/server/src/external/redis/initUtils/redisV2Availability.ts index 9784ea723..d179c825e 100644 --- a/server/src/external/redis/initUtils/redisV2Availability.ts +++ b/server/src/external/redis/initUtils/redisV2Availability.ts @@ -1,15 +1,9 @@ -import { - hasRedisV2Config, - redisV2, -} from "../initRedisV2.js"; +import { redisV2 } from "../initRedisV2.js"; import { createRedisAvailability, type RedisAvailabilitySnapshot, } from "./createRedisAvailability.js"; -import { - getRedisAvailability, - shouldUseRedis, -} from "./redisAvailability.js"; +import { getRedisAvailability, shouldUseRedis } from "./redisAvailability.js"; import { redis as primaryRedis } from "./redisClientRegistry.js"; const usesPrimaryRedis = redisV2 === primaryRedis; @@ -18,7 +12,7 @@ const getPrimaryBackedRedisV2Availability = (): RedisAvailabilitySnapshot => { const availability = getRedisAvailability(); return { - configured: hasRedisV2Config, + configured: true, state: availability.state, status: availability.status, }; @@ -34,7 +28,7 @@ const redisV2Availability = usesPrimaryRedis } : createRedisAvailability({ redis: redisV2, - hasConfig: hasRedisV2Config, + hasConfig: true, logPrefix: "RedisV2", logType: "redis_v2_availability_state_set", }); @@ -50,7 +44,7 @@ const { export { getRedisV2Availability, primeRedisV2Monitor, + shouldUseRedisV2, startRedisV2Monitor, stopRedisV2Monitor, - shouldUseRedisV2, }; diff --git a/server/src/external/redis/initUtils/redisV2Config.ts b/server/src/external/redis/initUtils/redisV2Config.ts index a60083f64..56fdbd4ad 100644 --- a/server/src/external/redis/initUtils/redisV2Config.ts +++ b/server/src/external/redis/initUtils/redisV2Config.ts @@ -4,16 +4,14 @@ export const REDIS_V2_COMMAND_TIMEOUT_MS = 1_000; export const getRedisV2ConnectionConfig = ({ cacheV2Url, - primaryCacheUrl, currentRegion, instanceName, }: { cacheV2Url?: string; - primaryCacheUrl?: string; currentRegion: string; instanceName: RedisV2InstanceName; }) => - cacheV2Url?.trim() && cacheV2Url.trim() !== primaryCacheUrl?.trim() + cacheV2Url?.trim() ? { cacheUrl: cacheV2Url.trim(), region: `${currentRegion}:v2`, diff --git a/server/tests/unit/redis/redis-v2-config.spec.ts b/server/tests/unit/redis/redis-v2-config.spec.ts index eea7a2031..ccc8d3693 100644 --- a/server/tests/unit/redis/redis-v2-config.spec.ts +++ b/server/tests/unit/redis/redis-v2-config.spec.ts @@ -1,59 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { - getRedisV2ConnectionConfig, - REDIS_V2_COMMAND_TIMEOUT_MS, - supportsUpstashShebangForRedisV2, -} from "@/external/redis/initUtils/redisV2Config.js"; +import { supportsUpstashShebangForRedisV2 } from "@/external/redis/initUtils/redisV2Config.js"; describe("redis V2 connection config", () => { - test("uses a distinct CACHE_V2_DRAGONFLY_URL without the Upstash shebang", () => { - expect( - getRedisV2ConnectionConfig({ - cacheV2Url: " redis://v2 ", - primaryCacheUrl: "redis://primary", - currentRegion: "us-west-2", - instanceName: "dragonfly", - }), - ).toEqual({ - cacheUrl: "redis://v2", - region: "us-west-2:v2", - supportsUpstashShebang: false, - commandTimeout: REDIS_V2_COMMAND_TIMEOUT_MS, - }); - }); - - test("uses the Upstash shebang when the upstash instance is selected", () => { - expect( - getRedisV2ConnectionConfig({ - cacheV2Url: " redis://v2 ", - primaryCacheUrl: "redis://primary", - currentRegion: "us-west-2", - instanceName: "upstash", - }), - ).toMatchObject({ - supportsUpstashShebang: true, - }); - }); - - test("falls back to primary Redis when the V2 URL is absent or matches primary", () => { - expect( - getRedisV2ConnectionConfig({ - cacheV2Url: undefined, - primaryCacheUrl: "redis://primary", - currentRegion: "us-west-2", - instanceName: "dragonfly", - }), - ).toBeNull(); - expect( - getRedisV2ConnectionConfig({ - cacheV2Url: " redis://primary ", - primaryCacheUrl: "redis://primary", - currentRegion: "us-west-2", - instanceName: "dragonfly", - }), - ).toBeNull(); - }); - test("enables the Upstash shebang only for the upstash instance", () => { expect(supportsUpstashShebangForRedisV2("upstash")).toBe(true); expect(supportsUpstashShebangForRedisV2("redis")).toBe(false);