Skip to content

Conversation

@aadesh18
Copy link

@aadesh18 aadesh18 commented Nov 30, 2025

This PR revolves around the following components

  1. Sequencer - sequences the updates in the internal db
  2. Poller - polls for the latest updates to sync with the external db
  3. Outgoing Request Handler - essentially a trigger that can make http requests based on a change in the internal db
  4. Sync Engine - syncs with the latest changes from the internal db to the external db

What has been done

  • Added a global sequence id for ProjectUser, ContactChannel and DeletedRow.
  • Added the deletedRow table to keep track of the rows that were deleted across ProjectUser and ContactChannel.
  • Added the OutgoingRequest table to keep track of the outgoing requests
  • Added function for the sequencer to call to sequence updates
  • Added a sequencer that sequences all the changes in the internal db every 50 ms
  • Added a poller that polls for the latest changes in the internal db every 50 ms, and adds to a queue
  • Added a Vercel cron that calls sequencer and poller every minute
  • Added a queue that fulfills the outgoing requests by making http calls (for external db sync, it calls the sync engine endpoint)
  • Added a sync engine that uses the defined sql mapping query in the user's schema to pull in the changes for the user, and sync them with the external db
  • Added tests to test out each functionality

How to review this PR:

  1. Review the migrations (sequence id, deletedRow, triggers, backlog sync) (all files created under the migrations folder)
  2. Review sequencer
  3. Review poller
  4. Review the changes in schema
  5. Review sync-engine (the function, and it's helper file)
  6. Review the schema changes, and query mappings
  7. Review the tests (basic, advanced and race, along with the helper file)
  8. Review the changes made in Dockerfile to support local testing using the postgres docker

Note

Introduces a cron-driven external DB sync pipeline with global sequencing, internal poller and webhook sync engine, new DB tables/functions, config schema/mappings, and comprehensive e2e tests.

  • Database (Prisma/Migrations):
    • Add global sequence (global_seq_id) and sequenceId/shouldUpdateSequenceId to ProjectUser, ContactChannel, DeletedRow with partial indexes.
    • Create DeletedRow (capture deletes) and OutgoingRequest (queue) tables; add unique/indexes.
    • Add triggers/functions: log_deleted_row, reset_sequence_id_on_update, backfill_null_sequence_ids, enqueue_tenant_sync.
  • Backend/API:
    • New internal routes: GET /api/latest/internal/external-db-sync/sequencer, GET /poller, POST /sync-engine (Upstash-verified) for sync orchestration.
    • Add cron wiring: vercel.json schedules and local scripts/run-cron-jobs.ts; start in dev via dev script.
    • Tweak route handler (remove noisy logging) without behavior change.
  • Sync Engine:
    • Implement src/lib/external-db-sync.ts to read tenant mappings and upsert to external Postgres (schema bootstrap, param checks, sequencing).
    • Add default mappings DEFAULT_DB_SYNC_MAPPINGS and config schema dbSync.externalDatabases in shared config.
  • Testing/Infra:
    • Add extensive e2e tests (basics, advanced, race conditions) for sequencing, idempotency, deletes, pagination, multi-mapping, and permissions.
    • Docker compose: add external-db-test Postgres for tests; e2e deps for pg types.

Written by Cursor Bugbot for commit 3f2a8ef. This will update automatically on new commits. Configure here.

Summary by CodeRabbit

  • New Features
    • External PostgreSQL database integration enabling automatic synchronization of user data to external systems
    • Automatic table provisioning, change tracking, and deletion propagation with multi-tenant support
    • Seamless data export with automatic re-sync of modified or deleted records

✏️ Tip: You can customize this high-level summary in your review settings.

@aadesh18 aadesh18 requested a review from N2D4 November 30, 2025 21:48
@CLAassistant
Copy link

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@vercel
Copy link

vercel bot commented Nov 30, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
stack-dashboard Ready Ready Preview Comment Dec 2, 2025 9:07pm
stack-demo Ready Ready Preview Comment Dec 2, 2025 9:07pm
stack-docs Error Error Dec 2, 2025 9:07pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Nov 30, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

This PR implements an external database synchronization system that enables syncing internal database changes (ProjectUser, ContactChannel, DeletedRow) to external PostgreSQL databases via sequence-based change tracking, cron-based polling infrastructure, and Upstash webhook integration.

Changes

Cohort / File(s) Summary
Configuration & Schema
packages/stack-shared/src/config/db-sync-mappings.ts, packages/stack-shared/src/config/schema.ts, packages/stack-shared/src/config/schema-fuzzer.test.ts
Added DEFAULT_DB_SYNC_MAPPINGS constant with users table mapping, internalDbFetchQuery, and postgres upsert logic; extended branchConfigSchema and organizationConfigDefaults with dbSync.externalDatabases support.
Cron Infrastructure
apps/backend/package.json, apps/backend/scripts/run-cron-jobs.ts, vercel.json
Introduced run-cron-jobs script and added concurrent cron-jobs label to dev task; added two cron job definitions (poller and sequencer) to Vercel config; script repeatedly calls internal sync endpoints with Bearer auth every 120 seconds.
Database Schema & Migration
apps/backend/prisma/schema.prisma, apps/backend/prisma/migrations/20251125030551_external_db_sync/migration.sql
Added global sequence tracking via global_seq_id; introduced OutgoingRequest and DeletedRow tables; added sequenceId and shouldUpdateSequenceId fields to ProjectUser and ContactChannel; created triggers for automatic sequence ID re-marking and deleted row logging.
Internal API Routes
apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts, apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts, apps/backend/src/app/api/latest/internal/external-db-sync/sync-engine/route.tsx
Added GET sequencer route (backfills sequence IDs in 1000-row batches, enqueues OutgoingRequest per tenant), GET poller route (claims pending requests, publishes to Upstash), and POST sync-engine webhook (validates Upstash signature, triggers syncExternalDatabases).
Sync Logic
apps/backend/src/lib/external-db-sync.ts
New module orchestrating external database synchronization: pushRowsToExternalDb (upserts rows with metadata), syncMapping (reads internal rows in batches, pushes to external DB), syncDatabase (manages PostgreSQL client lifecycle), syncExternalDatabases (entry point per tenancy).
Dependency Updates
apps/backend/package.json, apps/e2e/package.json
Added @types/pg and pg library to both backends and e2e devDependencies.
Docker Configuration
docker/dependencies/docker.compose.yaml
Increased PostgreSQL max_connections to 500 to support E2E testing.
E2E Tests & Utilities
apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts, apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts, apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts, apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts, apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-high-volume.test.ts
Created TestDbManager utility class for isolated database provisioning and cleanup; comprehensive test suites validating CRUD operations, multi-tenant isolation, idempotency, race conditions, special character handling, batching (200+ users, 1500 users), and concurrent update/delete scenarios.

Sequence Diagram

sequenceDiagram
    participant Cron as Cron Scheduler<br/>(run-cron-jobs.ts)
    participant Seq as Sequencer Route
    participant IDB as Internal DB<br/>(Prisma)
    participant OReq as OutgoingRequest<br/>Table
    participant Poll as Poller Route
    participant Upstash as Upstash<br/>Queue
    participant Sync as Sync Engine<br/>Route
    participant EDB as External DB<br/>(PostgreSQL)

    Cron->>Seq: GET /sequencer (cron secret)
    Seq->>IDB: Backfill null sequenceIds<br/>in batches (ProjectUser,<br/>ContactChannel, DeletedRow)
    Seq->>IDB: Fetch affected tenancyIds
    Seq->>OReq: Enqueue OutgoingRequest<br/>per tenant (if not pending)
    Seq->>Cron: 200 { ok, iterations }

    Cron->>Poll: GET /poller (cron secret)
    Poll->>OReq: Claim up to 100 pending<br/>requests (SKIP LOCKED,<br/>3-min window)
    Poll->>Upstash: Publish per-request<br/>payload to queue
    Poll->>Cron: 200 { ok, total }

    Upstash->>Sync: POST /sync-engine<br/>(Upstash signature,<br/>tenancyId in body)
    Sync->>IDB: Fetch tenancy config
    Sync->>IDB: Call syncExternalDatabases
    IDB->>EDB: Fetch internal rows<br/>since lastSequenceId
    IDB->>EDB: Upsert rows + metadata
    Sync->>OReq: Mark OutgoingRequest<br/>as fulfilled
    Sync->>Upstash: 200 { ok, statusCode }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

  • external-db-sync.ts: Dense business logic with batching, transaction management (FOR UPDATE SKIP LOCKED), error handling, and metadata tracking across multiple tables; requires careful validation of upsert query construction and tenancy isolation.
  • sequencer & poller routes: Concurrent transaction semantics with SKIP LOCKED; sequence ID backfill logic; proper cleanup and fulfillment tracking of OutgoingRequest entries.
  • sync-engine route: Upstash webhook signature verification; integration point between async queue and sync orchestration.
  • Database migration: Complex trigger logic for automatic sequence re-marking and deleted row logging; multiple composite indexes; requires validation of trigger fire conditions and data consistency guarantees.
  • E2E test suite: Comprehensive but complex; includes high-volume scenarios (1500 users), race condition handling, multi-tenant isolation validation, and resource lifecycle management via TestDbManager.

Areas requiring extra attention:

  • Cross-tenant data leakage prevention in syncMapping and pushRowsToExternalDb
  • SKIP LOCKED transaction semantics and pagination correctness in poller
  • Trigger behavior under concurrent updates and deletes
  • TestDbManager resource cleanup (connection termination before database drop)
  • Sequence ID ordering guarantees across all three synced tables

Possibly related PRs

  • Customizable ports #962: Modifies backend port configuration (NEXT_PUBLIC_STACK_PORT_PREFIX) that the new run-cron-jobs.ts script depends on for constructing localhost:8102 endpoint URLs.

Poem

🐰 A rabbit hops through sequences, so neat,
With OutgoingRequests keeping sync complete,
From postgres to postgres, the data takes flight,
Via Upstash's queue in the dead of night,
Change tracking triggers make updates so bright!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 61.90% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'External db sync' is concise and clearly indicates the main feature being added, though it lacks specificity about the comprehensive cron-driven pipeline being introduced.
Description check ✅ Passed The description is detailed and well-structured with clear sections explaining components, implementation details, and review guidance that align with the PR's comprehensive scope.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch external-db-sync

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you merge all these migrations into a single one and document what each of the steps do?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done - merged them into one and added comments

@N2D4 N2D4 marked this pull request as ready for review November 30, 2025 23:42
Copilot AI review requested due to automatic review settings November 30, 2025 23:42
@claude
Copy link
Contributor

claude bot commented Nov 30, 2025

Claude Code is working…

I'll analyze this and get back to you.

View job run

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a comprehensive external database synchronization system to replicate data from Stack's internal database to user-configured external databases in near real-time.

Key changes:

  • Adds sequence ID tracking for ProjectUser, ContactChannel, and DeletedRow tables
  • Implements a sequencer that assigns sequence IDs to track change order
  • Implements a poller that queues sync requests via QStash
  • Implements a sync engine that pulls changes and pushes them to external databases

Reviewed changes

Copilot reviewed 27 out of 28 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
vercel.json Adds cron jobs for sequencer and poller endpoints (runs every minute)
pnpm-lock.yaml Updates package dependencies including pg client library
package.json Adds @types/pg dependency for TypeScript support
apps/e2e/package.json Adds pg and @types/pg for E2E testing
docker/dependencies/docker.compose.yaml Adds external-db-test PostgreSQL service for testing
apps/backend/prisma/schema.prisma Adds sequenceId fields, OutgoingRequest and DeletedRow tables
apps/backend/prisma/migrations/* Database migrations for sequence IDs, triggers, and sync tables
packages/stack-shared/src/config/schema.ts Adds dbSync configuration schema for external databases
packages/stack-shared/src/config/db-sync-mappings.ts Defines default SQL mappings for PartialUsers table
apps/backend/src/lib/external-db-sync.ts Core sync logic for pushing data to external databases
apps/backend/src/app/api/latest/internal/external-db-sync/* API endpoints for sequencer, poller, and sync engine
apps/backend/scripts/run-cron-jobs.ts Local development script to simulate cron jobs
apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-*.test.ts Comprehensive E2E tests for basic, advanced, and race condition scenarios
apps/backend/src/route-handlers/smart-route-handler.tsx Removes extensive logging from API requests
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 40 to 45
throw new Error(
` Column count mismatch for table "${tableName}".\n` +
`→ upsertQuery expects ${expectedParamCount} parameters.\n` +
`→ internalDbFetchQuery returned ${orderedKeys.length} columns (excluding tenancyId).\n` +
`Fix your SELECT column order or your SQL parameter order.`
);
Copy link

Copilot AI Nov 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message has inconsistent formatting with a leading space on line 41 but not on lines 42-44. Consider removing the leading space for consistency.

Copilot uses AI. Check for mistakes.
Comment on lines 82 to 87
const rawSourceTables: any = (mapping as any).sourceTables;
const sourceTables: string[] = rawSourceTables
? Object.values(rawSourceTables)
: [];

const rawTargetPk: any = (mapping as any).targetTablePrimaryKey;
Copy link

Copilot AI Nov 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type casting with as any bypasses type safety. Consider defining proper TypeScript interfaces for sourceTables and targetTablePrimaryKey to maintain type safety throughout the codebase.

Suggested change
const rawSourceTables: any = (mapping as any).sourceTables;
const sourceTables: string[] = rawSourceTables
? Object.values(rawSourceTables)
: [];
const rawTargetPk: any = (mapping as any).targetTablePrimaryKey;
const rawSourceTables = mapping.sourceTables;
const sourceTables: string[] = rawSourceTables
? Object.values(rawSourceTables)
: [];
const rawTargetPk = mapping.targetTablePrimaryKey;

Copilot uses AI. Check for mistakes.
Comment on lines 53 to 67
const rows = await tx.$queryRaw<OutgoingRequest[]>`
UPDATE "OutgoingRequest"
SET "fulfilledAt" = NOW()
WHERE "id" IN (
SELECT id
FROM "OutgoingRequest"
WHERE "fulfilledAt" IS NULL
ORDER BY "createdAt"
LIMIT 100
FOR UPDATE SKIP LOCKED
)
RETURNING *;
`;
return rows;
});
Copy link

Copilot AI Nov 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FOR UPDATE SKIP LOCKED clause prevents multiple pollers from processing the same requests, but there's no guarantee that all pending requests will be processed if the poller times out. Consider adding a mechanism to track and retry requests that were claimed but never completed (e.g., where fulfilledAt is set but the QStash publish failed).

Copilot uses AI. Check for mistakes.
'Poller does not see second update until commit',
async () => {
const dbName = 'race_second_uncommitted_poll_test';
const { externalClient, user, baselineSeq } =
Copy link

Copilot AI Nov 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable baselineSeq.

Suggested change
const { externalClient, user, baselineSeq } =
const { externalClient, user } =

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,1108 @@
import { DEFAULT_DB_SYNC_MAPPINGS } from '@stackframe/stack-shared/dist/config/db-sync-mappings';
import { generateSecureRandomString } from '@stackframe/stack-shared/dist/utils/crypto';
Copy link

Copilot AI Nov 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import generateSecureRandomString.

Suggested change
import { generateSecureRandomString } from '@stackframe/stack-shared/dist/utils/crypto';

Copilot uses AI. Check for mistakes.
import { DEFAULT_EMAIL_TEMPLATES, DEFAULT_EMAIL_THEMES, DEFAULT_EMAIL_THEME_ID } from "../helpers/emails";
import * as schemaFields from "../schema-fields";
import { productSchema, userSpecifiedIdSchema, yupBoolean, yupDate, yupMixed, yupNever, yupNumber, yupObject, yupRecord, yupString, yupTuple, yupUnion } from "../schema-fields";
import { productSchema, userSpecifiedIdSchema, yupArray, yupBoolean, yupDate, yupMixed, yupNever, yupNumber, yupObject, yupRecord, yupString, yupTuple, yupUnion } from "../schema-fields";
Copy link

Copilot AI Nov 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused import yupArray.

Suggested change
import { productSchema, userSpecifiedIdSchema, yupArray, yupBoolean, yupDate, yupMixed, yupNever, yupNumber, yupObject, yupRecord, yupString, yupTuple, yupUnion } from "../schema-fields";
import { productSchema, userSpecifiedIdSchema, yupBoolean, yupDate, yupMixed, yupNever, yupNumber, yupObject, yupRecord, yupString, yupTuple, yupUnion } from "../schema-fields";

Copilot uses AI. Check for mistakes.
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Nov 30, 2025

Greptile Overview

Greptile Summary

This PR implements a comprehensive external database synchronization system that continuously replicates changes from Stack's internal database to user-configured external databases. The implementation uses a sequence-based approach with four main components: a sequencer that assigns monotonically increasing IDs to changed rows, a poller that queues sync requests, a QStash-based queue for reliable delivery, and a sync engine that executes the actual data transfer using user-defined SQL mappings.

Key Changes:

  • Added sequenceId column to ProjectUser, ContactChannel, and DeletedRow tables with unique indexes
  • Created DeletedRow table to track deletions with triggers on ProjectUser and ContactChannel
  • Created OutgoingRequest table to queue sync operations
  • Implemented database triggers to automatically mark changed rows for sequencing
  • Added backfill_null_sequence_ids() function that processes up to 1000 rows per call and enqueues tenant syncs
  • Built sequencer and poller endpoints that run as Vercel cron jobs every minute (looping every 50ms internally)
  • Implemented sync engine that executes user-configured SQL queries to fetch and upsert data
  • Added comprehensive test suite covering basic operations, race conditions, and high-volume scenarios
  • Configured default PartialUsers mapping that syncs user and contact channel data

Architecture Strengths:

  • Sequence IDs provide total ordering of changes, preventing out-of-order updates
  • FOR UPDATE SKIP LOCKED prevents duplicate processing across concurrent workers
  • Triggers ensure no changes are missed, even during failures
  • QStash provides reliable async execution with retries
  • Comprehensive test coverage including race conditions and multi-tenant isolation

Concerns:

  • Uses $queryRawUnsafe with user-configured SQL queries (line 133 in external-db-sync.ts), though this is admin-controlled configuration
  • No rate limiting on external database connections
  • Error handling swallows some errors rather than alerting (e.g., sync failures just log to console)
  • The cron jobs run every minute but loop internally for 2 minutes, which could cause overlapping executions

Confidence Score: 3/5

  • This PR introduces a complex sync system with potential SQL injection risks, though mitigated by admin-only configuration
  • The implementation is architecturally sound with proper sequence-based ordering, concurrency controls, and comprehensive tests. However, the use of $queryRawUnsafe with user-configured SQL (even from admin config) presents a security concern. The overlapping cron execution pattern (2-minute loops every 1 minute) could cause resource issues. Error handling silently logs failures rather than alerting operators. These issues don't make it unsafe to merge but warrant attention.
  • apps/backend/src/lib/external-db-sync.ts requires attention for SQL injection mitigation and error handling improvements

Important Files Changed

File Analysis

Filename Score Overview
apps/backend/prisma/migrations/20251128210004_add_sync_functions/migration.sql 5/5 Added backfill function and tenant sync queue function for processing changes
apps/backend/src/lib/external-db-sync.ts 3/5 Core sync engine that pulls data from internal DB and pushes to external databases - has SQL injection concern
apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts 5/5 Sequencer endpoint that assigns sequence IDs to changed rows every 50ms
apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts 5/5 Poller endpoint that processes outgoing sync requests and publishes to QStash
apps/backend/src/app/api/latest/internal/external-db-sync/sync-engine/route.tsx 5/5 Webhook endpoint triggered by QStash to execute actual database sync
packages/stack-shared/src/config/db-sync-mappings.ts 5/5 Default PartialUsers mapping with queries for fetching and upserting sync data

Sequence Diagram

sequenceDiagram
    participant User as User Action
    participant Internal as Internal DB
    participant Trigger as DB Triggers
    participant Sequencer as Sequencer<br/>(Cron: Every 1min)
    participant Poller as Poller<br/>(Cron: Every 1min)
    participant Queue as OutgoingRequest<br/>Table
    participant QStash as QStash Queue
    participant SyncEngine as Sync Engine
    participant External as External DB

    User->>Internal: INSERT/UPDATE/DELETE User/Contact
    Internal->>Trigger: Fire trigger
    Trigger->>Internal: Set shouldUpdateSequenceId=TRUE
    
    Note over Sequencer: Runs every 50ms for 2min
    Sequencer->>Internal: Call backfill_null_sequence_ids()
    Internal->>Internal: Assign sequenceId to changed rows
    Internal->>Internal: Set shouldUpdateSequenceId=FALSE
    Internal->>Queue: INSERT into OutgoingRequest<br/>(via enqueue_tenant_sync)
    
    Note over Poller: Runs every 50ms for 2min
    Poller->>Queue: SELECT pending requests<br/>(FOR UPDATE SKIP LOCKED)
    Queue->>Poller: Return up to 100 requests
    Poller->>Queue: UPDATE fulfilledAt=NOW()
    Poller->>QStash: publishJSON(sync-engine endpoint)
    
    QStash->>SyncEngine: POST /sync-engine
    SyncEngine->>SyncEngine: Verify Upstash signature
    SyncEngine->>Internal: Execute internalDbFetchQuery<br/>(with tenancyId parameter)
    Internal->>SyncEngine: Return changed rows (ordered by sequenceId)
    SyncEngine->>External: Connect to external DB
    SyncEngine->>External: Create table if needed<br/>(targetTableSchema)
    loop For each row
        SyncEngine->>External: Execute externalDbUpdateQuery<br/>(upsert with sequence check)
    end
    External->>External: Update only if newSeq > oldSeq
    SyncEngine->>QStash: Return success

Loading

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

27 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

}
}

const rows = await internalPrisma.$queryRawUnsafe<any[]>(fetchQuery, tenancyId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: SQL injection vulnerability: fetchQuery comes from user-controlled config (mapping.internalDbFetchQuery) and is executed with $queryRawUnsafe. Malicious SQL in config could read/modify internal database data.

Suggested change
const rows = await internalPrisma.$queryRawUnsafe<any[]>(fetchQuery, tenancyId);
const rows = await internalPrisma.$queryRaw<any[]>`${fetchQuery}` as any;
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/backend/src/lib/external-db-sync.ts
Line: 133:133

Comment:
**logic:** SQL injection vulnerability: `fetchQuery` comes from user-controlled config (`mapping.internalDbFetchQuery`) and is executed with `$queryRawUnsafe`. Malicious SQL in config could read/modify internal database data.

```suggestion
  const rows = await internalPrisma.$queryRaw<any[]>`${fetchQuery}` as any;
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good bot!

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (20)
apps/backend/prisma/migrations/20251125030551_add_sequence_id/migration.sql (1)

1-6: Consider documenting the sequence increment strategy.

The INCREMENT BY 11 in the global sequence is unusual and likely implements a per-instance ID distribution strategy for multi-instance deployments. Add an inline SQL comment explaining this choice for future maintainers.

Ref: Past review feedback suggested consolidating all sequence-related migrations and documenting each step. Consider grouping this with the subsequent shouldUpdateSequenceId and index migrations into a single, well-documented migration file.

-- Add this comment to explain the strategy
CREATE SEQUENCE global_seq_id
    AS BIGINT
    START 1
    INCREMENT BY 11  -- Increment by 11 to enable per-instance ID ranges (e.g., instance 0: 1,12,23...; instance 1: 2,13,24...) in multi-instance deployments
    NO MINVALUE
    NO MAXVALUE;
apps/backend/prisma/migrations/20251128210001_add_should_update_sequence_id_indexes/migration.sql (1)

1-7: LGTM!

The partial indexes on shouldUpdateSequenceId = TRUE are an efficient choice for queries filtering unsynced records, reducing index size by excluding already-synced rows. The naming and SPLIT_STATEMENT_SENTINEL structure are consistent with related migrations.

Consider adding an inline SQL comment explaining the partial index strategy:

-- Partial index for efficient querying of unsynced records
CREATE INDEX "ProjectUser_shouldUpdateSequenceId_idx" ON "ProjectUser"("shouldUpdateSequenceId") WHERE "shouldUpdateSequenceId" = TRUE;
apps/backend/prisma/migrations/20251128210000_add_should_update_sequence_id_columns/migration.sql (1)

1-7: LGTM!

The addition of shouldUpdateSequenceId with default TRUE across ProjectUser, ContactChannel, and DeletedRow ensures all existing records are marked for synchronization to external databases. This flag enables efficient querying of unsynced records paired with the partial indexes in the follow-up migration.

Add inline SQL comments to clarify the column's purpose:

-- Track which records need to be synced to external databases (reset to FALSE after sync)
ALTER TABLE "ProjectUser" ADD COLUMN "shouldUpdateSequenceId" BOOLEAN NOT NULL DEFAULT TRUE;
apps/backend/prisma/migrations/20251125180000_add_deleted_row_table/migration.sql (1)

8-9: Consider data retention strategy for fulfilled deletions.

The fulfilledAt timestamp tracks when deletions have been synced, but there's no documented retention policy or cleanup process for old rows. Over time, the DeletedRow table could accumulate millions of fulfilled records, impacting query performance and storage.

Recommend documenting a data retention policy (e.g., "delete rows where fulfilledAt < NOW() - INTERVAL '30 days'") and implementing a cleanup job.

apps/backend/prisma/migrations/20251125172224_add_outgoing_request/migration.sql (1)

2-12: Consider adding an index on createdAt for query performance.

The poller route orders by createdAt when claiming pending requests (ORDER BY "createdAt"). As the table grows, this ordering operation on unfulfilled requests could benefit from a composite index.

 CREATE INDEX  "OutgoingRequest_fulfilledAt_idx" ON "OutgoingRequest"("fulfilledAt");
+
+CREATE INDEX  "OutgoingRequest_createdAt_idx" ON "OutgoingRequest"("createdAt");

Alternatively, a composite index (fulfilledAt, createdAt) could further optimize the query pattern used in the poller.

apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts (2)

81-87: Fragile environment detection pattern.

Using .includes("development") on the environment string is unusual and could match unintended values. The standard approach is direct equality comparison.

-if (getNodeEnvironment().includes("development") || getNodeEnvironment().includes("test")) {
+const env = getNodeEnvironment();
+if (env === "development" || env === "test") {

74-74: Consider defining a type for qstashOptions.

Casting to any loses type safety. Define an interface for the expected shape of qstashOptions to catch errors at compile time.

interface QstashOptions {
  url: string;
  body: unknown;
}
// ...
const options = request.qstashOptions as QstashOptions;
packages/stack-shared/src/config/schema.ts (1)

11-11: Unused import: yupArray

The import yupArray was added but doesn't appear to be used in this file. Based on the code, only yupTuple is used for array-like structures (Line 216).

-import { productSchema, userSpecifiedIdSchema, yupArray, yupBoolean, yupDate, yupMixed, yupNever, yupNumber, yupObject, yupRecord, yupString, yupTuple, yupUnion } from "../schema-fields";
+import { productSchema, userSpecifiedIdSchema, yupBoolean, yupDate, yupMixed, yupNever, yupNumber, yupObject, yupRecord, yupString, yupTuple, yupUnion } from "../schema-fields";
apps/backend/scripts/run-cron-jobs.ts (2)

25-29: Cron jobs don't run immediately on startup.

The setInterval schedules jobs to run after the first 60-second delay. Consider whether the jobs should also run immediately when the script starts:

  for (const endpoint of endpoints) {
+   run(endpoint); // Run immediately on startup
    setInterval(() => {
      run(endpoint);
    }, 60000);
  }

16-23: Consider adding a timeout to the fetch request.

The fetch call has no timeout. If the backend hangs, this could cause the cron job to stall indefinitely. Per coding guidelines, blocking calls without timeouts can cause cascading issues.

  const run = (endpoint: string) => runAsynchronously(async () => {
    console.log(`Running ${endpoint}...`);
-   const res = await fetch(`${baseUrl}${endpoint}`, {
-     headers: { 'Authorization': `Bearer ${cronSecret}` },
-   });
+   const controller = new AbortController();
+   const timeoutId = setTimeout(() => controller.abort(), 120000); // 2 minute timeout
+   try {
+     const res = await fetch(`${baseUrl}${endpoint}`, {
+       headers: { 'Authorization': `Bearer ${cronSecret}` },
+       signal: controller.signal,
+     });
+     if (!res.ok) throw new StackAssertionError(`Failed to call ${endpoint}: ${res.status} ${res.statusText}\n${await res.text()}`, { res });
+     console.log(`${endpoint} completed.`);
+   } finally {
+     clearTimeout(timeoutId);
+   }
-   if (!res.ok) throw new StackAssertionError(`Failed to call ${endpoint}: ${res.status} ${res.statusText}\n${await res.text()}`, { res });
-   console.log(`${endpoint} completed.`);
  });
apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts (1)

50-65: Consider adding a circuit breaker for repeated failures.

The loop catches errors and continues (Lines 53-55), which could lead to repeatedly hitting the database with failing queries for the full 2-minute duration. Consider tracking consecutive failures and breaking early:

    let iterations = 0;
+   let consecutiveFailures = 0;
+   const maxConsecutiveFailures = 10;

    while (performance.now() - startTime < maxDurationMs) {
      try {
        await globalPrismaClient.$executeRaw`SELECT backfill_null_sequence_ids()`;
+       consecutiveFailures = 0; // Reset on success
      } catch (error) {
        console.warn('[sequencer] Failed to run backfill_null_sequence_ids:', error);
+       consecutiveFailures++;
+       if (consecutiveFailures >= maxConsecutiveFailures) {
+         console.error('[sequencer] Too many consecutive failures, breaking loop');
+         break;
+       }
      }

      iterations++;
      // ...
    }
apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts (1)

298-349: Consider parameterizing the long sleep duration.

The 70-second sleep makes this test slow for CI. If the poller interval is configurable in tests, you could reduce the wait time proportionally. Alternatively, mark this as a "slow test" that runs only in nightly builds.

apps/backend/prisma/schema.prisma (1)

895-911: Consider adding an index on sequenceId for DeletedRow if sync performance becomes a concern.

The model has a partial index on shouldUpdateSequenceId (migration 20251128210001) that helps the backfill function. The sync engine's query filters by tenancyId and tableName, then orders by sequenceId. While the @unique constraint on sequenceId provides an index, a composite index on (tenancyId, tableName, sequenceId) could optimize the sync fetch query if DeletedRow volumes grow significantly. This is an optional optimization rather than a requirement.

apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts (3)

2-2: Unused import: generateSecureRandomString

This import is not used anywhere in the file.

-import { generateSecureRandomString } from '@stackframe/stack-shared/dist/utils/crypto';

390-390: Missing test timeout

This test lacks an explicit timeout, unlike other tests in this file. Consider adding TEST_TIMEOUT for consistency and to prevent hanging tests.

-  });
+  }, TEST_TIMEOUT);

1011-1011: Prefer slice() over deprecated substr()

substr() is deprecated in favor of slice() or substring().

-    const testRunId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+    const testRunId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
apps/backend/src/lib/external-db-sync.ts (3)

48-69: Row-by-row processing may cause performance bottleneck

Each row is processed with a separate await externalClient.query() call. For high-volume syncs (e.g., the 200-user test), this results in 200+ sequential round-trips to the external database.

Consider batching multiple rows into a single transaction or using multi-value inserts for better throughput.

+  // Batch rows within a transaction for better performance
+  await externalClient.query('BEGIN');
+  try {
     for (const row of newRows) {
       const { tenancyId, ...rest } = row;
       await externalClient.query(upsertQuery, Object.values(rest));
     }
+    await externalClient.query('COMMIT');
+  } catch (err) {
+    await externalClient.query('ROLLBACK');
+    throw err;
+  }

82-90: Type safety: Excessive as any casts

Using as any to access sourceTables and targetTablePrimaryKey bypasses TypeScript's type checking. If the CompleteConfig type is correct, consider updating it to include these properties properly, or use type guards.


192-203: Sequential database syncing limits throughput

Each external database is synced sequentially. For tenants with multiple external DBs configured, this could be parallelized with Promise.all() to reduce total sync time.

-  for (const [dbId, dbConfig] of Object.entries(externalDatabases)) {
-    await syncDatabase(dbId, dbConfig, internalPrisma, tenancy.id);
-  }
+  await Promise.all(
+    Object.entries(externalDatabases).map(([dbId, dbConfig]) =>
+      syncDatabase(dbId, dbConfig, internalPrisma, tenancy.id)
+    )
+  );
apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts (1)

209-215: Consider adding type for externalDatabases parameter

The externalDatabases parameter is typed as any. Consider using the proper type from the config schema for better type safety.

-export async function createProjectWithExternalDb(externalDatabases: any, projectOptions?: { display_name?: string, description?: string }) {
+export async function createProjectWithExternalDb(
+  externalDatabases: Record<string, { type: string; connectionString: string; mappings?: unknown }>,
+  projectOptions?: { display_name?: string; description?: string }
+) {
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between eb5e1cb and 3f2a8ef.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (27)
  • apps/backend/package.json (2 hunks)
  • apps/backend/prisma/migrations/20251125030551_add_sequence_id/migration.sql (1 hunks)
  • apps/backend/prisma/migrations/20251125172224_add_outgoing_request/migration.sql (1 hunks)
  • apps/backend/prisma/migrations/20251125180000_add_deleted_row_table/migration.sql (1 hunks)
  • apps/backend/prisma/migrations/20251128210000_add_should_update_sequence_id_columns/migration.sql (1 hunks)
  • apps/backend/prisma/migrations/20251128210001_add_should_update_sequence_id_indexes/migration.sql (1 hunks)
  • apps/backend/prisma/migrations/20251128210002_add_reset_sequence_id_function_and_triggers/migration.sql (1 hunks)
  • apps/backend/prisma/migrations/20251128210003_log_deleted_row_function_and_triggers/migration.sql (1 hunks)
  • apps/backend/prisma/migrations/20251128210004_add_sync_functions/migration.sql (1 hunks)
  • apps/backend/prisma/schema.prisma (4 hunks)
  • apps/backend/scripts/run-cron-jobs.ts (1 hunks)
  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts (1 hunks)
  • apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts (1 hunks)
  • apps/backend/src/app/api/latest/internal/external-db-sync/sync-engine/route.tsx (1 hunks)
  • apps/backend/src/lib/external-db-sync.ts (1 hunks)
  • apps/backend/src/route-handlers/smart-route-handler.tsx (1 hunks)
  • apps/e2e/package.json (1 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts (1 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts (1 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts (1 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts (1 hunks)
  • docker/dependencies/docker.compose.yaml (2 hunks)
  • package.json (1 hunks)
  • packages/stack-shared/src/config/db-sync-mappings.ts (1 hunks)
  • packages/stack-shared/src/config/schema-fuzzer.test.ts (1 hunks)
  • packages/stack-shared/src/config/schema.ts (4 hunks)
  • vercel.json (1 hunks)
🧰 Additional context used
📓 Path-based instructions (5)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: For blocking alerts and errors, never use toast, as they are easily missed by the user. Instead, use alerts
Keep hover/click transitions snappy and fast. Don't delay the action with a pre-transition; apply transitions after the action instead
NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error). Use runAsynchronously or runAsynchronouslyWithAlert instead for asynchronous error handling
When creating hover transitions, avoid hover-enter transitions and just use hover-exit transitions (e.g., transition-colors hover:transition-none)
Use ES6 maps instead of records wherever possible

Files:

  • packages/stack-shared/src/config/schema-fuzzer.test.ts
  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
  • packages/stack-shared/src/config/db-sync-mappings.ts
  • apps/backend/src/app/api/latest/internal/external-db-sync/sync-engine/route.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts
  • apps/backend/src/route-handlers/smart-route-handler.tsx
  • apps/backend/scripts/run-cron-jobs.ts
  • apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts
  • apps/backend/src/lib/external-db-sync.ts
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts
  • packages/stack-shared/src/config/schema.ts
**/*.{test,spec}.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

When writing tests, prefer .toMatchInlineSnapshot over other selectors, if possible

Files:

  • packages/stack-shared/src/config/schema-fuzzer.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts
apps/backend/src/app/api/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

The project uses a custom route handler system in the backend for consistent API responses

Files:

  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
  • apps/backend/src/app/api/latest/internal/external-db-sync/sync-engine/route.tsx
  • apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts
apps/{backend,dashboard}/src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

NEVER use Next.js dynamic functions if you can avoid them. Instead, prefer using a client component and prefer usePathname instead of await params

Files:

  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
  • apps/backend/src/app/api/latest/internal/external-db-sync/sync-engine/route.tsx
  • apps/backend/src/route-handlers/smart-route-handler.tsx
  • apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts
  • apps/backend/src/lib/external-db-sync.ts
packages/stack-shared/src/config/schema.ts

📄 CodeRabbit inference engine (AGENTS.md)

Whenever making backwards-incompatible changes to the config schema, update the migration functions in packages/stack-shared/src/config/schema.ts

Files:

  • packages/stack-shared/src/config/schema.ts
🧠 Learnings (4)
📚 Learning: 2025-11-28T21:21:39.123Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:21:39.123Z
Learning: Applies to packages/stack-shared/src/config/schema.ts : Whenever making backwards-incompatible changes to the config schema, update the migration functions in `packages/stack-shared/src/config/schema.ts`

Applied to files:

  • packages/stack-shared/src/config/schema-fuzzer.test.ts
  • packages/stack-shared/src/config/db-sync-mappings.ts
  • apps/backend/prisma/migrations/20251128210004_add_sync_functions/migration.sql
  • apps/backend/package.json
  • apps/backend/prisma/schema.prisma
  • apps/backend/src/lib/external-db-sync.ts
  • packages/stack-shared/src/config/schema.ts
📚 Learning: 2025-11-28T21:21:39.123Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:21:39.123Z
Learning: Applies to apps/backend/src/app/api/**/*.{ts,tsx} : The project uses a custom route handler system in the backend for consistent API responses

Applied to files:

  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
  • apps/backend/src/app/api/latest/internal/external-db-sync/sync-engine/route.tsx
  • apps/backend/src/route-handlers/smart-route-handler.tsx
  • apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts
📚 Learning: 2025-11-28T21:21:39.123Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:21:39.123Z
Learning: Always add new E2E tests when changing the API or SDK interface

Applied to files:

  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts
📚 Learning: 2025-11-28T21:21:39.123Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:21:39.123Z
Learning: Applies to apps-{frontend,config}.ts{,x} : To update the list of apps available, edit `apps-frontend.tsx` and `apps-config.ts`

Applied to files:

  • packages/stack-shared/src/config/schema.ts
🧬 Code graph analysis (6)
apps/backend/src/app/api/latest/internal/external-db-sync/sync-engine/route.tsx (5)
apps/backend/src/route-handlers/smart-route-handler.tsx (1)
  • createSmartRouteHandler (185-270)
packages/stack-shared/src/schema-fields.ts (5)
  • yupObject (247-251)
  • yupTuple (217-221)
  • yupString (187-190)
  • yupNumber (191-194)
  • yupBoolean (195-198)
apps/backend/src/lib/upstash.tsx (1)
  • ensureUpstashSignature (16-35)
apps/backend/src/lib/tenancies.tsx (1)
  • getTenancy (82-91)
apps/backend/src/lib/external-db-sync.ts (1)
  • syncExternalDatabases (192-203)
apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts (2)
apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts (6)
  • TestDbManager (16-67)
  • createProjectWithExternalDb (209-215)
  • waitForTable (150-169)
  • waitForCondition (73-88)
  • TEST_TIMEOUT (10-10)
  • waitForSyncedDeletion (125-145)
apps/e2e/tests/backend/backend-helpers.ts (1)
  • niceBackendFetch (109-173)
apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts (2)
apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts (9)
  • TestDbManager (16-67)
  • createProjectWithExternalDb (209-215)
  • waitForSyncedData (93-120)
  • verifyInExternalDb (182-189)
  • TEST_TIMEOUT (10-10)
  • waitForSyncedDeletion (125-145)
  • verifyNotInExternalDb (174-177)
  • waitForTable (150-169)
  • waitForCondition (73-88)
apps/e2e/tests/backend/backend-helpers.ts (1)
  • niceBackendFetch (109-173)
apps/backend/scripts/run-cron-jobs.ts (3)
packages/stack-shared/src/utils/env.tsx (1)
  • getEnvVariable (16-58)
packages/stack-shared/src/utils/promises.tsx (1)
  • runAsynchronously (343-366)
packages/stack-shared/src/utils/errors.tsx (1)
  • StackAssertionError (69-85)
apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts (2)
apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts (9)
  • TestDbManager (16-67)
  • createProjectWithExternalDb (209-215)
  • waitForCondition (73-88)
  • TEST_TIMEOUT (10-10)
  • waitForTable (150-169)
  • waitForSyncedData (93-120)
  • HIGH_VOLUME_TIMEOUT (11-11)
  • waitForSyncedDeletion (125-145)
  • verifyNotInExternalDb (174-177)
packages/stack-shared/src/config/db-sync-mappings.ts (1)
  • DEFAULT_DB_SYNC_MAPPINGS (1-166)
packages/stack-shared/src/config/schema.ts (2)
packages/stack-shared/src/schema-fields.ts (5)
  • yupObject (247-251)
  • yupRecord (283-322)
  • userSpecifiedIdSchema (442-442)
  • yupString (187-190)
  • yupTuple (217-221)
packages/stack-shared/src/config/db-sync-mappings.ts (1)
  • DEFAULT_DB_SYNC_MAPPINGS (1-166)
🪛 GitHub Check: restart-dev-and-test
apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts

[warning] 334-334:
Trailing spaces not allowed


[warning] 334-334:
Trailing spaces not allowed

apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts

[failure] 223-223:
Expected indentation of 2 spaces but found 4


[failure] 222-222:
Expected indentation of 4 spaces but found 6


[failure] 221-221:
Expected indentation of 2 spaces but found 4


[warning] 31-31:
Trailing spaces not allowed


[failure] 223-223:
Expected indentation of 2 spaces but found 4


[failure] 222-222:
Expected indentation of 4 spaces but found 6


[failure] 221-221:
Expected indentation of 2 spaces but found 4


[warning] 31-31:
Trailing spaces not allowed

🪛 GitHub Check: restart-dev-and-test-with-custom-base-port
apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts

[warning] 334-334:
Trailing spaces not allowed


[warning] 334-334:
Trailing spaces not allowed

apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts

[failure] 223-223:
Expected indentation of 2 spaces but found 4


[failure] 222-222:
Expected indentation of 4 spaces but found 6


[failure] 221-221:
Expected indentation of 2 spaces but found 4


[warning] 31-31:
Trailing spaces not allowed


[failure] 223-223:
Expected indentation of 2 spaces but found 4


[failure] 222-222:
Expected indentation of 4 spaces but found 6


[failure] 221-221:
Expected indentation of 2 spaces but found 4


[warning] 31-31:
Trailing spaces not allowed

🪛 GitHub Check: setup-tests
apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts

[warning] 334-334:
Trailing spaces not allowed


[warning] 334-334:
Trailing spaces not allowed

apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts

[failure] 223-223:
Expected indentation of 2 spaces but found 4


[failure] 222-222:
Expected indentation of 4 spaces but found 6


[failure] 221-221:
Expected indentation of 2 spaces but found 4


[warning] 31-31:
Trailing spaces not allowed


[failure] 223-223:
Expected indentation of 2 spaces but found 4


[failure] 222-222:
Expected indentation of 4 spaces but found 6


[failure] 221-221:
Expected indentation of 2 spaces but found 4


[warning] 31-31:
Trailing spaces not allowed

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
  • GitHub Check: CodeQL analysis (javascript-typescript)
  • GitHub Check: Vercel Agent Review
  • GitHub Check: Cursor Bugbot
  • GitHub Check: claude-review
🔇 Additional comments (38)
package.json (1)

68-68: LGTM!

The addition of @types/pg to the monorepo root devDependencies provides TypeScript type support for PostgreSQL interactions across backend migrations, utilities, and end-to-end tests introduced in this PR.

apps/e2e/package.json (1)

21-23: LGTM!

The additions of pg and @types/pg to e2e devDependencies enable end-to-end tests to interact with external PostgreSQL databases provisioned by the new external-db-test service. Version pinning is consistent with the monorepo root.

docker/dependencies/docker.compose.yaml (1)

207-231: LGTM!

The new external-db-test PostgreSQL service follows the established patterns used by other test databases in the compose file (port mapping with NEXT_PUBLIC_STACK_PORT_PREFIX, volume persistence, hardcoded dev credentials). This isolates external DB sync tests from the main database and enables reproducible e2e testing.

apps/backend/package.json (2)

10-10: LGTM!

The addition of run-cron-jobs to the dev concurrency labels integrates the background cron scheduler into local development, enabling testing of the sequencer and poller workflows that drive external DB synchronization.

Please verify that scripts/run-cron-jobs.ts properly handles process signals (SIGTERM, SIGINT) so that pnpm run dev terminates cleanly without leaving orphaned cron processes.


38-39: LGTM!

The run-cron-jobs script follows the established pattern: wrapped with with-env for environment setup and uses tsx for TypeScript execution. Dependencies (pg and tsx) are already present in the file.

apps/backend/prisma/migrations/20251125180000_add_deleted_row_table/migration.sql (1)

15-19: LGTM!

The indexes on tenancyId, tableName, and sequenceId are well-chosen for the expected query patterns: filtering by tenant, scanning deleted rows from specific tables, and correlating with sequence IDs for sync ordering.

vercel.json (1)

2-11: LGTM!

The cron configuration correctly schedules both the poller and sequencer endpoints to run every minute. This aligns with the internal route implementations that handle authentication via CRON_SECRET.

packages/stack-shared/src/config/schema-fuzzer.test.ts (1)

52-72: LGTM!

The dbSync fuzzer configuration properly mirrors the new schema structure with representative test values for all fields including externalDatabases, mappings, and their nested properties.

apps/backend/src/route-handlers/smart-route-handler.tsx (1)

101-102: Verify the logging removal is intentional.

Based on the AI summary, this change removes development-time logging including request/response details and timing measurements. While this reduces log noise, ensure you have alternative observability (e.g., via Sentry spans at lines 71-80 and 232-248) to debug production issues.

apps/backend/prisma/migrations/20251128210003_log_deleted_row_function_and_triggers/migration.sql (2)

44-54: Triggers are correctly attached.

Both triggers are appropriately configured as BEFORE DELETE FOR EACH ROW, which ensures the OLD record is available for logging before the actual deletion occurs.


2-42: Well-structured trigger function for audit logging.

The implementation correctly uses BEFORE DELETE to capture OLD row data and dynamically extracts primary key columns via pg_index/pg_attribute. Both ProjectUser and ContactChannel tables have the required tenancyId column, so the function will work as implemented. However, a couple of observations remain:

  1. If additional tables are added to this trigger function in the future without a tenancyId column, the function will fail at runtime. Consider documenting this requirement or adding a check.
  2. The dynamic PK extraction loop runs on every delete. For high-volume deletes, consider whether this overhead is acceptable or if caching the PK structure would be beneficial.
packages/stack-shared/src/config/schema.ts (2)

204-223: New dbSync schema added to branch config.

The schema structure looks reasonable. A few observations:

  1. connectionString is marked as defined() (required), but the defaults (Lines 594-595) set type and connectionString to undefined. This could cause validation issues when a database entry exists but isn't fully configured.

  2. The targetTablePrimaryKey uses yupTuple([yupString().defined()]) which only allows exactly one string element. If composite primary keys with multiple columns are needed, consider using yupArray(yupString().defined()) instead.

  3. Per the coding guidelines for this file, backwards-incompatible changes to the config schema require migration functions in migrateConfigOverride. Since this is a new field with defaults, existing configs should remain valid, but please confirm this is intentional.

Based on learnings, whenever making backwards-incompatible changes to the config schema, update the migration functions. Please verify that adding dbSync doesn't require a migration for existing projects.


591-598: Defaults for dbSync.externalDatabases look consistent with the schema.

The default factory function returns undefined for type and connectionString, with DEFAULT_DB_SYNC_MAPPINGS for mappings. This aligns with the schema where these fields are optional until explicitly configured.

apps/backend/scripts/run-cron-jobs.ts (1)

32-36: Error handling for main() follows project conventions.

The runAsynchronously usage for the run function and the .catch() on main() with process.exit(1) is appropriate for a standalone script. The eslint disable comment acknowledges the intentional deviation.

apps/backend/src/app/api/latest/internal/external-db-sync/sync-engine/route.tsx (1)

7-31: Route handler structure follows project conventions.

The use of createSmartRouteHandler with metadata, request/response schemas aligns with the project's custom route handler system. The Upstash signature validation via ensureUpstashSignature is appropriate for webhook security.

apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts (2)

38-42: Auth check is straightforward and correct.

The Bearer token comparison against CRON_SECRET is appropriate for internal cron-triggered endpoints. The 401 StatusError on mismatch is the correct response.


14-37: Route handler metadata and schema are well-defined.

The hidden: true flag appropriately hides this internal endpoint from public documentation. The response schema correctly reflects the returned structure.

apps/backend/prisma/migrations/20251128210002_add_reset_sequence_id_function_and_triggers/migration.sql (1)

1-29: LGTM!

The trigger logic correctly re-flags rows for sequence ID updates: when a processed row (shouldUpdateSequenceId = FALSE) gets modified, the trigger resets it to TRUE so the sequencer picks it up again. The BEFORE UPDATE timing ensures the flag is set before the row is committed.

apps/backend/prisma/migrations/20251128210004_add_sync_functions/migration.sql (1)

24-99: LGTM! Batched processing with concurrency-safe locking.

The FOR UPDATE SKIP LOCKED pattern correctly handles concurrent sequencer invocations without deadlocks. Each table is limited to 1000 rows per invocation—ensure the sequencer cron runs frequently enough (50ms as documented) to drain backlogs during high-write periods.

apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts (1)

222-750: Well-structured race condition test suite.

The tests comprehensively cover critical scenarios: uncommitted visibility, commit ordering, multi-row transactions, and sequence ID precedence. The finally blocks ensure proper client cleanup even on test failures.

packages/stack-shared/src/config/db-sync-mappings.ts (2)

77-164: Correct sequence-based conflict resolution.

The asymmetric comparison (>= for deletes, > for upserts) ensures delete events at the same sequence ID take precedence—preventing stale data resurrection. The ON CONFLICT clause's final guard (EXCLUDED."sequenceId" > "PartialUsers"."sequenceId") adds a second layer of protection against out-of-order processing.


1-76: LGTM!

The mapping correctly handles the BooleanTrue enum conversion (line 34) and uses GREATEST() to track the maximum sequence ID across the joined tables. The LIMIT 1000 ORDER BY sequenceId enables paginated incremental fetching.

apps/backend/prisma/schema.prisma (1)

176-178: LGTM!

The schema additions are well-designed: @unique on sequenceId prevents duplicates, @default(true) on shouldUpdateSequenceId ensures new rows are automatically queued for sequencing, and the OutgoingRequest index on fulfilledAt supports efficient pending-request lookups.

Also applies to: 258-260, 884-893

apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts (6)

19-29: LGTM!

Test setup and teardown are properly structured with describe.sequential to avoid concurrency issues, and the TestDbManager lifecycle is correctly managed.


39-157: LGTM!

Multi-tenant isolation test is thorough - it properly validates that User A only appears in Project A's databases and User B only in Project B's, with explicit negative assertions to confirm no cross-tenant leakage.


167-237: LGTM!

The sequenceId tracking test correctly validates ordering guarantees using BigInt for comparison, which is appropriate for potentially large sequence values.


434-505: LGTM!

The high-volume batching test is well-designed with retry logic, unique email generation, and appropriate timeout handling for the 200-user stress test.


625-692: LGTM!

The write protection test properly validates that readonly PostgreSQL credentials prevent unauthorized modifications while still allowing reads - this is a valuable security verification.


883-980: LGTM!

The complex lifecycle test thoroughly exercises the full create → update → update → delete → recreate → update sequence with proper sequenceId tracking and ID verification at each step.

apps/backend/src/lib/external-db-sync.ts (1)

6-8: LGTM!

Simple and clean configuration accessor function.

apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts (5)

1-17: LGTM!

Imports and test suite setup are correctly structured with describe.sequential to avoid shared state issues.


37-60: LGTM!

Insert test correctly validates that new users appear in the external DB after sync completes.


113-150: LGTM!

Delete test properly validates the full deletion flow including verifying 404 response from the internal API before checking external DB sync.


304-333: LGTM!

Resilience test is valuable - it confirms that one bad database configuration doesn't block synchronization to healthy databases.


520-565: LGTM!

The race condition protection test cleverly simulates an out-of-order delete by directly issuing a conditional DELETE against the external DB, validating that sequence-based guards work correctly.

apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts (3)

16-67: LGTM!

TestDbManager class is well-designed with proper resource management - unique database names prevent collisions, and cleanup safely handles errors during database drops.


73-88: LGTM!

Generic polling helper with configurable timeout, interval, and descriptive error messages - clean and reusable.


93-120: LGTM!

waitForSyncedData properly handles the case where the table doesn't exist yet (error code 42P01) and supports optional name verification.

Comment on lines 1 to 12
CREATE TABLE "DeletedRow" (
"id" UUID NOT NULL,
"tenancyId" UUID NOT NULL,
"tableName" TEXT NOT NULL,
"sequenceId" BIGINT,
"primaryKey" JSONB NOT NULL,
"data" JSONB,
"deletedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"fulfilledAt" TIMESTAMP(3),

CONSTRAINT "DeletedRow_pkey" PRIMARY KEY ("id")
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find the tenancy table definition and schema files
find . -type f \( -name "*.prisma" -o -name "*.sql" \) | head -20

Repository: stack-auth/stack-auth

Length of output: 1667


🏁 Script executed:

# Search for Tenancy table references in migration files
rg -i "tenancy|tenant" --type sql | head -30

Repository: stack-auth/stack-auth

Length of output: 2870


🏁 Script executed:

# Look for the Prisma schema file
find . -name "schema.prisma" -o -name "*.prisma" | head -10

Repository: stack-auth/stack-auth

Length of output: 100


🏁 Script executed:

# Read the Prisma schema to understand Tenancy model
cat ./apps/backend/prisma/schema.prisma | head -100

Repository: stack-auth/stack-auth

Length of output: 2728


🏁 Script executed:

# Check the specific migration file mentioned in the review
cat ./apps/backend/prisma/migrations/20251125180000_add_deleted_row_table/migration.sql

Repository: stack-auth/stack-auth

Length of output: 632


🏁 Script executed:

# Search for other tables with tenancyId to see FK patterns
rg "FOREIGN KEY.*tenancyId" apps/backend/prisma/migrations/ | head -10

Repository: stack-auth/stack-auth

Length of output: 3153


🏁 Script executed:

# Search for DeletedRow references in the codebase to understand its purpose
rg "DeletedRow" --type-list | head -5

Repository: stack-auth/stack-auth

Length of output: 454


🏁 Script executed:

# Search for any Prisma model definition for DeletedRow
rg "model DeletedRow" apps/backend/prisma/schema.prisma -A 20

Repository: stack-auth/stack-auth

Length of output: 423


🏁 Script executed:

# Check if there's any recent discussion or comments about this table
rg -i "deleted.*row|sync.*deletion" apps/backend/prisma/migrations/ | grep -i comment

Repository: stack-auth/stack-auth

Length of output: 47


🏁 Script executed:

# Search for DeletedRow usage in code files (not just migrations)
find apps/backend -type f \( -name "*.ts" -o -name "*.js" \) -exec rg "DeletedRow" {} + | head -20

Repository: stack-auth/stack-auth

Length of output: 47


🏁 Script executed:

# Check the broader migration context - look at surrounding migrations
ls -la apps/backend/prisma/migrations/ | grep "2025" | tail -5

Repository: stack-auth/stack-auth

Length of output: 571


🏁 Script executed:

# Check if there are any follow-up migrations that might add the FK
rg "DeletedRow" apps/backend/prisma/migrations/ --type sql

Repository: stack-auth/stack-auth

Length of output: 1667


🏁 Script executed:

# Look at the sync functions to understand DeletedRow purpose
cat apps/backend/prisma/migrations/20251128210004_add_sync_functions/migration.sql | head -100

Repository: stack-auth/stack-auth

Length of output: 2821


🏁 Script executed:

# Check for any code comments or documentation files mentioning DeletedRow or sync strategy
find apps/backend -type f \( -name "*.md" -o -name "*.ts" -o -name "*.js" \) -exec grep -l "DeletedRow\|deleted.*row" {} \; 2>/dev/null | head -10

Repository: stack-auth/stack-auth

Length of output: 47


🏁 Script executed:

# Look at the trigger that logs to DeletedRow to understand when it's populated
cat apps/backend/prisma/migrations/20251128210003_log_deleted_row_function_and_triggers/migration.sql

Repository: stack-auth/stack-auth

Length of output: 1189


Add foreign key constraint on tenancyId to maintain referential integrity.

The DeletedRow table tracks deletions for external database synchronization but lacks a foreign key constraint on tenancyId, creating orphaned records if a tenancy is deleted. This is inconsistent with other tables in the schema that reference tenancies. Add:

ALTER TABLE "DeletedRow" ADD CONSTRAINT "DeletedRow_tenancyId_fkey" FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE RESTRICT;

Using ON DELETE RESTRICT prevents accidental tenancy deletion while there are pending syncs, which is appropriate for this audit log pattern.

🤖 Prompt for AI Agents
In
apps/backend/prisma/migrations/20251125180000_add_deleted_row_table/migration.sql
around lines 1 to 12, the "DeletedRow" table is missing a foreign key on
tenancyId causing possible orphaned records; add a foreign key constraint named
"DeletedRow_tenancyId_fkey" that references "Tenancy"(id) with ON DELETE
RESTRICT to preserve referential integrity and prevent deletion of tenancies
while pending deleted-row syncs exist.

Comment on lines 52 to 66
try {
await syncExternalDatabases(tenancy);
} catch (error: any) {
console.error(` Error syncing external databases for tenant ${tenantId}:`, error);
}

return {
statusCode: 200,
bodyType: "json",
body: {
success: true,
tenantId,
timestamp,
},
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Returns success: true even when sync fails.

The handler catches errors from syncExternalDatabases at Lines 52-56 but still returns success: true at Line 61. This could mask sync failures from the caller. Consider returning success: false when an error occurs:

+   let syncSuccess = true;
    try {
      await syncExternalDatabases(tenancy);
    } catch (error: any) {
      console.error(` Error syncing external databases for tenant ${tenantId}:`, error);
+     syncSuccess = false;
    }

    return {
      statusCode: 200,
      bodyType: "json",
      body: {
-       success: true,
+       success: syncSuccess,
        tenantId,
        timestamp,
      },
    };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
await syncExternalDatabases(tenancy);
} catch (error: any) {
console.error(` Error syncing external databases for tenant ${tenantId}:`, error);
}
return {
statusCode: 200,
bodyType: "json",
body: {
success: true,
tenantId,
timestamp,
},
};
let syncSuccess = true;
try {
await syncExternalDatabases(tenancy);
} catch (error: any) {
console.error(` Error syncing external databases for tenant ${tenantId}:`, error);
syncSuccess = false;
}
return {
statusCode: 200,
bodyType: "json",
body: {
success: syncSuccess,
tenantId,
timestamp,
},
};
🤖 Prompt for AI Agents
In
apps/backend/src/app/api/latest/internal/external-db-sync/sync-engine/route.tsx
around lines 52 to 66, the try/catch logs syncExternalDatabases errors but the
handler always returns success: true; update the handler to return success:
false (and an appropriate error message/status) when an exception is caught:
capture the error in the catch block, set success to false and include error
details (or a sanitized message) and an appropriate HTTP status code in the
returned body so callers can detect failures.

}
}

const rows = await internalPrisma.$queryRawUnsafe<any[]>(fetchQuery, tenancyId);
Copy link
Contributor

@coderabbitai coderabbitai bot Nov 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there's validation for mapping queries in the config schema
rg -n "internalDbFetchQuery|externalDbUpdateQuery" --type ts -A 3 -B 3

Repository: stack-auth/stack-auth

Length of output: 6462


🏁 Script executed:

#!/bin/bash
# Check how mappings are loaded and where they come from
rg -n "externalDbSyncMappings|DB_SYNC_MAPPINGS" --type ts -A 2 -B 2 | head -100

Repository: stack-auth/stack-auth

Length of output: 2787


🏁 Script executed:

#!/bin/bash
# Look at the actual query definitions to see how tenancyId is used
cat packages/stack-shared/src/config/db-sync-mappings.ts | head -80

Repository: stack-auth/stack-auth

Length of output: 3583


🏁 Script executed:

#!/bin/bash
# Check for any validation logic that ensures queries are safe
rg -n "internalDbFetchQuery|externalDbUpdateQuery" apps/backend/src/lib/external-db-sync.ts -B 5 -A 5

Repository: stack-auth/stack-auth

Length of output: 1525


Add validation to ensure mapping queries use parameter placeholders

Mapping configurations (internalDbFetchQuery, externalDbUpdateQuery) are user-configurable but only validated as strings. If users provide custom queries without using parameter placeholders (e.g., $1, $2), SQL injection is possible. Add schema validation or runtime checks to enforce that user-provided queries use parameterized queries for any dynamic values, not string interpolation.

🤖 Prompt for AI Agents
In apps/backend/src/lib/external-db-sync.ts around line 133, the code executes
user-provided SQL via $queryRawUnsafe without verifying that the user-supplied
mapping queries use parameter placeholders; add a runtime validation step (or
extend the configuration schema) before executing fetch/update queries to ensure
any dynamic values use positional parameter placeholders like $1, $2 (e.g., with
a simple regex /\$\d+/) and reject or throw a clear error if placeholders are
absent or inconsistent with expected parameter count; apply this check to both
internalDbFetchQuery and externalDbUpdateQuery, and fail-fast (throw or reject
configuration) rather than executing unsafe raw SQL.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good bot

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@N2D4, glad to help! Let me know if you need any clarification on implementing the validation check for the mapping queries. 🐰


🧠 Learnings used
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:21:39.123Z
Learning: Applies to packages/stack-shared/src/config/schema.ts : Whenever making backwards-incompatible changes to the config schema, update the migration functions in `packages/stack-shared/src/config/schema.ts`

Comment on lines 184 to 188
} catch (error: any) {
console.error(`Error syncing external DB ${dbId}:`, error);
} finally {
await externalClient.end();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Errors are silently swallowed

Sync errors are logged but not propagated. This means:

  1. Callers cannot detect partial failures
  2. Monitoring/alerting may miss issues
  3. The function appears successful even when databases fail to sync

Consider collecting errors and either throwing an aggregate error or returning a result object with success/failure details.

-  } catch (error: any) {
-    console.error(`Error syncing external DB ${dbId}:`, error);
+  } catch (error: any) {
+    console.error(`Error syncing external DB ${dbId}:`, error);
+    throw error; // Or collect and aggregate errors
   } finally {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error: any) {
console.error(`Error syncing external DB ${dbId}:`, error);
} finally {
await externalClient.end();
}
} catch (error: any) {
console.error(`Error syncing external DB ${dbId}:`, error);
throw error; // Or collect and aggregate errors
} finally {
await externalClient.end();
}
🤖 Prompt for AI Agents
In apps/backend/src/lib/external-db-sync.ts around lines 184 to 188, the catch
block only logs errors and the function always proceeds to finally/return, which
swallows failures; modify the function to capture sync errors and propagate them
by either (a) aggregating errors into a single Error (or a custom
AggregateError) and rethrowing after finally, or (b) changing the function
signature to return a result object that includes per-db success/failure details
and any error messages; ensure externalClient.end() remains in finally, collect
any caught error into a local variable/array, and after the finally block either
throw the aggregated error or return the result object so callers can detect and
act on partial/complete failures.

expect(res.rows.length).toBe(1);
expect(res.rows[0].displayName).toBe('Resilience User');
}, TEST_TIMEOUT);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix trailing whitespace

Static analysis reports trailing spaces on this line.

-  
+
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
🧰 Tools
🪛 GitHub Check: restart-dev-and-test

[warning] 334-334:
Trailing spaces not allowed


[warning] 334-334:
Trailing spaces not allowed

🪛 GitHub Check: restart-dev-and-test-with-custom-base-port

[warning] 334-334:
Trailing spaces not allowed


[warning] 334-334:
Trailing spaces not allowed

🪛 GitHub Check: setup-tests

[warning] 334-334:
Trailing spaces not allowed


[warning] 334-334:
Trailing spaces not allowed

🤖 Prompt for AI Agents
In apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts
around line 334 there is trailing whitespace at the end of the line; remove the
extra spaces (trim the line ending) so the file has no trailing space on that
line, and re-run the linter/formatter to ensure no other trailing whitespace
remains.

Comment on lines 54 to 68
UPDATE "OutgoingRequest"
SET "fulfilledAt" = NOW()
WHERE "id" IN (
SELECT id
FROM "OutgoingRequest"
WHERE "fulfilledAt" IS NULL
ORDER BY "createdAt"
LIMIT 100
FOR UPDATE SKIP LOCKED
)
RETURNING *;
`;
return rows;
});
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outgoing requests are marked as fulfilled before being sent to QStash, so if the HTTP request fails, they're lost forever and never retried.

View Details
📝 Patch Details
diff --git a/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts b/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
index dcdaee3b..533e2478 100644
--- a/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
+++ b/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
@@ -51,17 +51,12 @@ export const GET = createSmartRouteHandler({
     async function claimPendingRequests(): Promise<OutgoingRequest[]> {
       return await retryTransaction(globalPrismaClient, async (tx) => {
         const rows = await tx.$queryRaw<OutgoingRequest[]>`
-          UPDATE "OutgoingRequest"
-          SET "fulfilledAt" = NOW()
-          WHERE "id" IN (
-            SELECT id
-            FROM "OutgoingRequest"
-            WHERE "fulfilledAt" IS NULL
-            ORDER BY "createdAt"
-            LIMIT 100
-            FOR UPDATE SKIP LOCKED
-          )
-          RETURNING *;
+          SELECT "id", "createdAt", "qstashOptions", "fulfilledAt"
+          FROM "OutgoingRequest"
+          WHERE "fulfilledAt" IS NULL
+          ORDER BY "createdAt"
+          LIMIT 100
+          FOR UPDATE SKIP LOCKED;
         `;
         return rows;
       });
@@ -92,6 +87,15 @@ export const GET = createSmartRouteHandler({
             body: options.body,
           });
 
+          // Only mark as fulfilled after successful QStash publish
+          await retryTransaction(globalPrismaClient, async (tx) => {
+            await tx.$queryRaw`
+              UPDATE "OutgoingRequest"
+              SET "fulfilledAt" = NOW()
+              WHERE "id" = ${request.id};
+            `;
+          });
+
           processed++;
         } catch (error) {
           console.error(

Analysis

Requests marked as fulfilled before QStash publish causes data loss on QStash failures

What fails: claimPendingRequests() marks outgoing requests as fulfilled (sets fulfilledAt) inside a database transaction that commits immediately, before the requests are sent to QStash. If upstash.publishJSON() fails in processRequests(), the error is caught and logged, but the request remains marked as fulfilled in the database and is never retried.

How to reproduce:

  1. Create a pending OutgoingRequest in the database with fulfilledAt IS NULL
  2. Configure the poller to call an unreachable or error-prone QStash endpoint
  3. Call the poller endpoint: GET /api/latest/internal/external-db-sync/poller with valid CRON_SECRET
  4. Observe: upstash.publishJSON() fails, error is logged, but request has fulfilledAt set
  5. Run poller again - the request is no longer picked up (WHERE clause filters out rows with fulfilledAt IS NOT NULL)

Result: Outgoing request is permanently lost despite QStash failure. The error log shows the request failed, but there's no way to retry it.

Expected: Only mark requests as fulfilled AFTER successfully sending them to QStash. If QStash fails, the request should remain in pending state and be picked up in the next poller run for retry.

Files modified:

  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts

Fix applied:

  • Changed claimPendingRequests() from UPDATE + RETURNING to SELECT only, removing the fulfilledAt = NOW() update
  • Moved the fulfilledAt UPDATE to processRequests() after successful upstash.publishJSON() call
  • This ensures requests only marked as fulfilled if QStash publish succeeds

tenantId,
timestamp,
},
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Sync engine returns success even when sync fails

The handler catches errors from syncExternalDatabases but still returns success: true in the response regardless of whether the sync succeeded or failed. When an exception is caught at line 54-56, the code logs the error but continues to the return statement which always sets success: true. This means callers (like QStash) cannot distinguish between successful and failed syncs, which could lead to data inconsistencies going undetected.

Fix in Cursor Fix in Web

`Failed to process outgoing request ${request.id}:`,
error,
);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Failed outgoing requests are permanently lost

The claimPendingRequests function atomically marks requests as fulfilled (SET "fulfilledAt" = NOW()) before they're actually processed. If upstash.publishJSON subsequently fails in processRequests, the error is logged but the request is already marked as fulfilled in the database. This causes failed sync requests to be permanently lost with no retry mechanism, potentially leaving external databases out of sync.

Fix in Cursor Fix in Web

setInterval(() => {
run(endpoint);
}, 60000);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Cron jobs don't run immediately on startup

The cron job script uses setInterval without an initial invocation of run(endpoint). This means when the dev server starts, there's a 60-second delay before the sequencer and poller first execute. Any database changes during this initial period won't trigger external sync until after the first interval fires.

Fix in Cursor Fix in Web

Copy link
Contributor

@N2D4 N2D4 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks awesome! i have a lot of comments but overall this is very exciting :]

- i really like how much of this PR is tests (almost two-thirds of all lines of code), made it very pleasant to review :]
- important: every time something is different than what we expect (eg. some assertion is not true, etc) it should always be loud and annoying. the worst thing that could happen is that an error is quiet, bothering users but not appearing in our alerts on sentry. throw new StackAssertionError​ is the preferred way to do this (or the throwErr​ function which does the same), with captureError for the cases where that doesn't work.
- good job on separating things into files! this folder structure makes a lot of sense, i think
- make sure to really understand the security sensitive parts of the PR. in particular i think the "grant access to the Users table to everyone" in the table schema query and the custom configs are really dangerous, a less thorough reviewer may have missed them

$$ LANGUAGE plpgsql;
-- SPLIT_STATEMENT_SENTINEL
-- SINGLE_STATEMENT_SENTINEL
CREATE FUNCTION backfill_null_sequence_ids()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is no longer on pgcron now, right? do we still need this? can't we just have this query in the sequencer endpoint?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done - moved it into sequencer

Comment on lines 176 to 177
sequenceId BigInt? @unique
shouldUpdateSequenceId Boolean @default(true)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these fields should be indexed, i think?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done - added indexes

Comment on lines 890 to 892
fulfilledAt DateTime?
@@index([fulfilledAt])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we can rename this to "startedFulfillingAt" just to make it clearer for the future

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done - renamed

Comment on lines 258 to 259
sequenceId BigInt? @unique
shouldUpdateSequenceId Boolean @default(true)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as above

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done - added indexes

Comment on lines 900 to 901
sequenceId BigInt? @unique
shouldUpdateSequenceId Boolean @default(true)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as above

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

);
}

if (newRows.length === 0) return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unnecessary

Comment on lines 23 to 46
const placeholderMatches = upsertQuery.match(/\$\d+/g) ?? [];
const expectedParamCount =
placeholderMatches.length === 0
? 0
: Math.max(...placeholderMatches.map((m) => Number(m.slice(1))));

if (expectedParamCount === 0) {
throw new Error(
`upsertQuery for table "${tableName}" contains no positional parameters ($1, $2, ...).` +
` Your mapping must use parameterized SQL.`
);
}
const sampleRow = newRows[0];
const { tenancyId: _ignore, ...restSample } = sampleRow;
const orderedKeys = Object.keys(restSample);

if (orderedKeys.length !== expectedParamCount) {
throw new Error(
` Column count mismatch for table "${tableName}".\n` +
`→ upsertQuery expects ${expectedParamCount} parameters.\n` +
`→ internalDbFetchQuery returned ${orderedKeys.length} columns (excluding tenancyId).\n` +
`Fix your SELECT column order or your SQL parameter order.`
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const placeholderMatches = upsertQuery.match(/\$\d+/g) ?? [];
const expectedParamCount =
placeholderMatches.length === 0
? 0
: Math.max(...placeholderMatches.map((m) => Number(m.slice(1))));
if (expectedParamCount === 0) {
throw new Error(
`upsertQuery for table "${tableName}" contains no positional parameters ($1, $2, ...).` +
` Your mapping must use parameterized SQL.`
);
}
const sampleRow = newRows[0];
const { tenancyId: _ignore, ...restSample } = sampleRow;
const orderedKeys = Object.keys(restSample);
if (orderedKeys.length !== expectedParamCount) {
throw new Error(
` Column count mismatch for table "${tableName}".\n` +
`→ upsertQuery expects ${expectedParamCount} parameters.\n` +
`→ internalDbFetchQuery returned ${orderedKeys.length} columns (excluding tenancyId).\n` +
`Fix your SELECT column order or your SQL parameter order.`
);
}
// Just for our own sanity, make sure that we have the right number of positional parameters
const placeholderMatches = upsertQuery.match(/\$\d+/g) ?? throwErr(`Could not find any positional parameters ($1, $2, ...) in the update SQL query.`);"
const expectedParamCount = Math.max(...placeholderMatches.map((m) => Number(m.slice(1))));
const sampleRow = newRows[0];
const orderedKeys = Object.keys(omit(sampleRow, "tenancyId"));
if (orderedKeys.length !== expectedParamCount) {
throw new StackAssertionError(deindent`
Column count mismatch for table ${JSON.stringify(tableName)}
upsertQuery expects ${expectedParamCount} parameters.
internalDbFetchQuery returned ${orderedKeys.length} columns (excluding tenancyId).
Fix your SELECT column order or your SQL parameter order.
`);
}

rowKeys.every((k, i) => k === orderedKeys[i]);

if (!validShape) {
throw new Error(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
throw new Error(
throw new StackAssertionError(

Comment on lines 48 to 70
for (const row of newRows) {
const { tenancyId, ...rest } = row;
const rowKeys = Object.keys(rest);

const validShape =
rowKeys.length === orderedKeys.length &&
rowKeys.every((k, i) => k === orderedKeys[i]);

if (!validShape) {
throw new Error(
` Row shape mismatch for table "${tableName}".\n` +
`Expected column order: [${orderedKeys.join(", ")}]\n` +
`Received column order: [${rowKeys.join(", ")}]\n` +
`Your SELECT must be explicit, ordered, and NEVER use SELECT *.\n` +
`Fix the SELECT in internalDbFetchQuery immediately.`
);
}
}
for (const row of newRows) {
const { tenancyId, ...rest } = row;
await externalClient.query(upsertQuery, Object.values(rest));
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these two for-loops can be merged into one

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done - merged

Comment on lines 48 to 69
for (const row of newRows) {
const { tenancyId, ...rest } = row;
const rowKeys = Object.keys(rest);

const validShape =
rowKeys.length === orderedKeys.length &&
rowKeys.every((k, i) => k === orderedKeys[i]);

if (!validShape) {
throw new Error(
` Row shape mismatch for table "${tableName}".\n` +
`Expected column order: [${orderedKeys.join(", ")}]\n` +
`Received column order: [${rowKeys.join(", ")}]\n` +
`Your SELECT must be explicit, ordered, and NEVER use SELECT *.\n` +
`Fix the SELECT in internalDbFetchQuery immediately.`
);
}
}
for (const row of newRows) {
const { tenancyId, ...rest } = row;
await externalClient.query(upsertQuery, Object.values(rest));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we check that the tenancyId is always the same? i think right now we don't do anything with it, right?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done - included a check

@N2D4 N2D4 requested a review from BilalG1 December 1, 2025 01:57
Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3f2a8ef and dcda402.

📒 Files selected for processing (1)
  • apps/backend/scripts/run-cron-jobs.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: For blocking alerts and errors, never use toast, as they are easily missed by the user. Instead, use alerts
Keep hover/click transitions snappy and fast. Don't delay the action with a pre-transition; apply transitions after the action instead
NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error). Use runAsynchronously or runAsynchronouslyWithAlert instead for asynchronous error handling
When creating hover transitions, avoid hover-enter transitions and just use hover-exit transitions (e.g., transition-colors hover:transition-none)
Use ES6 maps instead of records wherever possible

Files:

  • apps/backend/scripts/run-cron-jobs.ts
🧬 Code graph analysis (1)
apps/backend/scripts/run-cron-jobs.ts (4)
packages/stack-shared/src/utils/env.tsx (1)
  • getEnvVariable (16-58)
apps/dashboard/src/components/vibe-coding/chat-adapters.ts (1)
  • run (22-59)
packages/stack-shared/src/utils/promises.tsx (2)
  • runAsynchronously (343-366)
  • wait (260-268)
packages/stack-shared/src/utils/errors.tsx (1)
  • StackAssertionError (69-85)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
  • GitHub Check: Vercel Agent Review
  • GitHub Check: build (22.x)
  • GitHub Check: build (22.x)
  • GitHub Check: build (22.x)
  • GitHub Check: check_prisma_migrations (22.x)
  • GitHub Check: setup-tests
  • GitHub Check: all-good
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: docker
  • GitHub Check: restart-dev-and-test-with-custom-base-port
  • GitHub Check: restart-dev-and-test

aadesh18 and others added 2 commits December 1, 2025 01:44
…er/route.ts

Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
…er/route.ts

Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (3)
apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts (3)

46-49: Consider renaming busySleepMs and revisiting the 2‑minute window

Minor/naming and tuning nit:

  • busySleepMs is a bit misleading since this is not a busy-spin; something like pollSleepMs or loopSleepMs would be clearer.
  • Previous feedback suggested a maxDurationMs of ~3 minutes to tolerate worst‑case Vercel cron skew (e.g. invocations at :05 and :06 still overlapping enough to drain backlogs). If that concern still applies, consider bumping from 2 to 3 minutes.

79-85: Document the host.docker.internal rewrite for dev/test

The dev/test rewrite of localhost/127.0.0.1 to host.docker.internal is subtle and environment‑specific. Adding a short comment here explaining that this is required for Dockerized e2e / local QStash to reach the backend from inside the container would help future readers understand why this block exists and avoid accidentally “simplifying” it away.

You might also cache getNodeEnvironment() in a local variable to avoid calling it twice, though that’s purely cosmetic.


51-68: Avoid marking OutgoingRequest as fulfilled before QStash publish succeeds

Right now claimPendingRequests sets fulfilledAt = NOW() in a transaction (lines 54-64), before processRequests actually calls upstash.publishJSON (lines 88-91). If publishJSON fails, the request stays marked as fulfilled and is never retried, so the external DB can silently fall out of sync. This matches prior automated feedback and should be treated as at-least-once delivery instead of maybe-once.

A safer pattern here is:

  • In claimPendingRequests, only select pending rows with FOR UPDATE SKIP LOCKED and do not touch fulfilledAt.
  • After a successful publishJSON call for a given request, set fulfilledAt = NOW() in a separate transaction.
  • Leave failed publishes with fulfilledAt IS NULL so they get picked up again on the next poll.

Concretely:

-    async function claimPendingRequests(): Promise<OutgoingRequest[]> {
-      return await retryTransaction(globalPrismaClient, async (tx) => {
-        const rows = await tx.$queryRaw<OutgoingRequest[]>`
-          UPDATE "OutgoingRequest"
-          SET "fulfilledAt" = NOW()
-          WHERE "id" IN (
-            SELECT id
-            FROM "OutgoingRequest"
-            WHERE "fulfilledAt" IS NULL
-            ORDER BY "createdAt"
-            LIMIT 100
-            FOR UPDATE SKIP LOCKED
-          )
-          RETURNING *;
-        `;
-        return rows;
-      });
-    }
+    async function claimPendingRequests(): Promise<OutgoingRequest[]> {
+      return await retryTransaction(globalPrismaClient, async (tx) => {
+        const rows = await tx.$queryRaw<OutgoingRequest[]>`
+          SELECT "id", "createdAt", "qstashOptions", "fulfilledAt"
+          FROM "OutgoingRequest"
+          WHERE "fulfilledAt" IS NULL
+          ORDER BY "createdAt"
+          LIMIT 100
+          FOR UPDATE SKIP LOCKED;
+        `;
+        return rows;
+      });
+    }

and after a successful publish:

-          await upstash.publishJSON({
-            url: fullUrl,
-            body: options.body,
-          });
-
-          processed++;
+          await upstash.publishJSON({
+            url: fullUrl,
+            body: options.body,
+          });
+
+          // Only mark as fulfilled after successful QStash publish
+          await retryTransaction(globalPrismaClient, async (tx) => {
+            await tx.$queryRaw`
+              UPDATE "OutgoingRequest"
+              SET "fulfilledAt" = NOW()
+              WHERE "id" = ${request.id};
+            `;
+          });
+
+          processed++;

This removes the permanent-loss failure mode; downstream code should already be idempotent given the sequencing/sync use case, but if not, please double-check that repeated publishes are acceptable.

Also applies to: 88-93

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between dcda402 and f0cc311.

📒 Files selected for processing (1)
  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: For blocking alerts and errors, never use toast, as they are easily missed by the user. Instead, use alerts
Keep hover/click transitions snappy and fast. Don't delay the action with a pre-transition; apply transitions after the action instead
NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error). Use runAsynchronously or runAsynchronouslyWithAlert instead for asynchronous error handling
When creating hover transitions, avoid hover-enter transitions and just use hover-exit transitions (e.g., transition-colors hover:transition-none)
Use ES6 maps instead of records wherever possible

Files:

  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
apps/backend/src/app/api/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

The project uses a custom route handler system in the backend for consistent API responses

Files:

  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
apps/{backend,dashboard}/src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

NEVER use Next.js dynamic functions if you can avoid them. Instead, prefer using a client component and prefer usePathname instead of await params

Files:

  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
🧠 Learnings (3)
📚 Learning: 2025-11-28T21:21:39.123Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:21:39.123Z
Learning: Applies to apps/backend/src/app/api/**/*.{ts,tsx} : The project uses a custom route handler system in the backend for consistent API responses

Applied to files:

  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
📚 Learning: 2025-11-28T21:21:39.123Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:21:39.123Z
Learning: Applies to **/*.{ts,tsx} : NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error). Use `runAsynchronously` or `runAsynchronouslyWithAlert` instead for asynchronous error handling

Applied to files:

  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
📚 Learning: 2025-11-28T21:21:39.123Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:21:39.123Z
Learning: Applies to **/*.{ts,tsx} : For blocking alerts and errors, never use `toast`, as they are easily missed by the user. Instead, use alerts

Applied to files:

  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
  • GitHub Check: build (22.x)
  • GitHub Check: build (22.x)
  • GitHub Check: build (22.x)
  • GitHub Check: restart-dev-and-test-with-custom-base-port
  • GitHub Check: docker
  • GitHub Check: setup-tests
  • GitHub Check: check_prisma_migrations (22.x)
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: all-good
  • GitHub Check: Vercel Agent Review
  • GitHub Check: restart-dev-and-test

@aadesh18 aadesh18 requested a review from N2D4 December 2, 2025 21:04
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (3)
apps/backend/scripts/run-cron-jobs.ts (1)

25-33: Cron jobs don't execute immediately on startup.

The loop waits before the first execution, meaning there's a delay of up to 120 seconds before the first sync triggers. Consider running the endpoint once before entering the loop if immediate execution is desired.

   for (const endpoint of endpoints) {
     runAsynchronously(async () => {
+      run(endpoint); // Initial immediate execution
       while (true) {
-        run(endpoint);
         // Vercel only guarantees minute-granularity for cron jobs, so we randomize the interval
         await wait(Math.random() * 120_000);
+        run(endpoint);
       }
     }); 
   }
apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts (1)

51-68: Critical: Claimed requests are lost if QStash publish fails.

The claimPendingRequests function sets startedFulfillingAt = NOW() before processing. If upstash.publishJSON fails (lines 90-93), the request remains claimed (startedFulfillingAt is set) and will never be retried since the WHERE clause filters for startedFulfillingAt IS NULL.

Consider either:

  1. Adding a fulfilledAt column that's only set on success, with retry logic for stale startedFulfillingAt
  2. Resetting startedFulfillingAt to NULL on failure
   async function claimPendingRequests(): Promise<OutgoingRequest[]> {
     return await retryTransaction(globalPrismaClient, async (tx) => {
       const rows = await tx.$queryRaw<OutgoingRequest[]>`
         UPDATE "OutgoingRequest"
         SET "startedFulfillingAt" = NOW()
         WHERE "id" IN (
           SELECT id
           FROM "OutgoingRequest"
-          WHERE "startedFulfillingAt" IS NULL
+          WHERE "startedFulfillingAt" IS NULL 
+            OR ("startedFulfillingAt" < NOW() - INTERVAL '5 minutes' AND "fulfilledAt" IS NULL)
           ORDER BY "createdAt"
           LIMIT 100
           FOR UPDATE SKIP LOCKED
         )
         RETURNING *;
       `;
       return rows;
     });
   }

And after successful publish:

// After upstash.publishJSON succeeds
await globalPrismaClient.$executeRaw`
  UPDATE "OutgoingRequest" 
  SET "fulfilledAt" = NOW() 
  WHERE "id" = ${request.id}
`;
apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts (1)

334-337: Hardcoded database credentials should be externalized.

This concern was previously flagged. The password should be read from environment variables rather than hardcoded in source.

🧹 Nitpick comments (6)
apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts (1)

345-394: Consider reducing the 70-second sleep duration.

The 70-second sleep makes this test very slow. While it's needed to ensure the poller runs during the transaction, consider whether a shorter duration (e.g., 15-20 seconds) would still validate the behavior while improving test runtime.

-          await sleep(70000);
+          await sleep(20000);
apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts (1)

16-92: Consider parallelizing tenant sync enqueueing.

The sequential await calls in the for loops (lines 39-41, 65-67, 89-91) process tenants one at a time. Since enqueueTenantSync operations are independent, you could batch them with Promise.all for better throughput during high-volume backfills.

However, given this runs continuously in a loop, the sequential approach may be intentional to avoid overwhelming the database. This is a minor optimization opportunity.

-  for (const { tenancyId } of projectUserTenants) {
-    await enqueueTenantSync(tenancyId);
-  }
+  await Promise.all(projectUserTenants.map(({ tenancyId }) => enqueueTenantSync(tenancyId)));
apps/backend/src/lib/external-db-sync.ts (1)

182-190: Consider returning sync results for observability.

Errors are captured but the function returns void, making it impossible for callers to know if any databases failed to sync. This was flagged in past reviews. Consider returning a result object with per-database success/failure status for better observability and potential retry logic:

-export async function syncExternalDatabases(tenancy: Tenancy) {
+export async function syncExternalDatabases(tenancy: Tenancy): Promise<{ results: Map<string, { success: boolean; error?: unknown }> }> {
   const externalDatabases = tenancy.config.dbSync.externalDatabases;
   const internalPrisma = await getPrismaClientForTenancy(tenancy);
+  const results = new Map<string, { success: boolean; error?: unknown }>();
 
   for (const [dbId, dbConfig] of Object.entries(externalDatabases)) {
     try {
       await syncDatabase(dbId, dbConfig, internalPrisma, tenancy.id);
+      results.set(dbId, { success: true });
     } catch (error) {
       // Log the error but continue syncing other databases
       // This ensures one bad database config doesn't block successful syncs to other databases
       captureError(`external-db-sync-${dbId}`, error);
+      results.set(dbId, { success: false, error });
     }
   }
+  return { results };
 }

Based on learnings, this also uses ES6 Map as preferred by coding guidelines.

apps/backend/prisma/migrations/20251125030551_external_db_sync/migration.sql (3)

1-8: Clarify the rationale for INCREMENT BY 11.

The sequence increments by 11 which is unusual. While this works, the choice seems arbitrary. Common reasons for non-1 increments include:

  • Reserving gaps for manual insertions
  • Distributing load across shards
  • Supporting interleaved sequences

Consider adding a comment explaining this design choice, or simplify to INCREMENT BY 1 if there's no specific requirement.


46-57: Consider adding a foreign key constraint or documenting the lack thereof.

The DeletedRow.tenancyId references a tenancy but has no foreign key constraint. This is likely intentional (to preserve deletion records even if the tenancy is deleted), but worth documenting. If tenancy deletion should cascade to DeletedRow, add the FK constraint.


132-172: Document the tenancyId requirement for tables using log_deleted_row.

The trigger function assumes all tracked tables have a tenancyId column (line 162), which currently holds true for both ProjectUser and ContactChannel. However, if future migrations add this trigger to a table without tenancyId, it will fail at runtime. Consider adding a comment in the migration documenting this requirement, or implement a defensive check within the function (e.g., checking column existence before accessing OLD."tenancyId").

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f0cc311 and 1d7a0de.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (17)
  • apps/backend/package.json (3 hunks)
  • apps/backend/prisma/migrations/20251125030551_external_db_sync/migration.sql (1 hunks)
  • apps/backend/prisma/schema.prisma (7 hunks)
  • apps/backend/scripts/run-cron-jobs.ts (1 hunks)
  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts (1 hunks)
  • apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts (1 hunks)
  • apps/backend/src/app/api/latest/internal/external-db-sync/sync-engine/route.tsx (1 hunks)
  • apps/backend/src/lib/external-db-sync.ts (1 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts (1 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts (1 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-high-volume.test.ts (1 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts (1 hunks)
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts (1 hunks)
  • docker/dependencies/docker.compose.yaml (1 hunks)
  • packages/stack-shared/src/config/db-sync-mappings.ts (1 hunks)
  • packages/stack-shared/src/config/schema-fuzzer.test.ts (1 hunks)
  • packages/stack-shared/src/config/schema.ts (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/stack-shared/src/config/schema-fuzzer.test.ts
  • docker/dependencies/docker.compose.yaml
  • apps/backend/src/app/api/latest/internal/external-db-sync/sync-engine/route.tsx
🧰 Additional context used
📓 Path-based instructions (5)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: For blocking alerts and errors, never use toast, as they are easily missed by the user. Instead, use alerts
Keep hover/click transitions snappy and fast. Don't delay the action with a pre-transition; apply transitions after the action instead
NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error). Use runAsynchronously or runAsynchronouslyWithAlert instead for asynchronous error handling
When creating hover transitions, avoid hover-enter transitions and just use hover-exit transitions (e.g., transition-colors hover:transition-none)
Use ES6 maps instead of records wherever possible

Files:

  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
  • apps/backend/src/lib/external-db-sync.ts
  • packages/stack-shared/src/config/db-sync-mappings.ts
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-high-volume.test.ts
  • apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts
  • apps/backend/scripts/run-cron-jobs.ts
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts
  • packages/stack-shared/src/config/schema.ts
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts
apps/backend/src/app/api/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

The project uses a custom route handler system in the backend for consistent API responses

Files:

  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
  • apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts
apps/{backend,dashboard}/src/**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

NEVER use Next.js dynamic functions if you can avoid them. Instead, prefer using a client component and prefer usePathname instead of await params

Files:

  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
  • apps/backend/src/lib/external-db-sync.ts
  • apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts
**/*.{test,spec}.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

When writing tests, prefer .toMatchInlineSnapshot over other selectors, if possible

Files:

  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-high-volume.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts
packages/stack-shared/src/config/schema.ts

📄 CodeRabbit inference engine (AGENTS.md)

Whenever making backwards-incompatible changes to the config schema, update the migration functions in packages/stack-shared/src/config/schema.ts

Files:

  • packages/stack-shared/src/config/schema.ts
🧠 Learnings (7)
📚 Learning: 2025-11-28T21:21:39.123Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:21:39.123Z
Learning: Applies to apps/backend/src/app/api/**/*.{ts,tsx} : The project uses a custom route handler system in the backend for consistent API responses

Applied to files:

  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
  • apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts
📚 Learning: 2025-11-28T21:21:39.123Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:21:39.123Z
Learning: Applies to **/*.{ts,tsx} : NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error). Use `runAsynchronously` or `runAsynchronouslyWithAlert` instead for asynchronous error handling

Applied to files:

  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
  • apps/backend/src/lib/external-db-sync.ts
📚 Learning: 2025-11-28T21:21:39.123Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:21:39.123Z
Learning: Applies to **/*.{ts,tsx} : For blocking alerts and errors, never use `toast`, as they are easily missed by the user. Instead, use alerts

Applied to files:

  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
📚 Learning: 2025-10-11T04:13:19.308Z
Learnt from: N2D4
Repo: stack-auth/stack-auth PR: 943
File: examples/convex/app/action/page.tsx:23-28
Timestamp: 2025-10-11T04:13:19.308Z
Learning: In the stack-auth codebase, use `runAsynchronouslyWithAlert` from `stackframe/stack-shared/dist/utils/promises` for async button click handlers and form submissions instead of manual try/catch blocks. This utility automatically handles errors and shows alerts to users.

Applied to files:

  • apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts
  • apps/backend/src/lib/external-db-sync.ts
  • apps/backend/scripts/run-cron-jobs.ts
📚 Learning: 2025-11-28T21:21:39.123Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:21:39.123Z
Learning: Applies to packages/stack-shared/src/config/schema.ts : Whenever making backwards-incompatible changes to the config schema, update the migration functions in `packages/stack-shared/src/config/schema.ts`

Applied to files:

  • apps/backend/src/lib/external-db-sync.ts
  • packages/stack-shared/src/config/db-sync-mappings.ts
  • packages/stack-shared/src/config/schema.ts
  • apps/backend/prisma/schema.prisma
  • apps/backend/package.json
📚 Learning: 2025-11-28T21:21:39.123Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:21:39.123Z
Learning: Applies to **/*.{ts,tsx} : Use ES6 maps instead of records wherever possible

Applied to files:

  • apps/backend/src/lib/external-db-sync.ts
📚 Learning: 2025-11-28T21:21:39.123Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-28T21:21:39.123Z
Learning: Always add new E2E tests when changing the API or SDK interface

Applied to files:

  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts
🧬 Code graph analysis (5)
apps/backend/src/lib/external-db-sync.ts (6)
packages/stack-shared/src/utils/errors.tsx (2)
  • throwErr (10-19)
  • StackAssertionError (69-85)
packages/stack-shared/src/config/db-sync-mappings.ts (1)
  • DEFAULT_DB_SYNC_MAPPINGS (1-165)
apps/backend/src/prisma-client.tsx (2)
  • PrismaClientTransaction (22-22)
  • getPrismaClientForTenancy (68-70)
packages/stack-shared/src/config/schema.ts (1)
  • CompleteConfig (1092-1092)
packages/stack-shared/src/utils/results.tsx (1)
  • error (36-41)
apps/backend/src/lib/tenancies.tsx (1)
  • Tenancy (53-53)
apps/backend/scripts/run-cron-jobs.ts (3)
packages/stack-shared/src/utils/env.tsx (1)
  • getEnvVariable (16-58)
packages/stack-shared/src/utils/promises.tsx (2)
  • runAsynchronously (343-366)
  • wait (260-268)
packages/stack-shared/src/utils/errors.tsx (1)
  • StackAssertionError (69-85)
apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts (1)
apps/e2e/tests/helpers.ts (2)
  • niceFetch (161-185)
  • STACK_BACKEND_BASE_URL (284-284)
packages/stack-shared/src/config/schema.ts (1)
packages/stack-shared/src/schema-fields.ts (4)
  • yupObject (247-251)
  • yupRecord (283-322)
  • userSpecifiedIdSchema (442-442)
  • yupString (187-190)
apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts (1)
apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts (8)
  • TestDbManager (34-118)
  • createProjectWithExternalDb (271-284)
  • waitForSyncedData (154-181)
  • TEST_TIMEOUT (11-11)
  • waitForSyncedDeletion (186-206)
  • verifyNotInExternalDb (235-238)
  • waitForTable (211-230)
  • waitForCondition (124-149)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (11)
  • GitHub Check: Vercel Agent Review
  • GitHub Check: restart-dev-and-test-with-custom-base-port
  • GitHub Check: build (22.x)
  • GitHub Check: all-good
  • GitHub Check: setup-tests
  • GitHub Check: build (22.x)
  • GitHub Check: restart-dev-and-test
  • GitHub Check: build (22.x)
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: docker
  • GitHub Check: check_prisma_migrations (22.x)
🔇 Additional comments (61)
apps/backend/package.json (1)

10-10: LGTM! Dev script and cron-jobs integration.

The new run-cron-jobs script is properly wired into the dev workflow, allowing local simulation of Vercel cron triggers for external DB sync.

Also applies to: 38-39

apps/backend/scripts/run-cron-jobs.ts (1)

1-3: Import issue resolved.

The wait function is now correctly imported from @stackframe/stack-shared/dist/utils/promises.

packages/stack-shared/src/config/schema.ts (2)

203-211: LGTM! External database sync configuration schema.

The schema properly restricts user configuration to only connection details (type, connectionString) without exposing internal SQL mappings. The yupRecord with userSpecifiedIdSchema ensures valid external database identifiers.


579-585: LGTM! Organization config defaults for dbSync.

The defaults correctly provide a factory function for dynamic external database entries with type: undefined and connectionString: undefined, aligning with the schema structure.

packages/stack-shared/src/config/db-sync-mappings.ts (3)

96-162: LGTM! Robust upsert pattern with metadata tracking.

The CTE-based approach properly handles:

  • Deletion via the deleted CTE
  • Upsert via INSERT ... ON CONFLICT DO UPDATE
  • Sequence tracking with GREATEST to handle out-of-order processing
  • Atomic metadata update in single transaction

19-20: REVOKE/GRANT statements are idempotent—no action needed.

The REVOKE ALL and GRANT SELECT statements execute every sync operation, but this is safe. PostgreSQL treats both statements as idempotent: running them repeatedly produces the same result as running them once, with no errors or conflicts regardless of prior role configurations. This is the standard and intended behavior.

Likely an incorrect or invalid review comment.


36-56: Indexes are already in place for ContactChannel subqueries.

The UNIQUE INDEX ContactChannel_tenancyId_projectUserId_type_isPrimary_key exists and covers all filter columns (tenancyId, projectUserId, type, isPrimary) used in both correlated subqueries. No additional indexing is needed.

apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts (3)

34-68: LGTM! Well-designed TestDbManager class.

The class properly:

  • Uses ES6 Map for database tracking (per coding guidelines)
  • Applies connection timeouts to prevent leaks
  • Generates unique database names to avoid conflicts
  • Tracks both logical names and actual database names separately

70-117: Robust cleanup with graceful error handling.

The cleanup sequence properly:

  1. Clears project configs first to stop sync cron
  2. Closes clients with timeout fallback
  3. Terminates lingering connections before dropping databases
  4. Handles errors gracefully without failing the entire cleanup

136-143: Good resilience for connection errors in polling.

Catching specific PostgreSQL error codes (57P01, 08006, 53300) and retrying is appropriate for test utilities that may encounter transient connection issues during concurrent test execution.

apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts (3)

13-13: Import issue resolved.

The captureError function is now correctly imported from @stackframe/stack-shared/dist/utils/errors.


79-87: Good: localhost replacement for Docker networking.

The comment explains why localhost/127.0.0.1 is replaced with host.docker.internal in dev/test environments where QStash runs in Docker.


96-101: Good error handling pattern.

Using captureError instead of console.error ensures errors are properly tracked and alerts are triggered, following the project's coding guidelines.

apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-high-volume.test.ts (3)

1-14: LGTM!

Imports are well-organized and all used symbols are referenced in the test.


16-27: LGTM!

Test suite setup follows the established pattern with proper initialization and cleanup with appropriate timeout.


39-176: High-volume pagination test is well-structured.

The test correctly exercises the >1000 row pagination path with:

  • Direct SQL batch inserts for performance
  • Appropriate 8-minute timeout for large data sync
  • Spot-checks at boundaries (first, middle, last)
  • Proper resource cleanup in finally block

One minor observation: the console.log statements are helpful for debugging but could be noisy in CI. Consider whether these should remain or be wrapped in a debug flag.

apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts (14)

1-18: LGTM!

Imports are clean and organized. The previously flagged unused generateSecureRandomString import has been removed.


20-31: LGTM!

Standard test suite setup with proper lifecycle management.


40-158: Multi-tenant isolation test is thorough.

Good coverage of cross-project data isolation with:

  • Two projects with different DB configurations (2 DBs vs 3 DBs)
  • Verification that each user only appears in their own project's databases
  • Explicit negative assertions (user A not in project B's DBs and vice versa)

168-242: Metadata tracking test validates sync progress correctly.

Properly verifies that _stack_sync_metadata table tracks last_synced_sequence_id and advances after new syncs.


252-297: Idempotency test is well-designed.

Confirms that re-running sync doesn't duplicate rows and preserves existing row IDs.


307-332: Special characters test covers important edge cases.

Tests emoji, quotes, and international characters (Chinese) to validate encoding safety.


342-438: High volume batching test is solid.

Similar structure to the high-volume file but with 200 users - good for faster CI runs while still exercising batching.


448-548: Complex sequence test validates interleaved operations.

Good coverage of update → delete → insert sequences with proper state verification.


558-625: Write protection test is important for security.

Validates that readonly credentials cannot modify data. The dynamic role creation and permission testing is well-structured.


635-675: Multiple updates before sync test validates last-write-wins semantics.

Correctly verifies that only the final update state is exported.


685-727: Delete before first sync test is a valuable edge case.

Ensures records deleted before initial sync never appear externally.


737-804: Re-create email after delete test validates proper handling of recycled identifiers.

Good verification that new users with previously-deleted emails get fresh IDs.


814-905: Complex lifecycle test is comprehensive.

Exercises the full create → update → update → delete → create → update path with proper assertions at each stage.


915-1111: High volume with deletes interleaved test validates batch consistency.

Good coverage of mixed insert/delete operations with proper verification of final dataset integrity.

apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts (9)

1-18: LGTM!

Imports and sleep helper are appropriate for race condition testing.


20-31: LGTM!

Standard test suite setup.


40-78: Concurrent sync test validates idempotency under parallel updates.

Good test for ensuring overlapping pollers don't cause duplicates or data corruption.


88-127: Delete-after-update test validates delete precedence.

Properly verifies that delete events win over closely preceding updates.


137-261: Pagination boundary delete test is well-structured.

Tests that deletions aren't skipped due to LIMIT boundaries with 300 users.


401-464: Transaction commit test is well-designed.

Validates that committed transactions are picked up by the poller.


472-542: Second uncommitted transaction test validates isolation.

Good coverage of the scenario where T1 is committed but T2 remains uncommitted.


558-666: Out-of-order commits test is excellent.

Tests a complex scenario with two different rows and interleaved commits to validate visibility rules.


676-769: Sequential updates lifecycle test rounds out the coverage.

Validates end-to-end flow with multiple sequential commits.

apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts (3)

1-12: LGTM!

Imports are appropriate for the route handler functionality.


94-114: Duplicate prevention logic is sound.

The NOT EXISTS check prevents duplicate sync requests for the same tenant. The use of gen_random_uuid() and proper JSON construction is correct.


116-175: Route handler is well-structured.

Good implementation following the project's custom route handler system with:

  • Proper Bearer token authentication against CRON_SECRET
  • Bounded execution time (3 minutes)
  • Error capture without crashing the loop
  • Appropriate response schema

The 50ms poll interval provides good responsiveness while not being too aggressive.

apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts (11)

1-14: LGTM!

Imports are clean and all symbols are used.


16-27: LGTM!

Standard test suite setup with proper lifecycle management.


37-60: Insert test is well-structured.

Clean test for basic user creation and sync verification.


70-103: Update test validates change propagation.

Good coverage of the update flow with before/after verification.


113-150: Delete test is comprehensive.

Includes both API-level 404 verification and external DB removal check.


160-205: Sync mechanism verification test validates timing guarantees.

Good test that verifies data only appears after sync by checking table existence before configuration.


215-256: Full CRUD lifecycle test is a solid integration test.

Exercises the complete create → update → delete flow with proper assertions.


266-297: Automatic table creation test validates schema provisioning.

Good verification that the sync engine creates necessary tables on demand.


307-336: Resilience test is important for production reliability.

Validates that one failing DB connection doesn't block successful targets. This is critical for graceful degradation.


347-390: Multi-email test validates user-centric schema correctly.

Good coverage ensuring only one row per user with primary email populated.


400-446: Metadata tracking test validates sync progress tracking.

Properly verifies _stack_sync_metadata table updates after each sync.

apps/backend/prisma/schema.prisma (4)

174-176: LGTM!

The sequenceId and shouldUpdateSequenceId fields are correctly added to ProjectUser with appropriate unique constraint and index for the sync backfill workflow.


257-259: LGTM!

The sequenceId and shouldUpdateSequenceId fields are correctly added to ContactChannel, mirroring the ProjectUser pattern for consistent change tracking.


885-894: LGTM!

The OutgoingRequest model is well-structured for queuing external sync requests. The composite index on (startedFulfillingAt, createdAt) supports efficient polling for pending requests ordered by creation time.


896-916: LGTM!

The DeletedRow model correctly captures deletion events with:

  • Proper tenant isolation via tenancyId
  • Global ordering via unique sequenceId
  • Composite index (tenancyId, tableName, sequenceId) for efficient querying per tenant/table
  • Partial index on shouldUpdateSequenceId for backfill queries

This addresses the past review feedback about indexing.

apps/backend/src/lib/external-db-sync.ts (2)

9-64: LGTM!

The pushRowsToExternalDb function has solid validation:

  • Parameter count validation against upsert query placeholders
  • Tenancy alignment check to prevent cross-tenant data mixing
  • Row shape validation ensuring explicit, ordered columns

Good defensive programming that will catch mapping configuration errors early.


94-95: No SQL injection risk from DEFAULT_DB_SYNC_MAPPINGS overrides.

The hardcoded DEFAULT_DB_SYNC_MAPPINGS cannot be overridden by tenant configuration—the codebase explicitly states "users cannot customize mappings" at line 154. The mapping is always sourced from Object.entries(DEFAULT_DB_SYNC_MAPPINGS) at line 156, and no alternative mapping mechanism exists. Additionally, tenancyId and lastSequenceId are correctly passed as separate arguments to $queryRawUnsafe, ensuring proper parameter binding rather than string interpolation.

apps/backend/prisma/migrations/20251125030551_external_db_sync/migration.sql (2)

83-91: LGTM! Partial indexes are correctly defined.

The partial indexes with WHERE "shouldUpdateSequenceId" = TRUE are efficient for the backfill query pattern, as they only index rows that need processing. This is a good optimization.


97-126: LGTM!

The reset_sequence_id_on_update function and triggers are well-designed:

  • Only triggers when shouldUpdateSequenceId = FALSE to avoid redundant updates
  • Uses BEFORE UPDATE to modify the row before it's written
  • Correctly marks rows for re-sync after any change

Comment on lines +151 to +175
try {
await externalClient.connect();

// Always use DEFAULT_DB_SYNC_MAPPINGS - users cannot customize mappings
// because internalDbFetchQuery runs against Stack Auth's internal DB
for (const [mappingId, mapping] of Object.entries(DEFAULT_DB_SYNC_MAPPINGS)) {
await syncMapping(
externalClient,
mappingId,
mapping,
internalPrisma,
dbId,
tenancyId,
dbConfig.type,
);
}

} catch (error) {
await externalClient.end();
captureError(`external-db-sync-${dbId}`, error);
return;
}

await externalClient.end();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Connection leak on error path.

If an error occurs after externalClient.connect() but before the catch block completes, and externalClient.end() itself throws, the connection may not be properly closed. Consider using try/finally to ensure cleanup:

+  let connected = false;
   try {
     await externalClient.connect();
+    connected = true;
 
     // Always use DEFAULT_DB_SYNC_MAPPINGS - users cannot customize mappings
     // because internalDbFetchQuery runs against Stack Auth's internal DB
     for (const [mappingId, mapping] of Object.entries(DEFAULT_DB_SYNC_MAPPINGS)) {
       await syncMapping(
         externalClient,
         mappingId,
         mapping,
         internalPrisma,
         dbId,
         tenancyId,
         dbConfig.type,
       );
     }
-
   } catch (error) {
-    await externalClient.end();
     captureError(`external-db-sync-${dbId}`, error);
-    return;
+  } finally {
+    if (connected) {
+      await externalClient.end();
+    }
   }
-
-  await externalClient.end();
 }
🤖 Prompt for AI Agents
In apps/backend/src/lib/external-db-sync.ts around lines 151 to 175, the current
try/catch can leak the externalClient connection if externalClient.end() itself
throws or if an exception occurs after connect() but before the catch completes;
change the structure to use try { await externalClient.connect(); ...await
syncMapping(...) } finally { try { await externalClient.end(); } catch (endErr)
{ captureError(`external-db-sync-${dbId}-close`, endErr); } } and keep the
existing captureError for the main error path inside the try block so that sync
errors are reported while guaranteeing the client is always closed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants