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
2 changes: 2 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
"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",
"db:regen-internal-subscriptions-to-latest": "pnpm run with-env:dev tsx scripts/db-migrations.ts regen-internal-subscriptions-to-latest",
"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 };
}
38 changes: 32 additions & 6 deletions apps/backend/scripts/db-migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ 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";
import { runRegenInternalSubscriptionsToLatest } from "./regen-internal-subscriptions-to-latest";

const getClickhouseClient = () => getClickhouseAdminClient();

Expand Down Expand Up @@ -179,12 +181,17 @@ 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.
regen-internal-subscriptions-to-latest
Bring every active internal-tenancy subscription up to the latest version of its
product (rewrites the stored snapshot; rebases Stripe metadata for live subs).
Idempotent. Run AFTER seed and AFTER backfill-internal-free-plans.
help Show this help message

Options:
--interactive Prompt before each new migration (not on conditional repeats)
Expand All @@ -202,6 +209,7 @@ const main = async () => {
await dropSchema();
await migrate(undefined, { interactive });
await seed();
await runBulldozerPaymentsInit(globalPrismaClient);
break;
}
case 'generate-migration-file': {
Expand All @@ -228,6 +236,24 @@ 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 'regen-internal-subscriptions-to-latest': {
// Explicit step — callers must guarantee the internal tenancy has been
// seeded. Bulldozer init runs first because the regen reads
// `sub.product` via the Subscription LFold; without init the per-sub
// equality check would compare against a stale view.
await runBulldozerPaymentsInit(globalPrismaClient);
await runRegenInternalSubscriptionsToLatest();
break;
}
case 'help': {
showHelp();
break;
Expand Down
Loading
Loading