mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
115 lines
4.0 KiB
TypeScript
115 lines
4.0 KiB
TypeScript
/**
|
|
* Grants the `free` plan to every billing team on Hexclave'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 { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors";
|
|
import { getOrUndefined } from "@hexclave/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 } } : {}),
|
|
},
|
|
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 HexclaveAssertionError("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 HexclaveAssertionError(
|
|
"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) {
|
|
// 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 };
|
|
}
|