Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"db:seed": "pnpm run with-env:dev tsx scripts/db-migrations.ts seed",
"db:init": "pnpm run with-env:dev tsx scripts/db-migrations.ts init",
"db:migrate": "pnpm run with-env:dev tsx scripts/db-migrations.ts migrate",
"db:backfill-internal-free-plans": "pnpm run with-env:dev tsx scripts/db-migrations.ts backfill-internal-free-plans",
"generate-migration-imports": "pnpm run with-env tsx scripts/generate-migration-imports.ts",
"generate-migration-imports:watch": "chokidar 'prisma/migrations/**/*.sql' -c 'pnpm run generate-migration-imports'",
"lint": "eslint .",
Expand Down
114 changes: 114 additions & 0 deletions apps/backend/scripts/backfill-internal-free-plans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Grants the `free` plan to every billing team on Stack Auth's own
* billing project that doesn't already have a plan. Runs at deploy /
* db init time.
*
* Why we need it: we used to give the free plan implicitly via an
* "include-by-default" rule. Removing that left some old teams with no
* subscription at all, which made plan-limit checks (user count,
* analytics events, etc.) read 0 quota and reject every request. This
* script puts everyone back on a clean baseline.
*
* Safe to re-run: a team that already has a plan in the free product
* line is left alone.
*/

import { ensureFreePlanForBillingTeam } from "@/lib/payments/ensure-free-plan";
// eslint-disable-next-line @typescript-eslint/no-deprecated -- idiomatic way to get the internal tenancy today (see plan-entitlements.ts)
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, type Tenancy } from "@/lib/tenancies";
import { globalPrismaClient } from "@/prisma-client";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects";

// Page size for streaming teams. Big enough to amortise round-trips,
// small enough to stay tiny in memory (~18KB per page).
const TEAM_BATCH_SIZE = 500;

function log(msg: string) {
console.log(`[Backfill][InternalFreePlans] ${msg}`);
}

/**
* Yields every billing team in the internal tenancy, page by page,
* ordered by `teamId`. Keyset pagination (`teamId > cursor`) so this
* stays fast on tenancies with millions of teams.
*/
async function* iterateInternalTeamIds(
internalTenancy: Tenancy,
batchSize: number,
): AsyncIterable<string> {
let cursor: string | null = null;
while (true) {
const batch: { teamId: string }[] = await globalPrismaClient.team.findMany({
where: {
tenancyId: internalTenancy.id,
...(cursor != null ? { teamId: { gt: cursor } } : {}),
},
Comment thread
nams1570 marked this conversation as resolved.
select: { teamId: true },
orderBy: { teamId: "asc" },
take: batchSize,
});
if (batch.length === 0) return;
for (const { teamId } of batch) {
yield teamId;
}
cursor = batch[batch.length - 1].teamId;
}
}

export async function runBackfillInternalFreePlans(): Promise<{
granted: number,
failed: number,
total: number,
}> {
log("Starting...");
const internalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID, true);
if (internalTenancy == null) {
throw new StackAssertionError("Internal billing tenancy not found", {
billingProjectId: "internal",
branchId: DEFAULT_BRANCH_ID,
});
}

// Fail fast if the `free` product is misconfigured. The grant call
// below silently no-ops in that case; raising here makes the deploy
// log point at the actual cause instead of "0 granted out of N teams".
const freePlanProduct = getOrUndefined(internalTenancy.config.payments.products, "free");
if (
freePlanProduct == null
|| freePlanProduct.customerType !== "team"
|| freePlanProduct.productLineId == null
) {
throw new StackAssertionError(
"Internal tenancy `free` product is not configured as a team-typed, product-line-tagged plan; cannot run backfill",
{ freePlanProduct },
);
}

let granted = 0;
let failed = 0;
let total = 0;

for await (const teamId of iterateInternalTeamIds(internalTenancy, TEAM_BATCH_SIZE)) {
total++;
try {
if (await ensureFreePlanForBillingTeam(teamId)) granted++;
} catch (e) {
Comment thread
nams1570 marked this conversation as resolved.
// Per-team isolation: log and keep going. One team's transient
// DB blip shouldn't leave every later team unprocessed; the next
// run will retry whatever failed here.
failed++;
const err = e instanceof Error ? e : new Error(String(e));
console.error(
`[Backfill][InternalFreePlans][team=${teamId}] Failed: ${err.message}`,
err,
);
}
if (total % 100 === 0) {
log(`Progress: ${total} (granted=${granted}, failed=${failed})`);
}
}

log(`Done. granted=${granted} failed=${failed} total=${total}`);
return { granted, failed, total };
}
24 changes: 18 additions & 6 deletions apps/backend/scripts/db-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import fs from "fs";
import path from "path";
import * as readline from "readline";
import { seed } from "../prisma/seed";
import { runBackfillInternalFreePlans } from "./backfill-internal-free-plans";
import { runBulldozerPaymentsInit } from "./bulldozer-payments-init";
import { runClickhouseMigrations } from "./clickhouse-migrations";

Expand Down Expand Up @@ -179,12 +180,13 @@ const showHelp = () => {
Usage: pnpm db-migrations <command> [options]

Commands:
reset Drop all data and recreate the database, then apply migrations and seed
generate-migration-file Generate a new migration file using Prisma, then reset and migrate
seed [Advanced] Run database seeding only
init Apply migrations and seed the database
migrate Apply migrations
help Show this help message
reset Drop all data and recreate the database, then apply migrations and seed
generate-migration-file Generate a new migration file using Prisma, then reset and migrate
seed [Advanced] Run database seeding only
init Apply migrations and seed the database
migrate Apply migrations
backfill-internal-free-plans Grant the free plan to internal-tenancy teams that have no plan. Run AFTER seed.
help Show this help message

Options:
--interactive Prompt before each new migration (not on conditional repeats)
Expand All @@ -202,6 +204,7 @@ const main = async () => {
await dropSchema();
await migrate(undefined, { interactive });
await seed();
await runBulldozerPaymentsInit(globalPrismaClient);
break;
}
case 'generate-migration-file': {
Expand All @@ -228,6 +231,15 @@ const main = async () => {
await runBulldozerPaymentsInit(globalPrismaClient);
break;
}
case 'backfill-internal-free-plans': {
// Explicit step — callers must guarantee the internal tenancy has been
// seeded before invoking this (the backfill throws loudly otherwise).
// Bulldozer init runs first so the Subscription LFold the backfill
// reads from is populated.
await runBulldozerPaymentsInit(globalPrismaClient);
await runBackfillInternalFreePlans();
break;
}
case 'help': {
showHelp();
break;
Expand Down
68 changes: 62 additions & 6 deletions apps/backend/src/lib/payments/ensure-free-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe.sequential("ensureFreePlanForBillingTeam (real DB)", () => {
prisma,
});

await ensureFreePlanForBillingTeam(billingTeamId);
expect(await ensureFreePlanForBillingTeam(billingTeamId)).toBe(false);

const subs = await getUnendedSubsForTeam(tenancy.id, billingTeamId, prisma);
expect(subs).toHaveLength(1);
Expand Down Expand Up @@ -114,7 +114,7 @@ describe.sequential("ensureFreePlanForBillingTeam (real DB)", () => {
prisma,
});

await ensureFreePlanForBillingTeam(billingTeamId);
expect(await ensureFreePlanForBillingTeam(billingTeamId)).toBe(false);

const subs = await getUnendedSubsForTeam(tenancy.id, billingTeamId, prisma);
expect(subs).toHaveLength(1);
Expand All @@ -125,19 +125,75 @@ describe.sequential("ensureFreePlanForBillingTeam (real DB)", () => {
const { tenancy, prisma } = await getInternal();
const billingTeamId = randomUUID();

await ensureFreePlanForBillingTeam(billingTeamId);
expect(await ensureFreePlanForBillingTeam(billingTeamId)).toBe(true);

const subs = await getUnendedSubsForTeam(tenancy.id, billingTeamId, prisma);
expect(subs).toHaveLength(1);
expect(subs[0].productId).toBe("free");
});

it("idempotent: sequential double-call creates exactly one free sub", async () => {
it("idempotent: sequential double-call creates exactly one free sub (second call returns false)", async () => {
const { tenancy, prisma } = await getInternal();
const billingTeamId = randomUUID();

await ensureFreePlanForBillingTeam(billingTeamId);
await ensureFreePlanForBillingTeam(billingTeamId);
expect(await ensureFreePlanForBillingTeam(billingTeamId)).toBe(true);
expect(await ensureFreePlanForBillingTeam(billingTeamId)).toBe(false);

const subs = await getUnendedSubsForTeam(tenancy.id, billingTeamId, prisma);
expect(subs).toHaveLength(1);
expect(subs[0].productId).toBe("free");
});

it("regression: a team whose only sub has ENDED is treated as orphaned and gets a fresh free grant", async () => {
// The "occupies the line" predicate gates on endedAt (not status), so a
// team whose only sub is canceled+ended in the past should be seen as
// orphaned and re-granted free. Pins this against the old "team has any
// sub" predicate that earlier scripts relied on.
const { tenancy, prisma } = await getInternal();
const billingTeamId = randomUUID();

const teamProduct = getOrUndefined(tenancy.config.payments.products, "team");
if (teamProduct == null) throw new Error("Internal tenancy missing `team` product");

const yesterday = new Date(Date.now() - 24 * 3600 * 1000);
const endedSubId = randomUUID();
await bulldozerWriteSubscription(prisma, {
id: endedSubId,
tenancyId: tenancy.id,
customerId: billingTeamId,
customerType: "TEAM",
productId: "team",
priceId: null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- ProductSnapshot is a structural JSON type
product: teamProduct as any,
quantity: 1,
stripeSubscriptionId: null,
status: "canceled",
currentPeriodStart: yesterday,
currentPeriodEnd: yesterday,
cancelAtPeriodEnd: false,
canceledAt: yesterday,
endedAt: yesterday,
refundedAt: null,
creationSource: "PURCHASE_PAGE",
createdAt: yesterday,
});

// Precondition: the team has exactly one sub on record, and it is the
// ended one (no unended subs exist). This is what makes the test
// meaningful — without it, a regression that ignored `endedAt` could
// still pass by virtue of some other unrelated sub being present.
const subMapBefore = await getSubscriptionMapForCustomer({
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- see `getUnendedSubsForTeam`
prisma: prisma as any,
tenancyId: tenancy.id,
customerType: "team",
customerId: billingTeamId,
});
expect(Object.keys(subMapBefore)).toEqual([endedSubId]);
expect(await getUnendedSubsForTeam(tenancy.id, billingTeamId, prisma)).toHaveLength(0);

expect(await ensureFreePlanForBillingTeam(billingTeamId)).toBe(true);

const subs = await getUnendedSubsForTeam(tenancy.id, billingTeamId, prisma);
expect(subs).toHaveLength(1);
Expand Down
28 changes: 19 additions & 9 deletions apps/backend/src/lib/payments/ensure-free-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ export async function createFreePlanSubscriptionRow(options: {
* no-ops on misconfiguration, when a plan is already owned, or when a
* concurrent caller already established the free sub.
*
* Returns `true` iff this call actually inserted a new free-plan subscription
* row (i.e. the team really was orphaned in the source of truth and we held
* the SERIALIZABLE slot that wrote the row). All other paths — misconfig,
* fast-path occupancy hit, slow-path occupancy hit, lost-race concurrent
* insert — return `false`. The deploy-time backfill uses this to count
* orphaned-and-actually-granted teams accurately rather than maintaining its
* own (LFold-lagging) predicate.
*
* Two-phase concurrency story:
*
* 1. Fast path — O(1) read against the `subscriptionMapByCustomer`
Expand All @@ -126,11 +134,11 @@ export async function createFreePlanSubscriptionRow(options: {
* directly, the SERIALIZABLE Prisma tx becomes a Bulldozer insert with
* its own concurrency story.
*/
export async function ensureFreePlanForBillingTeam(billingTeamId: string): Promise<void> {
export async function ensureFreePlanForBillingTeam(billingTeamId: string): Promise<boolean> {
const internalTenancy = await getInternalBillingTenancy();
const freePlanProduct = getOrUndefined(internalTenancy.config.payments.products, "free");
if (freePlanProduct == null || freePlanProduct.customerType !== "team" || freePlanProduct.productLineId == null) {
return;
return false;
}
const freeProductLineId = freePlanProduct.productLineId;

Expand Down Expand Up @@ -175,7 +183,7 @@ export async function ensureFreePlanForBillingTeam(billingTeamId: string): Promi
customerId: billingTeamId,
});
if (Object.values(subscriptionMap).some(productLineStillOccupiedBy)) {
return;
return false;
}

// Slow path: the team appears to have no occupying sub. Re-check under
Expand Down Expand Up @@ -216,11 +224,13 @@ export async function ensureFreePlanForBillingTeam(billingTeamId: string): Promi
});
}, { level: "serializable" });

if (createdSub != null) {
// Bulldozer write happens outside the tx — it issues its own BEGIN/
// COMMIT and can't nest. If it fails after the Prisma insert committed,
// the sub exists in Prisma but not yet in Bulldozer; same trade-off as
// all other dual-write call sites, reconciled by the next sync.
await bulldozerWriteSubscription(internalPrisma, createdSub);
if (createdSub == null) {
return false;
}
// Bulldozer write happens outside the tx — it issues its own BEGIN/
// COMMIT and can't nest. If it fails after the Prisma insert committed,
// the sub exists in Prisma but not yet in Bulldozer; same trade-off as
// all other dual-write call sites, reconciled by the next sync.
await bulldozerWriteSubscription(internalPrisma, createdSub);
return true;
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"wait-until-postgres-is-ready:pg_isready": "until pg_isready -h localhost -p ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}28 && pg_isready -h localhost -p ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}34; do sleep 1; done",
"wait-until-postgres-is-ready": "command -v pg_isready >/dev/null 2>&1 && pnpm run wait-until-postgres-is-ready:pg_isready || sleep 10 # not everyone has pg_isready installed, so we fallback to sleeping",
"wait-until-clickhouse-is-ready": "pnpm exec wait-on http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}36/ping",
"start-deps:no-delay": "pnpm pre && pnpm run deps-compose up --detach --build && pnpm run wait-until-postgres-is-ready && pnpm run wait-until-clickhouse-is-ready && pnpm run db:init && echo \"\\nDependencies started in the background as Docker containers. 'pnpm run stop-deps' to stop them\"n",
"start-deps:no-delay": "pnpm pre && pnpm run deps-compose up --detach --build && pnpm run wait-until-postgres-is-ready && pnpm run wait-until-clickhouse-is-ready && pnpm run db:init && pnpm run db:backfill-internal-free-plans && echo \"\\nDependencies started in the background as Docker containers. 'pnpm run stop-deps' to stop them\"n",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Stray n outside the closing quote in the echo command.

After JSON-unescaping, the shell sees:

echo "\nDependencies started in the background as Docker containers. 'pnpm run stop-deps' to stop them"n

Bash performs adjacent-token concatenation, so echo receives a single argument ending in ...stop themn, which prints as ...stop themn instead of the intended message. The n is a leftover fragment of a \n escape.

🐛 Proposed fix
-    "start-deps:no-delay": "pnpm pre && pnpm run deps-compose up --detach --build && pnpm run wait-until-postgres-is-ready && pnpm run wait-until-clickhouse-is-ready && pnpm run db:init && pnpm run db:backfill-internal-free-plans && echo \"\\nDependencies started in the background as Docker containers. 'pnpm run stop-deps' to stop them\"n",
+    "start-deps:no-delay": "pnpm pre && pnpm run deps-compose up --detach --build && pnpm run wait-until-postgres-is-ready && pnpm run wait-until-clickhouse-is-ready && pnpm run db:init && pnpm run db:backfill-internal-free-plans && echo \"\\nDependencies started in the background as Docker containers. 'pnpm run stop-deps' to stop them\"",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` at line 39, The package.json script "start-deps:no-delay"
contains a stray trailing "n" after the closing quote in the echo command,
causing the printed message to end with "n"; open the "start-deps:no-delay"
script and remove the extraneous trailing "n" so the echo argument ends with the
closing quote only (i.e., ensure the echo string is "\\nDependencies started...
'pnpm run stop-deps' to stop them" with no characters after the final quote).

"start-deps": "POSTGRES_DELAY_MS=${POSTGRES_DELAY_MS:-0} pnpm run start-deps:no-delay",
"restart-deps": "pnpm pre && pnpm run stop-deps && pnpm run start-deps",
"restart-deps:no-delay": "pnpm pre && pnpm run stop-deps && pnpm run start-deps:no-delay",
Expand All @@ -49,6 +49,7 @@
"db:seed": "pnpm pre && pnpm run --filter=@stackframe/backend db:seed",
"db:init": "pnpm pre && pnpm run --filter=@stackframe/backend db:init",
"db:migrate": "pnpm pre && pnpm run --filter=@stackframe/backend db:migrate",
"db:backfill-internal-free-plans": "pnpm pre && pnpm run --filter=@stackframe/backend db:backfill-internal-free-plans",
"fern": "pnpm pre && pnpm run --filter=@stackframe/docs fern",
"dev:full": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"pnpm run generate-setup-prompt-docs:watch\" \"turbo run dev --concurrency 99999\"",
"dev": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"pnpm run generate-openapi-docs:watch\" \"pnpm run generate-setup-prompt-docs:watch\" \"turbo run dev --concurrency 99999 --filter=./apps/* --filter=@stackframe/docs-mintlify --filter=@stackframe/stack-docs --filter=./packages/* --filter=./examples/demo \"",
Expand Down
Loading