mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
### Context One script grants free plan to any team which is a customer of the internal project who doesnt have it already. We also want to migrate our users (internal) to the latest version of their products. Needed because some subs on dev right now dont have a plan. And internal isnt using latest version of its own growth plan. ### Describing the Paths we want to Account for 1. Users on production who currently don't have a plan should get free plans, since this script is run with every migrate 2. Users on production should get the latest version of each plan of ours. So a forced migration to latest version of internal project plans 3. No other project's products/product lines should be affected. They will continue to have product versioning 4. 2 should apply to test mode subscriptions as well, on top of stripe subscriptions. All of them should be refreshed 5. Internal project itself should get latest version of its own growth plan 6. If the bulldozer write fails, we should be able to recover on next migration (this should already be handled by init bulldozer script, because it checks if prisma db and bulldozer db are out of sync) 7. if the regenerate or backfill fail, we should be able to recover just by rerunning the script 8. Product version table should not balloon. No table should really balloon ### What I've tested on local 1. Put in 1000 db subscription rows, made them all stale and then ran the regen script. It took about 6 minutes to update all of them, and it was idempotent so rerunning it again did nothing. 2. With proper stripe keys I switched off of test mode on the internal app, granted a product to a new team and updated the product's item list. At this point I checked and the new team had the outdated version of the product. Then I ran the regen script and the new team was moved to latest product version. 3. Tried the above with the internal team's growth plan too and it worked as well. 4. Backfill actually grants free plan ### Deployment strategy in prod Run the backfill and the regen scripts once each after your migrations on the prod db. `pnpm db:backfill-internal-free-plans` will make sure every team has a free plan at least if they dont have an existing plan (and it is idempotent). After that, run `pnpm db:regen-internal-subscriptions-to-latest` which will migrate every user to the latest version of their plan (i.e latest snapshot). This should also be idempotent. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Automated backfill to grant internal free plans to qualifying billing teams. * Regeneration tool to refresh internal subscription snapshots to the latest product versions. * **Chores** * Added CLI commands and package scripts to run backfill and regen jobs. * Database init now runs payment initialization before backfill/regen. * **Tests** * Integration and unit tests added/updated to validate backfill, regeneration, and free-plan idempotency. [](https://app.coderabbit.ai/change-stack/hexclave/stack-auth/pull/1421) <!-- end of auto-generated comment: release notes by coderabbit.ai -->
274 lines
9.3 KiB
TypeScript
274 lines
9.3 KiB
TypeScript
import { applyMigrations } from "@/auto-migrations";
|
|
import { MIGRATION_FILES_DIR, getMigrationFiles } from "@/auto-migrations/utils";
|
|
import { Prisma } from "@/generated/prisma/client";
|
|
import { getClickhouseAdminClient } from "@/lib/clickhouse";
|
|
import { globalPrismaClient, globalPrismaSchema, sqlQuoteIdent } from "@/prisma-client";
|
|
import { spawnSync } from "child_process";
|
|
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();
|
|
|
|
const dropSchema = async () => {
|
|
await globalPrismaClient.$executeRaw(Prisma.sql`DROP SCHEMA ${sqlQuoteIdent(globalPrismaSchema)} CASCADE`);
|
|
await globalPrismaClient.$executeRaw(Prisma.sql`CREATE SCHEMA ${sqlQuoteIdent(globalPrismaSchema)}`);
|
|
await globalPrismaClient.$executeRaw(Prisma.sql`GRANT ALL ON SCHEMA ${sqlQuoteIdent(globalPrismaSchema)} TO postgres`);
|
|
await globalPrismaClient.$executeRaw(Prisma.sql`GRANT ALL ON SCHEMA ${sqlQuoteIdent(globalPrismaSchema)} TO public`);
|
|
const clickhouseClient = getClickhouseClient();
|
|
await clickhouseClient.command({ query: "DROP DATABASE IF EXISTS analytics_internal" });
|
|
await clickhouseClient.command({ query: "CREATE DATABASE IF NOT EXISTS analytics_internal" });
|
|
};
|
|
|
|
|
|
const askQuestion = (question: string) => {
|
|
const rl = readline.createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout,
|
|
});
|
|
|
|
return new Promise<string>((resolve) => {
|
|
rl.question(question, (answer) => {
|
|
rl.close();
|
|
resolve(answer);
|
|
});
|
|
});
|
|
};
|
|
|
|
const promptDropDb = async () => {
|
|
const answer = (await askQuestion(
|
|
'Are you sure you want to drop everything in the database? This action cannot be undone. (y/N): ',
|
|
)).trim();
|
|
|
|
if (answer.toLowerCase() !== 'y') {
|
|
console.log('Operation cancelled');
|
|
process.exit(0);
|
|
}
|
|
};
|
|
|
|
const formatMigrationName = (input: string) =>
|
|
input
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '_')
|
|
.replace(/^_+|_+$/g, '');
|
|
|
|
const promptMigrationName = async () => {
|
|
while (true) {
|
|
const rawName = (await askQuestion('Enter a migration name: ')).trim();
|
|
const formattedName = formatMigrationName(rawName);
|
|
|
|
if (!formattedName) {
|
|
console.log('Migration name cannot be empty. Please try again.');
|
|
continue;
|
|
}
|
|
|
|
if (formattedName !== rawName) {
|
|
console.log(`Using sanitized migration name: ${formattedName}`);
|
|
}
|
|
|
|
return formattedName;
|
|
}
|
|
};
|
|
|
|
const timestampPrefix = () => new Date().toISOString().replace(/\D/g, '').slice(0, 14);
|
|
|
|
const generateMigrationFile = async () => {
|
|
const migrationName = await promptMigrationName();
|
|
const folderName = `${timestampPrefix()}_${migrationName}`;
|
|
const migrationDir = path.join(MIGRATION_FILES_DIR, folderName);
|
|
const migrationSqlPath = path.join(migrationDir, 'migration.sql');
|
|
|
|
console.log(`Generating migration ${folderName}...`);
|
|
const diffResult = spawnSync(
|
|
'pnpm',
|
|
[
|
|
'-s',
|
|
'prisma',
|
|
'migrate',
|
|
'diff',
|
|
'--from-config-datasource',
|
|
'--to-schema',
|
|
'prisma/schema.prisma',
|
|
'--script',
|
|
],
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: 'utf8',
|
|
},
|
|
);
|
|
|
|
if (diffResult.error || diffResult.status !== 0) {
|
|
console.error(diffResult.stdout);
|
|
console.error(diffResult.stderr);
|
|
throw diffResult.error ?? new Error(`Failed to generate migration (exit code ${diffResult.status})`);
|
|
}
|
|
|
|
const sql = diffResult.stdout;
|
|
|
|
if (!sql.trim()) {
|
|
console.log('No schema changes detected. Migration file was not created.');
|
|
} else {
|
|
fs.mkdirSync(migrationDir, { recursive: true });
|
|
fs.writeFileSync(migrationSqlPath, sql, 'utf8');
|
|
console.log(`Migration written to ${path.relative(process.cwd(), migrationSqlPath)}`);
|
|
console.log('Applying migration...');
|
|
await migrate([{ migrationName: folderName, sql }]);
|
|
}
|
|
};
|
|
|
|
const promptContinueMigration = async (migrationName: string) => {
|
|
const answer = (await askQuestion(
|
|
`\n🔄 Ready to apply migration: ${migrationName}\nPress Enter to continue or 'q' to quit: `,
|
|
)).trim();
|
|
|
|
if (answer.toLowerCase() === 'q') {
|
|
console.log('Migration cancelled by user');
|
|
process.exit(0);
|
|
}
|
|
};
|
|
|
|
const migrate = async (selectedMigrationFiles?: { migrationName: string, sql: string }[], options?: { interactive?: boolean }) => {
|
|
const startTime = performance.now();
|
|
const migrationFiles = selectedMigrationFiles ?? getMigrationFiles(MIGRATION_FILES_DIR);
|
|
const totalMigrations = migrationFiles.length;
|
|
|
|
const result = await applyMigrations({
|
|
prismaClient: globalPrismaClient,
|
|
migrationFiles,
|
|
logging: true,
|
|
schema: globalPrismaSchema,
|
|
onBeforeMigration: options?.interactive ? promptContinueMigration : undefined,
|
|
});
|
|
|
|
const endTime = performance.now();
|
|
const duration = ((endTime - startTime) / 1000).toFixed(2);
|
|
|
|
// Print summary
|
|
console.log('\n' + '='.repeat(60));
|
|
console.log('📊 MIGRATION SUMMARY');
|
|
console.log('='.repeat(60));
|
|
console.log(`✅ Migrations completed successfully`);
|
|
console.log(`⏱️ Duration: ${duration} seconds`);
|
|
console.log(`📁 Total migrations in folder: ${totalMigrations}`);
|
|
console.log(`🆕 Newly applied migrations: ${result.newlyAppliedMigrationNames.length}`);
|
|
console.log(`✓ Already applied migrations: ${totalMigrations - result.newlyAppliedMigrationNames.length}`);
|
|
|
|
if (result.newlyAppliedMigrationNames.length > 0) {
|
|
console.log('\n📝 Newly applied migrations:');
|
|
result.newlyAppliedMigrationNames.forEach((name, index) => {
|
|
console.log(` ${index + 1}. ${name}`);
|
|
});
|
|
} else {
|
|
console.log('\n✨ Database is already up to date!');
|
|
}
|
|
|
|
console.log('='.repeat(60) + '\n');
|
|
|
|
await runClickhouseMigrations();
|
|
|
|
return result;
|
|
};
|
|
|
|
const showHelp = () => {
|
|
console.log(`Database Migration Script
|
|
|
|
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
|
|
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)
|
|
`);
|
|
};
|
|
|
|
const main = async () => {
|
|
const args = process.argv.slice(2);
|
|
const command = args[0];
|
|
const interactive = args.includes('--interactive');
|
|
|
|
switch (command) {
|
|
case 'reset': {
|
|
await promptDropDb();
|
|
await dropSchema();
|
|
await migrate(undefined, { interactive });
|
|
await seed();
|
|
await runBulldozerPaymentsInit(globalPrismaClient);
|
|
break;
|
|
}
|
|
case 'generate-migration-file': {
|
|
await promptDropDb();
|
|
await dropSchema();
|
|
await migrate(undefined, { interactive });
|
|
await generateMigrationFile();
|
|
await seed();
|
|
break;
|
|
}
|
|
case 'seed': {
|
|
await seed();
|
|
await runBulldozerPaymentsInit(globalPrismaClient);
|
|
break;
|
|
}
|
|
case 'init': {
|
|
await migrate(undefined, { interactive });
|
|
await seed();
|
|
await runBulldozerPaymentsInit(globalPrismaClient);
|
|
break;
|
|
}
|
|
case 'migrate': {
|
|
await migrate(undefined, { interactive });
|
|
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;
|
|
}
|
|
default: {
|
|
console.error('Unknown command.');
|
|
showHelp();
|
|
process.exit(1);
|
|
}
|
|
}
|
|
};
|
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
main().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|