Auto migration (#526)

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->

<!-- ELLIPSIS_HIDDEN -->


----

> [!IMPORTANT]
> Introduces an automated database migration system, replacing manual
Prisma commands with new scripts and updating workflows, configurations,
and tests accordingly.
> 
>   - **Auto-Migration System**:
> - Introduces `db-migrations.ts` script for handling database
migrations automatically.
> - Adds utility functions in `utils.tsx` for managing migration files.
> - Implements `applyMigrations` and `runMigrationNeeded` in `index.tsx`
for executing migrations.
>   - **Workflow and Scripts**:
> - Updates GitHub workflows (`check-prisma-migrations.yaml`,
`e2e-api-tests.yaml`) to use new migration commands.
> - Replaces `prisma migrate` commands with `db:init`, `db:migrate`,
etc., in `package.json` and `README.md`.
>   - **Testing**:
> - Adds `auto-migration.tests.ts` for testing migration logic and
concurrency handling.
>   - **Configuration**:
> - Updates `.env.development` and `vitest.config.ts` for new
environment variables and paths.
> - Modifies `turbo.json` and `package.json` to include new migration
tasks and scripts.
> 
> <sup>This description was created by </sup>[<img alt="Ellipsis"
src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup>
for 2c24183879. You can
[customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this
summary. It will automatically update as commits are pushed.</sup>


<!-- ELLIPSIS_HIDDEN -->

---------

Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
Zai Shi 2025-07-24 02:38:37 +02:00 committed by GitHub
parent 9f9a1038fa
commit a7acab4646
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
71 changed files with 1097 additions and 199 deletions

View File

@ -39,4 +39,4 @@ jobs:
run: docker run -d --name postgres-prisma-diff-shadow -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=PLACEHOLDER-PASSWORD--dfaBC1hm1v -e POSTGRES_DB=postgres -p 5432:5432 postgres:latest
- name: Check for differences in Prisma schema and migrations
run: pnpm run prisma migrate diff --from-migrations ./prisma/migrations --to-schema-datamodel ./prisma/schema.prisma --shadow-database-url postgres://postgres:PLACEHOLDER-PASSWORD--dfaBC1hm1v@localhost:5432/postgres --exit-code
run: cd apps/backend && pnpm run prisma migrate diff --from-migrations ./prisma/migrations --to-schema-datamodel ./prisma/schema.prisma --shadow-database-url postgres://postgres:PLACEHOLDER-PASSWORD--dfaBC1hm1v@localhost:5432/postgres --exit-code

View File

@ -17,6 +17,7 @@ jobs:
env:
NODE_ENV: test
STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING: yes
STACK_DIRECT_DATABASE_CONNECTION_STRING: "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:5432/stackframe"
strategy:
matrix:
@ -96,7 +97,7 @@ jobs:
run: npx wait-on tcp:localhost:8113
- name: Initialize database
run: pnpm run prisma -- migrate reset --force
run: pnpm run db:init
- name: Start stack-backend in background
uses: JarvusInnovations/background-action@v1.0.7

View File

@ -19,6 +19,7 @@ jobs:
STACK_ENABLE_HARDCODED_PASSKEY_CHALLENGE_FOR_TESTING: yes
STACK_OVERRIDE_SOURCE_OF_TRUTH: '{"type": "postgres", "connectionString": "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:5432/source-of-truth-db?schema=sot-schema"}'
STACK_TEST_SOURCE_OF_TRUTH: true
STACK_DIRECT_DATABASE_CONNECTION_STRING: "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:5432/stackframe"
strategy:
matrix:
@ -97,11 +98,14 @@ jobs:
- name: Wait on Svix
run: npx wait-on tcp:localhost:8113
- name: Initialize source of truth database
run: "STACK_DIRECT_DATABASE_CONNECTION_STRING='postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:5432/source-of-truth-db?schema=sot-schema' pnpm run prisma -- migrate reset --force --skip-seed"
- name: Create source-of-truth database and schema
run: |
psql postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:5432/postgres -c "CREATE DATABASE \"source-of-truth-db\";"
psql postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:5432/source-of-truth-db -c "CREATE SCHEMA \"sot-schema\";"
- name: Initialize database
run: pnpm run prisma -- migrate reset --force
run: pnpm run db:init
- name: Start stack-backend in background
uses: JarvusInnovations/background-action@v1.0.7
with:

View File

@ -163,10 +163,10 @@ pnpm run prisma studio
### Database migrations
If you make changes to the Prisma schema, you need to run the following command to create a migration:
If you make changes to the Prisma schema, you need to run the following command to create a migration file:
```sh
pnpm run prisma migrate dev
pnpm run db:migration-gen
```
### Chat with the codebase

View File

@ -17,12 +17,19 @@
"codegen-prisma:watch": "pnpm run prisma generate --watch",
"codegen-route-info": "pnpm run with-env tsx scripts/generate-route-info.ts",
"codegen-route-info:watch": "pnpm run with-env tsx watch --clear-screen=false scripts/generate-route-info.ts",
"codegen": "pnpm run codegen-prisma && pnpm run codegen-route-info",
"codegen": "pnpm run with-env pnpm run generate-migration-imports && pnpm run with-env bash -c 'if [ \"$STACK_ACCELERATE_ENABLED\" = \"true\" ]; then pnpm run prisma generate --no-engine && pnpm run generate-openapi; else pnpm run codegen-prisma && pnpm run generate-openapi; fi' && pnpm run codegen-route-info",
"codegen:watch": "concurrently -n \"prisma,docs,route-info\" -k \"pnpm run codegen-prisma:watch\" \"pnpm run watch-docs\" \"pnpm run codegen-route-info:watch\"",
"psql-inner": "psql $STACK_DATABASE_CONNECTION_STRING",
"psql": "pnpm run with-env pnpm run psql-inner",
"prisma": "pnpm run with-env prisma",
"prisma-studio": "pnpm run with-env prisma studio --port 8106 --browser none",
"prisma": "pnpm run with-env prisma",
"db:migration-gen": "pnpm run with-env tsx scripts/db-migrations.ts generate-migration-file",
"db:reset": "pnpm run with-env tsx scripts/db-migrations.ts reset",
"db:seed": "pnpm run with-env tsx scripts/db-migrations.ts seed",
"db:init": "pnpm run with-env tsx scripts/db-migrations.ts init",
"db:migrate": "pnpm run with-env tsx scripts/db-migrations.ts migrate",
"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": "next lint",
"watch-docs": "pnpm run with-env bash -c 'tsx watch --clear-screen=false scripts/generate-openapi-fumadocs.ts && pnpm run --filter=@stackframe/stack-docs generate-openapi-docs'",
"generate-openapi": "pnpm run with-env tsx scripts/generate-openapi.ts",
@ -62,6 +69,8 @@
"@vercel/otel": "^1.10.4",
"ai": "^4.3.17",
"bcrypt": "^5.1.1",
"chokidar-cli": "^3.0.0",
"dotenv": "^16.4.5",
"dotenv-cli": "^7.3.0",
"freestyle-sandboxes": "^0.0.92",
"jose": "^5.2.2",
@ -70,6 +79,7 @@
"nodemailer": "^6.9.10",
"oidc-provider": "^8.5.1",
"openid-client": "5.6.4",
"postgres": "^3.4.5",
"pg": "^8.16.3",
"posthog-node": "^4.1.0",
"react": "19.0.0",
@ -77,6 +87,7 @@
"semver": "^7.6.3",
"sharp": "^0.32.6",
"svix": "^1.25.0",
"vite": "^6.1.0",
"yaml": "^2.4.5",
"yup": "^1.4.0",
"zod": "^3.23.8"

View File

@ -37,10 +37,9 @@ DELETE FROM "ProxiedOAuthProviderConfig"
WHERE "type" = 'FACEBOOK';
-- AlterEnum
BEGIN;
-- SPLIT_STATEMENT_SENTINEL
CREATE TYPE "ProxiedOAuthProviderType_new" AS ENUM ('GITHUB', 'GOOGLE', 'MICROSOFT', 'SPOTIFY');
ALTER TABLE "ProxiedOAuthProviderConfig" ALTER COLUMN "type" TYPE "ProxiedOAuthProviderType_new" USING ("type"::text::"ProxiedOAuthProviderType_new");
ALTER TYPE "ProxiedOAuthProviderType" RENAME TO "ProxiedOAuthProviderType_old";
ALTER TYPE "ProxiedOAuthProviderType_new" RENAME TO "ProxiedOAuthProviderType";
DROP TYPE "ProxiedOAuthProviderType_old";
COMMIT;

View File

@ -8,6 +8,8 @@ UPDATE "Project" SET "userCount" = (
);
-- Create function to update userCount
-- SPLIT_STATEMENT_SENTINEL
-- SINGLE_STATEMENT_SENTINEL
CREATE OR REPLACE FUNCTION update_project_user_count()
RETURNS TRIGGER AS $$
BEGIN
@ -30,6 +32,7 @@ BEGIN
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- SPLIT_STATEMENT_SENTINEL
-- Create triggers
DROP TRIGGER IF EXISTS project_user_insert_trigger ON "ProjectUser";

View File

@ -5,13 +5,12 @@
*/
-- AlterEnum
BEGIN;
CREATE TYPE "PermissionScope_new" AS ENUM ('PROJECT', 'TEAM');
ALTER TABLE "Permission" ALTER COLUMN "scope" TYPE "PermissionScope_new" USING ("scope"::text::"PermissionScope_new");
ALTER TYPE "PermissionScope" RENAME TO "PermissionScope_old";
ALTER TYPE "PermissionScope_new" RENAME TO "PermissionScope";
DROP TYPE "PermissionScope_old";
COMMIT;
-- SPLIT_STATEMENT_SENTINEL
-- AlterTable
ALTER TABLE "Permission" ADD COLUMN "isDefaultProjectPermission" BOOLEAN NOT NULL DEFAULT false;

View File

@ -167,17 +167,14 @@ ALTER TABLE "AuthMethod" DROP COLUMN "authMethodConfigId",
DROP COLUMN "projectConfigId";
-- AlterTable
BEGIN;
ALTER TABLE "ConnectedAccount" ADD COLUMN "configOAuthProviderId" TEXT;
UPDATE "ConnectedAccount" SET "configOAuthProviderId" = "oauthProviderConfigId";
ALTER TABLE "ConnectedAccount" ALTER COLUMN "configOAuthProviderId" SET NOT NULL;
ALTER TABLE "ConnectedAccount" DROP COLUMN "oauthProviderConfigId";
ALTER TABLE "ConnectedAccount" DROP COLUMN "connectedAccountConfigId";
ALTER TABLE "ConnectedAccount" DROP COLUMN "projectConfigId";
COMMIT;
-- AlterTable
BEGIN;
ALTER TABLE "EmailTemplate" DROP CONSTRAINT "EmailTemplate_pkey";
ALTER TABLE "EmailTemplate" ADD COLUMN "projectId" TEXT;
@ -189,12 +186,15 @@ JOIN "Project" P ON P."configId" = PC."id"
WHERE ET."projectConfigId" = PC."id";
-- Check if we have any null projectId values
-- SPLIT_STATEMENT_SENTINEL
-- SINGLE_STATEMENT_SENTINEL
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM "EmailTemplate" WHERE "projectId" IS NULL) THEN
RAISE EXCEPTION 'Some EmailTemplate records have null projectId values after migration';
END IF;
END $$;
-- SPLIT_STATEMENT_SENTINEL
-- Now make the column NOT NULL
ALTER TABLE "EmailTemplate" ALTER COLUMN "projectId" SET NOT NULL;
@ -204,38 +204,30 @@ ALTER TABLE "EmailTemplate" ADD CONSTRAINT "EmailTemplate_pkey" PRIMARY KEY ("pr
-- Drop the old column
ALTER TABLE "EmailTemplate" DROP COLUMN "projectConfigId";
COMMIT;
-- AlterTable
BEGIN;
ALTER TABLE "OAuthAccessToken" ADD COLUMN "configOAuthProviderId" TEXT;
UPDATE "OAuthAccessToken" SET "configOAuthProviderId" = "oAuthProviderConfigId";
ALTER TABLE "OAuthAccessToken" ALTER COLUMN "configOAuthProviderId" SET NOT NULL;
ALTER TABLE "OAuthAccessToken" DROP COLUMN "oAuthProviderConfigId";
COMMIT;
-- AlterTable
BEGIN;
ALTER TABLE "OAuthAuthMethod" ADD COLUMN "configOAuthProviderId" TEXT;
UPDATE "OAuthAuthMethod" SET "configOAuthProviderId" = "oauthProviderConfigId";
ALTER TABLE "OAuthAuthMethod" ALTER COLUMN "configOAuthProviderId" SET NOT NULL;
ALTER TABLE "OAuthAuthMethod" DROP COLUMN "oauthProviderConfigId";
ALTER TABLE "OAuthAuthMethod" DROP COLUMN "projectConfigId";
COMMIT;
-- AlterTable
BEGIN;
ALTER TABLE "OAuthToken" ADD COLUMN "configOAuthProviderId" TEXT;
UPDATE "OAuthToken" SET "configOAuthProviderId" = "oAuthProviderConfigId";
ALTER TABLE "OAuthToken" ALTER COLUMN "configOAuthProviderId" SET NOT NULL;
ALTER TABLE "OAuthToken" DROP COLUMN "oAuthProviderConfigId";
COMMIT;
-- AlterTable
ALTER TABLE "Project" DROP COLUMN "configId";
-- AlterTable
BEGIN;
ALTER TABLE "ProjectUserDirectPermission" ADD COLUMN "permissionId" TEXT;
-- Update permissionId with values from Permission table
@ -249,10 +241,8 @@ ALTER TABLE "ProjectUserDirectPermission" ALTER COLUMN "permissionId" SET NOT NU
-- Drop the old column
ALTER TABLE "ProjectUserDirectPermission" DROP COLUMN "permissionDbId";
COMMIT;
-- AlterTable
BEGIN;
ALTER TABLE "ProjectUserOAuthAccount" ADD COLUMN "configOAuthProviderId" TEXT;
UPDATE "ProjectUserOAuthAccount" SET "configOAuthProviderId" = "oauthProviderConfigId";
ALTER TABLE "ProjectUserOAuthAccount" ALTER COLUMN "configOAuthProviderId" SET NOT NULL;
@ -260,13 +250,13 @@ ALTER TABLE "ProjectUserOAuthAccount" DROP CONSTRAINT "ProjectUserOAuthAccount_p
ALTER TABLE "ProjectUserOAuthAccount" DROP COLUMN "oauthProviderConfigId";
ALTER TABLE "ProjectUserOAuthAccount" DROP COLUMN "projectConfigId";
ALTER TABLE "ProjectUserOAuthAccount" ADD CONSTRAINT "ProjectUserOAuthAccount_pkey" PRIMARY KEY ("tenancyId", "configOAuthProviderId", "providerAccountId");
COMMIT;
-- AlterTable
BEGIN;
ALTER TABLE "TeamMemberDirectPermission" ADD COLUMN "permissionId" TEXT;
-- Check for rows where both or neither field is populated
-- SPLIT_STATEMENT_SENTINEL
-- SINGLE_STATEMENT_SENTINEL
DO $$
BEGIN
IF EXISTS (
@ -277,6 +267,7 @@ BEGIN
RAISE EXCEPTION 'Invalid state: Each TeamMemberDirectPermission must have exactly one of permissionDbId or systemPermission set';
END IF;
END $$;
-- SPLIT_STATEMENT_SENTINEL
-- Update permissionId using systemPermission when available
UPDATE "TeamMemberDirectPermission"
@ -295,7 +286,6 @@ ALTER TABLE "TeamMemberDirectPermission" ALTER COLUMN "permissionId" SET NOT NUL
-- Then drop the old columns
ALTER TABLE "TeamMemberDirectPermission" DROP COLUMN "permissionDbId";
ALTER TABLE "TeamMemberDirectPermission" DROP COLUMN "systemPermission";
COMMIT;
-- DropTable
DROP TABLE "AuthMethodConfig";

View File

@ -58,7 +58,7 @@ async function seed() {
}
const internalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID);
const internalPrisma = getPrismaClientForTenancy(internalTenancy);
const internalPrisma = await getPrismaClientForTenancy(internalTenancy);
internalProject = await createOrUpdateProject({
projectId: 'internal',
@ -142,7 +142,7 @@ async function seed() {
}
if (adminGithubId) {
const githubAccount = await getPrismaClientForTenancy(internalTenancy).projectUserOAuthAccount.findFirst({
const githubAccount = await internalPrisma.projectUserOAuthAccount.findFirst({
where: {
tenancyId: internalTenancy.id,
configOAuthProviderId: 'github',
@ -153,7 +153,7 @@ async function seed() {
if (githubAccount) {
console.log(`GitHub account already exists, skipping creation`);
} else {
await getPrismaClientForTenancy(internalTenancy).projectUserOAuthAccount.create({
await internalPrisma.projectUserOAuthAccount.create({
data: {
tenancyId: internalTenancy.id,
projectUserId: newUser.projectUserId,

View File

@ -0,0 +1,107 @@
import { applyMigrations } from "@/auto-migrations";
import { MIGRATION_FILES_DIR, getMigrationFiles } from "@/auto-migrations/utils";
import { globalPrismaClient, globalPrismaSchema } from "@/prisma-client";
import { execSync } from "child_process";
import * as readline from 'readline';
const dropSchema = async () => {
await globalPrismaClient.$executeRaw`DROP SCHEMA ${globalPrismaSchema} CASCADE`;
await globalPrismaClient.$executeRaw`CREATE SCHEMA ${globalPrismaSchema}`;
await globalPrismaClient.$executeRaw`GRANT ALL ON SCHEMA ${globalPrismaSchema} TO postgres`;
await globalPrismaClient.$executeRaw`GRANT ALL ON SCHEMA ${globalPrismaSchema} TO public`;
};
const seed = async () => {
execSync('pnpm run db-seed-script', { stdio: 'inherit' });
};
const promptDropDb = async () => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const answer = await new Promise<string>(resolve => {
rl.question('Are you sure you want to drop everything in the database? This action cannot be undone. (y/N): ', resolve);
});
rl.close();
if (answer.toLowerCase() !== 'y') {
console.log('Operation cancelled');
process.exit(0);
}
};
const migrate = async () => {
await applyMigrations({
prismaClient: globalPrismaClient,
migrationFiles: getMigrationFiles(MIGRATION_FILES_DIR),
logging: true,
schema: globalPrismaSchema,
});
};
const showHelp = () => {
console.log(`Database Migration Script
Usage: pnpm db-migrations <command>
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
`);
};
const main = async () => {
const args = process.argv.slice(2);
const command = args[0];
switch (command) {
case 'reset': {
await promptDropDb();
await dropSchema();
await migrate();
await seed();
break;
}
case 'generate-migration-file': {
execSync('pnpm prisma migrate dev --skip-seed', { stdio: 'inherit' });
await dropSchema();
await migrate();
await seed();
break;
}
case 'seed': {
await seed();
break;
}
case 'init': {
await migrate();
await seed();
break;
}
case 'migrate': {
await migrate();
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);
});

View File

@ -0,0 +1,13 @@
import { writeFileSyncIfChanged } from '@stackframe/stack-shared/dist/utils/fs';
import fs from 'fs';
import path from 'path';
import { MIGRATION_FILES_DIR, getMigrationFiles } from '../src/auto-migrations/utils';
const migrationFiles = getMigrationFiles(MIGRATION_FILES_DIR);
fs.mkdirSync(path.join(process.cwd(), 'src', 'generated'), { recursive: true });
writeFileSyncIfChanged(
path.join(process.cwd(), 'src', 'generated', 'migration-files.tsx'),
`export const MIGRATION_FILES = ${JSON.stringify(migrationFiles, null, 2)};\n`
);

View File

@ -41,6 +41,8 @@ async function ensureUserCanManageApiKeys(
throw new StatusError(StatusError.BadRequest, "Cannot provide both userId and teamId");
}
const prisma = await getPrismaClientForTenancy(auth.tenancy);
if (auth.type === "client") {
if (!auth.user) {
throw new KnownErrors.UserAuthenticationRequired();
@ -57,7 +59,7 @@ async function ensureUserCanManageApiKeys(
// Check team API key permissions
if (options.teamId !== undefined) {
const userId = auth.user.id;
const hasManageApiKeysPermission = await getPrismaClientForTenancy(auth.tenancy).$transaction(async (tx) => {
const hasManageApiKeysPermission = await prisma.$transaction(async (tx) => {
const permissions = await listPermissions(tx, {
scope: 'team',
tenancy: auth.tenancy,
@ -198,7 +200,9 @@ function createApiKeyHandlers<Type extends "user" | "team">(type: Type) {
type,
});
const apiKey = await getPrismaClientForTenancy(auth.tenancy).projectApiKey.create({
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const apiKey = await prisma.projectApiKey.create({
data: {
id: apiKeyId,
description: body.description,
@ -244,8 +248,9 @@ function createApiKeyHandlers<Type extends "user" | "team">(type: Type) {
}),
handler: async ({ auth, body }) => {
await throwIfFeatureDisabled(auth.tenancy.config, type);
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const apiKey = await getPrismaClientForTenancy(auth.tenancy).projectApiKey.findUnique({
const apiKey = await prisma.projectApiKey.findUnique({
where: {
tenancyId: auth.tenancy.id,
secretApiKey: body.api_key,
@ -299,7 +304,8 @@ function createApiKeyHandlers<Type extends "user" | "team">(type: Type) {
teamId,
});
const apiKeys = await getPrismaClientForTenancy(auth.tenancy).projectApiKey.findMany({
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const apiKeys = await prisma.projectApiKey.findMany({
where: {
tenancyId: auth.tenancy.id,
projectUserId: userId,
@ -319,7 +325,9 @@ function createApiKeyHandlers<Type extends "user" | "team">(type: Type) {
onRead: async ({ auth, query, params }) => {
await throwIfFeatureDisabled(auth.tenancy.config, type);
const apiKey = await getPrismaClientForTenancy(auth.tenancy).projectApiKey.findUnique({
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const apiKey = await prisma.projectApiKey.findUnique({
where: {
tenancyId_id: {
tenancyId: auth.tenancy.id,
@ -342,7 +350,9 @@ function createApiKeyHandlers<Type extends "user" | "team">(type: Type) {
onUpdate: async ({ auth, data, params, query }) => {
await throwIfFeatureDisabled(auth.tenancy.config, type);
const existingApiKey = await getPrismaClientForTenancy(auth.tenancy).projectApiKey.findUnique({
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const existingApiKey = await prisma.projectApiKey.findUnique({
where: {
tenancyId_id: {
tenancyId: auth.tenancy.id,
@ -361,7 +371,7 @@ function createApiKeyHandlers<Type extends "user" | "team">(type: Type) {
});
// Update the API key
const updatedApiKey = await getPrismaClientForTenancy(auth.tenancy).projectApiKey.update({
const updatedApiKey = await prisma.projectApiKey.update({
where: {
tenancyId_id: {
tenancyId: auth.tenancy.id,

View File

@ -24,7 +24,7 @@ export const POST = createSmartRouteHandler({
bodyType: yupString().oneOf(["success"]).defined(),
}),
async handler({ auth: { tenancy }, body: { login_code, refresh_token } }) {
const prisma = getPrismaClientForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
// Find the CLI auth attempt
const cliAuth = await prisma.cliAuthAttempt.findUnique({

View File

@ -37,7 +37,7 @@ export const POST = createSmartRouteHandler({
}).defined(),
}),
async handler({ auth: { tenancy }, body: { polling_code } }) {
const prisma = getPrismaClientForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
// Find the CLI auth attempt
const cliAuth = await prisma.cliAuthAttempt.findFirst({

View File

@ -33,7 +33,7 @@ export const POST = createSmartRouteHandler({
const expiresAt = new Date(Date.now() + expires_in_millis);
// Create a new CLI auth attempt
const prisma = getPrismaClientForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
const cliAuth = await prisma.cliAuthAttempt.create({
data: {
tenancyId: tenancy.id,

View File

@ -37,7 +37,7 @@ export const mfaVerificationCodeHandler = createVerificationCodeHandler({
body: signInResponseSchema.defined(),
}),
async validate(tenancy, method, data, body) {
const prisma = getPrismaClientForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
const user = await prisma.projectUser.findUniqueOrThrow({
where: {
tenancyId_projectUserId: {

View File

@ -112,7 +112,7 @@ const handler = createSmartRouteHandler({
if (!tenancy) {
throw new StackAssertionError("Tenancy in outerInfo not found; has it been deleted?", { tenancyId });
}
const prisma = getPrismaClientForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
try {
if (outerInfoDB.expiresAt < new Date()) {

View File

@ -12,7 +12,7 @@ import { usersCrudHandlers } from "../../../users/crud";
import { createMfaRequiredError } from "../../mfa/sign-in/verification-code-handler";
export async function ensureUserForEmailAllowsOtp(tenancy: Tenancy, email: string): Promise<UsersCrud["Admin"]["Read"] | null> {
const prisma = getPrismaClientForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
const contactChannel = await getAuthContactChannel(
prisma,
{

View File

@ -95,7 +95,7 @@ export const registerVerificationCodeHandler = createVerificationCodeHandler({
}
const registrationInfo = verification.registrationInfo;
const prisma = getPrismaClientForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
await retryTransaction(prisma, async (tx) => {
const authMethods = await tx.passkeyAuthMethod.findMany({

View File

@ -45,7 +45,7 @@ export const passkeySignInVerificationCodeHandler = createVerificationCodeHandle
const credentialId = authentication_response.id;
const prisma = getPrismaClientForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
// Get passkey from DB with userHandle
const passkey = await prisma.passkeyAuthMethod.findFirst({
where: {

View File

@ -35,7 +35,7 @@ export const POST = createSmartRouteHandler({
throw new KnownErrors.PasswordAuthenticationNotEnabled();
}
const prisma = getPrismaClientForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
// TODO filter in the query
const contactChannel = await getAuthContactChannel(

View File

@ -37,7 +37,7 @@ export const POST = createSmartRouteHandler({
throw passwordError;
}
const prisma = getPrismaClientForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
await retryTransaction(prisma, async (tx) => {
const authMethods = await tx.passwordAuthMethod.findMany({
where: {

View File

@ -38,7 +38,7 @@ export const POST = createSmartRouteHandler({
throw new KnownErrors.PasswordAuthenticationNotEnabled();
}
const prisma = getPrismaClientForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
const contactChannel = await getAuthContactChannel(
prisma,
{

View File

@ -40,7 +40,7 @@ export const POST = createSmartRouteHandler({
throw passwordError;
}
const prisma = getPrismaClientForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
await retryTransaction(prisma, async (tx) => {
const authMethods = await tx.passwordAuthMethod.findMany({
where: {

View File

@ -17,7 +17,7 @@ export const sessionsCrudHandlers = createLazyProxy(() => createCrudHandlers(ses
user_id: userIdOrMeSchema.defined(),
}).defined(),
onList: async ({ auth, query }) => {
const prisma = getPrismaClientForTenancy(auth.tenancy);
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const schema = getPrismaSchemaForTenancy(auth.tenancy);
const listImpersonations = auth.type === 'admin';
@ -86,7 +86,7 @@ export const sessionsCrudHandlers = createLazyProxy(() => createCrudHandlers(ses
return result;
},
onDelete: async ({ auth, params }: { auth: SmartRequestAuth, params: { id: string }, query: { user_id?: string } }) => {
const prisma = getPrismaClientForTenancy(auth.tenancy);
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const session = await globalPrismaClient.projectUserRefreshToken.findFirst({
where: {
tenancyId: auth.tenancy.id,

View File

@ -29,7 +29,7 @@ export const POST = createSmartRouteHandler({
async handler({ auth: { tenancy }, headers: { "x-stack-refresh-token": refreshTokenHeaders } }, fullReq) {
const refreshToken = refreshTokenHeaders[0];
const prisma = getPrismaClientForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
const sessionObj = await globalPrismaClient.projectUserRefreshToken.findFirst({
where: {
tenancyId: tenancy.id,

View File

@ -31,7 +31,7 @@ export const DELETE = createSmartRouteHandler({
}
try {
const prisma = getPrismaClientForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
const result = await globalPrismaClient.projectUserRefreshToken.deleteMany({
where: {
tenancyId: tenancy.id,

View File

@ -39,7 +39,7 @@ export const connectedAccountAccessTokenCrudHandlers = createLazyProxy(() => cre
const providerInstance = await getProvider(provider);
// ====================== retrieve access token if it exists ======================
const prisma = getPrismaClientForTenancy(auth.tenancy);
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const accessTokens = await prisma.oAuthAccessToken.findMany({
where: {
tenancyId: auth.tenancy.id,

View File

@ -53,7 +53,9 @@ export const POST = createSmartRouteHandler({
}
}
const contactChannel = await getPrismaClientForTenancy(auth.tenancy).contactChannel.findUnique({
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const contactChannel = await prisma.contactChannel.findUnique({
where: {
tenancyId_projectUserId_id: {
tenancyId: auth.tenancy.id,

View File

@ -39,7 +39,9 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl
}
}
const contactChannel = await getPrismaClientForTenancy(auth.tenancy).contactChannel.findUnique({
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const contactChannel = await prisma.contactChannel.findUnique({
where: {
tenancyId_projectUserId_id: {
tenancyId: auth.tenancy.id,
@ -71,7 +73,9 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl
}
}
const contactChannel = await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const contactChannel = await retryTransaction(prisma, async (tx) => {
await ensureContactChannelDoesNotExists(tx, {
tenancyId: auth.tenancy.id,
userId: data.user_id,
@ -166,7 +170,9 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl
}
}
const updatedContactChannel = await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const updatedContactChannel = await retryTransaction(prisma, async (tx) => {
const existingContactChannel = await ensureContactChannelExists(tx, {
tenancyId: auth.tenancy.id,
userId: params.user_id,
@ -230,7 +236,9 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl
}
}
await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
await retryTransaction(prisma, async (tx) => {
await ensureContactChannelExists(tx, {
tenancyId: auth.tenancy.id,
userId: params.user_id,
@ -256,7 +264,9 @@ export const contactChannelsCrudHandlers = createLazyProxy(() => createCrudHandl
}
}
const contactChannels = await getPrismaClientForTenancy(auth.tenancy).contactChannel.findMany({
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const contactChannels = await prisma.contactChannel.findMany({
where: {
tenancyId: auth.tenancy.id,
projectUserId: query.user_id,

View File

@ -54,7 +54,9 @@ export const contactChannelVerificationCodeHandler = createVerificationCodeHandl
},
} as const;
const contactChannel = await getPrismaClientForTenancy(tenancy).contactChannel.findUnique({
const prisma = await getPrismaClientForTenancy(tenancy);
const contactChannel = await prisma.contactChannel.findUnique({
where: uniqueKeys,
});
@ -63,7 +65,7 @@ export const contactChannelVerificationCodeHandler = createVerificationCodeHandl
throw new StatusError(400, "Contact channel not found. Was your contact channel deleted?");
}
await getPrismaClientForTenancy(tenancy).contactChannel.update({
await prisma.contactChannel.update({
where: uniqueKeys,
data: {
isVerified: true,

View File

@ -29,7 +29,7 @@ export const notificationPreferencesCrudHandlers = createLazyProxy(() => createC
throw new StatusError(StatusError.Forbidden, "You can only manage your own notification preferences");
}
}
const prismaClient = getPrismaClientForTenancy(auth.tenancy);
const prismaClient = await getPrismaClientForTenancy(auth.tenancy);
await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId });
const notificationPreference = await prismaClient.userNotificationPreference.upsert({
@ -72,7 +72,7 @@ export const notificationPreferencesCrudHandlers = createLazyProxy(() => createC
throw new StatusError(StatusError.Forbidden, "You can only view your own notification preferences");
}
}
const prismaClient = getPrismaClientForTenancy(auth.tenancy);
const prismaClient = await getPrismaClientForTenancy(auth.tenancy);
await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId });
const notificationPreferences = await prismaClient.userNotificationPreference.findMany({

View File

@ -62,7 +62,9 @@ export const POST = createSmartRouteHandler({
}
const activeTheme = themeList[auth.tenancy.completeConfig.emails.theme];
const users = await getPrismaClientForTenancy(auth.tenancy).projectUser.findMany({
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const users = await prisma.projectUser.findMany({
where: {
tenancyId: auth.tenancy.id,
projectUserId: {

View File

@ -40,7 +40,10 @@ export async function GET(request: NextRequest) {
});
const tenancy = await getSoleTenancyFromProjectBranch(verificationCode.projectId, verificationCode.branchId);
await getPrismaClientForTenancy(tenancy).userNotificationPreference.upsert({
const prisma = await getPrismaClientForTenancy(tenancy);
await prisma.userNotificationPreference.upsert({
where: {
tenancyId_projectUserId_notificationCategoryId: {
tenancyId: tenancy.id,

View File

@ -84,7 +84,7 @@ export const POST = createSmartRouteHandler({
throw new StackAssertionError("Tenancy not found");
}
const prisma = getPrismaClientForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
const projectUser = await prisma.projectUser.findUnique({
where: {
tenancyId_projectUserId: {
@ -113,7 +113,10 @@ export const POST = createSmartRouteHandler({
if (!tenancy) {
throw new StackAssertionError("Tenancy not found");
}
const userIdsWithManageApiKeysPermission = await getPrismaClientForTenancy(tenancy).$transaction(async (tx) => {
const prisma = await getPrismaClientForTenancy(tenancy);
const userIdsWithManageApiKeysPermission = await prisma.$transaction(async (tx) => {
if (!updatedApiKey.teamId) {
throw new StackAssertionError("Team ID not specified in team API key");
}
@ -129,7 +132,7 @@ export const POST = createSmartRouteHandler({
return permissions.map(p => p.user_id);
});
const usersWithManageApiKeysPermission = await getPrismaClientForTenancy(tenancy).projectUser.findMany({
const usersWithManageApiKeysPermission = await prisma.projectUser.findMany({
where: {
tenancyId: updatedApiKey.tenancyId,
projectUserId: {

View File

@ -53,7 +53,9 @@ export const integrationProjectTransferCodeHandler = createVerificationCodeHandl
if (provisionedProject.count === 0) throw new StatusError(400, "The project to transfer was not provisioned or has already been transferred.");
const recentDbUser = await getPrismaClientForTenancy(tenancy).projectUser.findUnique({
const prisma = await getPrismaClientForTenancy(tenancy);
const recentDbUser = await prisma.projectUser.findUnique({
where: {
tenancyId_projectUserId: {
tenancyId: tenancy.id,
@ -63,7 +65,7 @@ export const integrationProjectTransferCodeHandler = createVerificationCodeHandl
}) ?? throwErr("Authenticated user not found in transaction. Something went wrong. Did the user delete their account at the wrong time? (Very unlikely.)");
const rduServerMetadata: any = recentDbUser.serverMetadata;
await getPrismaClientForTenancy(tenancy).projectUser.update({
await prisma.projectUser.update({
where: {
tenancyId_projectUserId: {
tenancyId: tenancy.id,

View File

@ -54,7 +54,9 @@ export const neonIntegrationProjectTransferCodeHandler = createVerificationCodeH
if (provisionedProject.count === 0) throw new StatusError(400, "The project to transfer was not provisioned by Neon or has already been transferred.");
const recentDbUser = await getPrismaClientForTenancy(tenancy).projectUser.findUnique({
const prisma = await getPrismaClientForTenancy(tenancy);
const recentDbUser = await prisma.projectUser.findUnique({
where: {
tenancyId_projectUserId: {
tenancyId: tenancy.id,
@ -64,7 +66,7 @@ export const neonIntegrationProjectTransferCodeHandler = createVerificationCodeH
}) ?? throwErr("Authenticated user not found in transaction. Something went wrong. Did the user delete their account at the wrong time? (Very unlikely.)");
const rduServerMetadata: any = recentDbUser.serverMetadata;
await getPrismaClientForTenancy(tenancy).projectUser.update({
await prisma.projectUser.update({
where: {
tenancyId_projectUserId: {
tenancyId: tenancy.id,

View File

@ -4,7 +4,7 @@ import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { createOpenAI } from "@ai-sdk/openai";
import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { generateText, ToolResult } from "ai";
import { ToolResult, generateText } from "ai";
import { InferType } from "yup";
const textContentSchema = yupObject({
@ -22,7 +22,7 @@ const toolCallContentSchema = yupObject({
});
const contentSchema = yupArray(yupUnion(textContentSchema, toolCallContentSchema)).defined();
const openai = createOpenAI({ apiKey: getEnvVariable("STACK_OPENAI_API_KEY") });
const openai = createOpenAI({ apiKey: getEnvVariable("STACK_OPENAI_API_KEY", "MISSING_OPENAI_API_KEY") });
export const POST = createSmartRouteHandler({
metadata: {

View File

@ -31,7 +31,9 @@ export const internalEmailsCrudHandlers = createLazyProxy(() => createCrudHandle
emailId: yupString().optional(),
}),
onList: async ({ auth }) => {
const emails = await getPrismaClientForTenancy(auth.tenancy).sentEmail.findMany({
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const emails = await prisma.sentEmail.findMany({
where: {
tenancyId: auth.tenancy.id,
},

View File

@ -46,7 +46,7 @@ async function loadUsersByCountry(tenancy: Tenancy): Promise<Record<string, numb
async function loadTotalUsers(tenancy: Tenancy, now: Date): Promise<DataPoints> {
const schema = getPrismaSchemaForTenancy(tenancy);
const prisma = getPrismaClientForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
return (await prisma.$queryRaw<{date: Date, dailyUsers: bigint, cumUsers: bigint}[]>`
WITH date_series AS (
SELECT GENERATE_SERIES(
@ -107,7 +107,7 @@ async function loadDailyActiveUsers(tenancy: Tenancy, now: Date) {
async function loadLoginMethods(tenancy: Tenancy): Promise<{method: string, count: number }[]> {
const schema = getPrismaSchemaForTenancy(tenancy);
const prisma = getPrismaClientForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
return await prisma.$queryRaw<{ method: string, count: number }[]>`
WITH tab AS (
SELECT
@ -202,6 +202,8 @@ export const GET = createSmartRouteHandler({
handler: async (req) => {
const now = new Date();
const prisma = await getPrismaClientForTenancy(req.auth.tenancy);
const [
totalUsers,
dailyUsers,
@ -211,7 +213,7 @@ export const GET = createSmartRouteHandler({
recentlyActive,
loginMethods
] = await Promise.all([
getPrismaClientForTenancy(req.auth.tenancy).projectUser.count({
prisma.projectUser.count({
where: { tenancyId: req.auth.tenancy.id, },
}),
loadTotalUsers(req.auth.tenancy, now),

View File

@ -41,8 +41,10 @@ export const projectsCrudHandlers = createLazyProxy(() => createCrudHandlers(pro
}
});
const prisma = await getPrismaClientForTenancy(auth.tenancy);
// delete managed ids from users
const users = await getPrismaClientForTenancy(auth.tenancy).projectUser.findMany({
const users = await prisma.projectUser.findMany({
where: {
mirroredProjectId: 'internal',
serverMetadata: {
@ -57,7 +59,7 @@ export const projectsCrudHandlers = createLazyProxy(() => createCrudHandlers(pro
(id: any) => id !== auth.project.id
) as string[];
await getPrismaClientForTenancy(auth.tenancy).projectUser.update({
await prisma.projectUser.update({
where: {
mirroredProjectId_mirroredBranchId_projectUserId: {
mirroredProjectId: 'internal',

View File

@ -26,7 +26,7 @@ async function checkInputValidity(options: {
allowSignIn: boolean,
allowConnectedAccounts: boolean,
})): Promise<void> {
const prismaClient = getPrismaClientForTenancy(options.tenancy);
const prismaClient = await getPrismaClientForTenancy(options.tenancy);
let providerConfigId: string;
if (options.type === 'update') {
@ -86,7 +86,7 @@ async function checkInputValidity(options: {
}
async function ensureProviderExists(tenancy: Tenancy, userId: string, providerId: string) {
const prismaClient = getPrismaClientForTenancy(tenancy);
const prismaClient = await getPrismaClientForTenancy(tenancy);
const provider = await prismaClient.projectUserOAuthAccount.findUnique({
where: {
tenancyId_id: {
@ -144,7 +144,7 @@ export const oauthProviderCrudHandlers = createLazyProxy(() => createCrudHandler
}
}
const prismaClient = getPrismaClientForTenancy(auth.tenancy);
const prismaClient = await getPrismaClientForTenancy(auth.tenancy);
await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId: params.user_id });
const oauthAccount = await ensureProviderExists(auth.tenancy, params.user_id, params.provider_id);
@ -168,7 +168,7 @@ export const oauthProviderCrudHandlers = createLazyProxy(() => createCrudHandler
}
}
const prismaClient = getPrismaClientForTenancy(auth.tenancy);
const prismaClient = await getPrismaClientForTenancy(auth.tenancy);
if (query.user_id) {
await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId: query.user_id });
@ -210,7 +210,7 @@ export const oauthProviderCrudHandlers = createLazyProxy(() => createCrudHandler
}
}
const prismaClient = getPrismaClientForTenancy(auth.tenancy);
const prismaClient = await getPrismaClientForTenancy(auth.tenancy);
await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId: params.user_id });
const existingOAuthAccount = await ensureProviderExists(auth.tenancy, params.user_id, params.provider_id);
@ -320,7 +320,7 @@ export const oauthProviderCrudHandlers = createLazyProxy(() => createCrudHandler
}
}
const prismaClient = getPrismaClientForTenancy(auth.tenancy);
const prismaClient = await getPrismaClientForTenancy(auth.tenancy);
await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId: params.user_id });
const existingOAuthAccount = await ensureProviderExists(auth.tenancy, params.user_id, params.provider_id);
@ -347,7 +347,7 @@ export const oauthProviderCrudHandlers = createLazyProxy(() => createCrudHandler
});
},
async onCreate({ auth, data }) {
const prismaClient = getPrismaClientForTenancy(auth.tenancy);
const prismaClient = await getPrismaClientForTenancy(auth.tenancy);
const providerConfig = getProviderConfig(auth.tenancy, data.provider_config_id);
await ensureUserExists(prismaClient, { tenancyId: auth.tenancy.id, userId: data.user_id });

View File

@ -21,9 +21,10 @@ export const projectPermissionDefinitionsCrudHandlers = createLazyProxy(() => cr
);
},
async onUpdate({ auth, data, params }) {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
return await updatePermissionDefinition(
globalPrismaClient,
getPrismaClientForTenancy(auth.tenancy),
prisma,
{
oldId: params.permission_id,
scope: "project",
@ -33,9 +34,10 @@ export const projectPermissionDefinitionsCrudHandlers = createLazyProxy(() => cr
);
},
async onDelete({ auth, params }) {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
return await deletePermissionDefinition(
globalPrismaClient,
getPrismaClientForTenancy(auth.tenancy),
prisma,
{
scope: "project",
tenancy: auth.tenancy,

View File

@ -21,7 +21,8 @@ export const projectPermissionsCrudHandlers = createLazyProxy(() => createCrudHa
permission_id: permissionDefinitionIdSchema.defined(),
}),
async onCreate({ auth, params }) {
const result = await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const result = await retryTransaction(prisma, async (tx) => {
await ensureUserExists(tx, { tenancyId: auth.tenancy.id, userId: params.user_id });
return await grantProjectPermission(tx, {
@ -42,7 +43,8 @@ export const projectPermissionsCrudHandlers = createLazyProxy(() => createCrudHa
return result;
},
async onDelete({ auth, params }) {
const result = await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const result = await retryTransaction(prisma, async (tx) => {
await ensureProjectPermissionExists(tx, {
tenancy: auth.tenancy,
userId: params.user_id,
@ -77,7 +79,9 @@ export const projectPermissionsCrudHandlers = createLazyProxy(() => createCrudHa
}
}
return await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
return await retryTransaction(prisma, async (tx) => {
return {
items: await listPermissions(tx, {
scope: 'project',

View File

@ -69,7 +69,9 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({
async handler(tenancy, {}, data, body, user) {
if (!user) throw new KnownErrors.UserAuthenticationRequired;
const oldMembership = await getPrismaClientForTenancy(tenancy).teamMember.findUnique({
const prisma = await getPrismaClientForTenancy(tenancy);
const oldMembership = await prisma.teamMember.findUnique({
where: {
tenancyId_projectUserId_teamId: {
tenancyId: tenancy.id,

View File

@ -16,7 +16,8 @@ export const teamInvitationsCrudHandlers = createLazyProxy(() => createCrudHandl
id: yupString().uuid().defined(),
}),
onList: async ({ auth, query }) => {
return await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
return await retryTransaction(prisma, async (tx) => {
if (auth.type === 'client') {
// Client can only:
// - list invitations in their own team if they have the $read_members AND $invite_members permissions
@ -58,7 +59,8 @@ export const teamInvitationsCrudHandlers = createLazyProxy(() => createCrudHandl
});
},
onDelete: async ({ auth, query, params }) => {
await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
await retryTransaction(prisma, async (tx) => {
if (auth.type === 'client') {
// Client can only:
// - delete invitations in their own team if they have the $remove_members permissions

View File

@ -32,7 +32,8 @@ export const POST = createSmartRouteHandler({
}).defined(),
}),
async handler({ auth, body }) {
await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
await retryTransaction(prisma, async (tx) => {
if (auth.type === "client") {
if (!auth.user) throw new KnownErrors.UserAuthenticationRequired();

View File

@ -31,7 +31,8 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa
user_id: userIdOrMeSchema.defined(),
}),
onList: async ({ auth, query }) => {
return await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
return await retryTransaction(prisma, async (tx) => {
if (auth.type === 'client') {
// Client can only:
// - list users in their own team if they have the $read_members permission
@ -85,7 +86,8 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa
});
},
onRead: async ({ auth, params }) => {
return await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
return await retryTransaction(prisma, async (tx) => {
if (auth.type === 'client') {
const currentUserId = auth.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser());
if (params.user_id !== currentUserId) {
@ -122,7 +124,8 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa
});
},
onUpdate: async ({ auth, data, params }) => {
return await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
return await retryTransaction(prisma, async (tx) => {
if (auth.type === 'client') {
const currentUserId = auth.user?.id ?? throwErr(new KnownErrors.CannotGetOwnUserWithoutUser());
if (params.user_id !== currentUserId) {

View File

@ -46,7 +46,8 @@ export const teamMembershipsCrudHandlers = createLazyProxy(() => createCrudHandl
user_id: userIdOrMeSchema.defined(),
}),
onCreate: async ({ auth, params }) => {
const result = await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const result = await retryTransaction(prisma, async (tx) => {
await ensureUserExists(tx, {
tenancyId: auth.tenancy.id,
userId: params.user_id,
@ -112,7 +113,8 @@ export const teamMembershipsCrudHandlers = createLazyProxy(() => createCrudHandl
return data;
},
onDelete: async ({ auth, params }) => {
await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
await retryTransaction(prisma, async (tx) => {
// Users are always allowed to remove themselves from a team
// Only users with the $remove_members permission can remove other users
if (auth.type === 'client') {

View File

@ -20,9 +20,10 @@ export const teamPermissionDefinitionsCrudHandlers = createLazyProxy(() => creat
);
},
async onUpdate({ auth, data, params }) {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
return await updatePermissionDefinition(
globalPrismaClient,
getPrismaClientForTenancy(auth.tenancy),
prisma,
{
oldId: params.permission_id,
scope: "team",
@ -36,9 +37,10 @@ export const teamPermissionDefinitionsCrudHandlers = createLazyProxy(() => creat
);
},
async onDelete({ auth, params }) {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
return await deletePermissionDefinition(
globalPrismaClient,
getPrismaClientForTenancy(auth.tenancy),
prisma,
{
scope: "team",
tenancy: auth.tenancy,

View File

@ -23,7 +23,8 @@ export const teamPermissionsCrudHandlers = createLazyProxy(() => createCrudHandl
permission_id: permissionDefinitionIdSchema.defined(),
}),
async onCreate({ auth, params }) {
const result = await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const result = await retryTransaction(prisma, async (tx) => {
await ensureTeamMembershipExists(tx, { tenancyId: auth.tenancy.id, teamId: params.team_id, userId: params.user_id });
return await grantTeamPermission(tx, {
@ -46,7 +47,8 @@ export const teamPermissionsCrudHandlers = createLazyProxy(() => createCrudHandl
return result;
},
async onDelete({ auth, params }) {
const result = await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const result = await retryTransaction(prisma, async (tx) => {
await ensureUserTeamPermissionExists(tx, {
tenancy: auth.tenancy,
teamId: params.team_id,
@ -84,7 +86,8 @@ export const teamPermissionsCrudHandlers = createLazyProxy(() => createCrudHandl
}
}
return await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
return await retryTransaction(prisma, async (tx) => {
return {
items: await listPermissions(tx, {
scope: 'team',

View File

@ -68,7 +68,9 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
addUserId = auth.user.id;
}
const db = await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const db = await retryTransaction(prisma, async (tx) => {
const db = await tx.team.create({
data: {
displayName: data.display_name,
@ -105,15 +107,17 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
return result;
},
onRead: async ({ params, auth }) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
if (auth.type === 'client') {
await ensureTeamMembershipExists(getPrismaClientForTenancy(auth.tenancy), {
await ensureTeamMembershipExists(prisma, {
tenancyId: auth.tenancy.id,
teamId: params.team_id,
userId: auth.user?.id ?? throwErr(new KnownErrors.UserAuthenticationRequired),
});
}
const db = await getPrismaClientForTenancy(auth.tenancy).team.findUnique({
const db = await prisma.team.findUnique({
where: {
tenancyId_teamId: {
tenancyId: auth.tenancy.id,
@ -129,7 +133,8 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
return teamPrismaToCrud(db);
},
onUpdate: async ({ params, auth, data }) => {
const db = await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const db = await retryTransaction(prisma, async (tx) => {
if (auth.type === 'client' && data.profile_image_url && !validateBase64Image(data.profile_image_url)) {
throw new StatusError(400, "Invalid profile image URL");
}
@ -174,7 +179,8 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
return result;
},
onDelete: async ({ params, auth }) => {
await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
await retryTransaction(prisma, async (tx) => {
if (auth.type === 'client') {
await ensureUserTeamPermissionExists(tx, {
tenancy: auth.tenancy,
@ -213,7 +219,8 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC
}
}
const db = await getPrismaClientForTenancy(auth.tenancy).team.findMany({
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const db = await prisma.team.findMany({
where: {
tenancyId: auth.tenancy.id,
...query.user_id ? {

View File

@ -166,7 +166,7 @@ export const getUsersLastActiveAtMillis = async (projectId: string, branchId: st
// Get the tenancy first to determine the source of truth
const tenancy = await getSoleTenancyFromProjectBranch(projectId, branchId);
const prisma = getPrismaClientForTenancy(tenancy);
const prisma = await getPrismaClientForTenancy(tenancy);
const schema = getPrismaSchemaForTenancy(tenancy);
const events = await prisma.$queryRaw<Array<{ userId: string, lastActiveAt: Date }>>`
SELECT data->>'userId' as "userId", MAX("eventStartedAt") as "lastActiveAt"
@ -358,8 +358,9 @@ export async function getUser(options: { userId: string } & ({ projectId: string
}
const environmentConfig = await rawQuery(globalPrismaClient, getRenderedEnvironmentConfigQuery({ projectId, branchId }));
const prisma = getPrismaClientForSourceOfTruth(environmentConfig.sourceOfTruth, branchId);
const result = await rawQuery(prisma, getUserQuery(projectId, branchId, options.userId, getPrismaSchemaForSourceOfTruth(environmentConfig.sourceOfTruth, branchId)));
const prisma = await getPrismaClientForSourceOfTruth(environmentConfig.sourceOfTruth, branchId);
const schema = getPrismaSchemaForSourceOfTruth(environmentConfig.sourceOfTruth, branchId);
const result = await rawQuery(prisma, getUserQuery(projectId, branchId, options.userId, schema));
return result;
}
@ -385,7 +386,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
},
onList: async ({ auth, query }) => {
const queryWithoutSpecialChars = query.query?.replace(/[^a-zA-Z0-9\-_.]/g, '');
const prisma = getPrismaClientForTenancy(auth.tenancy);
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const where = {
tenancyId: auth.tenancy.id,
@ -464,7 +465,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
});
const passwordHash = await getPasswordHashFromData(data);
const prisma = getPrismaClientForTenancy(auth.tenancy);
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const result = await retryTransaction(prisma, async (tx) => {
await checkAuthData(tx, {
tenancyId: auth.tenancy.id,
@ -637,7 +638,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
onUpdate: async ({ auth, data, params }) => {
const primaryEmail = data.primary_email ? normalizeEmail(data.primary_email) : data.primary_email;
const passwordHash = await getPasswordHashFromData(data);
const prisma = getPrismaClientForTenancy(auth.tenancy);
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const result = await retryTransaction(prisma, async (tx) => {
await ensureUserExists(tx, { tenancyId: auth.tenancy.id, userId: params.user_id });
@ -969,7 +970,8 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
return result;
},
onDelete: async ({ auth, params }) => {
const { teams } = await retryTransaction(getPrismaClientForTenancy(auth.tenancy), async (tx) => {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const { teams } = await retryTransaction(prisma, async (tx) => {
await ensureUserExists(tx, { tenancyId: auth.tenancy.id, userId: params.user_id });
const teams = await tx.team.findMany({

View File

@ -0,0 +1,394 @@
import { PrismaClient } from "@prisma/client";
import postgres from 'postgres';
import { ExpectStatic } from "vitest";
import { applyMigrations, runMigrationNeeded } from "./index";
const TEST_DB_PREFIX = 'stack_auth_test_db';
const getTestDbURL = (testDbName: string) => {
// @ts-ignore - ImportMeta.env is provided by Vite
const base = import.meta.env.STACK_DIRECT_DATABASE_CONNECTION_STRING.replace(/\/[^/]*$/, '');
return {
full: `${base}/${testDbName}`,
base,
};
};
const applySql = async (options: { sql: string | string[], fullDbURL: string }) => {
const sql = postgres(options.fullDbURL);
try {
for (const query of Array.isArray(options.sql) ? options.sql : [options.sql]) {
await sql.unsafe(query);
}
} finally {
await sql.end();
}
};
const setupTestDatabase = async () => {
const randomSuffix = Math.random().toString(16).substring(2, 12);
const testDbName = `${TEST_DB_PREFIX}_${randomSuffix}`;
const dbURL = getTestDbURL(testDbName);
await applySql({ sql: `CREATE DATABASE ${testDbName}`, fullDbURL: dbURL.base });
const prismaClient = new PrismaClient({
datasources: {
db: {
url: dbURL.full,
},
},
});
await prismaClient.$connect();
return {
prismaClient,
testDbName,
dbURL,
};
};
const teardownTestDatabase = async (prismaClient: PrismaClient, testDbName: string) => {
await prismaClient.$disconnect();
const dbURL = getTestDbURL(testDbName);
await applySql({
sql: [
`
SELECT pg_terminate_backend(pg_stat_activity.pid)
FROM pg_stat_activity
WHERE pg_stat_activity.datname = '${testDbName}'
AND pid <> pg_backend_pid();
`,
`DROP DATABASE IF EXISTS ${testDbName}`
],
fullDbURL: dbURL.base
});
// Wait a bit to ensure connections are terminated
await new Promise(resolve => setTimeout(resolve, 500));
};
function runTest(fn: (options: { expect: ExpectStatic, prismaClient: PrismaClient, dbURL: { full: string, base: string } }) => Promise<void>) {
return async ({ expect }: { expect: ExpectStatic }) => {
const { prismaClient, testDbName, dbURL } = await setupTestDatabase();
try {
await fn({ prismaClient, expect, dbURL });
} finally {
await teardownTestDatabase(prismaClient, testDbName);
}
};
}
const exampleMigrationFiles1 = [
{
migrationName: "001-create-table",
sql: "CREATE TABLE test (id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL);",
},
{
migrationName: "002-update-table",
sql: "ALTER TABLE test ADD COLUMN age INTEGER NOT NULL DEFAULT 0;",
},
];
const examplePrismaBasedInitQueries = [
// Settings
`SET statement_timeout = 0`,
`SET lock_timeout = 0`,
`SET idle_in_transaction_session_timeout = 0`,
`SET client_encoding = 'UTF8'`,
`SET standard_conforming_strings = on`,
`SELECT pg_catalog.set_config('search_path', '', false)`,
`SET check_function_bodies = false`,
`SET xmloption = content`,
`SET client_min_messages = warning`,
`SET row_security = off`,
`ALTER SCHEMA public OWNER TO postgres`,
`COMMENT ON SCHEMA public IS ''`,
`SET default_tablespace = ''`,
`SET default_table_access_method = heap`,
`CREATE TABLE public."User" (
id integer NOT NULL,
name text NOT NULL
)`,
`ALTER TABLE public."User" OWNER TO postgres`,
`CREATE TABLE public._prisma_migrations (
id character varying(36) NOT NULL,
checksum character varying(64) NOT NULL,
finished_at timestamp with time zone,
migration_name character varying(255) NOT NULL,
logs text,
rolled_back_at timestamp with time zone,
started_at timestamp with time zone DEFAULT now() NOT NULL,
applied_steps_count integer DEFAULT 0 NOT NULL
)`,
`ALTER TABLE public._prisma_migrations OWNER TO postgres`,
`INSERT INTO public._prisma_migrations (id, checksum, finished_at, migration_name, logs, rolled_back_at, started_at, applied_steps_count)
VALUES ('a34e5ccf-c472-44c7-9d9c-0d4580d18ac3', '9785d85f8c5a8b3dbfbbbd8143cc7485bb48dd8bf30ca3eafd3cd2e1ba15a953', '2025-03-14 21:50:26.794721+00', '20250314215026_init', NULL, NULL, '2025-03-14 21:50:26.656161+00', 1)`,
`INSERT INTO public._prisma_migrations (id, checksum, finished_at, migration_name, logs, rolled_back_at, started_at, applied_steps_count)
VALUES ('7e7f0e5b-f91b-40fa-b061-d8f2edd274ed', '6853f42ae69239976b84d058430774c8faa83488545e84162844dab84b47294d', '2025-03-14 21:50:47.761397+00', '20250314215047_name', NULL, NULL, '2025-03-14 21:50:47.624814+00', 1)`,
`ALTER TABLE ONLY public."User" ADD CONSTRAINT "User_pkey" PRIMARY KEY (id)`,
`ALTER TABLE ONLY public._prisma_migrations ADD CONSTRAINT _prisma_migrations_pkey PRIMARY KEY (id)`,
`REVOKE USAGE ON SCHEMA public FROM PUBLIC`
];
const examplePrismaBasedMigrationFiles = [
{
migrationName: '20250314215026_init',
sql: `CREATE TABLE "User" ("id" INTEGER NOT NULL, CONSTRAINT "User_pkey" PRIMARY KEY ("id"));`,
},
{
migrationName: '20250314215047_name',
sql: `ALTER TABLE "User" ADD COLUMN "name" TEXT NOT NULL;`,
},
{
migrationName: '20250314215050_age',
sql: `ALTER TABLE "User" ADD COLUMN "age" INTEGER NOT NULL DEFAULT 0;`,
},
];
import.meta.vitest?.test("connects to DB", runTest(async ({ expect, prismaClient }) => {
const result = await prismaClient.$executeRaw`SELECT 1`;
expect(result).toBe(1);
}));
import.meta.vitest?.test("applies migrations", runTest(async ({ expect, prismaClient }) => {
const { newlyAppliedMigrationNames } = await applyMigrations({ prismaClient, migrationFiles: exampleMigrationFiles1, schema: 'public' });
expect(newlyAppliedMigrationNames).toEqual(['001-create-table', '002-update-table']);
await prismaClient.$executeRaw`INSERT INTO test (name) VALUES ('test_value')`;
const result = await prismaClient.$queryRaw`SELECT name FROM test` as { name: string }[];
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(1);
expect(result[0].name).toBe('test_value');
const ageResult = await prismaClient.$queryRaw`SELECT age FROM test WHERE name = 'test_value'` as { age: number }[];
expect(Array.isArray(ageResult)).toBe(true);
expect(ageResult.length).toBe(1);
expect(ageResult[0].age).toBe(0);
}));
import.meta.vitest?.test("first apply half of the migrations, then apply the other half", runTest(async ({ expect, prismaClient }) => {
const { newlyAppliedMigrationNames } = await applyMigrations({ prismaClient, migrationFiles: exampleMigrationFiles1.slice(0, 1), schema: 'public' });
expect(newlyAppliedMigrationNames).toEqual(['001-create-table']);
const { newlyAppliedMigrationNames: newlyAppliedMigrationNames2 } = await applyMigrations({ prismaClient, migrationFiles: exampleMigrationFiles1, schema: 'public' });
expect(newlyAppliedMigrationNames2).toEqual(['002-update-table']);
await prismaClient.$executeRaw`INSERT INTO test (name) VALUES ('test_value')`;
const result = await prismaClient.$queryRaw`SELECT name FROM test` as { name: string }[];
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(1);
expect(result[0].name).toBe('test_value');
const ageResult = await prismaClient.$queryRaw`SELECT age FROM test WHERE name = 'test_value'` as { age: number }[];
expect(Array.isArray(ageResult)).toBe(true);
expect(ageResult.length).toBe(1);
expect(ageResult[0].age).toBe(0);
}));
import.meta.vitest?.test("applies migrations concurrently", runTest(async ({ expect, prismaClient }) => {
const [result1, result2] = await Promise.all([
applyMigrations({ prismaClient, migrationFiles: exampleMigrationFiles1, artificialDelayInSeconds: 1, schema: 'public' }),
applyMigrations({ prismaClient, migrationFiles: exampleMigrationFiles1, artificialDelayInSeconds: 1, schema: 'public' }),
]);
const l1 = result1.newlyAppliedMigrationNames.length;
const l2 = result2.newlyAppliedMigrationNames.length;
// One of the two migrations should be applied, but not both
expect((l1 === 2 && l2 === 0) || (l1 === 0 && l2 === 2)).toBe(true);
await prismaClient.$executeRaw`INSERT INTO test (name) VALUES ('test_value')`;
const result = await prismaClient.$queryRaw`SELECT name FROM test` as { name: string }[];
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(1);
expect(result[0].name).toBe('test_value');
}));
import.meta.vitest?.test("applies migrations concurrently with 20 concurrent migrations", runTest(async ({ expect, prismaClient }) => {
const promises = Array.from({ length: 20 }, () =>
applyMigrations({ prismaClient, migrationFiles: exampleMigrationFiles1, artificialDelayInSeconds: 1, schema: 'public' })
);
const results = await Promise.all(promises);
// Count how many migrations were applied by each promise
const appliedCounts = results.map(result => result.newlyAppliedMigrationNames.length);
// Only one of the promises should have applied all migrations, the rest should have applied none
const successfulApplies = appliedCounts.filter(count => count === 2);
const emptyApplies = appliedCounts.filter(count => count === 0);
expect(successfulApplies.length).toBe(1);
expect(emptyApplies.length).toBe(19);
await prismaClient.$executeRaw`INSERT INTO test (name) VALUES ('test_value')`;
const result = await prismaClient.$queryRaw`SELECT name FROM test` as { name: string }[];
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(1);
expect(result[0].name).toBe('test_value');
}));
import.meta.vitest?.test("applies migration with a DB previously migrated with prisma", runTest(async ({ expect, prismaClient, dbURL }) => {
await applySql({ sql: examplePrismaBasedInitQueries, fullDbURL: dbURL.full });
const result = await applyMigrations({ prismaClient, migrationFiles: examplePrismaBasedMigrationFiles, schema: 'public' });
expect(result.newlyAppliedMigrationNames).toEqual(['20250314215050_age']);
// apply migrations again
const result2 = await applyMigrations({ prismaClient, migrationFiles: examplePrismaBasedMigrationFiles, schema: 'public' });
expect(result2.newlyAppliedMigrationNames).toEqual([]);
}));
import.meta.vitest?.test("applies migration while running a query", runTest(async ({ expect, prismaClient, dbURL }) => {
await runMigrationNeeded({
prismaClient,
migrationFiles: exampleMigrationFiles1,
artificialDelayInSeconds: 1,
schema: 'public',
});
await prismaClient.$executeRaw`INSERT INTO test (name) VALUES ('test_value')`;
const result = await prismaClient.$queryRaw`SELECT name FROM test` as { name: string }[];
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(1);
expect(result[0].name).toBe('test_value');
}));
import.meta.vitest?.test("applies migration while running concurrent queries", runTest(async ({ expect, prismaClient, dbURL }) => {
const runMigrationAndInsert = async (testValue: string) => {
await runMigrationNeeded({
prismaClient,
migrationFiles: exampleMigrationFiles1,
schema: 'public',
});
await prismaClient.$executeRaw`INSERT INTO test (name) VALUES (${testValue})`;
};
await Promise.all([
runMigrationAndInsert('test_value1'),
runMigrationAndInsert('test_value2'),
]);
const result1 = await prismaClient.$queryRaw`SELECT name FROM test` as { name: string }[];
expect(Array.isArray(result1)).toBe(true);
expect(result1.length).toBe(2);
expect(result1.some(r => r.name === 'test_value1')).toBe(true);
expect(result1.some(r => r.name === 'test_value2')).toBe(true);
}));
import.meta.vitest?.test("applies migration while running an interactive transaction", runTest(async ({ expect, prismaClient, dbURL }) => {
return await prismaClient.$transaction(async (tx, ...args) => {
await runMigrationNeeded({
prismaClient,
migrationFiles: exampleMigrationFiles1,
schema: 'public',
});
await tx.$executeRaw`INSERT INTO test (name) VALUES ('test_value')`;
const result = await tx.$queryRaw`SELECT name FROM test` as { name: string }[];
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(1);
expect(result[0].name).toBe('test_value');
}, {
isolationLevel: undefined,
});
}));
import.meta.vitest?.test("applies migration while running concurrent interactive transactions", runTest(async ({ expect, prismaClient, dbURL }) => {
const runTransactionWithMigration = async (testValue: string) => {
return await prismaClient.$transaction(async (tx) => {
await runMigrationNeeded({
prismaClient,
schema: 'public',
migrationFiles: exampleMigrationFiles1,
artificialDelayInSeconds: 1,
});
await tx.$executeRaw`INSERT INTO test (name) VALUES (${testValue})`;
return testValue;
});
};
const results = await Promise.all([
runTransactionWithMigration('concurrent_tx_1'),
runTransactionWithMigration('concurrent_tx_2'),
]);
expect(results).toEqual(['concurrent_tx_1', 'concurrent_tx_2']);
const result = await prismaClient.$queryRaw`SELECT name FROM test` as { name: string }[];
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(2);
expect(result.some(r => r.name === 'concurrent_tx_1')).toBe(true);
expect(result.some(r => r.name === 'concurrent_tx_2')).toBe(true);
}));
import.meta.vitest?.test("does not apply migrations if they are already applied", runTest(async ({ expect, prismaClient, dbURL }) => {
await applyMigrations({ prismaClient, migrationFiles: exampleMigrationFiles1, schema: 'public' });
const result = await applyMigrations({ prismaClient, migrationFiles: exampleMigrationFiles1, schema: 'public' });
expect(result.newlyAppliedMigrationNames).toEqual([]);
}));
import.meta.vitest?.test("does not apply a migration again if all migrations are already applied, and some future migrations are also applied (rollback scenario)", runTest(async ({ expect, prismaClient, dbURL }) => {
// First, apply all migrations
const initialResult = await applyMigrations({ prismaClient, migrationFiles: exampleMigrationFiles1, schema: 'public' });
expect(initialResult.newlyAppliedMigrationNames).toEqual(['001-create-table', '002-update-table']);
// Verify the table structure is complete
await prismaClient.$executeRaw`INSERT INTO test (name, age) VALUES ('test_value', 25)`;
const fullResult = await prismaClient.$queryRaw`SELECT name, age FROM test` as { name: string, age: number }[];
expect(fullResult.length).toBe(1);
expect(fullResult[0].name).toBe('test_value');
expect(fullResult[0].age).toBe(25);
// Now try to apply only a subset of the migrations (simulating a rollback scenario)
// This should not re-apply any migrations since they're already applied
const subsetResult = await applyMigrations({ prismaClient, migrationFiles: exampleMigrationFiles1.slice(0, 1), schema: 'public' });
expect(subsetResult.newlyAppliedMigrationNames).toEqual([]);
// Verify the data is still intact and no migrations were re-run
const finalResult = await prismaClient.$queryRaw`SELECT name, age FROM test` as { name: string, age: number }[];
expect(finalResult.length).toBe(1);
expect(finalResult[0].name).toBe('test_value');
expect(finalResult[0].age).toBe(25);
}));
import.meta.vitest?.test("a migration that fails for whatever reasons rolls back all statements successfully, and then reapplying a fixed version of the migration is also successful", runTest(async ({ expect, prismaClient, dbURL }) => {
const exampleMigration3 = {
migrationName: '003-create-table',
sql: `
CREATE TABLE should_exist_after_the_third_migration (id INTEGER);
`,
};
const failingMigrationFiles = [...exampleMigrationFiles1.slice(0, -1), {
migrationName: exampleMigrationFiles1[exampleMigrationFiles1.length - 1].migrationName,
sql: `
CREATE TABLE should_not_exist (id INTEGER);
SELECT 1/0;
`
}, exampleMigration3];
await expect(applyMigrations({ prismaClient, migrationFiles: failingMigrationFiles, schema: 'public' })).rejects.toThrow();
// Verify that the first part of the migration was applied but rolled back
await expect(prismaClient.$queryRaw`SELECT * FROM test`).resolves.toBeDefined();
// Verify that the table from the third migration was also not created due to rollback
await expect(prismaClient.$queryRaw`SELECT * FROM should_exist_after_the_third_migration`).rejects.toThrow();
// Verify that the failing table was not created due to rollback
await expect(prismaClient.$queryRaw`SELECT * FROM should_not_exist`).rejects.toThrow();
const result = await applyMigrations({ prismaClient, migrationFiles: [...exampleMigrationFiles1, exampleMigration3], schema: 'public' });
expect(result.newlyAppliedMigrationNames).toEqual(['002-update-table', '003-create-table']);
await expect(prismaClient.$queryRaw`SELECT * FROM should_exist_after_the_third_migration`).resolves.toBeDefined();
}));

View File

@ -0,0 +1,194 @@
import { sqlQuoteIdent } from '@/prisma-client';
import { Prisma, PrismaClient } from '@prisma/client';
import { MIGRATION_FILES } from './../generated/migration-files';
// The bigint key for the pg advisory lock
const MIGRATION_LOCK_ID = 59129034;
class MigrationNeededError extends Error {
constructor() {
super('MIGRATION_NEEDED');
this.name = 'MigrationNeededError';
}
}
function getMigrationError(error: unknown): string {
// P2010: Raw query failed error
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2010') {
if (error.meta?.code === 'P0001') {
const errorName = (error.meta as { message: string }).message.split(' ')[1];
return errorName;
}
}
throw error;
}
function isMigrationNeededError(error: unknown): boolean {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
// 42P01: relation does not exist error
if (/relation "(?:.*\.)?SchemaMigration" does not exist/.test(error.message) || /No such table: (?:.*\.)?SchemaMigration/.test(error.message)) {
return true;
}
}
if (error instanceof MigrationNeededError) {
return true;
}
return false;
}
async function getAppliedMigrations(options: {
prismaClient: PrismaClient,
schema: string,
}) {
const [_1, _2, _3, appliedMigrations] = await options.prismaClient.$transaction([
options.prismaClient.$executeRaw`SELECT pg_advisory_xact_lock(${MIGRATION_LOCK_ID})`,
options.prismaClient.$executeRaw(Prisma.sql`
SET search_path TO ${sqlQuoteIdent(options.schema)};
`),
options.prismaClient.$executeRaw`
DO $$
BEGIN
CREATE TABLE IF NOT EXISTS "SchemaMigration" (
"id" TEXT NOT NULL DEFAULT gen_random_uuid(),
"finishedAt" TIMESTAMP(3) NOT NULL,
"migrationName" TEXT NOT NULL UNIQUE,
CONSTRAINT "SchemaMigration_pkey" PRIMARY KEY ("id")
);
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = '_prisma_migrations'
) THEN
INSERT INTO "SchemaMigration" ("migrationName", "finishedAt")
SELECT
migration_name,
finished_at
FROM _prisma_migrations
WHERE migration_name NOT IN (
SELECT "migrationName" FROM "SchemaMigration"
)
AND finished_at IS NOT NULL;
END IF;
END $$;
`,
options.prismaClient.$queryRaw`SELECT "migrationName" FROM "SchemaMigration"`,
]);
return (appliedMigrations as { migrationName: string }[]).map((migration) => migration.migrationName);
}
export async function applyMigrations(options: {
prismaClient: PrismaClient,
migrationFiles?: { migrationName: string, sql: string }[],
artificialDelayInSeconds?: number,
logging?: boolean,
schema: string,
}): Promise<{
newlyAppliedMigrationNames: string[],
}> {
const migrationFiles = options.migrationFiles ?? MIGRATION_FILES;
const appliedMigrationNames = await getAppliedMigrations({ prismaClient: options.prismaClient, schema: options.schema });
const newMigrationFiles = migrationFiles.filter(x => !appliedMigrationNames.includes(x.migrationName));
const newlyAppliedMigrationNames = [];
for (const migration of newMigrationFiles) {
if (options.logging) {
console.log(`Applying migration ${migration.migrationName}`);
}
const transaction = [];
transaction.push(options.prismaClient.$executeRaw`
SELECT pg_advisory_xact_lock(${MIGRATION_LOCK_ID});
`);
transaction.push(options.prismaClient.$executeRaw(Prisma.sql`
SET search_path TO ${sqlQuoteIdent(options.schema)};
`));
transaction.push(options.prismaClient.$executeRaw`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM "SchemaMigration"
WHERE "migrationName" = '${Prisma.raw(migration.migrationName)}'
) THEN
RAISE EXCEPTION 'MIGRATION_ALREADY_APPLIED';
END IF;
END
$$;
`);
for (const statement of migration.sql.split('SPLIT_STATEMENT_SENTINEL')) {
if (statement.includes('SINGLE_STATEMENT_SENTINEL')) {
transaction.push(options.prismaClient.$queryRaw`${Prisma.raw(statement)}`);
} else {
transaction.push(options.prismaClient.$executeRaw`
DO $$
BEGIN
${Prisma.raw(statement)}
END
$$;
`);
}
}
if (options.artificialDelayInSeconds) {
transaction.push(options.prismaClient.$executeRaw`
SELECT pg_sleep(${options.artificialDelayInSeconds});
`);
}
transaction.push(options.prismaClient.$executeRaw`
INSERT INTO "SchemaMigration" ("migrationName", "finishedAt")
VALUES (${migration.migrationName}, clock_timestamp())
`);
try {
await options.prismaClient.$transaction(transaction);
} catch (e) {
const error = getMigrationError(e);
if (error === 'MIGRATION_ALREADY_APPLIED') {
if (options.logging) {
console.log(`Migration ${migration.migrationName} already applied, skipping`);
}
continue;
}
throw e;
}
newlyAppliedMigrationNames.push(migration.migrationName);
}
return { newlyAppliedMigrationNames };
};
export async function runMigrationNeeded(options: {
prismaClient: PrismaClient,
schema: string,
migrationFiles?: { migrationName: string, sql: string }[],
artificialDelayInSeconds?: number,
}): Promise<void> {
const migrationFiles = options.migrationFiles ?? MIGRATION_FILES;
try {
const result = await options.prismaClient.$queryRaw(Prisma.sql`
SELECT * FROM ${sqlQuoteIdent(options.schema)}."SchemaMigration"
ORDER BY "finishedAt" ASC
`);
for (const migration of migrationFiles) {
if (!(result as any).includes(migration.migrationName)) {
throw new MigrationNeededError();
}
}
} catch (e) {
if (isMigrationNeededError(e)) {
await applyMigrations({
prismaClient: options.prismaClient,
migrationFiles: options.migrationFiles,
artificialDelayInSeconds: options.artificialDelayInSeconds,
schema: options.schema,
});
} else {
throw e;
}
}
}

View File

@ -0,0 +1,31 @@
import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings";
import fs from "fs";
import path from "path";
export const MIGRATION_FILES_DIR = path.join(process.cwd(), 'prisma', 'migrations');
export function getMigrationFiles(migrationDir: string): { migrationName: string, sql: string }[] {
const folders = fs.readdirSync(migrationDir).filter(folder =>
fs.statSync(path.join(migrationDir, folder)).isDirectory()
);
const result: { migrationName: string, sql: string }[] = [];
for (const folder of folders) {
const folderPath = path.join(migrationDir, folder);
const sqlFiles = fs.readdirSync(folderPath).filter(file => file.endsWith('.sql'));
for (const sqlFile of sqlFiles) {
const sqlContent = fs.readFileSync(path.join(folderPath, sqlFile), 'utf8');
result.push({
migrationName: folder,
sql: sqlContent
});
}
}
result.sort((a, b) => stringCompare(a.migrationName, b.migrationName));
return result;
}

View File

@ -266,7 +266,9 @@ export async function sendEmailWithoutRetries(options: SendEmailOptions): Promis
throw new StackAssertionError("Tenancy not found");
}
await getPrismaClientForTenancy(tenancy).sentEmail.create({
const prisma = await getPrismaClientForTenancy(tenancy);
await prisma.sentEmail.create({
data: {
tenancyId: options.tenancyId,
to: typeof options.to === 'string' ? [options.to] : options.to,

View File

@ -31,7 +31,10 @@ export const hasNotificationEnabled = async (tenancy: Tenancy, userId: string, n
if (!notificationCategory) {
throw new StackAssertionError('Invalid notification category id', { notificationCategoryId });
}
const userNotificationPreference = await getPrismaClientForTenancy(tenancy).userNotificationPreference.findFirst({
const prisma = await getPrismaClientForTenancy(tenancy);
const userNotificationPreference = await prisma.userNotificationPreference.findFirst({
where: {
tenancyId: tenancy.id,
projectUserId: userId,

View File

@ -232,7 +232,7 @@ export async function createOrUpdateProject(
// Update owner metadata
const internalEnvironmentConfig = await rawQuery(globalPrismaClient, getRenderedEnvironmentConfigQuery({ projectId: "internal", branchId: DEFAULT_BRANCH_ID }));
const prisma = getPrismaClientForSourceOfTruth(internalEnvironmentConfig.sourceOfTruth, DEFAULT_BRANCH_ID);
const prisma = await getPrismaClientForSourceOfTruth(internalEnvironmentConfig.sourceOfTruth, DEFAULT_BRANCH_ID);
await prisma.$transaction(async (tx) => {
for (const userId of options.ownerIds ?? []) {
const projectUserTx = await tx.projectUser.findUnique({

View File

@ -136,7 +136,8 @@ export class OAuthModel implements AuthorizationCodeModel {
async saveToken(token: Token, client: Client, user: User): Promise<Token | Falsey> {
if (token.refreshToken) {
const tenancy = await getSoleTenancyFromProjectBranch(...getProjectBranchFromClientId(client.id));
const projectUser = await getPrismaClientForTenancy(tenancy).projectUser.findUniqueOrThrow({
const prisma = await getPrismaClientForTenancy(tenancy);
const projectUser = await prisma.projectUser.findUniqueOrThrow({
where: {
tenancyId_projectUserId: {
tenancyId: tenancy.id,

View File

@ -2,13 +2,14 @@ import { PrismaNeon } from "@prisma/adapter-neon";
import { PrismaPg } from '@prisma/adapter-pg';
import { Prisma, PrismaClient } from "@prisma/client";
import { OrganizationRenderedConfig } from "@stackframe/stack-shared/dist/config/schema";
import { getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env';
import { getEnvVariable, getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env';
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { globalVar } from "@stackframe/stack-shared/dist/utils/globals";
import { deepPlainEquals, filterUndefined, typedFromEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects";
import { ignoreUnhandledRejection } from "@stackframe/stack-shared/dist/utils/promises";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { isPromise } from "util/types";
import { runMigrationNeeded } from "./auto-migrations";
import { Tenancy } from "./lib/tenancies";
import { traceSpan } from "./utils/telemetry";
@ -28,6 +29,8 @@ if (getNodeEnvironment().includes('development')) {
}
export const globalPrismaClient = prismaClientsStore.global;
const dbString = getEnvVariable("STACK_DIRECT_DATABASE_CONNECTION_STRING", "");
export const globalPrismaSchema = dbString === "" ? "public" : getSchemaFromConnectionString(dbString);
function getNeonPrismaClient(connectionString: string) {
let neonPrismaClient = prismaClientsStore.neon.get(connectionString);
@ -36,11 +39,16 @@ function getNeonPrismaClient(connectionString: string) {
neonPrismaClient = new PrismaClient({ adapter });
prismaClientsStore.neon.set(connectionString, neonPrismaClient);
}
return neonPrismaClient;
}
export function getPrismaClientForTenancy(tenancy: Tenancy) {
return getPrismaClientForSourceOfTruth(tenancy.completeConfig.sourceOfTruth, tenancy.branchId);
function getSchemaFromConnectionString(connectionString: string) {
return (new URL(connectionString)).searchParams.get('schema') ?? "public";
}
export async function getPrismaClientForTenancy(tenancy: Tenancy) {
return await getPrismaClientForSourceOfTruth(tenancy.completeConfig.sourceOfTruth, tenancy.branchId);
}
export function getPrismaSchemaForTenancy(tenancy: Tenancy) {
@ -50,45 +58,49 @@ export function getPrismaSchemaForTenancy(tenancy: Tenancy) {
function getPostgresPrismaClient(connectionString: string) {
let postgresPrismaClient = prismaClientsStore.postgres.get(connectionString);
if (!postgresPrismaClient) {
const schema = (new URL(connectionString)).searchParams.get('schema');
const schema = getSchemaFromConnectionString(connectionString);
const adapter = new PrismaPg({ connectionString }, schema ? { schema } : undefined);
postgresPrismaClient = {
client: new PrismaClient({ adapter }),
schema: schema ?? null,
schema,
};
prismaClientsStore.postgres.set(connectionString, postgresPrismaClient);
}
return postgresPrismaClient;
}
export function getPrismaClientForSourceOfTruth(sourceOfTruth: OrganizationRenderedConfig["sourceOfTruth"], branchId: string) {
export async function getPrismaClientForSourceOfTruth(sourceOfTruth: OrganizationRenderedConfig["sourceOfTruth"], branchId: string) {
switch (sourceOfTruth.type) {
case 'neon': {
if (!(branchId in sourceOfTruth.connectionStrings)) {
throw new Error(`No connection string provided for Neon source of truth for branch ${branchId}`);
}
return getNeonPrismaClient(sourceOfTruth.connectionStrings[branchId]);
const connectionString = sourceOfTruth.connectionStrings[branchId];
const neonPrismaClient = getNeonPrismaClient(connectionString);
await runMigrationNeeded({ prismaClient: neonPrismaClient, schema: getSchemaFromConnectionString(connectionString) });
return neonPrismaClient;
}
case 'postgres': {
return getPostgresPrismaClient(sourceOfTruth.connectionString).client;
const postgresPrismaClient = getPostgresPrismaClient(sourceOfTruth.connectionString);
await runMigrationNeeded({ prismaClient: postgresPrismaClient.client, schema: getSchemaFromConnectionString(sourceOfTruth.connectionString) });
return postgresPrismaClient.client;
}
case 'hosted': {
return globalPrismaClient;
}
default: {
// @ts-expect-error sourceOfTruth should be never, otherwise we're missing a switch-case
throw new StackAssertionError(`Unknown source of truth type: ${sourceOfTruth.type}`);
}
}
}
export function getPrismaSchemaForSourceOfTruth(sourceOfTruth: OrganizationRenderedConfig["sourceOfTruth"], branchId: string) {
switch (sourceOfTruth.type) {
case 'postgres': {
return getPostgresPrismaClient(sourceOfTruth.connectionString).schema ?? 'public';
return getSchemaFromConnectionString(sourceOfTruth.connectionString);
}
default: {
return 'public';
case 'neon': {
return getSchemaFromConnectionString(sourceOfTruth.connectionStrings[branchId]);
}
case 'hosted': {
return globalPrismaSchema;
}
}
}
@ -280,13 +292,14 @@ async function rawQueryArray<Q extends RawQuery<any>[]>(tx: PrismaClientTransact
// Prisma does a query for every rawQuery call by default, even if we batch them with transactions
// So, instead we combine all queries into one, and then return them as a single JSON result
const combinedQuery = RawQuery.all(queries);
const combinedQuery = RawQuery.all([...queries]);
// TODO: check that combinedQuery supports the prisma client that created tx
// Supabase's index advisor only analyzes rows that start with "SELECT" (for some reason)
// Since ours starts with "WITH", we prepend a SELECT to it
const sqlQuery = Prisma.sql`SELECT * FROM (${combinedQuery.sql}) AS _`;
const rawResult = await tx.$queryRaw(sqlQuery);
const postProcessed = combinedQuery.postProcess(rawResult as any);

View File

@ -1,7 +1,24 @@
import { resolve } from 'path'
import { loadEnv } from 'vite'
import { defineConfig, mergeConfig } from 'vitest/config'
import sharedConfig from '../../vitest.shared'
export default mergeConfig(
sharedConfig,
defineConfig({}),
defineConfig({
test: {
testTimeout: 20000,
env: {
...loadEnv('', process.cwd(), ''),
...loadEnv('development', process.cwd(), ''),
},
},
resolve: {
alias: {
'@': resolve(__dirname, './src')
}
},
envDir: __dirname,
envPrefix: 'STACK_',
})
)

View File

@ -1,7 +1,7 @@
# Basic
NEXT_PUBLIC_STACK_API_URL=# enter your stack endpoint here, For local development: http://localhost:8102 (no trailing slash)
NEXT_PUBLIC_STACK_PROJECT_ID=internal
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=# enter your Stack publishable client key here. For local development, just enter a random string, then run `pnpm prisma migrate reset`
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=# enter your Stack publishable client key here. For local development, just enter a random string, then run `pnpm db:reset`
STACK_SECRET_SERVER_KEY=# enter your Stack secret client key here. For local development, do the same as above
NEXT_PUBLIC_STACK_EXTRA_REQUEST_HEADERS=# a list of extra request headers to add to all Stack Auth API requests, as a JSON record

View File

@ -5,5 +5,6 @@ STACK_INTERNAL_PROJECT_CLIENT_KEY=
STACK_INTERNAL_PROJECT_SERVER_KEY=
STACK_INTERNAL_PROJECT_ADMIN_KEY=
STACK_TEST_SOURCE_OF_TRUTH=
STACK_DIRECT_DATABASE_CONNECTION_STRING=
INBUCKET_API_URL=

View File

@ -4,6 +4,7 @@ STACK_INTERNAL_PROJECT_ID=internal
STACK_INTERNAL_PROJECT_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only
STACK_INTERNAL_PROJECT_SERVER_KEY=this-secret-server-key-is-for-local-development-only
STACK_INTERNAL_PROJECT_ADMIN_KEY=this-super-secret-admin-key-is-for-local-development-only
STACK_DIRECT_DATABASE_CONNECTION_STRING=postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:5432/stackframe
INBUCKET_API_URL=http://localhost:8105
STACK_SVIX_SERVER_URL=http://localhost:8113

View File

@ -29,6 +29,6 @@ STACK_SVIX_SERVER_URL=# this is only needed if you self-host the Svix service
NEXT_PUBLIC_STACK_SVIX_SERVER_URL=# this is only needed if you are using docker compose and the external and internal urls are different. This is the external url for the Svix service.
STACK_SVIX_API_KEY=
STACK_OPENAI_API_KEY=# enter your openai api key if you want to use the openai related features
STACK_SKIP_MIGRATIONS=# true to skip prisma migrations
STACK_SKIP_SEED_SCRIPT=# true to skip the seed script

View File

@ -146,13 +146,7 @@ pnpm start:dashboard
You need to initialize the database with the following command with the backend environment variables set:
```sh
pnpm prisma migrate deploy
```
The database is still empty; you need to create a project with the ID "internal" used by the dashboard to authenticate itself. You can do this with the following command:
```sh
pnpm prisma db seed
pnpm db:init
```
Now you can go to the dashboard (e.g., https://your-dashboard-url.com) and sign up for an account.

View File

@ -26,16 +26,20 @@
"codegen": "pnpm pre && turbo run codegen && pnpm run generate-sdks && pnpm run generate-openapi && pnpm run generate-openapi-fumadocs",
"deps-compose": "docker compose -p stack-dependencies -f docker/dependencies/docker.compose.yaml",
"stop-deps": "POSTGRES_DELAY_MS=0 pnpm run deps-compose kill && POSTGRES_DELAY_MS=0 pnpm run deps-compose down -v",
"init-db": "pnpm pre && pnpm run prisma migrate deploy && pnpm run prisma db seed",
"wait-until-postgres-is-ready:pg_isready": "until pg_isready -h localhost -p 5432; 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",
"start-deps:no-delay": "pnpm pre && pnpm run deps-compose up --detach --build && pnpm run wait-until-postgres-is-ready && pnpm run init-db && 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 db:init && echo \"\\nDependencies started in the background as Docker containers. 'pnpm run stop-deps' to stop them\"n",
"start-deps": "POSTGRES_DELAY_MS=${POSTGRES_DELAY_MS:-20} 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",
"psql": "pnpm pre && pnpm run --filter=@stackframe/stack-backend psql",
"explain-query": "pnpm pre && echo 'Paste your query (end with Ctrl-D):' && query=$(cat) && echo 'Connecting to Postgres...' && printf \"EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON)\n$query\" | pnpm run --silent psql -qAt | sed -n '/\\[/,$p' > explained-query.untracked.json && echo 'Explained query saved to explained-query.untracked.json. To analyze it, open it in the query analyzer at https://tatiyants.com/pev/#/plans/new'",
"prisma": "pnpm pre && pnpm run --filter=@stackframe/stack-backend prisma",
"db:migration-gen": "pnpm pre && pnpm run --filter=@stackframe/stack-backend db:migration-gen",
"db:reset": "pnpm pre && pnpm run --filter=@stackframe/stack-backend db:reset",
"db:seed": "pnpm pre && pnpm run --filter=@stackframe/stack-backend db:seed",
"db:init": "pnpm pre && pnpm run --filter=@stackframe/stack-backend db:init",
"db:migrate": "pnpm pre && pnpm run --filter=@stackframe/stack-backend db:migrate",
"fern": "pnpm pre && pnpm run --filter=@stackframe/docs fern",
"dev:full": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"pnpm run generate-docs:watch\" \"turbo run dev --concurrency 99999\"",
"dev": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"pnpm run generate-docs:watch\" \"pnpm run generate-openapi-docs:watch\" \"turbo run dev --concurrency 99999 --filter=./apps/* --filter=@stackframe/stack-docs --filter=./packages/* --filter=./examples/demo \"",
"dev:basic": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"pnpm run generate-docs:watch\" \"turbo run dev --concurrency 99999 --filter=@stackframe/stack-backend --filter=@stackframe/stack-dashboard --filter=@stackframe/mock-oauth-server\"",

View File

@ -47,7 +47,7 @@ importers:
version: 6.21.0(eslint@8.30.0)(typescript@5.3.3)
'@vitejs/plugin-react':
specifier: ^4.3.3
version: 4.3.3(vite@6.1.0(@types/node@20.17.6)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.31.1)(tsx@4.19.3)(yaml@2.6.0))
version: 4.3.3(vite@6.1.0(@types/node@20.17.6)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.19.3)(yaml@2.6.0))
chokidar-cli:
specifier: ^3.0.0
version: 3.0.0
@ -98,10 +98,10 @@ importers:
version: 5.3.3
vite-tsconfig-paths:
specifier: ^4.3.2
version: 4.3.2(typescript@5.3.3)(vite@6.1.0(@types/node@20.17.6)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.31.1)(tsx@4.19.3)(yaml@2.6.0))
version: 4.3.2(typescript@5.3.3)(vite@6.1.0(@types/node@20.17.6)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.19.3)(yaml@2.6.0))
vitest:
specifier: ^1.6.0
version: 1.6.0(@types/node@20.17.6)(jsdom@24.1.3)(lightningcss@1.30.1)(terser@5.31.1)
version: 1.6.0(@types/node@20.17.6)(jsdom@24.1.3)(lightningcss@1.30.1)(terser@5.43.1)
wait-on:
specifier: ^8.0.1
version: 8.0.1
@ -189,6 +189,12 @@ importers:
bcrypt:
specifier: ^5.1.1
version: 5.1.1(encoding@0.1.13)
chokidar-cli:
specifier: ^3.0.0
version: 3.0.0
dotenv:
specifier: ^16.4.5
version: 16.4.7
dotenv-cli:
specifier: ^7.3.0
version: 7.4.1
@ -216,6 +222,9 @@ importers:
pg:
specifier: ^8.16.3
version: 8.16.3
postgres:
specifier: ^3.4.5
version: 3.4.5
posthog-node:
specifier: ^4.1.0
version: 4.1.0
@ -234,6 +243,9 @@ importers:
svix:
specifier: ^1.25.0
version: 1.25.0(encoding@0.1.13)
vite:
specifier: ^6.1.0
version: 6.1.0(@types/node@20.17.6)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.15.5)(yaml@2.4.5)
yaml:
specifier: ^2.4.5
version: 2.4.5
@ -378,7 +390,7 @@ importers:
version: 0.2.1(next@15.4.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
posthog-js:
specifier: ^1.235.0
version: 1.235.4
version: 1.255.1
react:
specifier: 19.0.0
version: 19.0.0
@ -612,7 +624,7 @@ importers:
version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
posthog-js:
specifier: ^1.235.0
version: 1.235.4
version: 1.255.1
react:
specifier: ^18.3.1
version: 18.3.1
@ -872,7 +884,7 @@ importers:
version: 5.3.3
vite:
specifier: ^6.1.0
version: 6.1.0(@types/node@22.15.34)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.31.1)(tsx@4.19.3)(yaml@2.6.0)
version: 6.1.0(@types/node@22.15.34)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.19.3)(yaml@2.6.0)
examples/middleware:
dependencies:
@ -968,7 +980,7 @@ importers:
version: 18.3.1
'@vitejs/plugin-react':
specifier: ^4.3.4
version: 4.3.4(vite@6.1.0(@types/node@22.15.34)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.31.1)(tsx@4.19.3)(yaml@2.6.0))
version: 4.3.4(vite@6.1.0(@types/node@22.15.34)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.19.3)(yaml@2.6.0))
eslint:
specifier: ^9.19.0
version: 9.21.0(jiti@2.4.2)
@ -989,7 +1001,7 @@ importers:
version: 8.25.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.3.3)
vite:
specifier: ^6.1.0
version: 6.1.0(@types/node@22.15.34)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.31.1)(tsx@4.19.3)(yaml@2.6.0)
version: 6.1.0(@types/node@22.15.34)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.19.3)(yaml@2.6.0)
examples/supabase:
dependencies:
@ -13609,8 +13621,12 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
posthog-js@1.235.4:
resolution: {integrity: sha512-CcAQpw7oaIoOwyaeqNZoKjciIMygrjgn6+cBSWFQcbo7aEmiO2666BZHZH/GBFmz0g2/w5abSpO7UntAj/69dw==}
postgres@3.4.5:
resolution: {integrity: sha512-cDWgoah1Gez9rN3H4165peY9qfpEo+SA61oQv65O3cRUE1pOEoJWwddwcqKE8XZYjbblOJlYDlLV4h67HrEVDg==}
engines: {node: '>=12'}
posthog-js@1.255.1:
resolution: {integrity: sha512-KMh0o9MhORhEZVjXpktXB5rJ8PfDk+poqBoTSoLzWgNjhJf6D8jcyB9jUMA6vVPfn4YeepVX5NuclDRqOwr5Mw==}
peerDependencies:
'@rrweb/types': 2.0.0-alpha.17
rrweb-snapshot: 2.0.0-alpha.17
@ -19021,14 +19037,14 @@ snapshots:
dependencies:
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
'@types/node': 20.17.6
'@types/node': 22.15.18
jest-mock: 29.7.0
'@jest/fake-timers@29.7.0':
dependencies:
'@jest/types': 29.6.3
'@sinonjs/fake-timers': 10.3.0
'@types/node': 20.17.6
'@types/node': 22.15.18
jest-message-util: 29.7.0
jest-mock: 29.7.0
jest-util: 29.7.0
@ -19070,7 +19086,7 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 20.17.6
'@types/node': 22.15.18
'@types/yargs': 17.0.33
chalk: 4.1.2
@ -22426,7 +22442,7 @@ snapshots:
'@babel/core': 7.26.0
'@sentry/babel-plugin-component-annotate': 2.22.6
'@sentry/cli': 2.38.2(encoding@0.1.13)
dotenv: 16.4.5
dotenv: 16.4.7
find-up: 5.0.0
glob: 9.3.5
magic-string: 0.30.8
@ -23279,7 +23295,7 @@ snapshots:
'@types/graceful-fs@4.1.9':
dependencies:
'@types/node': 20.17.6
'@types/node': 22.15.18
'@types/hast@2.3.10':
dependencies:
@ -23793,25 +23809,25 @@ snapshots:
next: 15.4.1(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
'@vitejs/plugin-react@4.3.3(vite@6.1.0(@types/node@20.17.6)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.31.1)(tsx@4.19.3)(yaml@2.6.0))':
'@vitejs/plugin-react@4.3.3(vite@6.1.0(@types/node@20.17.6)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.19.3)(yaml@2.6.0))':
dependencies:
'@babel/core': 7.26.0
'@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.0)
'@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.0)
'@types/babel__core': 7.20.5
react-refresh: 0.14.2
vite: 6.1.0(@types/node@20.17.6)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.31.1)(tsx@4.19.3)(yaml@2.6.0)
vite: 6.1.0(@types/node@20.17.6)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.19.3)(yaml@2.6.0)
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-react@4.3.4(vite@6.1.0(@types/node@22.15.34)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.31.1)(tsx@4.19.3)(yaml@2.6.0))':
'@vitejs/plugin-react@4.3.4(vite@6.1.0(@types/node@22.15.34)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.19.3)(yaml@2.6.0))':
dependencies:
'@babel/core': 7.26.9
'@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.9)
'@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.9)
'@types/babel__core': 7.20.5
react-refresh: 0.14.2
vite: 6.1.0(@types/node@22.15.34)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.31.1)(tsx@4.19.3)(yaml@2.6.0)
vite: 6.1.0(@types/node@22.15.34)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.19.3)(yaml@2.6.0)
transitivePeerDependencies:
- supports-color
@ -24952,7 +24968,7 @@ snapshots:
chrome-launcher@0.15.2:
dependencies:
'@types/node': 20.17.6
'@types/node': 22.15.18
escape-string-regexp: 4.0.0
is-wsl: 2.2.0
lighthouse-logger: 1.4.2
@ -24963,7 +24979,7 @@ snapshots:
chromium-edge-launcher@0.2.0:
dependencies:
'@types/node': 20.17.6
'@types/node': 22.15.18
escape-string-regexp: 4.0.0
is-wsl: 2.2.0
lighthouse-logger: 1.4.2
@ -25743,7 +25759,7 @@ snapshots:
dotenv-cli@7.4.1:
dependencies:
cross-spawn: 7.0.3
dotenv: 16.4.5
dotenv: 16.4.7
dotenv-expand: 10.0.0
minimist: 1.2.8
@ -28528,7 +28544,7 @@ snapshots:
'@jest/environment': 29.7.0
'@jest/fake-timers': 29.7.0
'@jest/types': 29.6.3
'@types/node': 20.17.6
'@types/node': 22.15.18
jest-mock: 29.7.0
jest-util: 29.7.0
@ -28540,7 +28556,7 @@ snapshots:
dependencies:
'@jest/types': 29.6.3
'@types/graceful-fs': 4.1.9
'@types/node': 20.17.6
'@types/node': 22.15.18
anymatch: 3.1.3
fb-watchman: 2.0.2
graceful-fs: 4.2.11
@ -28567,7 +28583,7 @@ snapshots:
jest-mock@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 20.17.6
'@types/node': 22.15.18
jest-util: 29.7.0
jest-regex-util@29.6.3: {}
@ -28575,7 +28591,7 @@ snapshots:
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 20.17.6
'@types/node': 22.15.18
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
@ -28598,7 +28614,7 @@ snapshots:
jest-worker@29.7.0:
dependencies:
'@types/node': 20.17.6
'@types/node': 22.15.18
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
@ -30982,7 +30998,9 @@ snapshots:
dependencies:
xtend: 4.0.2
posthog-js@1.235.4:
postgres@3.4.5: {}
posthog-js@1.255.1:
dependencies:
core-js: 3.41.0
fflate: 0.4.8
@ -33927,13 +33945,13 @@ snapshots:
replace-ext: 2.0.0
teex: 1.0.1
vite-node@1.6.0(@types/node@20.17.6)(lightningcss@1.30.1)(terser@5.31.1):
vite-node@1.6.0(@types/node@20.17.6)(lightningcss@1.30.1)(terser@5.43.1):
dependencies:
cac: 6.7.14
debug: 4.4.0
pathe: 1.1.2
picocolors: 1.1.1
vite: 5.4.14(@types/node@20.17.6)(lightningcss@1.30.1)(terser@5.31.1)
vite: 5.4.14(@types/node@20.17.6)(lightningcss@1.30.1)(terser@5.43.1)
transitivePeerDependencies:
- '@types/node'
- less
@ -33945,18 +33963,18 @@ snapshots:
- supports-color
- terser
vite-tsconfig-paths@4.3.2(typescript@5.3.3)(vite@6.1.0(@types/node@20.17.6)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.31.1)(tsx@4.19.3)(yaml@2.6.0)):
vite-tsconfig-paths@4.3.2(typescript@5.3.3)(vite@6.1.0(@types/node@20.17.6)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.19.3)(yaml@2.6.0)):
dependencies:
debug: 4.4.0
globrex: 0.1.2
tsconfck: 3.1.5(typescript@5.3.3)
optionalDependencies:
vite: 6.1.0(@types/node@20.17.6)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.31.1)(tsx@4.19.3)(yaml@2.6.0)
vite: 6.1.0(@types/node@20.17.6)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.19.3)(yaml@2.6.0)
transitivePeerDependencies:
- supports-color
- typescript
vite@5.4.14(@types/node@20.17.6)(lightningcss@1.30.1)(terser@5.31.1):
vite@5.4.14(@types/node@20.17.6)(lightningcss@1.30.1)(terser@5.43.1):
dependencies:
esbuild: 0.21.5
postcss: 8.5.3
@ -33965,9 +33983,9 @@ snapshots:
'@types/node': 20.17.6
fsevents: 2.3.3
lightningcss: 1.30.1
terser: 5.31.1
terser: 5.43.1
vite@6.1.0(@types/node@20.17.6)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.31.1)(tsx@4.19.3)(yaml@2.6.0):
vite@6.1.0(@types/node@20.17.6)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.15.5)(yaml@2.4.5):
dependencies:
esbuild: 0.24.2
postcss: 8.5.3
@ -33977,11 +33995,25 @@ snapshots:
fsevents: 2.3.3
jiti: 2.4.2
lightningcss: 1.30.1
terser: 5.31.1
terser: 5.43.1
tsx: 4.15.5
yaml: 2.4.5
vite@6.1.0(@types/node@20.17.6)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.19.3)(yaml@2.6.0):
dependencies:
esbuild: 0.24.2
postcss: 8.5.3
rollup: 4.34.8
optionalDependencies:
'@types/node': 20.17.6
fsevents: 2.3.3
jiti: 2.4.2
lightningcss: 1.30.1
terser: 5.43.1
tsx: 4.19.3
yaml: 2.6.0
vite@6.1.0(@types/node@22.15.34)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.31.1)(tsx@4.19.3)(yaml@2.6.0):
vite@6.1.0(@types/node@22.15.34)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.19.3)(yaml@2.6.0):
dependencies:
esbuild: 0.24.2
postcss: 8.5.3
@ -33991,11 +34023,11 @@ snapshots:
fsevents: 2.3.3
jiti: 2.4.2
lightningcss: 1.30.1
terser: 5.31.1
terser: 5.43.1
tsx: 4.19.3
yaml: 2.6.0
vitest@1.6.0(@types/node@20.17.6)(jsdom@24.1.3)(lightningcss@1.30.1)(terser@5.31.1):
vitest@1.6.0(@types/node@20.17.6)(jsdom@24.1.3)(lightningcss@1.30.1)(terser@5.43.1):
dependencies:
'@vitest/expect': 1.6.0
'@vitest/runner': 1.6.0
@ -34014,8 +34046,8 @@ snapshots:
strip-literal: 2.1.0
tinybench: 2.9.0
tinypool: 0.8.4
vite: 5.4.14(@types/node@20.17.6)(lightningcss@1.30.1)(terser@5.31.1)
vite-node: 1.6.0(@types/node@20.17.6)(lightningcss@1.30.1)(terser@5.31.1)
vite: 5.4.14(@types/node@20.17.6)(lightningcss@1.30.1)(terser@5.43.1)
vite-node: 1.6.0(@types/node@20.17.6)(lightningcss@1.30.1)(terser@5.43.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.17.6

View File

@ -94,9 +94,6 @@
"codegen": {
"cache": false
},
"prisma": {
"cache": false
},
"typecheck": {
"dependsOn": []
},
@ -105,6 +102,21 @@
},
"generate-keys": {
"cache": false
},
"db:migration-gen": {
"cache": false
},
"db:reset": {
"cache": false
},
"db:seed": {
"cache": false
},
"db:init": {
"cache": false
},
"db:migrate": {
"cache": false
}
}
}