merged from main

This commit is contained in:
John Yeo
2026-05-14 14:33:12 +08:00
19 changed files with 272 additions and 129 deletions

2
ai

Submodule ai updated: 761b842553...008fd7a292

View File

@@ -5902,6 +5902,8 @@
"@autumn/server/@types/node": ["@types/node@25.6.2", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw=="],
"@autumn/server/@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260510.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260510.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260510.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260510.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260510.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260510.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260510.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260510.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-05U6/Im+vmqGrFAVrHSeuoXBCwShhbiA+93VpSwEBYP4LMWk2JW9q87MydamL5g6ISEjIVlwQ4Dx35CauPAwpA=="],
"@autumn/server/autumn-js": ["autumn-js@0.1.85", "", { "dependencies": { "query-string": "^9.2.2", "rou3": "^0.6.1", "swr": "^2.3.3", "zod": "^4.0.0" }, "peerDependencies": { "better-auth": "^1.3.17", "better-call": "^1.0.12", "convex": "^1.25.4" }, "optionalPeers": ["better-auth", "better-call", "convex"] }, "sha512-PDud/t8z5bDJcD7ptyHzTaoJ0A8zkxvQ4TYcJ48RtgKDdOkVY36D1T6udVLwLDnWw4J5KXwJgEuGxHdd+cuABw=="],
"@autumn/server/ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.4", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^8.0.0", "stack-utils": "^2.0.6", "string-width": "^8.1.1", "terminal-size": "^4.0.1", "type-fest": "^5.4.1", "widest-line": "^6.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="],
@@ -6818,6 +6820,8 @@
"@useautumn/sdk/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"@useautumn/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@vercel/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
@@ -7656,6 +7660,20 @@
"@autumn/server/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"@autumn/server/@typescript/native-preview/@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260510.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YpG99bf/Va1aLGP8SUQy1ClUvi4c6uTFrEQ0B5KzZb9TsOwH1RIrc/2n8UO3IAuilvwEA0EU4q8fEO3otVP2Sw=="],
"@autumn/server/@typescript/native-preview/@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260510.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-NUwhwHpQn7aSX2GGBuY2bjec+hFnIz2DAna4ksVneexVE20h2U0MFzBvWrqH2C0PzPxVvGOMg4fGCvhTs93nlw=="],
"@autumn/server/@typescript/native-preview/@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260510.1", "", { "os": "linux", "cpu": "arm" }, "sha512-UE+PIWWg7vvszSU0gS9rzgIIHCWexz3hMZDHpHRSLAleAvULCNI3EzwTRFOA4BHyQ8eReD1KZ8e76BuStEPspw=="],
"@autumn/server/@typescript/native-preview/@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260510.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-T7Zuy6h0sU+38w+N3A+YgW0XVqxIMjeHyu+945rJkiP9zk52Mwp663t1ndyeAE/N2zV+q0SWQmHNuFSXl99wJw=="],
"@autumn/server/@typescript/native-preview/@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260510.1", "", { "os": "linux", "cpu": "x64" }, "sha512-gJu4q4YREvjR2Lx1jUaCd/bRbTuyKf2r3rJ4tReuHyAvNse23HdGI0a9w4Z3wUbvRznxYt640IIItWsr/f3LEQ=="],
"@autumn/server/@typescript/native-preview/@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260510.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-00DtjrtkdAHOU/soYr8ncrjUvIsple8nvb29ZUATnLraNnzUgv5AS3yMve/pG/N7rVLlKy2FrXlVyVW7WAx29w=="],
"@autumn/server/@typescript/native-preview/@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260510.1", "", { "os": "win32", "cpu": "x64" }, "sha512-27UeujQTEPFxhfkZL7aHnA1TlNol3nwDVFp5d6jFoP14yTXMe47kBnAJLEU2ta3REZE5PzLCs7HLV8H4VdxGgA=="],
"@autumn/server/autumn-js/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@autumn/server/ink/@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="],

View File

@@ -8,24 +8,19 @@ 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,
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<Record<RedisV2InstanceName, string>> = {
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,
};

View File

@@ -22,7 +22,7 @@ const formatRedisEndpoint = ({ cacheUrl }: { cacheUrl: string }) => {
export const createRedisClient = ({
cacheUrl,
region,
supportsUpstashShebang = true,
supportsUpstashShebang = false,
commandTimeout = REDIS_COMMAND_TIMEOUT_MS,
}: {
cacheUrl: string;

View File

@@ -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,
};

View File

@@ -5,18 +5,18 @@ export const REDIS_V2_COMMAND_TIMEOUT_MS =
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`,
supportsUpstashShebang: supportsUpstashShebangForRedisV2("upstash"),
supportsUpstashShebang: supportsUpstashShebangForRedisV2(instanceName),
commandTimeout: REDIS_V2_COMMAND_TIMEOUT_MS,
}
: null;

View File

@@ -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;

View File

@@ -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" };

View File

@@ -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<StripeSubscriptionCreatedContext | undefined> => {
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 };
};

View File

@@ -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,

View File

@@ -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<boolean> => {
const sessions = await stripeCli.checkout.sessions.list({
subscription: subscription.id,
limit: 1,
});
const session = sessions.data[0];
return Boolean(session?.metadata?.autumn_metadata_id);
};

View File

@@ -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)`,

View File

@@ -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(
{

View File

@@ -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<Stripe.Subscription> => {
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);
});

View File

@@ -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<typeof buildEntitySubjectScenario>;
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 () => {

View File

@@ -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 = {

View File

@@ -1,43 +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_UPSTASH_URL with the Upstash shebang", () => {
expect(
getRedisV2ConnectionConfig({
cacheV2Url: " redis://v2 ",
primaryCacheUrl: "redis://primary",
currentRegion: "us-west-2",
}),
).toEqual({
cacheUrl: "redis://v2",
region: "us-west-2:v2",
supportsUpstashShebang: true,
commandTimeout: REDIS_V2_COMMAND_TIMEOUT_MS,
});
});
test("falls back to primary Redis when CACHE_V2_UPSTASH_URL is absent or matches primary", () => {
expect(
getRedisV2ConnectionConfig({
cacheV2Url: undefined,
primaryCacheUrl: "redis://primary",
currentRegion: "us-west-2",
}),
).toBeNull();
expect(
getRedisV2ConnectionConfig({
cacheV2Url: " redis://primary ",
primaryCacheUrl: "redis://primary",
currentRegion: "us-west-2",
}),
).toBeNull();
});
test("enables the Upstash shebang only for the upstash instance", () => {
expect(supportsUpstashShebangForRedisV2("upstash")).toBe(true);
expect(supportsUpstashShebangForRedisV2("redis")).toBe(false);

View File

@@ -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 };
};

View File

@@ -82,10 +82,10 @@ export const SavedViews = ({
return (
<>
<DropdownMenuLabel className="p-0 pt-1 px-3">
<span className="text-t3 text-xs">Saved views</span>
</DropdownMenuLabel>
<DropdownMenuGroup className="p-1">
<DropdownMenuLabel className="p-0 pt-1 px-3">
<span className="text-t3 text-xs">Saved views</span>
</DropdownMenuLabel>
{views.map((view: SavedView) => (
<div
key={view.id}