Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3922e4b
Port database layer to PostgreSQL
jkomyno May 4, 2026
ec430ae
Add Prisma Next hybrid contract validation
jkomyno May 5, 2026
982c5f8
Add Prisma Next runtime SQL comparison tools
jkomyno May 5, 2026
6ec9580
Document Prisma Next null optional relation query issue
jkomyno May 5, 2026
1849348
Add more Prisma Next runtime module comparisons
jkomyno May 5, 2026
52602ac
Add workspace and folder Prisma Next comparisons
jkomyno May 5, 2026
9066be7
Add integration tag token webhook and domain comparisons
jkomyno May 5, 2026
6060211
Add partner program and customer comparisons
jkomyno May 5, 2026
f1b3f88
Add write-focused Prisma Next comparisons
jkomyno May 5, 2026
306281c
Add edge and analytics Prisma Next comparisons
jkomyno May 5, 2026
c1ea760
Add usage counter Prisma Next comparisons
jkomyno May 5, 2026
ff47ad0
Add program network Prisma Next comparisons
jkomyno May 5, 2026
bd6dd24
Add commissions and payouts Prisma Next comparisons
jkomyno May 5, 2026
49c3406
Add notification email Prisma Next comparisons
jkomyno May 5, 2026
fbcfa93
Add postback Prisma Next comparisons
jkomyno May 5, 2026
c725229
Add program application Prisma Next comparisons
jkomyno May 5, 2026
dff27be
Add bounty Prisma Next comparisons
jkomyno May 5, 2026
8294ad9
Exclude comparison harness from raw SQL inventory
jkomyno May 5, 2026
b8c5cba
Expand program network Prisma Next comparisons
jkomyno May 5, 2026
14d32c4
Add partner group Prisma Next comparisons
jkomyno May 5, 2026
b529e3c
Update Prisma Next hybrid contract
jkomyno May 5, 2026
a4a309f
Use clean Prisma Next idless models build
jkomyno May 5, 2026
42ccb5a
chore(prisma): refresh prisma next updatedAt build
jkomyno May 5, 2026
37374ea
docs(prisma-next): document atomic write expectations
jkomyno May 5, 2026
964ad0b
docs(prisma-next): document write transaction expectations
jkomyno May 5, 2026
3c9530b
docs(prisma-next): reframe atomic operations around ADR 180
jkomyno May 6, 2026
346c4f2
chore(prisma-next): refresh temporal updatedAt build
jkomyno May 7, 2026
70feef3
chore(prisma-next): refresh local origin main tarballs
jkomyno May 13, 2026
eadb18a
fix(prisma-next): align comparison harness with current runtime
jkomyno May 13, 2026
75725e3
docs(prisma-next): record latest origin main comparison
jkomyno May 13, 2026
146ab6b
Merge origin/main into prisma-next port
jkomyno May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: "pnpm"

- name: Install dependencies
Expand Down
34 changes: 13 additions & 21 deletions .github/workflows/playwright.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ jobs:
env:
CI: "true"
NODE_OPTIONS: "--max-old-space-size=8192"
DATABASE_URL: "mysql://root:@localhost:3306/planetscale"
PLANETSCALE_DATABASE_URL: "http://root:unused@localhost:3900/planetscale"
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/dub?schema=public"

NEXTAUTH_SECRET: "e2e-test-secret-at-least-32-chars-long"
NEXTAUTH_URL: "http://partners.localhost:8888"
Expand Down Expand Up @@ -70,19 +69,19 @@ jobs:
STRIPE_APP_SECRET_KEY: "xx"

services:
mysql:
image: mysql:8.0
postgres:
image: postgres:16
env:
MYSQL_DATABASE: planetscale
MYSQL_ROOT_HOST: "%"
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
POSTGRES_DB: dub
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1"
--health-cmd="pg_isready -U postgres -d dub"
--health-interval=10s
--health-timeout=5s
--health-retries=5
ports:
- 3306:3306
- 5432:5432

mailhog:
image: mailhog/mailhog:latest
Expand Down Expand Up @@ -113,24 +112,13 @@ jobs:
- name: Check out code
uses: actions/checkout@v4

- name: Start PlanetScale simulator
run: |
docker run -d --name ps-http-sim \
--network host \
ghcr.io/mattrobenolt/ps-http-sim:latest \
-listen-addr=0.0.0.0 \
-listen-port=3900 \
-mysql-dbname=planetscale \
-mysql-no-pass \
-mysql-addr=127.0.0.1

- name: Setup pnpm
uses: pnpm/action-setup@v3

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 24
cache: "pnpm"

- name: Install dependencies
Expand All @@ -155,6 +143,10 @@ jobs:
working-directory: apps/web
run: pnpm prisma:push

- name: Apply PostgreSQL postdeploy SQL
working-directory: packages/prisma
run: pnpm postdeploy:postgres

- name: Seed test data
working-directory: apps/web
run: pnpm tsx playwright/seed.ts
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ dist/
# typescript
*.tsbuildinfo
next-env.d.ts
packages/prisma/.tmp/

# miscellaneous
apps/web/scripts/fix.ts
Expand All @@ -50,4 +51,4 @@ packages/stripe-app/.build/*
playwright-report/
**/playwright/.auth/
test-results/
blob-report/
blob-report/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Our platform powers 100M+ clicks and 2M+ links monthly, and is used by world-cla
- [Prisma](https://www.prisma.io/) – ORM
- [Upstash](https://upstash.com/) – redis
- [Tinybird](https://tinybird.com/) – analytics
- [PlanetScale](https://planetscale.com/) – database
- [PostgreSQL](https://www.postgresql.org/) – database
- [NextAuth.js](https://next-auth.js.org/) – auth
- [BoxyHQ](https://boxyhq.com/enterprise-sso) – SSO/SAML
- [Turborepo](https://turbo.build/repo) – monorepo
Expand Down
12 changes: 4 additions & 8 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,10 @@ ENCRYPTION_KEY=
# Email unsubscribe token secret (optional, falls back to NEXTAUTH_SECRET)
UNSUBSCRIBE_TOKEN_SECRET=

# MySQL Database via Planetscale
# Get your MySQL Database URL here: https://planetscale.com/docs/tutorials/connect-nodejs-app
DATABASE_URL="mysql://root:@localhost:3306/planetscale"
# Set local development documentation for connecting to a local database: https://dub.co/docs/local-development#step-4-set-up-planetscale-mysql-database
PLANETSCALE_DATABASE_URL="http://root:unused@localhost:3900/planetscale"
# PostgreSQL Database
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/dub?schema=public"
# Optional webhook to trigger an external PostgreSQL backup job from the backup cron route
POSTGRES_BACKUP_WEBHOOK_URL=

# Upstash Redis – required for Redis caching
# Get your Redis REST URL and Token here: https://upstash.com/docs/redis/overall/getstarted
Expand Down Expand Up @@ -172,9 +171,6 @@ PAYPAL_WEBHOOK_ID=
# Program lander generation
FIRECRAWL_API_KEY=

# for generating planetscale backups (https://planetscale.com/docs/api/service-tokens)
PLANETSCALE_SERVICE_TOKEN=

# Hubspot
HUBSPOT_CLIENT_ID=
HUBSPOT_CLIENT_SECRET=
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { sqlGranularityMap } from "@/lib/planetscale/granularity";
import {
pgDateBucket,
sqlGranularityMap,
} from "@/lib/postgres/granularity";
import { TZDate } from "@date-fns/tz";
import { prisma } from "@dub/prisma";
import { Prisma } from "@dub/prisma/client";
Expand Down Expand Up @@ -27,15 +30,15 @@ export async function getCommissionsTimeseries({
sqlGranularityMap[granularity];

const commissions = await prisma.$queryRaw<Commission[]>`
SELECT
DATE_FORMAT(CONVERT_TZ(createdAt, "UTC", ${timezone || "UTC"}), ${dateFormat}) AS start,
SELECT
${pgDateBucket({ column: Prisma.sql`"createdAt"`, timezone: timezone || "UTC", dateFormat })} AS start,
SUM(earnings) AS commissions
FROM Commission
WHERE
createdAt >= ${startDate}
AND createdAt < ${endDate}
AND status IN ("pending", "processed", "paid")
AND ${programId ? Prisma.sql`programId = ${programId}` : Prisma.sql`programId != ${ACME_PROGRAM_ID}`}
FROM "Commission"
WHERE
"createdAt" >= ${startDate}
AND "createdAt" < ${endDate}
AND status IN ('pending', 'processed', 'paid')
AND ${programId ? Prisma.sql`"programId" = ${programId}` : Prisma.sql`"programId" != ${ACME_PROGRAM_ID}`}
GROUP BY start
ORDER BY start ASC;`;

Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/(ee)/api/admin/partners/delete-account/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { withAdmin } from "@/lib/auth";
import { conn } from "@/lib/planetscale";
import { conn } from "@/lib/postgres";
import { stripe } from "@/lib/stripe";
import { recordLink } from "@/lib/tinybird";
import { prisma } from "@dub/prisma";
Expand Down Expand Up @@ -142,7 +142,7 @@ export const POST = withAdmin(
);
}

await conn.execute(`DELETE FROM Partner WHERE id = ?`, [partner.id]);
await conn.execute(`DELETE FROM "Partner" WHERE id = ?`, [partner.id]);
console.log(`Deleted partner ${partner.email} (${partner.id})`);
}

Expand Down
17 changes: 10 additions & 7 deletions apps/web/app/(ee)/api/admin/payouts/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates";
import { withAdmin } from "@/lib/auth";
import { sqlGranularityMap } from "@/lib/planetscale/granularity";
import {
pgDateBucket,
sqlGranularityMap,
} from "@/lib/postgres/granularity";
import { analyticsQuerySchema } from "@/lib/zod/schemas/analytics";
import { prisma } from "@dub/prisma";
import { InvoiceStatus, Prisma } from "@dub/prisma/client";
Expand Down Expand Up @@ -93,17 +96,17 @@ export const GET = withAdmin(async ({ searchParams }) => {
{ date: Date; payouts: number; fees: number; total: number }[]
>`
SELECT
DATE_FORMAT(CONVERT_TZ(createdAt, "UTC", ${timezone}), ${dateFormat}) as date,
${pgDateBucket({ column: Prisma.sql`"createdAt"`, timezone, dateFormat })} as date,
SUM(amount) as payouts,
SUM(fee) as fees,
SUM(total) as total
FROM Invoice
FROM "Invoice"
WHERE
${programId ? Prisma.sql`programId = ${programId}` : Prisma.sql`programId != ${ACME_PROGRAM_ID}`}
${programId ? Prisma.sql`"programId" = ${programId}` : Prisma.sql`"programId" != ${ACME_PROGRAM_ID}`}
AND ${status ? Prisma.sql`status = ${status}` : Prisma.sql`status != 'failed'`}
AND createdAt >= ${startDate}
AND createdAt <= ${endDate}
GROUP BY DATE_FORMAT(CONVERT_TZ(createdAt, "UTC", ${timezone}), ${dateFormat})
AND "createdAt" >= ${startDate}
AND "createdAt" <= ${endDate}
GROUP BY date
ORDER BY date ASC;
`;

Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/(ee)/api/appsflyer/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const GET = withAxiom(async (req) => {
where: {
integrationId: APPSFLYER_INTEGRATION_ID,
settings: {
path: "$.appIds",
path: ["appIds"],
array_contains: appId,
},
},
Expand Down
39 changes: 21 additions & 18 deletions apps/web/app/(ee)/api/commissions/analytics/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import {
commissionAnalyticsQuerySchema,
commissionAnalyticsSchema,
} from "@/lib/commissions/schema";
import { sqlGranularityMap } from "@/lib/planetscale/granularity";
import {
pgDateBucket,
sqlGranularityMap,
} from "@/lib/postgres/granularity";
import type { CommissionAnalyticsQuery } from "@/lib/types";
import { prisma } from "@dub/prisma";
import { CommissionStatus, CommissionType, Prisma } from "@dub/prisma/client";
Expand Down Expand Up @@ -59,9 +62,9 @@ function commissionSqlConditions({
partnerTagIdParam: string | undefined;
}): Prisma.Sql[] {
const conditions: Prisma.Sql[] = [
Prisma.sql`c.programId = ${programId}`,
Prisma.sql`c.createdAt >= ${startDate}`,
Prisma.sql`c.createdAt < ${endDate}`,
Prisma.sql`c."programId" = ${programId}`,
Prisma.sql`c."createdAt" >= ${startDate}`,
Prisma.sql`c."createdAt" < ${endDate}`,
status
? Prisma.sql`c.status = ${status}`
: Prisma.sql`c.status NOT IN (${Prisma.join([...excludedStatuses])})`,
Expand All @@ -71,8 +74,8 @@ function commissionSqlConditions({
const list = Prisma.join(partnerFilter.values.map((v) => Prisma.sql`${v}`));
conditions.push(
partnerFilter.sqlOperator === "NOT IN"
? Prisma.sql`c.partnerId NOT IN (${list})`
: Prisma.sql`c.partnerId IN (${list})`,
? Prisma.sql`c."partnerId" NOT IN (${list})`
: Prisma.sql`c."partnerId" IN (${list})`,
);
}

Expand All @@ -94,10 +97,10 @@ function commissionSqlConditions({
? Prisma.sql`NOT IN`
: Prisma.sql`IN`;
conditions.push(Prisma.sql`EXISTS (
SELECT 1 FROM ProgramEnrollment pe
WHERE pe.programId = c.programId
AND pe.partnerId = c.partnerId
AND pe.groupId ${op} (${list})
SELECT 1 FROM "ProgramEnrollment" pe
WHERE pe."programId" = c."programId"
AND pe."partnerId" = c."partnerId"
AND pe."groupId" ${op} (${list})
)`);
}
}
Expand Down Expand Up @@ -312,15 +315,15 @@ async function byGroupId({
const rows = await prisma.$queryRaw<CommissionGroupIdQueryRow[]>(
Prisma.sql`
SELECT
pe.groupId AS groupId,
pe."groupId" AS "groupId",
SUM(c.earnings) AS earnings,
COUNT(c.id) AS count
FROM Commission c
JOIN ProgramEnrollment pe
ON pe.programId = c.programId
AND pe.partnerId = c.partnerId
FROM "Commission" c
JOIN "ProgramEnrollment" pe
ON pe."programId" = c."programId"
AND pe."partnerId" = c."partnerId"
WHERE ${whereClause}
GROUP BY pe.groupId
GROUP BY pe."groupId"
ORDER BY earnings DESC`,
);

Expand Down Expand Up @@ -631,10 +634,10 @@ async function byTimeseries({
const rows = await prisma.$queryRaw<CommissionTimeseriesRow[]>(
Prisma.sql`
SELECT
DATE_FORMAT(CONVERT_TZ(c.createdAt, "UTC", ${timezone || "UTC"}), ${dateFormat}) AS start,
${pgDateBucket({ column: Prisma.sql`c."createdAt"`, timezone: timezone || "UTC", dateFormat })} AS start,
SUM(c.earnings) AS earnings,
COUNT(c.id) AS count
FROM Commission c
FROM "Commission" c
WHERE ${whereClause}
GROUP BY start
ORDER BY start ASC`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const GET = withCron(async () => {
},
],
submissionRequirements: {
path: "$.socialMetrics",
path: ["socialMetrics"],
not: Prisma.JsonNull,
},
},
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/(ee)/api/cron/domains/delete/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export async function POST(req: Request) {
),
),

// Remove the link from MySQL
// Remove the link from Postgres
prisma.link.deleteMany({
where: {
id: { in: links.map((link) => link.id) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ export async function calculatePartnerSimilarity(
): Promise<number> {
const [result] = await prisma.$queryRaw<PartnerSimilarityResult[]>`
SELECT
COUNT(DISTINCT CASE WHEN e1.partnerId IS NOT NULL AND e2.partnerId IS NOT NULL THEN e1.partnerId END) AS sharedPartnersCount,
(SELECT COUNT(*) FROM ProgramEnrollment WHERE programId = ${program1Id}) AS program1PartnersCount,
(SELECT COUNT(*) FROM ProgramEnrollment WHERE programId = ${program2Id}) AS program2PartnersCount
COUNT(DISTINCT CASE WHEN e1."partnerId" IS NOT NULL AND e2."partnerId" IS NOT NULL THEN e1."partnerId" END) AS "sharedPartnersCount",
(SELECT COUNT(*) FROM "ProgramEnrollment" WHERE "programId" = ${program1Id}) AS "program1PartnersCount",
(SELECT COUNT(*) FROM "ProgramEnrollment" WHERE "programId" = ${program2Id}) AS "program2PartnersCount"
FROM
ProgramEnrollment e1
"ProgramEnrollment" e1
JOIN
ProgramEnrollment e2 ON e1.partnerId = e2.partnerId
"ProgramEnrollment" e2 ON e1."partnerId" = e2."partnerId"
WHERE
e1.programId = ${program1Id} AND e2.programId = ${program2Id}
e1."programId" = ${program1Id} AND e2."programId" = ${program2Id}
`;

const { sharedPartnersCount, program1PartnersCount, program2PartnersCount } =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ export const POST = withCron(async ({ rawBody }) => {
const _previousMonthAnalytics = previousAnalyticsMap.get(partner.id);
const _currentMonthAnalytics = currentAnalyticsMap.get(partner.id);

// Get lifetime analytics from MySQL
// Get lifetime analytics from Postgres
const _lifetimeAnalytics = programEnrollments
.find((enrollment) => enrollment.partner.id === partner.id)
?.links.reduce(
Expand All @@ -253,7 +253,7 @@ export const POST = withCron(async ({ rawBody }) => {
{ clicks: 0, leads: 0, sales: 0 },
);

// Get earnings data from MySQL
// Get earnings data from Postgres
const _previousMonthEarnings = previousEarningsMap.get(partner.id);
const _currentMonthEarnings = currentEarningsMap.get(partner.id);
const _lifetimeEarnings = lifetimeEarningsMap.get(partner.id);
Expand Down
6 changes: 4 additions & 2 deletions apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { includeProgramEnrollment } from "@/lib/api/links/include-program-enroll
import { includeTags } from "@/lib/api/links/include-tags";
import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
import { conn } from "@/lib/planetscale";
import { conn } from "@/lib/postgres";
import { storage } from "@/lib/storage";
import { recordLink } from "@/lib/tinybird";
import { redis } from "@/lib/upstash";
Expand Down Expand Up @@ -420,7 +420,9 @@ export async function POST(req: Request) {

try {
// Finally, delete the partner account
await conn.execute(`DELETE FROM Partner WHERE id = ?`, [sourcePartnerId]);
await conn.execute(`DELETE FROM "Partner" WHERE id = ?`, [
sourcePartnerId,
]);
console.log(
`Deleted partner ${sourceAccount.email} (${sourceAccount.id})`,
);
Expand Down
Loading