-
Notifications
You must be signed in to change notification settings - Fork 9.6k
feat: prisma DB multi-tenancy (cal.eu) #21364
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 80 commits
ef098b3
824a57a
da77ede
f01f5b1
2f057c5
9844789
802e9cb
0f2c8bb
1b7f885
9a2568d
383bd7c
f054bb6
6670fd6
4f6d3c0
cfc5be8
ca4cce0
0b4de20
1e6a310
089827b
a4f7c49
e6b5563
06bf7ca
c3d58d4
c3986a1
91caf39
aad4953
2246eca
2a9e64b
2479620
fb5e84f
05765fb
97be3a9
3c36d55
857ba7c
0e269cd
2c0b225
1039991
6c5c634
e280e91
3b4c7ad
cc53f78
05613c4
39db512
4268733
bf9b758
7449134
e4cd320
c7f00b0
7a44c43
9659e55
e421c27
eeb70fc
35a8c83
a519499
d10e2af
a89bb2e
15ccdcb
c63c429
e4370ca
99cb078
083f341
b580e90
6b5db9f
f30e002
7e1157c
4d50582
8bec6ea
33ebff9
c99e961
ecf6e28
582eb57
277c54d
f5a3f94
57f48a3
17b8617
cd3ac7f
6936208
3fd62c5
12c52b8
2c9d12f
440e41e
bdfacd0
75ec19c
14b168d
e6ff029
9c7cd50
5eae7d6
7a20dc4
19cd8c3
1d70c2a
cde530d
d23e970
722c64b
8b36fb6
902af2f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
import { Injectable, Scope } from "@nestjs/common"; | ||
import { Inject } from "@nestjs/common"; | ||
import { ConfigService } from "@nestjs/config"; | ||
import { REQUEST } from "@nestjs/core"; | ||
import { PrismaClient } from "@prisma/client"; | ||
|
||
import { getTenantFromHost } from "@calcom/prisma/store/tenants"; | ||
|
||
@Injectable({ scope: Scope.REQUEST }) | ||
export class TenantAwarePrismaService { | ||
public prisma: PrismaClient; | ||
|
||
constructor( | ||
@Inject(REQUEST) private readonly request: Request, | ||
private readonly configService: ConfigService | ||
) { | ||
const host = this.request.headers["host"] || ""; | ||
const tenant = getTenantFromHost(host); | ||
|
||
let dbUrl = this.configService.get("db.url", { infer: true }); | ||
if (tenant === "eu") { | ||
dbUrl = this.configService.get("db.euUrl", { infer: true }); | ||
} | ||
|
||
this.prisma = new PrismaClient({ | ||
datasources: { | ||
db: { | ||
url: dbUrl, | ||
}, | ||
}, | ||
}); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
import { handler } from "@calcom/features/calendar-cache/api/cron"; | ||
import { withMultiTenantPrisma } from "@calcom/prisma/store/withMultiTenantPrisma"; | ||
|
||
import { defaultResponderForAppDir } from "../../defaultResponderForAppDir"; | ||
|
||
export const GET = withMultiTenantPrisma(defaultResponderForAppDir(handler)); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,6 @@ | ||
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; | ||
|
||
import { GET as handler } from "@calcom/features/tasker/api/cleanup"; | ||
import { withMultiTenantPrisma } from "@calcom/prisma/store/withMultiTenantPrisma"; | ||
|
||
export const GET = defaultResponderForAppDir(handler); | ||
export const GET = withMultiTenantPrisma(defaultResponderForAppDir(handler)); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import { NextResponse } from "next/server"; | ||
|
||
import { prisma } from "@calcom/prisma"; | ||
import { withPrismaRoute } from "@calcom/prisma/store/withPrismaRoute"; | ||
|
||
async function handler(req: Request) { | ||
const users = await prisma.user.findMany({ | ||
select: { id: true, name: true, email: true }, | ||
take: 5, | ||
}); | ||
|
||
return NextResponse.json({ | ||
tenant: req.headers.get("host") || "unknown", | ||
users, | ||
}); | ||
} | ||
|
||
export const GET = withPrismaRoute(handler); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
import { NextResponse } from "next/server"; | ||
|
||
import { prisma } from "@calcom/prisma"; | ||
import { withPrismaRoute } from "@calcom/prisma/store/withPrismaRoute"; | ||
|
||
export const GET = withPrismaRoute(async (req) => { | ||
const user = await prisma.user.findFirst({ where: { id: 1 }, select: { id: true, name: true } }); | ||
return NextResponse.json({ user }); | ||
}); |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same for pages |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import type { PrismaClient } from "@prisma/client"; | ||
|
||
import { withPrismaPage } from "@calcom/prisma/store/withPrismaPage"; | ||
|
||
interface HomePageProps { | ||
prisma: PrismaClient; | ||
host: string; // host can be used if needed, e.g. for display | ||
} | ||
|
||
// This is the actual page component logic, now cleaner. | ||
async function HomePageContent({ prisma, host }: HomePageProps) { | ||
const users = await prisma.user.findMany({ where: { id: 1 }, select: { id: true, name: true } }); | ||
|
||
return ( | ||
<div> | ||
<h1>Users for tenant ({host})</h1> | ||
<ul> | ||
{users.map((user) => ( | ||
<li key={user.id}>{user.name}</li> | ||
))} | ||
</ul> | ||
</div> | ||
); | ||
} | ||
|
||
// Wrap the page content component with the HOC | ||
const Home = withPrismaPage(HomePageContent); | ||
|
||
export default Home; |
This file was deleted.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This got migrated from pages to app dir. It was the only cron remaining there. And I didn't want to make a specific helper for pages. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,21 +1,11 @@ | ||
import type { Prisma } from "@prisma/client"; | ||
import { PrismaClient as PrismaClientWithoutExtension } from "@prisma/client"; | ||
import { withAccelerate } from "@prisma/extension-accelerate"; | ||
|
||
import { bookingIdempotencyKeyExtension } from "./extensions/booking-idempotency-key"; | ||
import { disallowUndefinedDeleteUpdateManyExtension } from "./extensions/disallow-undefined-delete-update-many"; | ||
import { excludeLockedUsersExtension } from "./extensions/exclude-locked-users"; | ||
import { excludePendingPaymentsExtension } from "./extensions/exclude-pending-payment-teams"; | ||
import { usageTrackingExtention } from "./extensions/usage-tracking"; | ||
import { bookingReferenceMiddleware } from "./middleware"; | ||
import type { PrismaClientWithExtensions } from "./store/prismaStore"; | ||
import { getPrisma, getTenantAwarePrisma } from "./store/prismaStore"; | ||
import { Tenant } from "./store/tenants"; | ||
|
||
const prismaOptions: Prisma.PrismaClientOptions = {}; | ||
|
||
const globalForPrisma = global as unknown as { | ||
prismaWithoutClientExtensions: PrismaClientWithoutExtension; | ||
prismaWithClientExtensions: PrismaClientWithExtensions; | ||
}; | ||
|
||
const loggerLevel = parseInt(process.env.NEXT_PUBLIC_LOGGER_LEVEL ?? "", 10); | ||
|
||
if (!isNaN(loggerLevel)) { | ||
|
@@ -37,49 +27,33 @@ if (!isNaN(loggerLevel)) { | |
} | ||
} | ||
|
||
// Prevents flooding with idle connections | ||
const prismaWithoutClientExtensions = | ||
globalForPrisma.prismaWithoutClientExtensions || new PrismaClientWithoutExtension(prismaOptions); | ||
|
||
export const customPrisma = (options?: Prisma.PrismaClientOptions) => | ||
new PrismaClientWithoutExtension({ ...prismaOptions, ...options }) | ||
.$extends(usageTrackingExtention()) | ||
.$extends(excludeLockedUsersExtension()) | ||
.$extends(excludePendingPaymentsExtension()) | ||
.$extends(bookingIdempotencyKeyExtension()) | ||
.$extends(disallowUndefinedDeleteUpdateManyExtension()) | ||
.$extends(withAccelerate()); | ||
|
||
// If any changed on middleware server restart is required | ||
// TODO: Migrate it to $extends | ||
bookingReferenceMiddleware(prismaWithoutClientExtensions); | ||
|
||
// FIXME: Due to some reason, there are types failing in certain places due to the $extends. Fix it and then enable it | ||
// Specifically we get errors like `Type 'string | Date | null | undefined' is not assignable to type 'Exact<string | Date | null | undefined, string | Date | null | undefined>'` | ||
const prismaWithClientExtensions = prismaWithoutClientExtensions | ||
.$extends(usageTrackingExtention()) | ||
.$extends(excludeLockedUsersExtension()) | ||
.$extends(excludePendingPaymentsExtension()) | ||
.$extends(bookingIdempotencyKeyExtension()) | ||
.$extends(disallowUndefinedDeleteUpdateManyExtension()) | ||
.$extends(withAccelerate()); | ||
|
||
export const prisma = globalForPrisma.prismaWithClientExtensions || prismaWithClientExtensions; | ||
export const prisma = new Proxy({} as PrismaClientWithExtensions, { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've opted for the proxy approach so we can keep using the regular |
||
get(target, prop) { | ||
if (process.env.NODE_ENV === "test") { | ||
const defaultPrisma = getPrisma(Tenant.US, prismaOptions); | ||
return Reflect.get(defaultPrisma, prop); | ||
} | ||
Comment on lines
+32
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So this doesn't break unit tests. E2E test should run normally tho. |
||
|
||
try { | ||
const tenantPrisma = getTenantAwarePrisma(prismaOptions); | ||
return Reflect.get(tenantPrisma, prop); | ||
} catch (error) { | ||
throw new Error( | ||
"Prisma was called outside of runWithTenants. Please wrap your code with runWithTenants or use a tenant-aware approach." | ||
); | ||
} | ||
}, | ||
}); | ||
|
||
// This prisma instance is meant to be used only for READ operations. | ||
// If self hosting, feel free to leave INSIGHTS_DATABASE_URL as empty and `readonlyPrisma` will default to `prisma`. | ||
export const readonlyPrisma = process.env.INSIGHTS_DATABASE_URL | ||
? customPrisma({ | ||
datasources: { db: { url: process.env.INSIGHTS_DATABASE_URL } }, | ||
}) | ||
? getPrisma(Tenant.INSIGHTS, prismaOptions) | ||
: prisma; | ||
|
||
if (process.env.NODE_ENV !== "production") { | ||
globalForPrisma.prismaWithoutClientExtensions = prismaWithoutClientExtensions; | ||
globalForPrisma.prismaWithClientExtensions = prisma; | ||
} | ||
|
||
type PrismaClientWithExtensions = typeof prismaWithClientExtensions; | ||
export type PrismaClient = PrismaClientWithExtensions; | ||
|
||
type OmitPrismaClient = Omit< | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've tried to cover the main entrypoints with re-usable wrappers.