From 554012890e8a521f515c3d5028bd76f9c446fc4f Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Sun, 11 Jan 2026 13:21:05 -0800 Subject: [PATCH 1/8] Support async replicas --- apps/backend/.env.development | 2 +- apps/dev-launchpad/public/index.html | 1 + docker/dependencies/docker.compose.yaml | 19 ++++++ docker/dev-postgres-replica/Dockerfile | 7 +++ docker/dev-postgres-replica/entrypoint.sh | 60 +++++++++++++++++++ .../dev-postgres-with-extensions/Dockerfile | 19 +++++- 6 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 docker/dev-postgres-replica/Dockerfile create mode 100644 docker/dev-postgres-replica/entrypoint.sh diff --git a/apps/backend/.env.development b/apps/backend/.env.development index 9a6cbf4746..99be7d1304 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -27,7 +27,7 @@ STACK_SPOTIFY_CLIENT_SECRET=MOCK STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS=true STACK_DATABASE_CONNECTION_STRING=postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}28/stackframe -STACK_DATABASE_REPLICA_CONNECTION_STRING=postgres://readonly:PASSWORD-PLACEHOLDER--readonlyuqfEC1hmmv@localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}28/stackframe +STACK_DATABASE_REPLICA_CONNECTION_STRING=postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}34/stackframe STACK_EMAIL_HOST=127.0.0.1 STACK_EMAIL_PORT=${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}29 diff --git a/apps/dev-launchpad/public/index.html b/apps/dev-launchpad/public/index.html index f6b50db54c..23af76c5fb 100644 --- a/apps/dev-launchpad/public/index.html +++ b/apps/dev-launchpad/public/index.html @@ -122,6 +122,7 @@

Background services

const backgroundServices = [ { suffix: "28", label: "PostgreSQL" }, + { suffix: "34", label: "PostgreSQL Replica (100ms lag)" }, { suffix: "29", label: "Inbucket SMTP" }, { suffix: "30", label: "Inbucket POP3" }, { suffix: "31", label: "OTel collector" }, diff --git a/docker/dependencies/docker.compose.yaml b/docker/dependencies/docker.compose.yaml index 3764c78b9b..00e931ada8 100644 --- a/docker/dependencies/docker.compose.yaml +++ b/docker/dependencies/docker.compose.yaml @@ -19,6 +19,24 @@ services: cap_add: - NET_ADMIN # required for the fake latency during dev + # ================= PostgreSQL Replica (with 100ms lag) ================= + + db-replica: + build: ../dev-postgres-replica + environment: + PGDATA: /var/lib/postgresql/data + PRIMARY_HOST: db + PRIMARY_PORT: 5432 + REPLICATOR_USER: replicator + REPLICATOR_PASSWORD: PASSWORD-PLACEHOLDER--replicatorpass + RECOVERY_MIN_APPLY_DELAY: ${REPLICA_LAG_MS:-100}ms + ports: + - "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}34:5432" + volumes: + - postgres-replica-data:/var/lib/postgresql/data + depends_on: + - db + # ================= PgHero ================= pghero: @@ -224,6 +242,7 @@ services: volumes: postgres-data: + postgres-replica-data: inbucket-data: svix-redis-data: svix-postgres-data: diff --git a/docker/dev-postgres-replica/Dockerfile b/docker/dev-postgres-replica/Dockerfile new file mode 100644 index 0000000000..4ed8808686 --- /dev/null +++ b/docker/dev-postgres-replica/Dockerfile @@ -0,0 +1,7 @@ +FROM postgres:15 + +# Copy the entrypoint script +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/dev-postgres-replica/entrypoint.sh b/docker/dev-postgres-replica/entrypoint.sh new file mode 100644 index 0000000000..748c2ccd0c --- /dev/null +++ b/docker/dev-postgres-replica/entrypoint.sh @@ -0,0 +1,60 @@ +#!/bin/bash +set -e + +# Configuration from environment variables +PRIMARY_HOST="${PRIMARY_HOST:-db}" +PRIMARY_PORT="${PRIMARY_PORT:-5432}" +REPLICATOR_USER="${REPLICATOR_USER:-replicator}" +REPLICATOR_PASSWORD="${REPLICATOR_PASSWORD:-PASSWORD-PLACEHOLDER--replicatorpass}" +RECOVERY_MIN_APPLY_DELAY="${RECOVERY_MIN_APPLY_DELAY:-100ms}" + +echo "Starting PostgreSQL replica with ${RECOVERY_MIN_APPLY_DELAY} apply delay..." + +# Wait for primary to be ready +echo "Waiting for primary at ${PRIMARY_HOST}:${PRIMARY_PORT}..." +until PGPASSWORD="${REPLICATOR_PASSWORD}" pg_isready -h "${PRIMARY_HOST}" -p "${PRIMARY_PORT}" -U "${REPLICATOR_USER}" 2>/dev/null; do + echo "Primary not ready yet, waiting..." + sleep 2 +done +echo "Primary is ready!" + +# If PGDATA is empty, do a base backup from primary +if [ -z "$(ls -A ${PGDATA} 2>/dev/null)" ]; then + echo "PGDATA is empty, performing base backup from primary..." + + # Perform base backup + PGPASSWORD="${REPLICATOR_PASSWORD}" pg_basebackup \ + -h "${PRIMARY_HOST}" \ + -p "${PRIMARY_PORT}" \ + -U "${REPLICATOR_USER}" \ + -D "${PGDATA}" \ + -Fp \ + -Xs \ + -P \ + -R + + echo "Base backup completed!" + + # Configure recovery settings with apply delay + cat >> "${PGDATA}/postgresql.auto.conf" <> /docker-entrypoint-initd RUN echo "GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly;" >> /docker-entrypoint-initdb.d/init.sql RUN echo "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO readonly;" >> /docker-entrypoint-initdb.d/init.sql +# Create a replication user for streaming replication to the replica +RUN echo "CREATE USER replicator WITH REPLICATION PASSWORD 'PASSWORD-PLACEHOLDER--replicatorpass';" >> /docker-entrypoint-initdb.d/init.sql + +# Create a script to add replication permissions to pg_hba.conf after init +# This script runs after the database is initialized but before it starts accepting connections +RUN echo '#!/bin/bash' > /docker-entrypoint-initdb.d/00-setup-replication.sh && \ + echo 'echo "host replication replicator all scram-sha-256" >> "$PGDATA/pg_hba.conf"' >> /docker-entrypoint-initdb.d/00-setup-replication.sh && \ + chmod +x /docker-entrypoint-initdb.d/00-setup-replication.sh + # Add args to Postgres entrypoint ENTRYPOINT ["sh", "-c", "\ # Add delay if POSTGRES_DELAY_MS is set \ @@ -35,6 +44,12 @@ ENTRYPOINT ["sh", "-c", "\ apt-get update && apt-get install -y iproute2 && tc qdisc add dev eth0 root netem delay ${POSTGRES_DELAY_MS}ms; \ fi; \ \ - # Start Postgres with extensions enabled \ - exec docker-entrypoint.sh postgres -c shared_preload_libraries='pg_stat_statements' -c pg_stat_statements.track=all \ + # Start Postgres with replication enabled and extensions \ + exec docker-entrypoint.sh postgres \ + -c shared_preload_libraries='pg_stat_statements' \ + -c pg_stat_statements.track=all \ + -c wal_level=replica \ + -c max_wal_senders=3 \ + -c wal_keep_size=64MB \ + -c hot_standby=on \ "] From c43192b210e67d4c1f5f555586a68b00ab5e7826 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Sun, 11 Jan 2026 17:35:38 -0800 Subject: [PATCH 2/8] Improvements --- apps/backend/.env.development | 1 + apps/backend/src/app/page.tsx | 7 ++ apps/backend/src/prisma-client.tsx | 128 +++++++++++++++++++++++- apps/dev-launchpad/public/index.html | 11 +- docker/dependencies/docker.compose.yaml | 15 ++- package.json | 2 +- 6 files changed, 156 insertions(+), 8 deletions(-) diff --git a/apps/backend/.env.development b/apps/backend/.env.development index 99be7d1304..574ae52fd4 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -28,6 +28,7 @@ STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS=true STACK_DATABASE_CONNECTION_STRING=postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}28/stackframe STACK_DATABASE_REPLICA_CONNECTION_STRING=postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}34/stackframe +STACK_DATABASE_REPLICATION_WAIT_STRATEGY=pg-stat-replication STACK_EMAIL_HOST=127.0.0.1 STACK_EMAIL_PORT=${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}29 diff --git a/apps/backend/src/app/page.tsx b/apps/backend/src/app/page.tsx index ec24a3b745..3c029a59ec 100644 --- a/apps/backend/src/app/page.tsx +++ b/apps/backend/src/app/page.tsx @@ -1,3 +1,4 @@ +import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; import Link from "next/link"; export default function Home() { @@ -10,6 +11,12 @@ export default function Home() { You can also return to https://stack-auth.com.

API v1
+ {getNodeEnvironment() === "development" && ( + <> +
+ Dev Stats
+ + )} ); } diff --git a/apps/backend/src/prisma-client.tsx b/apps/backend/src/prisma-client.tsx index 3f80dba094..a8c8305233 100644 --- a/apps/backend/src/prisma-client.tsx +++ b/apps/backend/src/prisma-client.tsx @@ -4,11 +4,12 @@ import { PrismaNeon } from "@prisma/adapter-neon"; import { PrismaPg } from '@prisma/adapter-pg'; import { readReplicas } from '@prisma/extension-read-replicas'; import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; +import { yupObject, yupValidate } from "@stackframe/stack-shared/dist/schema-fields"; import { getEnvVariable, getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env'; -import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { captureError, StackAssertionError, throwErr } 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 { concatStacktracesIfRejected, ignoreUnhandledRejection } from "@stackframe/stack-shared/dist/utils/promises"; +import { concatStacktracesIfRejected, ignoreUnhandledRejection, wait } from "@stackframe/stack-shared/dist/utils/promises"; import { throwingProxy } from "@stackframe/stack-shared/dist/utils/proxies"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { traceSpan } from "@stackframe/stack-shared/dist/utils/telemetry"; @@ -140,10 +141,130 @@ export type PrismaClientWithReplica = Omi $replica: () => Omit, }; + +/** + * Waits until ALL replicas have caught up to the specified WAL LSN. + * This ensures read-after-write consistency when using read replicas. + * + * Strategy types (STACK_DATABASE_REPLICATION_WAIT_STRATEGY): + * - "none": Don't wait for replication (default) + * - "pg-stat-replication": Use pg_stat_replication (for local dev with streaming replication) + * - "aurora": Use aurora_replica_status() (for AWS Aurora) + */ +async function waitForReplication(primary: PrismaClient, lsn: string): Promise { + const strategy = getEnvVariable("STACK_DATABASE_REPLICATION_WAIT_STRATEGY", "none"); + return await traceSpan({ + description: 'waiting for replication', + attributes: { + 'stack.db-replication.strategy': strategy, + 'stack.db-replication.lsn': lsn, + }, + }, async () => { + if (strategy === "none") { + return; + } + + const minLsnSubquery = { + "pg-stat-replication": `(SELECT MIN(replay_lsn) FROM pg_stat_replication)`, + "aurora": `(SELECT MIN(current_read_lsn::pg_lsn) FROM aurora_replica_status())`, + }[strategy] ?? throwErr(`Unknown replication wait strategy: ${strategy}`); + + // Validate LSN format (format: hex/hex, e.g., "0/1234ABC"). We do this just to be extra safe as we're using $queryRawUnsafe later + if (!/^[0-9A-Fa-f]+\/[0-9A-Fa-f]+$/.test(lsn)) { + throw new StackAssertionError(`Invalid LSN format: ${lsn}`); + } + + // Poll until all replicas have caught up to the target LSN + // Using $queryRawUnsafe because DO blocks don't support bind parameters + await (primary as any).$queryRawUnsafe(` + DO $$ + DECLARE + min_replica_lsn pg_lsn; + BEGIN + LOOP + SELECT ${minLsnSubquery} INTO min_replica_lsn; + + -- Exit if no replicas connected or all replicas have caught up + IF min_replica_lsn IS NULL OR min_replica_lsn >= '${lsn}'::pg_lsn THEN + EXIT; + END IF; + + -- Wait 10ms and check again + PERFORM pg_sleep(0.01); + END LOOP; + END $$; + `); + }); +} + +/** + * Extends a Prisma client to wait for replication after all operations. + * This ensures read-after-write consistency when using a read replica. + */ +function extendWithReplicationWait(client: T): T { + const strategy = getEnvVariable("STACK_DATABASE_REPLICATION_WAIT_STRATEGY", "none"); + if (strategy === "none") { + return client; + } + + const readLsnAndWaitForReplication = async (client: PrismaClient) => { + await traceSpan({ + description: 'getting current LSN and waiting for replication', + attributes: { + 'stack.db-replication.strategy': strategy, + }, + }, async (span) => { + try { + const [{ lsn }] = await (client as any).$queryRaw<[{ lsn: string }]>`SELECT pg_current_wal_lsn()::text AS lsn`; + await waitForReplication(client, lsn); + } catch (e) { + span.setAttribute('stack.db-replication.error', `${e}`); + captureError("prisma-client-replication-error", new StackAssertionError("Error getting current LSN and waiting for replication. We'll just wait 50ms instead, but please fix this as the replication may not be working.", { cause: e })); + await wait(50); + } + }); + }; + + return client.$extends({ + client: { + async $transaction(args: any) { + // eslint-disable-next-line no-restricted-syntax + const result = await client.$transaction(args); + await readLsnAndWaitForReplication(client); + return result; + }, + }, + query: { + async $allOperations(params: { args: any, query: (args: any) => Promise, operation: string, model?: string, __internalParams?: unknown }) { + const { args, query, operation, model } = params; + + // __internalParams is an undocumented property, so let's validate that it fits our schema with yup first + const internalParamsSchema = yupObject({ + transaction: yupObject().nullable(), + }); + const internalParams = await yupValidate(internalParamsSchema, params.__internalParams); + + if (internalParams.transaction) { + // we're inside a transaction, so we don't need to wait for replication + return await query(args); + } + + const result = await query(args); + await readLsnAndWaitForReplication(client); + return result; + }, + }, + }) as T; +} + function extendWithReadReplicas(client: T, replicaConnectionString: string): PrismaClientWithReplica { // Create a separate PrismaClient for the read replica const replicaClient = getPostgresPrismaClient(replicaConnectionString).client; - return client.$extends(readReplicas({ + + // First extend with replication wait, then with read replicas + const clientWithReplicationWait = extendWithReplicationWait(client); + + return clientWithReplicationWait.$extends(readReplicas({ replicas: [replicaClient], })) as PrismaClientWithReplica; } @@ -437,7 +558,6 @@ async function rawQueryArray[]>(tx: PrismaClientTransact const queryClient = allReadOnly && '$replica' in tx ? (tx as any).$replica() : tx; - // eslint-disable-next-line no-restricted-syntax -- $queryRaw is allowed here const rawResult = await queryClient.$queryRaw(sqlQuery); const postProcessed = combinedQuery.postProcess(rawResult as any); diff --git a/apps/dev-launchpad/public/index.html b/apps/dev-launchpad/public/index.html index 23af76c5fb..38f9f0785f 100644 --- a/apps/dev-launchpad/public/index.html +++ b/apps/dev-launchpad/public/index.html @@ -122,7 +122,7 @@

Background services

const backgroundServices = [ { suffix: "28", label: "PostgreSQL" }, - { suffix: "34", label: "PostgreSQL Replica (100ms lag)" }, + { suffix: "34", label: "PostgreSQL Replica (15ms lag)" }, { suffix: "29", label: "Inbucket SMTP" }, { suffix: "30", label: "Inbucket POP3" }, { suffix: "31", label: "OTel collector" }, @@ -268,6 +268,15 @@

Background services

importance: 1, img: "https://pghero.dokkuapp.com/assets/pghero-88a0d052.png", }, + { + name: "PgHero (Replica)", + portSuffix: "35", + description: [ + "For replica database performance analysis", + ], + importance: 1, + img: "https://pghero.dokkuapp.com/assets/pghero-88a0d052.png", + }, { name: "PgAdmin", portSuffix: "17", diff --git a/docker/dependencies/docker.compose.yaml b/docker/dependencies/docker.compose.yaml index 00e931ada8..c409521336 100644 --- a/docker/dependencies/docker.compose.yaml +++ b/docker/dependencies/docker.compose.yaml @@ -19,7 +19,7 @@ services: cap_add: - NET_ADMIN # required for the fake latency during dev - # ================= PostgreSQL Replica (with 100ms lag) ================= + # ================= PostgreSQL Replica (with replication lag) ================= db-replica: build: ../dev-postgres-replica @@ -29,7 +29,7 @@ services: PRIMARY_PORT: 5432 REPLICATOR_USER: replicator REPLICATOR_PASSWORD: PASSWORD-PLACEHOLDER--replicatorpass - RECOVERY_MIN_APPLY_DELAY: ${REPLICA_LAG_MS:-100}ms + RECOVERY_MIN_APPLY_DELAY: ${STACK_DATABASE_REPLICA_LAG_MS:-15}ms ports: - "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}34:5432" volumes: @@ -46,6 +46,17 @@ services: ports: - "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}16:8080" + # ================= PgHero (Replica) ================= + + pghero-replica: + image: ankane/pghero:latest + environment: + DATABASE_URL: postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@db-replica:5432/stackframe + ports: + - "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}35:8080" + depends_on: + - db-replica + # ================= PgAdmin ================= pgadmin: diff --git a/package.json b/package.json index e69e20f0ab..9f4d4e635e 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "codegen:backend": "pnpm pre && turbo run codegen --filter=@stackframe/stack-backend...", "deps-compose": "docker compose -p stack-dependencies-${NEXT_PUBLIC_STACK_PORT_PREFIX:-81} -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", - "wait-until-postgres-is-ready:pg_isready": "until pg_isready -h localhost -p ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}28; do sleep 1; done", + "wait-until-postgres-is-ready:pg_isready": "until pg_isready -h localhost -p ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}28 && pg_isready -h localhost -p ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}34; 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 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:-0} pnpm run start-deps:no-delay", From 4b6e1f5a0d5b3bb53fea70434c44a469e08f3cdf Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Sun, 11 Jan 2026 19:02:28 -0800 Subject: [PATCH 3/8] f --- .../backend/endpoints/api/v1/emails/email-queue.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts index 47cce6cd18..fa7ea89187 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts @@ -1720,7 +1720,7 @@ describe("email outbox pagination", () => { method: "GET", accessType: "server", }); - logIfTestFails({ allResponse }); + logIfTestFails("allResponse", allResponse); expect(allResponse.status).toBe(200); expect(allResponse.body.items.length).toBe(5); @@ -1729,7 +1729,7 @@ describe("email outbox pagination", () => { method: "GET", accessType: "server", }); - logIfTestFails({ page1Response }); + logIfTestFails("page1Response", page1Response); expect(page1Response.status).toBe(200); expect(page1Response.body.items.length).toBe(2); expect(page1Response.body.is_paginated).toBe(true); @@ -1741,7 +1741,7 @@ describe("email outbox pagination", () => { method: "GET", accessType: "server", }); - logIfTestFails({ page2Response }); + logIfTestFails("page2Response", page2Response); expect(page2Response.status).toBe(200); expect(page2Response.body.items.length).toBe(2); @@ -1758,7 +1758,7 @@ describe("email outbox pagination", () => { method: "GET", accessType: "server", }); - logIfTestFails({ page3Response }); + logIfTestFails("page3Response", page3Response); expect(page3Response.status).toBe(200); expect(page3Response.body.items.length).toBe(1); // Only 1 remaining expect(page3Response.body.pagination.next_cursor).toBeNull(); // No more pages From ffd466d6181f45b93d86b68efb5a4e6767109e47 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Sun, 11 Jan 2026 17:39:59 -0800 Subject: [PATCH 4/8] Delete Claude Code review --- .github/workflows/claude-code-review.yml | 78 ------------------------ 1 file changed, 78 deletions(-) delete mode 100644 .github/workflows/claude-code-review.yml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 59e884d1e4..0000000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Claude Code Review - -on: - pull_request: - types: [ready_for_review] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@beta - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - - # Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4) - # model: "claude-opus-4-20250514" - - # Direct prompt for automated review (no @claude mention needed) - direct_prompt: | - Please review this pull request and provide feedback on: - - Code quality and best practices - - Potential bugs or issues - - Performance considerations - - Security concerns - - Test coverage - - Be constructive and helpful in your feedback. - - # Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR - # use_sticky_comment: true - - # Optional: Customize review based on file types - # direct_prompt: | - # Review this PR focusing on: - # - For TypeScript files: Type safety and proper interface usage - # - For API endpoints: Security, input validation, and error handling - # - For React components: Performance, accessibility, and best practices - # - For tests: Coverage, edge cases, and test quality - - # Optional: Different prompts for different authors - # direct_prompt: | - # ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' && - # 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' || - # 'Please provide a thorough code review focusing on our coding standards and best practices.' }} - - # Optional: Add specific tools for running tests or linting - # allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)" - - # Optional: Skip review for certain conditions - # if: | - # !contains(github.event.pull_request.title, '[skip-review]') && - # !contains(github.event.pull_request.title, '[WIP]') - From f6da04cde9a0199d65370bb3c0a09dc365002b2b Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Sun, 11 Jan 2026 19:08:34 -0800 Subject: [PATCH 5/8] fixes --- apps/backend/src/prisma-client.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/prisma-client.tsx b/apps/backend/src/prisma-client.tsx index fa5236c888..9e3af02799 100644 --- a/apps/backend/src/prisma-client.tsx +++ b/apps/backend/src/prisma-client.tsx @@ -245,7 +245,7 @@ function extendWithReplicationWait(client: T): T { // __internalParams is an undocumented property, so let's validate that it fits our schema with yup first const internalParamsSchema = yupObject({ transaction: yupObject().nullable(), - }); + }).defined(); const internalParams = await yupValidate(internalParamsSchema, params.__internalParams); if (internalParams.transaction) { From 66694e2d225df3deb6b1a3de63d079d1d1ce5477 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Sun, 11 Jan 2026 20:41:31 -0800 Subject: [PATCH 6/8] Better error logging --- .../endpoints/api/v1/emails/email-queue.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts index fa7ea89187..0a12842e08 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts @@ -1,5 +1,5 @@ import { wait } from "@stackframe/stack-shared/dist/utils/promises"; -import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; +import { deindent, nicify } from "@stackframe/stack-shared/dist/utils/strings"; import beautify from "js-beautify"; import { describe } from "vitest"; import { it, logIfTestFails } from "../../../../../helpers"; @@ -1720,7 +1720,7 @@ describe("email outbox pagination", () => { method: "GET", accessType: "server", }); - logIfTestFails("allResponse", allResponse); + logIfTestFails("allResponse", nicify(allResponse)); expect(allResponse.status).toBe(200); expect(allResponse.body.items.length).toBe(5); @@ -1729,7 +1729,7 @@ describe("email outbox pagination", () => { method: "GET", accessType: "server", }); - logIfTestFails("page1Response", page1Response); + logIfTestFails("page1Response", nicify(page1Response)); expect(page1Response.status).toBe(200); expect(page1Response.body.items.length).toBe(2); expect(page1Response.body.is_paginated).toBe(true); @@ -1741,7 +1741,7 @@ describe("email outbox pagination", () => { method: "GET", accessType: "server", }); - logIfTestFails("page2Response", page2Response); + logIfTestFails("page2Response", nicify(page2Response)); expect(page2Response.status).toBe(200); expect(page2Response.body.items.length).toBe(2); @@ -1758,7 +1758,7 @@ describe("email outbox pagination", () => { method: "GET", accessType: "server", }); - logIfTestFails("page3Response", page3Response); + logIfTestFails("page3Response", nicify(page3Response)); expect(page3Response.status).toBe(200); expect(page3Response.body.items.length).toBe(1); // Only 1 remaining expect(page3Response.body.pagination.next_cursor).toBeNull(); // No more pages From 9e9f637366da7972b97c23d3141c9ed529624c6d Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Sun, 11 Jan 2026 20:54:11 -0800 Subject: [PATCH 7/8] Fix tests --- .../backend/endpoints/api/v1/emails/email-queue.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts index 0a12842e08..58ec85ce8c 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts @@ -1689,9 +1689,10 @@ describe("email outbox pagination", () => { const draftId = createDraftResponse.body.id; // Create 5 users + const mailboxes = await Promise.all(Array.from({ length: 5 }, async () => await bumpEmailAddress())); const userIds: string[] = []; for (let i = 0; i < 5; i++) { - const email = `pagination-test-${i}@example.com`; + const email = mailboxes[i].emailAddress; const createUserResponse = await niceBackendFetch("/api/v1/users", { method: "POST", accessType: "server", @@ -1715,6 +1716,12 @@ describe("email outbox pagination", () => { }); expect(sendResponse.status).toBe(200); + // Wait until all emails are sent + for (const mailbox of mailboxes) { + await mailbox.waitForMessagesWithSubject("Pagination Test Email"); + } + + // Ensure there are 5 emails in the outbox const allResponse = await niceBackendFetch("/api/v1/emails/outbox", { method: "GET", From b9f3d37f1b5afd22b63643044f951dcbf1710ea6 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 12 Jan 2026 15:02:59 -0800 Subject: [PATCH 8/8] Fixes --- apps/backend/src/prisma-client.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/prisma-client.tsx b/apps/backend/src/prisma-client.tsx index 9e3af02799..13e4c7c282 100644 --- a/apps/backend/src/prisma-client.tsx +++ b/apps/backend/src/prisma-client.tsx @@ -231,9 +231,9 @@ function extendWithReplicationWait(client: T): T { return client.$extends({ client: { - async $transaction(args: any) { + async $transaction(...args: Parameters) { // eslint-disable-next-line no-restricted-syntax - const result = await client.$transaction(args); + const result = await client.$transaction(...args); await readLsnAndWaitForReplication(client); return result; },