diff --git a/apps/backend/package.json b/apps/backend/package.json index 7672ae06d..57a227a17 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -7,7 +7,7 @@ "typecheck": "tsc --noEmit", "with-env": "dotenv -c development --", "with-env:prod": "dotenv -c --", - "dev": "concurrently -n \"dev,codegen,prisma-studio\" -k \"next dev --turbopack --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02\" \"pnpm run codegen:watch\" \"pnpm run prisma-studio\"", + "dev": "concurrently -n \"dev,codegen,prisma-studio,cron-jobs\" -k \"next dev --turbopack --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02\" \"pnpm run codegen:watch\" \"pnpm run prisma-studio\" \"pnpm run run-cron-jobs\"", "build": "pnpm run codegen && next build", "docker-build": "pnpm run codegen && next build --experimental-build-mode compile", "build-self-host-migration-script": "tsup --config scripts/db-migrations.tsup.config.ts", @@ -35,7 +35,8 @@ "generate-openapi-fumadocs": "pnpm run with-env tsx scripts/generate-openapi-fumadocs.ts", "generate-keys": "pnpm run with-env tsx scripts/generate-keys.ts", "db-seed-script": "pnpm run db:seed", - "verify-data-integrity": "pnpm run with-env tsx scripts/verify-data-integrity.ts" + "verify-data-integrity": "pnpm run with-env tsx scripts/verify-data-integrity.ts", + "run-cron-jobs": "pnpm run with-env tsx scripts/run-cron-jobs.ts" }, "prisma": { "seed": "pnpm run db-seed-script" diff --git a/apps/backend/prisma/migrations/20251125030551_add_sequence_id/migration.sql b/apps/backend/prisma/migrations/20251125030551_add_sequence_id/migration.sql new file mode 100644 index 000000000..f5ab9caf1 --- /dev/null +++ b/apps/backend/prisma/migrations/20251125030551_add_sequence_id/migration.sql @@ -0,0 +1,18 @@ +CREATE SEQUENCE global_seq_id + AS BIGINT + START 1 + INCREMENT BY 11 + NO MINVALUE + NO MAXVALUE; + +-- SPLIT_STATEMENT_SENTINEL +ALTER TABLE "ContactChannel" ADD COLUMN "sequenceId" BIGINT; + +-- SPLIT_STATEMENT_SENTINEL +ALTER TABLE "ProjectUser" ADD COLUMN "sequenceId" BIGINT; + +-- SPLIT_STATEMENT_SENTINEL +CREATE UNIQUE INDEX "ContactChannel_sequenceId_key" ON "ContactChannel"("sequenceId"); + +-- SPLIT_STATEMENT_SENTINEL +CREATE UNIQUE INDEX "ProjectUser_sequenceId_key" ON "ProjectUser"("sequenceId"); diff --git a/apps/backend/prisma/migrations/20251125172224_add_outgoing_request/migration.sql b/apps/backend/prisma/migrations/20251125172224_add_outgoing_request/migration.sql new file mode 100644 index 000000000..23528849b --- /dev/null +++ b/apps/backend/prisma/migrations/20251125172224_add_outgoing_request/migration.sql @@ -0,0 +1,13 @@ + +CREATE TABLE "OutgoingRequest" ( + "id" UUID NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "qstashOptions" JSONB NOT NULL, + "fulfilledAt" TIMESTAMP(3), + + CONSTRAINT "OutgoingRequest_pkey" PRIMARY KEY ("id") +); + + +CREATE INDEX "OutgoingRequest_fulfilledAt_idx" ON "OutgoingRequest"("fulfilledAt"); + diff --git a/apps/backend/prisma/migrations/20251125180000_add_deleted_row_table/migration.sql b/apps/backend/prisma/migrations/20251125180000_add_deleted_row_table/migration.sql new file mode 100644 index 000000000..1eb79c2b0 --- /dev/null +++ b/apps/backend/prisma/migrations/20251125180000_add_deleted_row_table/migration.sql @@ -0,0 +1,21 @@ +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") +); + + +CREATE UNIQUE INDEX "DeletedRow_sequenceId_key" ON "DeletedRow"("sequenceId"); + +CREATE INDEX "DeletedRow_tableName_idx" ON "DeletedRow"("tableName"); + +CREATE INDEX "DeletedRow_tenancyId_idx" ON "DeletedRow"("tenancyId"); + + diff --git a/apps/backend/prisma/migrations/20251128210000_add_should_update_sequence_id_columns/migration.sql b/apps/backend/prisma/migrations/20251128210000_add_should_update_sequence_id_columns/migration.sql new file mode 100644 index 000000000..41065eb3a --- /dev/null +++ b/apps/backend/prisma/migrations/20251128210000_add_should_update_sequence_id_columns/migration.sql @@ -0,0 +1,8 @@ +ALTER TABLE "ProjectUser" ADD COLUMN "shouldUpdateSequenceId" BOOLEAN NOT NULL DEFAULT TRUE; + +-- SPLIT_STATEMENT_SENTINEL +ALTER TABLE "ContactChannel" ADD COLUMN "shouldUpdateSequenceId" BOOLEAN NOT NULL DEFAULT TRUE; + +-- SPLIT_STATEMENT_SENTINEL +ALTER TABLE "DeletedRow" ADD COLUMN "shouldUpdateSequenceId" BOOLEAN NOT NULL DEFAULT TRUE; + diff --git a/apps/backend/prisma/migrations/20251128210001_add_should_update_sequence_id_indexes/migration.sql b/apps/backend/prisma/migrations/20251128210001_add_should_update_sequence_id_indexes/migration.sql new file mode 100644 index 000000000..2dde16659 --- /dev/null +++ b/apps/backend/prisma/migrations/20251128210001_add_should_update_sequence_id_indexes/migration.sql @@ -0,0 +1,8 @@ +CREATE INDEX "ProjectUser_shouldUpdateSequenceId_idx" ON "ProjectUser"("shouldUpdateSequenceId") WHERE "shouldUpdateSequenceId" = TRUE; + +-- SPLIT_STATEMENT_SENTINEL +CREATE INDEX "ContactChannel_shouldUpdateSequenceId_idx" ON "ContactChannel"("shouldUpdateSequenceId") WHERE "shouldUpdateSequenceId" = TRUE; + +-- SPLIT_STATEMENT_SENTINEL +CREATE INDEX "DeletedRow_shouldUpdateSequenceId_idx" ON "DeletedRow"("shouldUpdateSequenceId") WHERE "shouldUpdateSequenceId" = TRUE; + diff --git a/apps/backend/prisma/migrations/20251128210002_add_reset_sequence_id_function_and_triggers/migration.sql b/apps/backend/prisma/migrations/20251128210002_add_reset_sequence_id_function_and_triggers/migration.sql new file mode 100644 index 000000000..e998923ba --- /dev/null +++ b/apps/backend/prisma/migrations/20251128210002_add_reset_sequence_id_function_and_triggers/migration.sql @@ -0,0 +1,30 @@ +-- SINGLE_STATEMENT_SENTINEL +CREATE FUNCTION reset_sequence_id_on_update() +RETURNS TRIGGER AS $$ +BEGIN + NEW."shouldUpdateSequenceId" := TRUE; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- SPLIT_STATEMENT_SENTINEL +CREATE TRIGGER mark_should_update_sequence_id_project_user +BEFORE UPDATE ON "ProjectUser" +FOR EACH ROW +WHEN (OLD."shouldUpdateSequenceId" = FALSE) +EXECUTE FUNCTION reset_sequence_id_on_update(); + +-- SPLIT_STATEMENT_SENTINEL +CREATE TRIGGER mark_should_update_sequence_id_contact_channel +BEFORE UPDATE ON "ContactChannel" +FOR EACH ROW +WHEN (OLD."shouldUpdateSequenceId" = FALSE) +EXECUTE FUNCTION reset_sequence_id_on_update(); + +-- SPLIT_STATEMENT_SENTINEL +CREATE TRIGGER mark_should_update_sequence_id_deleted_row +BEFORE UPDATE ON "DeletedRow" +FOR EACH ROW +WHEN (OLD."shouldUpdateSequenceId" = FALSE) +EXECUTE FUNCTION reset_sequence_id_on_update(); + diff --git a/apps/backend/prisma/migrations/20251128210003_log_deleted_row_function_and_triggers/migration.sql b/apps/backend/prisma/migrations/20251128210003_log_deleted_row_function_and_triggers/migration.sql new file mode 100644 index 000000000..edcf433e2 --- /dev/null +++ b/apps/backend/prisma/migrations/20251128210003_log_deleted_row_function_and_triggers/migration.sql @@ -0,0 +1,55 @@ +-- SINGLE_STATEMENT_SENTINEL +CREATE FUNCTION log_deleted_row() +RETURNS TRIGGER AS $function$ +DECLARE + row_data jsonb; + pk jsonb := '{}'::jsonb; + col record; +BEGIN + row_data := to_jsonb(OLD); + + FOR col IN + SELECT a.attname + FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = TG_RELID + AND i.indisprimary + LOOP + pk := pk || jsonb_build_object(col.attname, row_data -> col.attname); + END LOOP; + + INSERT INTO "DeletedRow" ( + "id", + "tenancyId", + "tableName", + "primaryKey", + "data", + "deletedAt", + "shouldUpdateSequenceId" + ) + VALUES ( + gen_random_uuid(), + OLD."tenancyId", + TG_TABLE_NAME, + pk, + row_data, + NOW(), + TRUE + ); + + RETURN OLD; +END; +$function$ LANGUAGE plpgsql; + +-- SPLIT_STATEMENT_SENTINEL +CREATE TRIGGER log_deleted_row_project_user +BEFORE DELETE ON "ProjectUser" +FOR EACH ROW +EXECUTE FUNCTION log_deleted_row(); + +-- SPLIT_STATEMENT_SENTINEL +CREATE TRIGGER log_deleted_row_contact_channel +BEFORE DELETE ON "ContactChannel" +FOR EACH ROW +EXECUTE FUNCTION log_deleted_row(); + diff --git a/apps/backend/prisma/migrations/20251128210004_add_sync_functions/migration.sql b/apps/backend/prisma/migrations/20251128210004_add_sync_functions/migration.sql new file mode 100644 index 000000000..239778e30 --- /dev/null +++ b/apps/backend/prisma/migrations/20251128210004_add_sync_functions/migration.sql @@ -0,0 +1,100 @@ +-- SINGLE_STATEMENT_SENTINEL +CREATE FUNCTION enqueue_tenant_sync(p_tenant_id uuid) +RETURNS void AS $$ +BEGIN + INSERT INTO "OutgoingRequest" ("id", "createdAt", "qstashOptions", "fulfilledAt") + SELECT + gen_random_uuid(), + NOW(), + json_build_object( + 'url', '/api/latest/internal/external-db-sync/sync-engine', + 'body', json_build_object('tenantId', p_tenant_id) + ), + NULL + WHERE NOT EXISTS ( + SELECT 1 + FROM "OutgoingRequest" + WHERE "fulfilledAt" IS NULL + AND ("qstashOptions"->'body'->>'tenantId')::uuid = p_tenant_id + ); +END; +$$ LANGUAGE plpgsql; +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +CREATE FUNCTION backfill_null_sequence_ids() +RETURNS void AS $$ +DECLARE + v_tenancy_id uuid; +BEGIN + FOR v_tenancy_id IN + WITH rows_to_update AS ( + SELECT "tenancyId", "projectUserId" + FROM "ProjectUser" + WHERE "shouldUpdateSequenceId" = TRUE + OR "sequenceId" IS NULL + LIMIT 1000 + FOR UPDATE SKIP LOCKED + ), + updated_rows AS ( + UPDATE "ProjectUser" pu + SET "sequenceId" = nextval('global_seq_id'), + "shouldUpdateSequenceId" = FALSE + FROM rows_to_update r + WHERE pu."tenancyId" = r."tenancyId" + AND pu."projectUserId" = r."projectUserId" + RETURNING pu."tenancyId" + ) + SELECT DISTINCT "tenancyId" FROM updated_rows + LOOP + PERFORM enqueue_tenant_sync(v_tenancy_id); + END LOOP; + + FOR v_tenancy_id IN + WITH rows_to_update AS ( + SELECT "tenancyId", "projectUserId", "id" + FROM "ContactChannel" + WHERE "shouldUpdateSequenceId" = TRUE + OR "sequenceId" IS NULL + LIMIT 1000 + FOR UPDATE SKIP LOCKED + ), + updated_rows AS ( + UPDATE "ContactChannel" cc + SET "sequenceId" = nextval('global_seq_id'), + "shouldUpdateSequenceId" = FALSE + FROM rows_to_update r + WHERE cc."tenancyId" = r."tenancyId" + AND cc."projectUserId" = r."projectUserId" + AND cc."id" = r."id" + RETURNING cc."tenancyId" + ) + SELECT DISTINCT "tenancyId" FROM updated_rows + LOOP + PERFORM enqueue_tenant_sync(v_tenancy_id); + END LOOP; + + FOR v_tenancy_id IN + WITH rows_to_update AS ( + SELECT "id", "tenancyId" + FROM "DeletedRow" + WHERE "shouldUpdateSequenceId" = TRUE + OR "sequenceId" IS NULL + LIMIT 1000 + FOR UPDATE SKIP LOCKED + ), + updated_rows AS ( + UPDATE "DeletedRow" dr + SET "sequenceId" = nextval('global_seq_id'), + "shouldUpdateSequenceId" = FALSE + FROM rows_to_update r + WHERE dr."id" = r."id" + RETURNING dr."tenancyId" + ) + SELECT DISTINCT "tenancyId" FROM updated_rows + LOOP + PERFORM enqueue_tenant_sync(v_tenancy_id); + END LOOP; + +END; +$$ LANGUAGE plpgsql; + diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 9ff7eb4ef..7c124765c 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -173,6 +173,9 @@ model ProjectUser { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + sequenceId BigInt? @unique + shouldUpdateSequenceId Boolean @default(true) + displayName String? serverMetadata Json? clientReadOnlyMetadata Json? @@ -252,6 +255,9 @@ model ContactChannel { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + sequenceId BigInt? @unique + shouldUpdateSequenceId Boolean @default(true) + type ContactChannelType isPrimary BooleanTrue? usedForAuth BooleanTrue? @@ -861,7 +867,7 @@ model CacheEntry { model SubscriptionInvoice { id String @default(uuid()) @db.Uuid - tenancyId String @db.Uuid + tenancyId String @db.Uuid stripeSubscriptionId String stripeInvoiceId String isSubscriptionCreationInvoice Boolean @@ -874,3 +880,32 @@ model SubscriptionInvoice { @@id([tenancyId, id]) @@unique([tenancyId, stripeInvoiceId]) } + +model OutgoingRequest { + id String @id @default(uuid()) @db.Uuid + + createdAt DateTime @default(now()) + + qstashOptions Json + fulfilledAt DateTime? + + @@index([fulfilledAt]) +} + +model DeletedRow { + id String @id @default(uuid()) @db.Uuid + tenancyId String @db.Uuid + tableName String + + sequenceId BigInt? @unique + shouldUpdateSequenceId Boolean @default(true) + + primaryKey Json + data Json? + + deletedAt DateTime @default(now()) + fulfilledAt DateTime? + + @@index([tableName]) + @@index([tenancyId]) +} diff --git a/apps/backend/scripts/run-cron-jobs.ts b/apps/backend/scripts/run-cron-jobs.ts new file mode 100644 index 000000000..49dbd2c4f --- /dev/null +++ b/apps/backend/scripts/run-cron-jobs.ts @@ -0,0 +1,36 @@ +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; + +const endpoints = [ + "/api/latest/internal/external-db-sync/sequencer", + "/api/latest/internal/external-db-sync/poller", +]; + +async function main() { + console.log("Starting cron jobs..."); + const cronSecret = getEnvVariable('CRON_SECRET'); + + const baseUrl = `http://localhost:${getEnvVariable('NEXT_PUBLIC_STACK_PORT_PREFIX', '81')}02`; + + const run = (endpoint: string) => runAsynchronously(async () => { + console.log(`Running ${endpoint}...`); + const res = await fetch(`${baseUrl}${endpoint}`, { + headers: { 'Authorization': `Bearer ${cronSecret}` }, + }); + if (!res.ok) throw new StackAssertionError(`Failed to call ${endpoint}: ${res.status} ${res.statusText}\n${await res.text()}`, { res }); + console.log(`${endpoint} completed.`); + }); + + for (const endpoint of endpoints) { + setInterval(() => { + run(endpoint); + }, 60000); + } +} + +// eslint-disable-next-line no-restricted-syntax +main().catch((err) => { + console.error(err); + process.exit(1); +}); 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 new file mode 100644 index 000000000..dcdaee3b1 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts @@ -0,0 +1,129 @@ +import { upstash } from "@/lib/upstash"; +import { globalPrismaClient, retryTransaction } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { OutgoingRequest } from "@prisma/client"; +import { + yupBoolean, + yupNumber, + yupObject, + yupString, + yupTuple, +} from "@stackframe/stack-shared/dist/schema-fields"; +import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { wait } from "@stackframe/stack-shared/dist/utils/promises"; + +export const GET = createSmartRouteHandler({ + metadata: { + summary: "Poll outgoing requests and push to QStash", + description: + "Internal endpoint invoked by Vercel Cron to process pending outgoing requests.", + tags: ["External DB Sync"], + hidden: true, + }, + request: yupObject({ + auth: yupObject({}).nullable().optional(), + method: yupString().oneOf(["GET"]).defined(), + headers: yupObject({ + authorization: yupTuple([yupString()]).defined(), + }).defined(), + query: yupObject({}).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + ok: yupBoolean().defined(), + requests_processed: yupNumber().defined(), + }).defined(), + }), + handler: async ({ headers }) => { + const authHeader = headers.authorization[0]; + if (authHeader !== `Bearer ${getEnvVariable("CRON_SECRET")}`) { + throw new StatusError(401, "Unauthorized"); + } + + const startTime = performance.now(); + const maxDurationMs = 2 * 60 * 1000; + const busySleepMs = 50; + + let totalRequestsProcessed = 0; + async function claimPendingRequests(): Promise { + return await retryTransaction(globalPrismaClient, async (tx) => { + const rows = await tx.$queryRaw` + 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 processRequests(requests: OutgoingRequest[]): Promise { + let processed = 0; + + for (const request of requests) { + try { + const options = request.qstashOptions as any; + const baseUrl = getEnvVariable("NEXT_PUBLIC_STACK_API_URL"); + + let fullUrl = options.url.startsWith("http") + ? options.url + : new URL(options.url, baseUrl).toString(); + + if (getNodeEnvironment().includes("development") || getNodeEnvironment().includes("test")) { + const url = new URL(fullUrl); + if (url.hostname === "localhost" || url.hostname === "127.0.0.1") { + url.hostname = "host.docker.internal"; + fullUrl = url.toString(); + } + } + + + await upstash.publishJSON({ + url: fullUrl, + body: options.body, + }); + + processed++; + } catch (error) { + console.error( + `Failed to process outgoing request ${request.id}:`, + error, + ); + } + } + + return processed; + } + + while (performance.now() - startTime < maxDurationMs) { + const pendingRequests = await claimPendingRequests(); + + totalRequestsProcessed += await processRequests(pendingRequests); + + const elapsed = performance.now() - startTime; + if (elapsed >= maxDurationMs) { + break; + } + + await wait(busySleepMs); + } + + return { + statusCode: 200, + bodyType: "json" as const, + body: { + ok: true, + requests_processed: totalRequestsProcessed, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts b/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts new file mode 100644 index 000000000..1b1e13a0f --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/external-db-sync/sequencer/route.ts @@ -0,0 +1,77 @@ +import { globalPrismaClient } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { + yupBoolean, + yupNumber, + yupObject, + yupString, + yupTuple, +} from "@stackframe/stack-shared/dist/schema-fields"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { wait } from "@stackframe/stack-shared/dist/utils/promises"; + +export const GET = createSmartRouteHandler({ + metadata: { + summary: "Run sequence ID backfill", + description: + "Internal endpoint invoked by Vercel Cron to backfill null sequence IDs.", + tags: ["External DB Sync"], + hidden: true, + }, + request: yupObject({ + auth: yupObject({}).nullable().optional(), + method: yupString().oneOf(["GET"]).defined(), + headers: yupObject({ + authorization: yupTuple([yupString()]).defined(), + }).defined(), + query: yupObject({}).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + ok: yupBoolean().defined(), + iterations: yupNumber().defined(), + }).defined(), + }), + handler: async ({ headers }) => { + const authHeader = headers.authorization[0]; + if (authHeader !== `Bearer ${getEnvVariable("CRON_SECRET")}`) { + throw new StatusError(401, "Unauthorized"); + } + + const startTime = performance.now(); + const maxDurationMs = 2 * 60 * 1000; + const sleepMs = 50; + + let iterations = 0; + + while (performance.now() - startTime < maxDurationMs) { + try { + await globalPrismaClient.$executeRaw`SELECT backfill_null_sequence_ids()`; + } catch (error) { + console.warn('[sequencer] Failed to run backfill_null_sequence_ids:', error); + } + + iterations++; + + const elapsed = performance.now() - startTime; + if (elapsed >= maxDurationMs) { + break; + } + + await wait(sleepMs); + } + + return { + statusCode: 200, + bodyType: "json" as const, + body: { + ok: true, + iterations, + }, + }; + }, +}); + diff --git a/apps/backend/src/app/api/latest/internal/external-db-sync/sync-engine/route.tsx b/apps/backend/src/app/api/latest/internal/external-db-sync/sync-engine/route.tsx new file mode 100644 index 000000000..3e1aa5da2 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/external-db-sync/sync-engine/route.tsx @@ -0,0 +1,68 @@ +import { syncExternalDatabases } from "@/lib/external-db-sync"; +import { getTenancy } from "@/lib/tenancies"; +import { ensureUpstashSignature } from "@/lib/upstash"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { yupBoolean, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields"; + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Sync engine webhook endpoint", + description: "Receives webhook from QStash to trigger external database sync for a tenant", + tags: ["External DB Sync"], + hidden: true, + }, + request: yupObject({ + headers: yupObject({ + "upstash-signature": yupTuple([yupString()]).defined(), + }).defined(), + body: yupObject({ + tenantId: yupString().defined(), + }).defined(), + method: yupString().oneOf(["POST"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + success: yupBoolean().defined(), + tenantId: yupString().defined(), + timestamp: yupString().defined(), + }).defined(), + }), + handler: async ({ body }, fullReq) => { + await ensureUpstashSignature(fullReq); + + const { tenantId } = body; + const timestamp = new Date().toISOString(); + + const tenancy = await getTenancy(tenantId); + if (!tenancy) { + console.error(`Tenant not found: ${tenantId}`); + return { + statusCode: 200, + bodyType: "json", + body: { + success: false, + tenantId, + timestamp, + }, + }; + } + + 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, + }, + }; + }, +}); diff --git a/apps/backend/src/lib/external-db-sync.ts b/apps/backend/src/lib/external-db-sync.ts new file mode 100644 index 000000000..06ede85ee --- /dev/null +++ b/apps/backend/src/lib/external-db-sync.ts @@ -0,0 +1,203 @@ +import { Tenancy } from "@/lib/tenancies"; +import { getPrismaClientForTenancy, PrismaClientTransaction } from "@/prisma-client"; +import type { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; +import { Client } from 'pg'; + +export function getExternalDatabases(config: CompleteConfig) { + return config.dbSync.externalDatabases; +} + +async function pushRowsToExternalDb( + externalClient: Client, + tableName: string, + newRows: any[], + upsertQuery?: string, +) { + if (!upsertQuery) { + throw new Error( + `Cannot sync table "${tableName}": No upsertQuery configured.` + ); + } + + if (newRows.length === 0) return; + 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.` + ); + } + + 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)); + } +} + + +async function syncMapping( + externalClient: Client, + mappingId: string, + mapping: CompleteConfig["dbSync"]["externalDatabases"][string]["mappings"][string], + internalPrisma: PrismaClientTransaction, + dbId: string, + tenancyId: string, +) { + + const rawSourceTables: any = (mapping as any).sourceTables; + const sourceTables: string[] = rawSourceTables + ? Object.values(rawSourceTables) + : []; + + const rawTargetPk: any = (mapping as any).targetTablePrimaryKey; + const targetTablePrimaryKey: string[] = rawTargetPk + ? Object.values(rawTargetPk) + : []; + + if (sourceTables.length === 0) { + console.error( + ` Invalid configuration for mapping #${mappingId}: 'sourceTables' resolved to an empty list.`, + ); + return; + } + + if (targetTablePrimaryKey.length === 0) { + console.error( + ` Invalid configuration for mapping #${mappingId}: 'targetTablePrimaryKey' resolved to an empty list.`, + ); + return; + } + + const fetchQuery = mapping.internalDbFetchQuery; + if (!fetchQuery || !mapping.targetTable) { + return; + } + + const tableName = mapping.targetTable; + + if (mapping.targetTableSchema) { + const checkTableQuery = ` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + ); + `; + const res = await externalClient.query(checkTableQuery, [tableName]); + if (!res.rows[0].exists) { + try { + await externalClient.query(mapping.targetTableSchema); + } catch (err: any) { + if (err.code !== '23505' || err.constraint !== 'pg_type_typname_nsp_index') { + throw err; + } + } + } + } + + const rows = await internalPrisma.$queryRawUnsafe(fetchQuery, tenancyId); + + await pushRowsToExternalDb( + externalClient, + tableName, + rows, + mapping.externalDbUpdateQuery, + ); +} + + +async function syncDatabase( + dbId: string, + dbConfig: CompleteConfig["dbSync"]["externalDatabases"][string], + internalPrisma: PrismaClientTransaction, + tenancyId: string, +) { + + const mappings = dbConfig.mappings; + + const isArray = Array.isArray(mappings); + const mappingCount = mappings + ? (isArray ? (mappings as any[]).length : Object.keys(mappings as Record).length) + : 0; + + if (!dbConfig.connectionString) { + return; + } + + const externalClient = new Client({ + connectionString: dbConfig.connectionString, + }); + + try { + await externalClient.connect(); + + if (!mappings || mappingCount === 0) { + return; + } + + for (const [mappingId, mapping] of Object.entries(mappings)) { + await syncMapping( + externalClient, + mappingId, + mapping as any, + internalPrisma, + dbId, + tenancyId, + ); + } + + } catch (error: any) { + console.error(`Error syncing external DB ${dbId}:`, error); + } finally { + await externalClient.end(); + } +} + + +export async function syncExternalDatabases(tenancy: Tenancy) { + const externalDatabases = getExternalDatabases(tenancy.config); + if (Object.keys(externalDatabases).length === 0) { + return; + } + + const internalPrisma = await getPrismaClientForTenancy(tenancy); + + for (const [dbId, dbConfig] of Object.entries(externalDatabases)) { + await syncDatabase(dbId, dbConfig, internalPrisma, tenancy.id); + } +} diff --git a/apps/backend/src/route-handlers/smart-route-handler.tsx b/apps/backend/src/route-handlers/smart-route-handler.tsx index e4db2b1cc..aa938c083 100644 --- a/apps/backend/src/route-handlers/smart-route-handler.tsx +++ b/apps/backend/src/route-handlers/smart-route-handler.tsx @@ -87,20 +87,8 @@ export function handleApiRequest(handler: (req: NextRequest, options: any, reque headers: Object.fromEntries(req.headers), }); - // During development, don't trash the console with logs from E2E tests - const disableExtendedLogging = getNodeEnvironment().includes('dev') && !!req.headers.get("x-stack-development-disable-extended-logging"); - let hasRequestFinished = false; try { - // censor long query parameters because they might contain sensitive data - const censoredUrl = new URL(req.url); - for (const [key, value] of censoredUrl.searchParams.entries()) { - if (value.length <= 8) { - continue; - } - censoredUrl.searchParams.set(key, value.slice(0, 4) + "--REDACTED--" + value.slice(-4)); - } - // request duration warning const warnAfterSeconds = 12; runAsynchronously(async () => { @@ -110,32 +98,20 @@ export function handleApiRequest(handler: (req: NextRequest, options: any, reque } }); - if (!disableExtendedLogging) console.log(`[API REQ] [${requestId}] ${req.method} ${censoredUrl}`); - const timeStart = performance.now(); + const res = await handler(req, options, requestId); - const time = (performance.now() - timeStart); if ([301, 302].includes(res.status)) { throw new StackAssertionError("HTTP status codes 301 and 302 should not be returned by our APIs because the behavior for non-GET methods is inconsistent across implementations. Use 303 (to rewrite method to GET) or 307/308 (to preserve the original method and data) instead.", { status: res.status, url: req.nextUrl, req, res }); } - if (!disableExtendedLogging) console.log(`[ RES] [${requestId}] ${req.method} ${censoredUrl}: ${res.status} (in ${time.toFixed(0)}ms)`); return res; } catch (e) { let statusError: StatusError; try { statusError = catchError(e, requestId); } catch (e) { - if (!disableExtendedLogging) console.log(`[ EXC] [${requestId}] ${req.method} ${req.url}: Non-error caught (such as a redirect), will be re-thrown. Digest: ${(e as any)?.digest}`); throw e; } - if (!disableExtendedLogging) console.log(`[ ERR] [${requestId}] ${req.method} ${req.url}: ${statusError.message}`); - - if (!isCommonError(statusError)) { - // HACK: Log a nicified version of the error instead of statusError to get around buggy Next.js pretty-printing - // https://www.reddit.com/r/nextjs/comments/1gkxdqe/comment/m19kxgn/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button - if (!disableExtendedLogging) console.debug(`For the error above with request ID ${requestId}, the full error is:`, errorToNiceString(statusError)); - } - const res = await createResponse(req, requestId, { statusCode: statusError.statusCode, bodyType: "binary", diff --git a/apps/e2e/package.json b/apps/e2e/package.json index f1aaee0dc..19efb1231 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -18,7 +18,9 @@ "dotenv": "^16.4.5" }, "devDependencies": { - "jose": "^5.6.3" + "@types/pg": "^8.15.6", + "jose": "^5.6.3", + "pg": "^8.16.3" }, "packageManager": "pnpm@10.23.0" } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts new file mode 100644 index 000000000..bedcb15b2 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-advanced.test.ts @@ -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'; +import { Client } from 'pg'; +import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { test } from '../../../../helpers'; +import { InternalApiKey, User, niceBackendFetch } from '../../../backend-helpers'; +import { + HIGH_VOLUME_TIMEOUT, + TEST_TIMEOUT, + TestDbManager, + createProjectWithExternalDb, + verifyNotInExternalDb, + waitForCondition, + waitForSyncedData, + waitForSyncedDeletion, + waitForTable +} from './external-db-sync-utils'; + +describe.sequential('External DB Sync - Advanced Tests', () => { + let dbManager: TestDbManager; + + beforeAll(async () => { + dbManager = new TestDbManager(); + await dbManager.init(); + }); + + afterAll(async () => { + await dbManager.cleanup(); + }); + + /** + * What it does: + * - Creates two separate projects with different external DB lists, one user per project, and triggers sync. + * - Queries every database to confirm each tenant’s user only appears in its own configured targets. + * + * Why it matters: + * - Prevents tenant data leakage by proving cross-project isolation at the sync layer. + */ + test('Multi-Tenant Isolation: User 1 -> 2 DBs, User 2 -> 3 DBs', async () => { + await InternalApiKey.createAndSetProjectKeys(); + + const db_a1 = await dbManager.createDatabase('tenant_a_db1'); + const db_a2 = await dbManager.createDatabase('tenant_a_db2'); + const db_b1 = await dbManager.createDatabase('tenant_b_db1'); + const db_b2 = await dbManager.createDatabase('tenant_b_db2'); + const db_b3 = await dbManager.createDatabase('tenant_b_db3'); + + await createProjectWithExternalDb({ + main_a1: { + type: 'postgres', + connectionString: db_a1, + }, + main_a2: { + type: 'postgres', + connectionString: db_a2, + } + }); + + const userA = await User.create({ emailAddress: 'user-a@example.com' }); + await niceBackendFetch(`/api/v1/users/${userA.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'User A' } + }); + + await createProjectWithExternalDb({ + main_b1: { + type: 'postgres', + connectionString: db_b1, + }, + main_b2: { + type: 'postgres', + connectionString: db_b2, + }, + main_b3: { + type: 'postgres', + connectionString: db_b3, + } + }); + + const userB = await User.create({ emailAddress: 'user-b@example.com' }); + await niceBackendFetch(`/api/v1/users/${userB.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'User B' } + }); + + const clientA1 = dbManager.getClient('tenant_a_db1'); + const clientA2 = dbManager.getClient('tenant_a_db2'); + const clientB1 = dbManager.getClient('tenant_b_db1'); + const clientB2 = dbManager.getClient('tenant_b_db2'); + const clientB3 = dbManager.getClient('tenant_b_db3'); + + await waitForCondition( + async () => { + try { + const res1 = await clientA1.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['user-a@example.com']); + const res2 = await clientA2.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['user-a@example.com']); + return res1.rows.length === 1 && res2.rows.length === 1; + } catch (err: any) { + if (err.code === '42P01') return false; + throw err; + } + }, + { description: 'User A to appear in both Project A databases', timeoutMs: 90000 } + ); + + await waitForCondition( + async () => { + try { + const res1 = await clientB1.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['user-b@example.com']); + const res2 = await clientB2.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['user-b@example.com']); + const res3 = await clientB3.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['user-b@example.com']); + return res1.rows.length === 1 && res2.rows.length === 1 && res3.rows.length === 1; + } catch (err: any) { + if (err.code === '42P01') return false; + throw err; + } + }, + { description: 'User B to appear in all three Project B databases', timeoutMs: 90000 } + ); + + const resA1 = await clientA1.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['user-a@example.com']); + expect(resA1.rows.length).toBe(1); + expect(resA1.rows[0].displayName).toBe('User A'); + + const resA2 = await clientA2.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['user-a@example.com']); + expect(resA2.rows.length).toBe(1); + expect(resA2.rows[0].displayName).toBe('User A'); + + const resB1_A = await clientB1.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['user-a@example.com']); + expect(resB1_A.rows.length).toBe(0); + + const resB2_A = await clientB2.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['user-a@example.com']); + expect(resB2_A.rows.length).toBe(0); + + const resB3_A = await clientB3.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['user-a@example.com']); + expect(resB3_A.rows.length).toBe(0); + + const resB1 = await clientB1.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['user-b@example.com']); + expect(resB1.rows.length).toBe(1); + expect(resB1.rows[0].displayName).toBe('User B'); + + const resB2 = await clientB2.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['user-b@example.com']); + expect(resB2.rows.length).toBe(1); + expect(resB2.rows[0].displayName).toBe('User B'); + + const resB3 = await clientB3.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['user-b@example.com']); + expect(resB3.rows.length).toBe(1); + expect(resB3.rows[0].displayName).toBe('User B'); + + const resA1_B = await clientA1.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['user-b@example.com']); + expect(resA1_B.rows.length).toBe(0); + + const resA2_B = await clientA2.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['user-b@example.com']); + expect(resA2_B.rows.length).toBe(0); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Syncs three baseline users to capture their sequence ordering, then exports a fourth user. + * - Compares sequenceIds to ensure the newest export exceeds the previous maximum. + * + * Why it matters: + * - Verifies ordering guarantees that drive pagination and conflict resolution. + */ + test('SequenceId Tracking: Verify sync uses sequenceId correctly', async () => { + const dbName = 'sequence_id_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + } + }); + + const client = dbManager.getClient(dbName); + + const user1 = await User.create({ emailAddress: 'seq1@example.com' }); + const user2 = await User.create({ emailAddress: 'seq2@example.com' }); + const user3 = await User.create({ emailAddress: 'seq3@example.com' }); + + await niceBackendFetch(`/api/v1/users/${user1.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'User 1' } + }); + await niceBackendFetch(`/api/v1/users/${user2.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'User 2' } + }); + await niceBackendFetch(`/api/v1/users/${user3.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'User 3' } + }); + + await waitForTable(client, 'PartialUsers'); + + await waitForCondition( + async () => { + const res = await client.query(`SELECT COUNT(*) as count FROM "PartialUsers"`); + return parseInt(res.rows[0].count) === 3; + }, + { description: 'all 3 users to be synced' } + ); + + const res1 = await client.query(`SELECT * FROM "PartialUsers" ORDER BY "sequenceId"`); + expect(res1.rows.length).toBe(3); + + const seq1 = BigInt(res1.rows[0].sequenceId); + const seq2 = BigInt(res1.rows[1].sequenceId); + const seq3 = BigInt(res1.rows[2].sequenceId); + + expect(seq2).toBeGreaterThan(seq1); + expect(seq3).toBeGreaterThan(seq2); + + const maxSeqRes = await client.query(`SELECT MAX("sequenceId") as max_seq FROM "PartialUsers"`); + const maxSeq = BigInt(maxSeqRes.rows[0].max_seq); + + const user4 = await User.create({ emailAddress: 'seq4@example.com' }); + await niceBackendFetch(`/api/v1/users/${user4.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'User 4' } + }); + + await waitForSyncedData(client, 'seq4@example.com', 'User 4'); + const res2 = await client.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['seq4@example.com']); + expect(res2.rows.length).toBe(1); + const seq4 = BigInt(res2.rows[0].sequenceId); + expect(seq4).toBeGreaterThan(maxSeq); + const finalRes = await client.query(`SELECT COUNT(*) as count FROM "PartialUsers"`); + expect(parseInt(finalRes.rows[0].count)).toBe(4); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Exports a single user, records its sequenceId, then syncs again after adding a second user. + * - Ensures the first user’s row count and sequenceId stay untouched. + * + * Why it matters: + * - Confirms repeated sync runs don’t duplicate or rewrite already exported rows. + */ + test('Idempotency & Resume: Multiple syncs should not duplicate', async () => { + const dbName = 'idempotency_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + } + }); + + const user1 = await User.create({ emailAddress: 'user1@example.com' }); + await niceBackendFetch(`/api/v1/users/${user1.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'User 1' } + }); + + const client = dbManager.getClient(dbName); + + await waitForSyncedData(client, 'user1@example.com', 'User 1'); + + let res = await client.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['user1@example.com']); + expect(res.rows.length).toBe(1); + expect(res.rows[0].displayName).toBe('User 1'); + const user1SequenceId = res.rows[0].sequenceId; + + const user2 = await User.create({ emailAddress: 'user2@example.com' }); + await niceBackendFetch(`/api/v1/users/${user2.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'User 2' } + }); + + await waitForSyncedData(client, 'user2@example.com', 'User 2'); + + const user1Row = await client.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['user1@example.com']); + const user2Row = await client.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['user2@example.com']); + + expect(user1Row.rows.length).toBe(1); + expect(user2Row.rows.length).toBe(1); + expect(user1Row.rows[0].displayName).toBe('User 1'); + expect(user2Row.rows[0].displayName).toBe('User 2'); + expect(user1Row.rows[0].sequenceId).toBe(user1SequenceId); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Configures two mappings (PartialUsers and SimpleUsers), syncs once, and reads both tables. + * - Verifies the exported display name matches across tables. + * + * Why it matters: + * - Shows a single source mapping can feed multiple targets consistently. + */ + test('Multiple Mappings: Sync to two different tables', async () => { + const dbName = 'multi_mapping_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + mappings: { + "PartialUsers": DEFAULT_DB_SYNC_MAPPINGS.PartialUsers, + "SimpleUsers": { + sourceTables: ['ContactChannel', 'ProjectUser'], + targetTable: 'SimpleUsers', + targetTablePrimaryKey: ['value'], + targetTableSchema: ` + CREATE TABLE "SimpleUsers" ( + "value" text PRIMARY KEY, + "displayName" text, + "sequenceId" bigint + ); + CREATE INDEX ON "SimpleUsers" ("sequenceId"); + `.trim(), + internalDbFetchQuery: ` + SELECT + "ContactChannel"."value", + "ProjectUser"."displayName", + GREATEST("ContactChannel"."sequenceId", "ProjectUser"."sequenceId") as "sequenceId" + FROM "ContactChannel" + JOIN "ProjectUser" ON "ContactChannel"."projectUserId" = "ProjectUser"."projectUserId" + AND "ContactChannel"."tenancyId" = "ProjectUser"."tenancyId" + WHERE "ContactChannel"."isPrimary" = 'TRUE' + AND "ContactChannel"."tenancyId" = $1::uuid + ORDER BY "sequenceId" ASC + LIMIT 1000 + `.trim(), + externalDbUpdateQuery: ` + INSERT INTO "SimpleUsers" ("value", "displayName", "sequenceId") + VALUES ($1, $2, $3) + ON CONFLICT ("value") DO UPDATE + SET + "displayName" = EXCLUDED."displayName", + "sequenceId" = EXCLUDED."sequenceId" + WHERE EXCLUDED."sequenceId" > "SimpleUsers"."sequenceId" + `.trim(), + } + } + } + }); + + const user = await User.create({ emailAddress: 'multi-map@example.com' }); + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Multi Map User' } + }); + + const client = dbManager.getClient(dbName); + + await waitForCondition( + async () => { + try { + const res = await client.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['multi-map@example.com']); + return res.rows.length === 1 && res.rows[0].displayName === 'Multi Map User'; + } catch (err: any) { + if (err.code === '42P01') return false; + throw err; + } + }, + { description: 'PartialUsers data to sync', timeoutMs: 90000 } + ); + + const res1 = await client.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['multi-map@example.com']); + expect(res1.rows[0].displayName).toBe('Multi Map User'); + + await waitForCondition( + async () => { + try { + const res = await client.query(`SELECT * FROM "SimpleUsers" WHERE "value" = $1`, ['multi-map@example.com']); + return res.rows.length === 1; + } catch (err: any) { + if (err.code === '42P01') return false; + throw err; + } + }, + { description: 'SimpleUsers data to sync', timeoutMs: 90000 } + ); + + const res2 = await client.query(`SELECT * FROM "SimpleUsers" WHERE "value" = $1`, ['multi-map@example.com']); + expect(res2.rows[0].displayName).toBe('Multi Map User'); + }); + + /** + * What it does: + * - Exports a user whose display name contains quotes, emoji, and non-Latin characters. + * - Queries PartialUsers to confirm the string survives unchanged. + * + * Why it matters: + * - Ensures text encoding and escaping don’t corrupt data during sync. + */ + test('Special Characters: Emojis, quotes, international symbols', async () => { + const dbName = 'special_chars_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + } + }); + + const specialName = "O'Connor 🚀 用户 \"Test\""; + const user = await User.create({ emailAddress: 'special@example.com' }); + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: specialName } + }); + + await waitForSyncedData(dbManager.getClient(dbName), 'special@example.com', specialName); + + const client = dbManager.getClient(dbName); + const res = await client.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['special@example.com']); + expect(res.rows.length).toBe(1); + expect(res.rows[0].displayName).toBe(specialName); + }); + + /** + * What it does: + * - Creates 200 users, triggers sync repeatedly, and waits for the external row count to reach 200. + * + * Why it matters: + * - Exercises batching code paths to ensure high volumes eventually flush completely. + */ + test('High Volume: 200+ users to test batching', async () => { + const dbName = 'high_volume_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + } + }); + + await InternalApiKey.createAndSetProjectKeys(); + + const batchSize = 20; + const totalUsers = 200; + let usersCreated = 0; + let attemptCounter = 0; + + const createUserWithRetry = async () => { + const maxRetries = 5; + for (let retry = 0; retry < maxRetries; retry++) { + const uniqueId = `${Date.now()}-${attemptCounter++}-${Math.floor(performance.now() * 1000000)}`; + const result = await niceBackendFetch('/api/v1/auth/password/sign-up', { + method: 'POST', + accessType: 'client', + body: { + email: `hv-${uniqueId}@example.com`, + password: 'testpassword123', + verification_callback_url: 'http://localhost:3000/verify', + }, + }); + if (result.status === 200) { + return result; + } + if (result.status === 409 && result.body?.code === 'USER_EMAIL_ALREADY_EXISTS') { + continue; + } + throw new Error(`Unexpected response: ${result.status} ${JSON.stringify(result.body)}`); + } + throw new Error('Failed to create user after max retries'); + }; + + while (usersCreated < totalUsers) { + const batchTarget = Math.min(batchSize, totalUsers - usersCreated); + const batchPromises = []; + for (let i = 0; i < batchTarget; i++) { + batchPromises.push(createUserWithRetry()); + } + await Promise.all(batchPromises); + usersCreated += batchTarget; + + if (usersCreated < totalUsers) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + + const client = dbManager.getClient(dbName); + + await waitForTable(client, 'PartialUsers'); + + await waitForCondition( + async () => { + const res = await client.query(`SELECT COUNT(*) as count FROM "PartialUsers"`); + return parseInt(res.rows[0].count) >= 200; + }, + { description: 'all 200 users to be synced', timeoutMs: 180000 } + ); + + const res = await client.query(`SELECT COUNT(*) as count FROM "PartialUsers"`); + const finalCount = parseInt(res.rows[0].count); + expect(finalCount).toBeGreaterThanOrEqual(200); + }, HIGH_VOLUME_TIMEOUT); + + /** + * What it does: + * - Starts with three users, then mixes updates, deletes, and inserts before re-syncing. + * - Validates the external table reflects the final expected set. + * + * Why it matters: + * - Proves sequencing rules handle interleaved operations correctly. + */ + test('Complex Sequence: Multiple operations in different orders', async () => { + const dbName = 'complex_sequence_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + } + }); + + const user1 = await User.create({ emailAddress: 'seq1@example.com' }); + const user2 = await User.create({ emailAddress: 'seq2@example.com' }); + const user3 = await User.create({ emailAddress: 'seq3@example.com' }); + + await niceBackendFetch(`/api/v1/users/${user1.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'User 1' } + }); + await niceBackendFetch(`/api/v1/users/${user2.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'User 2' } + }); + await niceBackendFetch(`/api/v1/users/${user3.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'User 3' } + }); + + const client = dbManager.getClient(dbName); + + await waitForCondition( + async () => { + try { + const res = await client.query(`SELECT COUNT(*) as count FROM "PartialUsers"`); + return parseInt(res.rows[0].count) === 3; + } catch (err: any) { + if (err.code === '42P01') return false; + throw err; + } + }, + { description: 'initial 3 users sync', timeoutMs: 90000 } + ); + + let res = await client.query(`SELECT COUNT(*) as count FROM "PartialUsers"`); + expect(parseInt(res.rows[0].count)).toBe(3); + + await niceBackendFetch(`/api/v1/users/${user2.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'User 2 Updated' } + }); + + await niceBackendFetch(`/api/v1/users/${user1.userId}`, { + accessType: 'admin', + method: 'DELETE', + }); + + const user4 = await User.create({ emailAddress: 'seq4@example.com' }); + await niceBackendFetch(`/api/v1/users/${user4.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'User 4' } + }); + + await waitForCondition( + async () => { + try { + const res = await client.query(`SELECT * FROM "PartialUsers" ORDER BY "value"`); + if (res.rows.length !== 3) return false; + + const emails = res.rows.map(r => r.value); + if (emails.includes('seq1@example.com')) return false; + if (!emails.includes('seq2@example.com')) return false; + if (!emails.includes('seq3@example.com')) return false; + if (!emails.includes('seq4@example.com')) return false; + + const user2Row = res.rows.find(r => r.value === 'seq2@example.com'); + return user2Row.displayName === 'User 2 Updated'; + } catch (err: any) { + if (err.code === '42P01') return false; + throw err; + } + }, + { description: 'final sync state correct', timeoutMs: 90000 } + ); + + res = await client.query(`SELECT * FROM "PartialUsers" ORDER BY "value"`); + expect(res.rows.length).toBe(3); + + const emails = res.rows.map(r => r.value); + expect(emails).not.toContain('seq1@example.com'); + expect(emails).toContain('seq2@example.com'); + expect(emails).toContain('seq3@example.com'); + expect(emails).toContain('seq4@example.com'); + + const user2Row = res.rows.find(r => r.value === 'seq2@example.com'); + expect(user2Row.displayName).toBe('User 2 Updated'); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Creates a readonly database role, grants SELECT on PartialUsers, and tests SELECT/INSERT/UPDATE/DELETE commands. + * - Expects reads to succeed while writes fail. + * + * Why it matters: + * - Protects external tables from being mutated by consumers using readonly credentials. + */ + test('External write protection: readonly client cannot modify PartialUsers', async () => { + const dbName = 'write_protection_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + }, + }); + + const superClient = dbManager.getClient(dbName); + + const user = await User.create({ emailAddress: 'write-protect@example.com' }); + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Write Protect User' }, + }); + await waitForTable(superClient, 'PartialUsers'); + await waitForSyncedData(superClient, 'write-protect@example.com', 'Write Protect User'); + + const readonlyUser = 'readonly_partialusers'; + const readonlyPassword = 'readonly_password'; + await superClient.query(`DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${readonlyUser}') THEN + CREATE ROLE ${readonlyUser} LOGIN PASSWORD '${readonlyPassword}'; + END IF; +END +$$;`); + + const url = new URL(connectionString); + url.username = readonlyUser; + url.password = readonlyPassword; + const readonlyClient = new Client({ connectionString: url.toString() }); + await readonlyClient.connect(); + + try { + const selectRes = await readonlyClient.query( + `SELECT * FROM "PartialUsers" WHERE "value" = $1`, + ['write-protect@example.com'], + ); + expect(selectRes.rows.length).toBe(1); + await expect( + readonlyClient.query( + `INSERT INTO "PartialUsers" ("id", "value") VALUES (gen_random_uuid(), $1)`, + ['should-not-insert@example.com'], + ), + ).rejects.toThrow(); + + await expect( + readonlyClient.query( + `UPDATE "PartialUsers" SET "displayName" = 'Hacked' WHERE "value" = $1`, + ['write-protect@example.com'], + ), + ).rejects.toThrow(); + + await expect( + readonlyClient.query( + `DELETE FROM "PartialUsers" WHERE "value" = $1`, + ['write-protect@example.com'], + ), + ).rejects.toThrow(); + } finally { + await readonlyClient.end(); + } + }, TEST_TIMEOUT); + + /** + * What it does: + * - Patches the same user three times without syncing, then syncs once. + * - Checks PartialUsers to confirm only the final name persists. + * + * Why it matters: + * - Verifies we export the latest snapshot instead of intermediate states. + */ + test('Multiple updates before sync: last update wins', async () => { + const dbName = 'multi_update_before_sync_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + }, + }); + + const client = dbManager.getClient(dbName); + + const user = await User.create({ emailAddress: 'multi-update@example.com' }); + + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Name v1' }, + }); + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Name v2' }, + }); + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Name v3' }, + }); + + await waitForTable(client, 'PartialUsers'); + await waitForSyncedData(client, 'multi-update@example.com', 'Name v3'); + + const row = await client.query( + `SELECT * FROM "PartialUsers" WHERE "value" = $1`, + ['multi-update@example.com'], + ); + expect(row.rows.length).toBe(1); + expect(row.rows[0].displayName).toBe('Name v3'); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Creates then deletes a user before the first sync happens. + * - Runs sync and checks that PartialUsers never receives the email. + * + * Why it matters: + * - Ensures we don’t leak records that were deleted before the initial export cycle. + */ + test('Delete before first sync: row is never exported', async () => { + const dbName = 'delete_before_first_sync_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + }, + }); + + const client = dbManager.getClient(dbName); + + const user = await User.create({ emailAddress: 'delete-before-sync@example.com' }); + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'To Be Deleted' }, + }); + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'DELETE', + }); + + await waitForTable(client, 'PartialUsers'); + + await waitForCondition( + async () => { + const res = await client.query( + `SELECT * FROM "PartialUsers" WHERE "value" = $1`, + ['delete-before-sync@example.com'], + ); + return res.rows.length === 0; + }, + { description: 'deleted user should never appear', timeoutMs: 90000 } + ); + + const res = await client.query( + `SELECT * FROM "PartialUsers" WHERE "value" = $1`, + ['delete-before-sync@example.com'], + ); + expect(res.rows.length).toBe(0); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Syncs a user, deletes it, recreates the same email, and syncs again. + * - Compares IDs and sequenceIds to confirm the new row is distinct and persistent. + * + * Why it matters: + * - Proves a previous delete doesn’t block future users with the same email. + */ + test('Re-create same email after delete exports fresh contact channel', async () => { + const dbName = 'recreate_email_after_delete_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + }, + }); + + const client = dbManager.getClient(dbName); + const email = 'recreate-after-delete@example.com'; + + const firstUser = await User.create({ emailAddress: email }); + await niceBackendFetch(`/api/v1/users/${firstUser.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Original Export' }, + }); + + await waitForSyncedData(client, email, 'Original Export'); + + let res = await client.query( + `SELECT "id", "sequenceId" FROM "PartialUsers" WHERE "value" = $1`, + [email], + ); + expect(res.rows.length).toBe(1); + const firstRow = res.rows[0]; + const firstSequence = BigInt(firstRow.sequenceId); + + await niceBackendFetch(`/api/v1/users/${firstUser.userId}`, { + accessType: 'admin', + method: 'DELETE', + }); + + await waitForSyncedDeletion(client, email); + await verifyNotInExternalDb(client, email); + + const secondUser = await User.create({ emailAddress: email }); + await niceBackendFetch(`/api/v1/users/${secondUser.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Recreated Export' }, + }); + + await waitForSyncedData(client, email, 'Recreated Export'); + + res = await client.query( + `SELECT "id", "sequenceId", "displayName" FROM "PartialUsers" WHERE "value" = $1`, + [email], + ); + expect(res.rows.length).toBe(1); + + const recreatedRow = res.rows[0]; + expect(recreatedRow.displayName).toBe('Recreated Export'); + expect(recreatedRow.id).not.toBe(firstRow.id); + expect(BigInt(recreatedRow.sequenceId)).toBeGreaterThan(firstSequence); + + await waitForCondition( + async () => { + const followUp = await client.query( + `SELECT "displayName" FROM "PartialUsers" WHERE "value" = $1`, + [email], + ); + return followUp.rows.length === 1 && followUp.rows[0].displayName === 'Recreated Export'; + }, + { description: 'recreated row persists after extra sync', timeoutMs: 90000 }, + ); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Performs a complex sequence: create → update → update → delete → create (same email) → update + * - Syncs after each phase and verifies the external DB reflects the correct state. + * + * Why it matters: + * - Proves the sync engine handles rapid lifecycle transitions on the same email correctly. + */ + test('Complex lifecycle: create → update → update → delete → create → update', async () => { + const dbName = 'complex_lifecycle_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + }, + }); + + const client = dbManager.getClient(dbName); + const email = 'lifecycle-test@example.com'; + + const user1 = await User.create({ emailAddress: email }); + await niceBackendFetch(`/api/v1/users/${user1.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Initial Name' }, + }); + + await waitForSyncedData(client, email, 'Initial Name'); + + let res = await client.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, [email]); + expect(res.rows.length).toBe(1); + expect(res.rows[0].displayName).toBe('Initial Name'); + const firstId = res.rows[0].id; + const firstSeq = BigInt(res.rows[0].sequenceId); + + await niceBackendFetch(`/api/v1/users/${user1.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Updated Once' }, + }); + + await waitForSyncedData(client, email, 'Updated Once'); + + res = await client.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, [email]); + expect(res.rows.length).toBe(1); + expect(res.rows[0].displayName).toBe('Updated Once'); + expect(res.rows[0].id).toBe(firstId); + expect(BigInt(res.rows[0].sequenceId)).toBeGreaterThan(firstSeq); + const secondSeq = BigInt(res.rows[0].sequenceId); + + await niceBackendFetch(`/api/v1/users/${user1.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Updated Twice' }, + }); + + await waitForSyncedData(client, email, 'Updated Twice'); + + res = await client.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, [email]); + expect(res.rows.length).toBe(1); + expect(res.rows[0].displayName).toBe('Updated Twice'); + expect(res.rows[0].id).toBe(firstId); + expect(BigInt(res.rows[0].sequenceId)).toBeGreaterThan(secondSeq); + + await niceBackendFetch(`/api/v1/users/${user1.userId}`, { + accessType: 'admin', + method: 'DELETE', + }); + + await waitForSyncedDeletion(client, email); + + res = await client.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, [email]); + expect(res.rows.length).toBe(0); + + const user2 = await User.create({ emailAddress: email }); + await niceBackendFetch(`/api/v1/users/${user2.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Recreated User' }, + }); + + await waitForSyncedData(client, email, 'Recreated User'); + + res = await client.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, [email]); + expect(res.rows.length).toBe(1); + expect(res.rows[0].displayName).toBe('Recreated User'); + expect(res.rows[0].id).not.toBe(firstId); + const newId = res.rows[0].id; + const newSeq = BigInt(res.rows[0].sequenceId); + + await niceBackendFetch(`/api/v1/users/${user2.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Final Name' }, + }); + + await waitForSyncedData(client, email, 'Final Name'); + + res = await client.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, [email]); + expect(res.rows.length).toBe(1); + expect(res.rows[0].displayName).toBe('Final Name'); + expect(res.rows[0].id).toBe(newId); + expect(BigInt(res.rows[0].sequenceId)).toBeGreaterThan(newSeq); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Exports 50 users, deletes 10, inserts 10 replacements, and syncs again. + * - Validates the final PartialUsers dataset contains the remaining 40 originals plus 10 replacements (total 50). + * + * Why it matters: + * - Proves high-volume batches stay accurate even when deletes and inserts interleave. + */ + test('High volume with deletes interleaved retains the expected dataset', async () => { + const dbName = 'high_volume_delete_mix_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + }, + }); + + await InternalApiKey.createAndSetProjectKeys(); + + const client = dbManager.getClient(dbName); + const initialUserCount = 50; + const deletions = 10; + const replacements = 10; + + const initialUsers: { userId: string, email: string }[] = []; + + const batchSize = 10; + const testRunId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + for (let batchStart = 0; batchStart < initialUserCount; batchStart += batchSize) { + const batchEnd = Math.min(batchStart + batchSize, initialUserCount); + + const batchPromises = []; + for (let i = batchStart; i < batchEnd; i++) { + const email = `interleave-${i}-${testRunId}@example.com`; + batchPromises.push( + User.create({ emailAddress: email }).then(async (user) => { + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: `Interleave User ${i}` }, + }); + return { userId: user.userId, email }; + }) + ); + } + + const batchResults = await Promise.all(batchPromises); + initialUsers.push(...batchResults); + + if (batchEnd < initialUserCount) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + + await waitForTable(client, 'PartialUsers'); + + await waitForCondition( + async () => { + const countRes = await client.query(`SELECT COUNT(*) as count FROM "PartialUsers"`); + return parseInt(countRes.rows[0].count) === initialUserCount; + }, + { description: 'initial batch exported', timeoutMs: 60000 }, + ); + + const deletedUsers = initialUsers.slice(0, deletions); + for (const entry of deletedUsers) { + await niceBackendFetch(`/api/v1/users/${entry.userId}`, { + accessType: 'admin', + method: 'DELETE', + }); + } + await waitForCondition( + async () => { + const countRes = await client.query(`SELECT COUNT(*) as count FROM "PartialUsers"`); + return parseInt(countRes.rows[0].count) === (initialUserCount - deletions); + }, + { description: 'deletions synced to external DB', timeoutMs: 180000 }, + ); + + const replacementEmails: string[] = []; + + await InternalApiKey.createAndSetProjectKeys(); + + const replacementPromises = []; + for (let i = 0; i < replacements; i++) { + const email = `interleave-replacement-${i}-${testRunId}@example.com`; + replacementPromises.push( + User.create({ emailAddress: email }).then(async (user) => { + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: `Replacement ${i}` }, + }); + return email; + }) + ); + } + + const createdReplacementEmails = await Promise.all(replacementPromises); + replacementEmails.push(...createdReplacementEmails); + + const expectedFinalCount = initialUserCount - deletions + replacements; + await waitForCondition( + async () => { + const countRes = await client.query(`SELECT COUNT(*) as count FROM "PartialUsers"`); + return parseInt(countRes.rows[0].count) === expectedFinalCount; + }, + { description: 'final mixed batch exported', timeoutMs: 180000 }, + ); + + const finalRows = await client.query(`SELECT "value" FROM "PartialUsers"`); + const finalEmails = new Set(finalRows.rows.map((row) => row.value)); + expect(finalEmails.size).toBe(expectedFinalCount); + + for (const deleted of deletedUsers) { + expect(finalEmails.has(deleted.email)).toBe(false); + } + for (const survivor of initialUsers.slice(deletions)) { + expect(finalEmails.has(survivor.email)).toBe(true); + } + for (const replacement of replacementEmails) { + expect(finalEmails.has(replacement)).toBe(true); + } + }, HIGH_VOLUME_TIMEOUT); +}); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts new file mode 100644 index 000000000..352cac34f --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-basics.test.ts @@ -0,0 +1,565 @@ +import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { test } from '../../../../helpers'; +import { User, niceBackendFetch } from '../../../backend-helpers'; +import { + TEST_TIMEOUT, + TestDbManager, + createProjectWithExternalDb, + verifyInExternalDb, + verifyNotInExternalDb, + waitForCondition, + waitForSyncedData, + waitForSyncedDeletion, + waitForTable +} from './external-db-sync-utils'; + +// Run tests sequentially to avoid concurrency issues with shared backend state +describe.sequential('External DB Sync - Basic Tests', () => { + let dbManager: TestDbManager; + + beforeAll(async () => { + dbManager = new TestDbManager(); + await dbManager.init(); + }); + + afterAll(async () => { + await dbManager.cleanup(); + }); + + /** + * What it does: + * - Creates a user, patches the display name, and triggers the sync once. + * - Checks PartialUsers for a matching row only after the sync completes. + * + * Why it matters: + * - Ensures inserts never appear externally until the sync pipeline runs. + */ + test('Insert: New user is synced to external DB', async () => { + const dbName = 'insert_only_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + } + }); + + const client = dbManager.getClient(dbName); + + const user = await User.create({ emailAddress: 'insert-only@example.com' }); + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Insert Only User' } + }); + + await waitForSyncedData(client, 'insert-only@example.com', 'Insert Only User'); + + await verifyInExternalDb(client, 'insert-only@example.com', 'Insert Only User'); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Exports a baseline row, mutates the display name, runs another sync, and reads PartialUsers. + * - Compares the stored display name to guarantee it reflects the latest mutation. + * + * Why it matters: + * - Proves updates propagate to the external DB instead of leaving stale data. + */ + test('Update: Existing user changes are reflected in external DB', async () => { + const dbName = 'update_only_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + } + }); + + const client = dbManager.getClient(dbName); + + const user = await User.create({ emailAddress: 'update-only@example.com' }); + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Before Update' } + }); + + await waitForSyncedData(client, 'update-only@example.com', 'Before Update'); + + await verifyInExternalDb(client, 'update-only@example.com', 'Before Update'); + + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'After Update' } + }); + + await waitForSyncedData(client, 'update-only@example.com', 'After Update'); + + await verifyInExternalDb(client, 'update-only@example.com', 'After Update'); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Syncs a user into PartialUsers, deletes the user internally, and waits for the deletion helper. + * - Queries PartialUsers to ensure the row disappears. + * + * Why it matters: + * - Validates deletion events propagate and prevent orphaned rows in external DBs. + */ + test('Delete: Deleted user is removed from external DB', async () => { + const dbName = 'delete_only_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + } + }, { + display_name: '🗑️ Delete Test Project', + description: 'Testing deletion sync to external database' + }); + + const client = dbManager.getClient(dbName); + + const user = await User.create({ emailAddress: 'delete-only@example.com' }); + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Delete Only User' } + }); + + await waitForSyncedData(client, 'delete-only@example.com', 'Delete Only User'); + + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'DELETE', + }); + const deletedUserResponse = await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'GET', + }); + expect(deletedUserResponse.status).toBe(404); + + await waitForSyncedDeletion(client, 'delete-only@example.com'); + await verifyNotInExternalDb(client, 'delete-only@example.com'); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Creates a user while verifying the PartialUsers table is absent before sync. + * - Triggers sync, waits for table creation, and confirms the row appears afterward. + * + * Why it matters: + * - Demonstrates that syncs control both table provisioning and data export timing. + */ + test('Sync Mechanism Verification: Data appears ONLY after sync', async () => { + const dbName = 'sync_verification_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + } + }, { + display_name: '🔄 Sync Verification Test Project', + description: 'Testing that data only appears after sync is triggered' + }); + + const user = await User.create({ emailAddress: 'sync-verify@example.com' }); + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Sync Verify User' } + }); + + const client = dbManager.getClient(dbName); + + const tableCheckBefore = await client.query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'PartialUsers' + ); + `); + expect(tableCheckBefore.rows[0].exists).toBe(false); + + await waitForTable(client, 'PartialUsers'); + + await waitForCondition( + async () => { + const res = await client.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['sync-verify@example.com']); + return res.rows.length > 0; + }, + { description: 'data to appear in external DB', timeoutMs: 90000 } + ); + await verifyInExternalDb(client, 'sync-verify@example.com', 'Sync Verify User'); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Runs create, update, and delete actions in order while syncing between each step. + * - Verifies PartialUsers reflects each intermediate state. + * + * Why it matters: + * - Confirms the sync handles the entire lifecycle without leaving stale records. + */ + test('Full CRUD Lifecycle: Create, Update, Delete', async () => { + const dbName = 'crud_lifecycle_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + } + }); + + const client = dbManager.getClient(dbName); + + const user = await User.create({ emailAddress: 'crud-test@example.com' }); + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Original Name' } + }); + + await waitForSyncedData(client, 'crud-test@example.com', 'Original Name'); + + await verifyInExternalDb(client, 'crud-test@example.com', 'Original Name'); + + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Updated Name' } + }); + + await waitForSyncedData(client, 'crud-test@example.com', 'Updated Name'); + await verifyInExternalDb(client, 'crud-test@example.com', 'Updated Name'); + + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'DELETE', + }); + + await waitForSyncedDeletion(client, 'crud-test@example.com'); + + await verifyNotInExternalDb(client, 'crud-test@example.com'); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Syncs a user into an empty database to trigger table auto-creation. + * - Queries `information_schema` and PartialUsers to confirm the table and row exist. + * + * Why it matters: + * - Ensures mappings can provision their own schema without manual migrations. + */ + test('Automatic Table Creation', async () => { + const dbName = 'auto_table_creation_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + } + }); + + const user = await User.create({ emailAddress: 'auto-create@example.com' }); + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Auto Create User' } + }); + + const client = dbManager.getClient(dbName); + + await waitForSyncedData(client, 'auto-create@example.com', 'Auto Create User'); + + const tableCheck = await client.query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'PartialUsers' + ); + `); + expect(tableCheck.rows[0].exists).toBe(true); + await verifyInExternalDb(client, 'auto-create@example.com', 'Auto Create User'); + }); + + /** + * What it does: + * - Configures one valid and one invalid external DB mapping for the same project. + * - Runs sync and verifies the healthy DB still receives the exported row. + * + * Why it matters: + * - Shows a failing database connection does not block successful targets. + */ + test('Resilience: One bad DB should not crash the sync', async () => { + const goodDbName = 'resilience_good_db'; + const goodConnectionString = await dbManager.createDatabase(goodDbName); + const badConnectionString = 'postgresql://invalid:invalid@invalid:5432/invalid'; + + await createProjectWithExternalDb({ + good_db: { + type: 'postgres', + connectionString: goodConnectionString, + }, + bad_db: { + type: 'postgres', + connectionString: badConnectionString, + } + }); + + const user = await User.create({ emailAddress: 'resilience@example.com' }); + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Resilience User' } + }); + + await waitForSyncedData(dbManager.getClient(goodDbName), 'resilience@example.com', 'Resilience User'); + + const client = dbManager.getClient(goodDbName); + const res = await client.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, ['resilience@example.com']); + expect(res.rows.length).toBe(1); + expect(res.rows[0].displayName).toBe('Resilience User'); + }, TEST_TIMEOUT); + + + /** + * What it does: + * - Creates a user with two contact channels and runs the sync. + * - Reads PartialUsers to assert both channel values are present with the same display name. + * + * Why it matters: + * - Confirms multi-channel users export all addresses instead of overwriting each other. + */ + test('Multi-ContactChannel: User with multiple contact channels syncs all', async () => { + const dbName = 'multi_contact_channel_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + } + }); + + const client = dbManager.getClient(dbName); + + const user = await User.create({ emailAddress: 'multi-contact@example.com' }); + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Multi Contact User' } + }); + const secondEmailResponse = await niceBackendFetch(`/api/v1/contact-channels`, { + accessType: 'admin', + method: 'POST', + body: { + user_id: user.userId, + type: 'email', + value: 'second-email@example.com', + is_verified: false, + used_for_auth: false, + } + }); + expect(secondEmailResponse.status).toBe(201); + + // Wait for BOTH contact channels to be synced + await waitForSyncedData(client, 'multi-contact@example.com', 'Multi Contact User'); + await waitForSyncedData(client, 'second-email@example.com', 'Multi Contact User'); + + const allRows = await client.query(`SELECT * FROM "PartialUsers" ORDER BY "value"`); + expect(allRows.rows.length).toBe(2); + + const emails = allRows.rows.map(r => r.value); + expect(emails).toContain('multi-contact@example.com'); + expect(emails).toContain('second-email@example.com'); + + expect(allRows.rows[0].displayName).toBe('Multi Contact User'); + expect(allRows.rows[1].displayName).toBe('Multi Contact User'); + + }, TEST_TIMEOUT); + + /** + * What it does: + * - Exports a user with multiple channels, deletes the user, and waits for deletion sync. + * - Ensures PartialUsers no longer contains any row for that user. + * + * Why it matters: + * - Validates that cascading deletes remove every external row tied to the user. + */ + test('Multi-ContactChannel Deletion: Deleting user cascades all contact channels', async () => { + const dbName = 'multi_contact_deletion_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + } + }); + + const client = dbManager.getClient(dbName); + + const user = await User.create({ emailAddress: 'cascade-delete@example.com' }); + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Cascade Delete User' } + }); + + await niceBackendFetch(`/api/v1/contact-channels`, { + accessType: 'admin', + method: 'POST', + body: { + user_id: user.userId, + type: 'email', + value: 'cascade-second@example.com', + is_verified: false, + used_for_auth: false, + } + }); + + await waitForSyncedData(client, 'cascade-delete@example.com', 'Cascade Delete User'); + + // Verify both are synced + const beforeDelete = await client.query(`SELECT * FROM "PartialUsers"`); + expect(beforeDelete.rows.length).toBe(2); + + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'DELETE', + }); + + await waitForSyncedDeletion(client, 'cascade-delete@example.com'); + + const afterDelete = await client.query(`SELECT * FROM "PartialUsers"`); + expect(afterDelete.rows.length).toBe(0); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Creates two contact channels, deletes only the secondary one, and runs the deletion sync helper. + * - Confirms PartialUsers retains the primary contact while the secondary row is removed. + * + * Why it matters: + * - Ensures granular channel deletions do not wipe the entire user from external DBs. + */ + test('Single ContactChannel Deletion: Deleting one channel keeps user and other channels', async () => { + const dbName = 'single_contact_deletion_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + } + }); + + const client = dbManager.getClient(dbName); + + const user = await User.create({ emailAddress: 'single-delete@example.com' }); + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Single Delete User' } + }); + + const secondEmailResponse = await niceBackendFetch(`/api/v1/contact-channels`, { + accessType: 'admin', + method: 'POST', + body: { + user_id: user.userId, + type: 'email', + value: 'single-keep@example.com', + is_verified: false, + used_for_auth: false, + } + }); + const secondChannelId = secondEmailResponse.body.id; + + await waitForSyncedData(client, 'single-delete@example.com', 'Single Delete User'); + await waitForSyncedData(client, 'single-keep@example.com', 'Single Delete User'); + + const beforeDelete = await client.query(`SELECT * FROM "PartialUsers" ORDER BY "value"`); + expect(beforeDelete.rows.length).toBe(2); + + const deleteResponse = await niceBackendFetch(`/api/v1/contact-channels/${user.userId}/${secondChannelId}`, { + accessType: 'admin', + method: 'DELETE', + }); + expect(deleteResponse.status).toBe(200); + + await waitForSyncedDeletion(client, 'single-keep@example.com'); + + const afterDelete = await client.query(`SELECT * FROM "PartialUsers"`); + expect(afterDelete.rows.length).toBe(1); + expect(afterDelete.rows[0].value).toBe('single-delete@example.com'); + expect(afterDelete.rows[0].displayName).toBe('Single Delete User'); + + }, TEST_TIMEOUT); + + /** + * What it does: + * - Exports a user, bumps its sequenceId with an update, and attempts to delete using the old sequenceId. + * - Verifies the row still exists with the latest data. + * + * Why it matters: + * - Demonstrates sequence guards prevent older deletes from clobbering newer updates. + */ + test('Race Condition Protection: Old delete cannot remove newer record', async () => { + const dbName = 'race_condition_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + } + }); + + const client = dbManager.getClient(dbName); + + const user = await User.create({ emailAddress: 'race-test@example.com' }); + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Original Name' } + }); + + await waitForSyncedData(client, 'race-test@example.com', 'Original Name'); + const firstRow = await verifyInExternalDb(client, 'race-test@example.com', 'Original Name'); + const firstSequenceId = BigInt(firstRow.sequenceId); + const contactChannelId = firstRow.id; + + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Updated Name' } + }); + + await waitForSyncedData(client, 'race-test@example.com', 'Updated Name'); + const secondRow = await verifyInExternalDb(client, 'race-test@example.com', 'Updated Name'); + const secondSequenceId = BigInt(secondRow.sequenceId); + const deleteAttempt = await client.query( + `DELETE FROM "PartialUsers" WHERE "id" = $1 AND "sequenceId" <= $2`, + [contactChannelId, firstSequenceId.toString()] + ); + + + expect(deleteAttempt.rowCount).toBe(0); + + const afterDelete = await verifyInExternalDb(client, 'race-test@example.com', 'Updated Name'); + expect(BigInt(afterDelete.sequenceId)).toBe(secondSequenceId); + + }, TEST_TIMEOUT); +}); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts new file mode 100644 index 000000000..125c5198d --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-race.test.ts @@ -0,0 +1,751 @@ +import { Client } from 'pg'; +import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { test } from '../../../../helpers'; +import { InternalApiKey, User, niceBackendFetch } from '../../../backend-helpers'; +import { + HIGH_VOLUME_TIMEOUT, + TEST_TIMEOUT, + TestDbManager, + createProjectWithExternalDb, + waitForCondition, + waitForSyncedDeletion, + waitForTable +} from './external-db-sync-utils'; + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +describe.sequential('External DB Sync - Race Condition Tests', () => { + let dbManager: TestDbManager; + + beforeAll(async () => { + dbManager = new TestDbManager(); + await dbManager.init(); + }); + + afterAll(async () => { + await dbManager.cleanup(); + }); + + /** + * What it does: + * - Updates a user, triggers two sync cycles concurrently, and waits for PartialUsers to show the last value. + * - Confirms only a single row exists with the final display name. + * + * Why it matters: + * - Demonstrates overlapping pollers remain idempotent instead of duplicating or reverting data. + */ + test('Concurrent sync triggers produce a single consistent export', async () => { + const dbName = 'race_parallel_sync_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + }, + }); + + const client = dbManager.getClient(dbName); + const user = await User.create({ emailAddress: 'parallel-sync@example.com' }); + + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Initial Name' }, + }); + + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Final Name' }, + }); + + await waitForTable(client, 'PartialUsers'); + + await waitForCondition( + async () => { + const res = await client.query( + `SELECT * FROM "PartialUsers" WHERE "value" = $1`, + ['parallel-sync@example.com'], + ); + return res.rows.length === 1 && res.rows[0].displayName === 'Final Name'; + }, + { description: 'sync to converge on final state', timeoutMs: 90000 }, + ); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Issues a final update, deletes the user immediately afterward, and runs the deletion helper. + * - Confirms PartialUsers has zero rows for that value. + * + * Why it matters: + * - Shows delete events win over closely preceding updates, preventing stale data resurrection. + */ + test('Immediate delete after update removes the contact channel', async () => { + const dbName = 'race_update_delete_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + }, + }); + + const client = dbManager.getClient(dbName); + const user = await User.create({ emailAddress: 'update-delete@example.com' }); + + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Before Delete' }, + }); + + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: 'Should Be Deleted' }, + }); + + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'DELETE', + }); + + await waitForTable(client, 'PartialUsers'); + await waitForSyncedDeletion(client, 'update-delete@example.com'); + + const res = await client.query( + `SELECT * FROM "PartialUsers" WHERE "value" = $1`, + ['update-delete@example.com'], + ); + expect(res.rows.length).toBe(0); + }, TEST_TIMEOUT); + + /** + * What it does: + * - Exports 300 users (forcing multi-page fetches), deletes a low-sequence contact channel, and syncs again. + * - Checks the deleted row is gone and the total count drops by exactly one. + * + * Why it matters: + * - Prevents pagination LIMIT boundaries from causing delete events to be skipped. + */ + test('Deletes near pagination boundaries are honored', async () => { + const dbName = 'race_pagination_delete_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + }, + }); + + const client = dbManager.getClient(dbName); + const totalUsers = 300; + const users = []; + + await InternalApiKey.createAndSetProjectKeys(); + const batchSize = 10; + + for (let batchStart = 0; batchStart < totalUsers; batchStart += batchSize) { + const batchEnd = Math.min(batchStart + batchSize, totalUsers); + + const batchPromises = []; + for (let i = batchStart; i < batchEnd; i++) { + const email = `page-user-${i}@example.com`; + batchPromises.push( + User.create({ emailAddress: email }).then(async (user) => { + await niceBackendFetch(`/api/v1/users/${user.userId}`, { + accessType: 'admin', + method: 'PATCH', + body: { display_name: `Paged User ${i}` }, + }); + return { email, userId: user.userId }; + }) + ); + } + + const batchUsers = await Promise.all(batchPromises); + users.push(...batchUsers); + + if (batchEnd < totalUsers) { + await new Promise(resolve => setTimeout(resolve, 200)); + } + if (batchEnd < totalUsers && batchEnd % 200 === 0) { + await InternalApiKey.createAndSetProjectKeys(); + } + } + + await waitForTable(client, 'PartialUsers'); + + await waitForCondition( + async () => { + const res = await client.query(`SELECT COUNT(*) AS count FROM "PartialUsers"`); + return parseInt(res.rows[0].count, 10) === totalUsers; + }, + { description: 'initial >300 users exported', timeoutMs: 60000 }, + ); + + const deletedUser = users[1]; + await niceBackendFetch(`/api/v1/users/${deletedUser.userId}`, { + accessType: 'admin', + method: 'DELETE', + }); + + await waitForCondition( + async () => { + const res = await client.query(`SELECT COUNT(*) AS count FROM "PartialUsers"`); + return parseInt(res.rows[0].count, 10) === totalUsers - 1; + }, + { description: 'pagination delete reflected', timeoutMs: 180000 }, + ); + + const deletedRow = await client.query( + `SELECT * FROM "PartialUsers" WHERE "value" = $1`, + [deletedUser.email], + ); + expect(deletedRow.rows.length).toBe(0); + }, HIGH_VOLUME_TIMEOUT); + + /** + * What it does: + * - Creates overlapping database transactions that update the same row + * - Commits them at different times while sync is happening + * - Verifies that the highest sequence ID wins in the external DB + * + * Why it matters: + * - Proves true database-level race conditions are handled correctly + * - Tests that sync captures all committed changes eventually + */ + describe('Race conditions with overlapping transactions', () => { + const LOCAL_TEST_TIMEOUT = 120_000; // Must be > 70s sleep + setup time + + async function setupExternalDbWithBaseline(dbName: string) { + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + }, + }); + + const externalClient = dbManager.getClient(dbName); + const user = await User.create({ emailAddress: `${dbName}@example.com` }); + + // Make sure the PartialUsers row exists + await waitForTable(externalClient, 'PartialUsers'); + + await waitForCondition( + async () => { + const res = await externalClient.query<{ + displayName: string | null, + sequenceId: string | null, + }>( + ` + SELECT "displayName", "sequenceId" + FROM "PartialUsers" + WHERE "value" = $1 + `, + [`${dbName}@example.com`], + ); + return res.rows.length === 1; + }, + { description: `baseline row for ${dbName}`, timeoutMs: 60000 }, + ); + + const baseline = await externalClient.query<{ + displayName: string | null, + sequenceId: string | null, + }>( + ` + SELECT "displayName", "sequenceId" + FROM "PartialUsers" + WHERE "value" = $1 + `, + [`${dbName}@example.com`], + ); + + if (baseline.rows.length !== 1) { + throw new Error(`Expected baseline row for ${dbName}, got ${baseline.rows.length}`); + } + + const baselineRow = baseline.rows[0]; + const baselineSeq = baselineRow.sequenceId + ? BigInt(baselineRow.sequenceId) + : BigInt(0); + + return { + externalClient, + user, + baselineSeq, + }; + } + + function makeInternalDbUrl() { + const portPrefix = process.env.NEXT_PUBLIC_STACK_PORT_PREFIX || '81'; + return `postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:${portPrefix}28/stackframe`; + } + + /** + * Scenario 1: + * Poller runs while a transaction is in-flight and uncommitted. + * Only the baseline committed value should be visible. + * + */ + test( + 'Poller ignores uncommitted overlapping updates', + async () => { + const dbName = 'race_uncommitted_poll_test'; + const { externalClient, user, baselineSeq } = + await setupExternalDbWithBaseline(dbName); + + const internalDbUrl = makeInternalDbUrl(); + const internalClient = new Client({ connectionString: internalDbUrl }); + + await internalClient.connect(); + + try { + await internalClient.query('BEGIN'); + await internalClient.query( + ` + UPDATE "ProjectUser" + SET "displayName" = 'Transaction 1', "updatedAt" = NOW() + WHERE "projectUserId" = $1 + `, + [user.userId], + ); + + await sleep(70000); + + const during = await externalClient.query<{ + displayName: string | null, + sequenceId: string | null, + }>( + ` + SELECT "displayName", "sequenceId" + FROM "PartialUsers" + WHERE "value" = $1 + `, + [`${dbName}@example.com`], + ); + + expect(during.rows.length).toBe(1); + const row = during.rows[0]; + + expect(row.displayName).not.toBe('Transaction 1'); + + const seq = row.sequenceId ? BigInt(row.sequenceId) : BigInt(0); + expect(seq).toBe(baselineSeq); + + await internalClient.query('ROLLBACK'); + } finally { + await internalClient.end(); + } + }, + LOCAL_TEST_TIMEOUT, + ); + + /** + * Scenario 2: + * First transaction commits, then poller runs. + * Poller should pick up Transaction 1 and sequenceId should increase. + */ + test( + 'Poller picks up first committed transaction', + async () => { + const dbName = 'race_after_first_commit_test'; + const { externalClient, user, baselineSeq } = + await setupExternalDbWithBaseline(dbName); + + const internalDbUrl = makeInternalDbUrl(); + const internalClient = new Client({ connectionString: internalDbUrl }); + + await internalClient.connect(); + + try { + // Commit Transaction 1 + await internalClient.query('BEGIN'); + await internalClient.query( + ` + UPDATE "ProjectUser" + SET "displayName" = 'Transaction 1', "updatedAt" = NOW() + WHERE "projectUserId" = $1 + `, + [user.userId], + ); + await internalClient.query('COMMIT'); + + await waitForCondition( + async () => { + const res = await externalClient.query<{ + displayName: string | null, + sequenceId: string, + }>( + ` + SELECT "displayName", "sequenceId" + FROM "PartialUsers" + WHERE "value" = $1 + `, + [`${dbName}@example.com`], + ); + return ( + res.rows.length === 1 && + res.rows[0].displayName === 'Transaction 1' + ); + }, + { description: 'Transaction 1 exported', timeoutMs: 90000 }, + ); + + const afterT1 = await externalClient.query<{ + displayName: string | null, + sequenceId: string, + }>( + ` + SELECT "displayName", "sequenceId" + FROM "PartialUsers" + WHERE "value" = $1 + `, + [`${dbName}@example.com`], + ); + + expect(afterT1.rows.length).toBe(1); + const row = afterT1.rows[0]; + expect(row.displayName).toBe('Transaction 1'); + + const seq1 = BigInt(row.sequenceId); + expect(seq1).toBeGreaterThan(baselineSeq); + } finally { + await internalClient.end(); + } + }, + LOCAL_TEST_TIMEOUT, + ); + + /** + * Scenario 3: + * First transaction is committed and synced. + * Second transaction has UPDATE done but is still uncommitted. + * Poller should STILL see Transaction 1 (not Transaction 2). + */ + test( + 'Poller does not see second update until commit', + async () => { + const dbName = 'race_second_uncommitted_poll_test'; + const { externalClient, user, baselineSeq } = + await setupExternalDbWithBaseline(dbName); + + const internalDbUrl = makeInternalDbUrl(); + const internalClient = new Client({ connectionString: internalDbUrl }); + + await internalClient.connect(); + + try { + await internalClient.query('BEGIN'); + await internalClient.query( + ` + UPDATE "ProjectUser" + SET "displayName" = 'Transaction 1', "updatedAt" = NOW() + WHERE "projectUserId" = $1 + `, + [user.userId], + ); + await internalClient.query('COMMIT'); + + await waitForTable(externalClient, 'PartialUsers'); + + const afterT1 = await externalClient.query<{ + displayName: string | null, + sequenceId: string, + }>( + ` + SELECT "displayName", "sequenceId" + FROM "PartialUsers" + WHERE "value" = $1 + `, + [`${dbName}@example.com`], + ); + + expect(afterT1.rows.length).toBe(1); + const afterT1Row = afterT1.rows[0]; + + const seq1 = BigInt(afterT1Row.sequenceId); + await internalClient.query('BEGIN'); + await internalClient.query( + ` + UPDATE "ProjectUser" + SET "displayName" = 'Transaction 2', "updatedAt" = NOW() + WHERE "projectUserId" = $1 + `, + [user.userId], + ); + + await sleep(7000); + + const duringT2 = await externalClient.query<{ + displayName: string | null, + sequenceId: string, + }>( + ` + SELECT "displayName", "sequenceId" + FROM "PartialUsers" + WHERE "value" = $1 + `, + [`${dbName}@example.com`], + ); + + expect(duringT2.rows.length).toBe(1); + const duringT2Row = duringT2.rows[0]; + expect(duringT2Row.displayName).not.toBe('Transaction 2'); + + const seqDuring = BigInt(duringT2Row.sequenceId); + expect(seqDuring).toBeGreaterThanOrEqual(seq1); + + await internalClient.query('ROLLBACK'); + } finally { + await internalClient.end(); + } + }, + LOCAL_TEST_TIMEOUT, + ); + + /** + * Scenario 4: + * Two different rows, out-of-order commits: + * - T1 starts + * - T2 starts + * - T2 updates row2 + * - T1 updates row1 + * - T2 commits + * - Sync → only T2's row visible, T1's row unchanged + * - T1 commits + * - Sync → T1's row now visible + * + * Uses two different users to avoid row-level locking. + */ + test( + 'Out-of-order commits on different rows: uncommitted changes invisible', + async () => { + const dbName = 'race_two_rows_out_of_order_test'; + const connectionString = await dbManager.createDatabase(dbName); + + await createProjectWithExternalDb({ + main: { + type: 'postgres', + connectionString, + }, + }); + + const externalClient = dbManager.getClient(dbName); + + const user1 = await User.create({ emailAddress: 'row1@example.com' }); + const user2 = await User.create({ emailAddress: 'row2@example.com' }); + + await waitForTable(externalClient, 'PartialUsers'); + + await waitForCondition( + async () => { + const res = await externalClient.query(`SELECT COUNT(*) as count FROM "PartialUsers"`); + return parseInt(res.rows[0].count, 10) === 2; + }, + { description: 'both users synced initially', timeoutMs: 60000 }, + ); + + const internalDbUrl = makeInternalDbUrl(); + const t1Client = new Client({ connectionString: internalDbUrl }); + const t2Client = new Client({ connectionString: internalDbUrl }); + + await t1Client.connect(); + await t2Client.connect(); + + try { + await t1Client.query('BEGIN'); + + await t2Client.query('BEGIN'); + + await t2Client.query( + ` + UPDATE "ProjectUser" + SET "displayName" = 'T2 Updated', "updatedAt" = NOW() + WHERE "projectUserId" = $1 + `, + [user2.userId], + ); + + await t1Client.query( + ` + UPDATE "ProjectUser" + SET "displayName" = 'T1 Updated', "updatedAt" = NOW() + WHERE "projectUserId" = $1 + `, + [user1.userId], + ); + + await t2Client.query('COMMIT'); + + await waitForCondition( + async () => { + const res = await externalClient.query<{ displayName: string | null }>( + `SELECT "displayName" FROM "PartialUsers" WHERE "value" = $1`, + ['row2@example.com'], + ); + return res.rows.length === 1 && res.rows[0].displayName === 'T2 Updated'; + }, + { description: 'T2 row synced after T2 commit', timeoutMs: 90000 }, + ); + + const row1BeforeT1Commit = await externalClient.query<{ displayName: string | null }>( + `SELECT "displayName" FROM "PartialUsers" WHERE "value" = $1`, + ['row1@example.com'], + ); + expect(row1BeforeT1Commit.rows.length).toBe(1); + expect(row1BeforeT1Commit.rows[0].displayName).not.toBe('T1 Updated'); + + await t1Client.query('COMMIT'); + + await waitForCondition( + async () => { + const res = await externalClient.query<{ displayName: string | null }>( + `SELECT "displayName" FROM "PartialUsers" WHERE "value" = $1`, + ['row1@example.com'], + ); + return res.rows.length === 1 && res.rows[0].displayName === 'T1 Updated'; + }, + { description: 'T1 row synced after T1 commit', timeoutMs: 90000 }, + ); + + const finalRow1 = await externalClient.query<{ displayName: string | null }>( + `SELECT "displayName" FROM "PartialUsers" WHERE "value" = $1`, + ['row1@example.com'], + ); + const finalRow2 = await externalClient.query<{ displayName: string | null }>( + `SELECT "displayName" FROM "PartialUsers" WHERE "value" = $1`, + ['row2@example.com'], + ); + + expect(finalRow1.rows[0].displayName).toBe('T1 Updated'); + expect(finalRow2.rows[0].displayName).toBe('T2 Updated'); + } finally { + await t1Client.end(); + await t2Client.end(); + } + }, + LOCAL_TEST_TIMEOUT, + ); + + /** + * Scenario 5: + * Full lifecycle: + * - baseline + * - Transaction 1 committed & synced + * - Transaction 2 committed after a later sync + * Final state must be Transaction 2 with a higher sequenceId. + */ + test( + 'Highest sequenceId wins after both transactions commit', + async () => { + const dbName = 'race_full_lifecycle_test'; + const { externalClient, user, baselineSeq } = + await setupExternalDbWithBaseline(dbName); + + const internalDbUrl = makeInternalDbUrl(); + const internalClient = new Client({ connectionString: internalDbUrl }); + + await internalClient.connect(); + + try { + await internalClient.query('BEGIN'); + await internalClient.query( + ` + UPDATE "ProjectUser" + SET "displayName" = 'Transaction 1', "updatedAt" = NOW() + WHERE "projectUserId" = $1 + `, + [user.userId], + ); + await internalClient.query('COMMIT'); + + await waitForCondition( + async () => { + const res = await externalClient.query<{ + displayName: string | null, + }>( + `SELECT "displayName" FROM "PartialUsers" WHERE "value" = $1`, + [`${dbName}@example.com`], + ); + return res.rows.length === 1 && res.rows[0].displayName === 'Transaction 1'; + }, + { description: 'T1 synced', timeoutMs: 90000 }, + ); + + const afterT1 = await externalClient.query<{ + displayName: string | null, + sequenceId: string, + }>( + ` + SELECT "displayName", "sequenceId" + FROM "PartialUsers" + WHERE "value" = $1 + `, + [`${dbName}@example.com`], + ); + + expect(afterT1.rows.length).toBe(1); + const afterT1Row = afterT1.rows[0]; + expect(afterT1Row.displayName).toBe('Transaction 1'); + + const seq1 = BigInt(afterT1Row.sequenceId); + expect(seq1).toBeGreaterThan(baselineSeq); + + await internalClient.query('BEGIN'); + await internalClient.query( + ` + UPDATE "ProjectUser" + SET "displayName" = 'Transaction 2', "updatedAt" = NOW() + WHERE "projectUserId" = $1 + `, + [user.userId], + ); + await internalClient.query('COMMIT'); + + await waitForCondition( + async () => { + const res = await externalClient.query<{ + displayName: string | null, + }>( + `SELECT "displayName" FROM "PartialUsers" WHERE "value" = $1`, + [`${dbName}@example.com`], + ); + return res.rows.length === 1 && res.rows[0].displayName === 'Transaction 2'; + }, + { description: 'T2 synced', timeoutMs: 90000 }, + ); + + const afterT2 = await externalClient.query<{ + displayName: string | null, + sequenceId: string, + }>( + ` + SELECT "displayName", "sequenceId" + FROM "PartialUsers" + WHERE "value" = $1 + `, + [`${dbName}@example.com`], + ); + + expect(afterT2.rows.length).toBe(1); + const afterT2Row = afterT2.rows[0]; + expect(afterT2Row.displayName).toBe('Transaction 2'); + + const seq2 = BigInt(afterT2Row.sequenceId); + expect(seq2).toBeGreaterThan(seq1); + } finally { + await internalClient.end(); + } + }, + LOCAL_TEST_TIMEOUT, + ); + }); +}); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts new file mode 100644 index 000000000..473862ebf --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/external-db-sync-utils.ts @@ -0,0 +1,225 @@ +import { Client } from 'pg'; +import { expect } from 'vitest'; +import { Project } from '../../../backend-helpers'; + + +const PORT_PREFIX = process.env.NEXT_PUBLIC_STACK_PORT_PREFIX || '81'; +export const POSTGRES_HOST = process.env.EXTERNAL_DB_TEST_HOST || `localhost:${PORT_PREFIX}32`; +export const POSTGRES_USER = process.env.EXTERNAL_DB_TEST_USER || 'postgres'; +export const POSTGRES_PASSWORD = process.env.EXTERNAL_DB_TEST_PASSWORD || 'external-db-test-password'; +export const TEST_TIMEOUT = 120000; +export const HIGH_VOLUME_TIMEOUT = 240000; + +/** + * Helper class to manage external test databases + */ +export class TestDbManager { + private setupClient: Client | null = null; + private databases: Map = new Map(); + private databaseNames: Set = new Set(); + + async init() { + this.setupClient = new Client({ + connectionString: `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/postgres`, + }); + await this.setupClient.connect(); + } + + async createDatabase(dbName: string): Promise { + if (!this.setupClient) throw new Error('TestDbManager not initialized'); + + const uniqueDbName = `${dbName}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + await this.setupClient.query(`CREATE DATABASE "${uniqueDbName}"`); + const connectionString = `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${uniqueDbName}`; + const client = new Client({ connectionString }); + await client.connect(); + + this.databases.set(dbName, client); + this.databaseNames.add(uniqueDbName); + return connectionString; + } + + getClient(dbName: string): Client { + const client = this.databases.get(dbName); + if (!client) throw new Error(`Database ${dbName} not found`); + return client; + } + + async cleanup() { + for (const client of this.databases.values()) { + await client.end(); + } + this.databases.clear(); + if (this.setupClient) { + for (const dbName of this.databaseNames) { + try { + await this.setupClient.query(`DROP DATABASE IF EXISTS "${dbName}"`); + } catch (err) { + console.warn(`Failed to drop database ${dbName}:`, err); + } + } + this.databaseNames.clear(); + + await this.setupClient.end(); + this.setupClient = null; + } + } +} + + +/** + * Wait for a condition to be true by polling, with timeout + */ +export async function waitForCondition( + checkFn: () => Promise, + options: { timeoutMs?: number, intervalMs?: number, description?: string } = {} +): Promise { + const { timeoutMs = 10000, intervalMs = 100, description = 'condition' } = options; + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + if (await checkFn()) { + return; + } + await new Promise(r => setTimeout(r, intervalMs)); + } + + throw new Error(`Timeout waiting for ${description} after ${timeoutMs}ms`); +} + +/** + * Wait for data to appear in external DB (relies on automatic cron job) + */ +export async function waitForSyncedData(client: Client, email: string, expectedName?: string) { + + await waitForCondition( + async () => { + let res; + try { + res = await client.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, [email]); + } catch (err: any) { + if (err && err.code === '42P01') { + return false; + } + throw err; + } + if (res.rows.length === 0) { + return false; + } + if (expectedName && res.rows[0].displayName !== expectedName) { + return false; + } + return true; + }, + { + description: `data for ${email} to appear in external DB`, + timeoutMs: 90000, + intervalMs: 500, + } + ); +} + +/** + * Wait for data to be removed from external DB (relies on automatic cron job) + */ +export async function waitForSyncedDeletion(client: Client, email: string) { + await waitForCondition( + async () => { + let res; + try { + res = await client.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, [email]); + } catch (err: any) { + if (err && err.code === '42P01') { + return false; + } + throw err; + } + return res.rows.length === 0; + }, + { + description: `data for ${email} to be removed from external DB`, + timeoutMs: 90000, + intervalMs: 500, + } + ); +} + +/** + * Wait for table to be created (relies on automatic cron job) + */ +export async function waitForTable(client: Client, tableName: string) { + await waitForCondition( + async () => { + const res = await client.query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + ); + `, [tableName]); + const exists = res.rows[0].exists; + return exists; + }, + { + description: `table ${tableName} to be created`, + timeoutMs: 90000, + intervalMs: 500, + } + ); +} + +/** + * Helper to verify data does NOT exist in external DB + */ +export async function verifyNotInExternalDb(client: Client, email: string) { + const res = await client.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, [email]); + expect(res.rows.length).toBe(0); +} + +/** + * Helper to verify data DOES exist in external DB + */ +export async function verifyInExternalDb(client: Client, email: string, expectedName?: string) { + const res = await client.query(`SELECT * FROM "PartialUsers" WHERE "value" = $1`, [email]); + expect(res.rows.length).toBe(1); + if (expectedName) { + expect(res.rows[0].displayName).toBe(expectedName); + } + return res.rows[0]; +} + +/** + * Helper to count total users in external DB + */ +export async function countUsersInExternalDb(client: Client): Promise { + try { + const res = await client.query(`SELECT COUNT(*) FROM "PartialUsers"`); + return parseInt(res.rows[0].count, 10); + } catch (err: any) { + if (err && err.code === '42P01') { + return 0; + } + throw err; + } +} + +/** + * Helper to create a project and update its config with external DB settings + */ +export async function createProjectWithExternalDb(externalDatabases: any, projectOptions?: { display_name?: string, description?: string }) { + const project = await Project.createAndSwitch(projectOptions); + await Project.updateConfig({ + "dbSync.externalDatabases": externalDatabases + }); + return project; +} + +/** + * Helper to remove external DB config from current project + */ +export async function cleanupProjectExternalDb() { + await Project.updateConfig({ + "dbSync.externalDatabases": {} + }); +} + diff --git a/docker/dependencies/docker.compose.yaml b/docker/dependencies/docker.compose.yaml index ec92768d5..5025f9082 100644 --- a/docker/dependencies/docker.compose.yaml +++ b/docker/dependencies/docker.compose.yaml @@ -204,6 +204,19 @@ services: environment: HOST_ON_HOST: host.docker.internal + # ================= External DB Sync Test Postgres ================= + + external-db-test: + image: "docker.io/postgres:16.1" + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: external-db-test-password + POSTGRES_DB: postgres + ports: + - "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}32:5432" + volumes: + - external-db-test-data:/var/lib/postgresql/data + # ================= volumes ================= @@ -215,6 +228,7 @@ volumes: s3mock-data: deno-cache: localstack-data: + external-db-test-data: # ================= configs ================= diff --git a/package.json b/package.json index f3c162b0f..e66aad1b6 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@changesets/cli": "^2.27.9", "@testing-library/react": "^15.0.7", "@types/node": "20.17.6", + "@types/pg": "^8.15.6", "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", diff --git a/packages/stack-shared/src/config/db-sync-mappings.ts b/packages/stack-shared/src/config/db-sync-mappings.ts new file mode 100644 index 000000000..40f7d7bda --- /dev/null +++ b/packages/stack-shared/src/config/db-sync-mappings.ts @@ -0,0 +1,166 @@ +export const DEFAULT_DB_SYNC_MAPPINGS = { + "PartialUsers": { + sourceTables: ["ContactChannel", "ProjectUser"], + targetTable: "PartialUsers", + targetTablePrimaryKey: ["id"], + targetTableSchema: ` + CREATE TABLE IF NOT EXISTS "PartialUsers" ( + "id" uuid PRIMARY KEY, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "type" text, + "isPrimary" boolean, + "isVerified" boolean, + "value" text, + "sequenceId" bigint, + "userUpdatedAt" timestamp with time zone, + "profileImageUrl" text, + "displayName" text, + "userCreatedAt" timestamp with time zone, + "isAnonymous" boolean + ); + CREATE INDEX ON "PartialUsers" ("sequenceId"); + REVOKE ALL ON "PartialUsers" FROM PUBLIC; + GRANT SELECT ON "PartialUsers" TO PUBLIC; + `.trim(), + internalDbFetchQuery: ` + SELECT * + FROM ( + SELECT + "ContactChannel"."id", + "ContactChannel"."createdAt", + "ContactChannel"."updatedAt", + "ContactChannel"."type"::text AS "type", + CASE WHEN "ContactChannel"."isPrimary" = 'TRUE' THEN true ELSE false END AS "isPrimary", + "ContactChannel"."isVerified", + "ContactChannel"."value", + GREATEST("ContactChannel"."sequenceId", "ProjectUser"."sequenceId") AS "sequenceId", + "ProjectUser"."updatedAt" AS "userUpdatedAt", + "ProjectUser"."profileImageUrl", + "ProjectUser"."displayName", + "ProjectUser"."createdAt" AS "userCreatedAt", + "ProjectUser"."isAnonymous", + "ContactChannel"."tenancyId", + false AS "isDeleted" + FROM "ContactChannel" + JOIN "ProjectUser" + ON "ContactChannel"."projectUserId" = "ProjectUser"."projectUserId" + AND "ContactChannel"."tenancyId" = "ProjectUser"."tenancyId" + WHERE "ContactChannel"."tenancyId" = $1::uuid + + UNION ALL + SELECT + ("DeletedRow"."primaryKey"->>'id')::uuid AS "id", + NULL::timestamptz AS "createdAt", + "DeletedRow"."deletedAt" AS "updatedAt", + NULL::text AS "type", + NULL::boolean AS "isPrimary", + NULL::boolean AS "isVerified", + NULL::text AS "value", + "DeletedRow"."sequenceId" AS "sequenceId", + NULL::timestamptz AS "userUpdatedAt", + NULL::text AS "profileImageUrl", + NULL::text AS "displayName", + NULL::timestamptz AS "userCreatedAt", + NULL::boolean AS "isAnonymous", + "DeletedRow"."tenancyId", + true AS "isDeleted" + FROM "DeletedRow" + WHERE + "DeletedRow"."tenancyId" = $1::uuid + AND "DeletedRow"."tableName" = 'ContactChannel' + ) AS "_src" + WHERE "sequenceId" IS NOT NULL + ORDER BY "sequenceId" ASC + LIMIT 1000 + `.trim(), + externalDbUpdateQuery: ` + WITH existing AS ( + SELECT "sequenceId" AS "oldSeq" + FROM "PartialUsers" + WHERE "id" = $1::uuid + ), + decision AS ( + SELECT + $1::uuid AS "id", + $2::timestamptz AS "createdAt", + $3::timestamptz AS "updatedAt", + $4::text AS "type", + $5::boolean AS "isPrimary", + $6::boolean AS "isVerified", + $7::text AS "value", + $8::bigint AS "newSeq", + $9::timestamptz AS "userUpdatedAt", + $10::text AS "profileImageUrl", + $11::text AS "displayName", + $12::timestamptz AS "userCreatedAt", + $13::boolean AS "isAnonymous", + $14::boolean AS "isDeleted", + (SELECT "oldSeq" FROM existing) AS "oldSeq" + ), + deleted AS ( + DELETE FROM "PartialUsers" p + USING decision d + WHERE + d."isDeleted" = true + AND ( + d."oldSeq" IS NULL + OR d."newSeq" >= d."oldSeq" + ) + AND p."id" = d."id" + RETURNING 1 + ) + INSERT INTO "PartialUsers" ( + "id", + "createdAt", + "updatedAt", + "type", + "isPrimary", + "isVerified", + "value", + "sequenceId", + "userUpdatedAt", + "profileImageUrl", + "displayName", + "userCreatedAt", + "isAnonymous" + ) + SELECT + d."id", + d."createdAt", + d."updatedAt", + d."type", + d."isPrimary", + d."isVerified", + d."value", + d."newSeq" AS "sequenceId", + d."userUpdatedAt", + d."profileImageUrl", + d."displayName", + d."userCreatedAt", + d."isAnonymous" + FROM decision d + WHERE + d."isDeleted" = false + AND ( + d."oldSeq" IS NULL + OR d."newSeq" > d."oldSeq" + ) + ON CONFLICT ("id") DO UPDATE SET + "createdAt" = EXCLUDED."createdAt", + "updatedAt" = EXCLUDED."updatedAt", + "type" = EXCLUDED."type", + "isPrimary" = EXCLUDED."isPrimary", + "isVerified" = EXCLUDED."isVerified", + "value" = EXCLUDED."value", + "sequenceId" = EXCLUDED."sequenceId", + "userUpdatedAt" = EXCLUDED."userUpdatedAt", + "profileImageUrl" = EXCLUDED."profileImageUrl", + "displayName" = EXCLUDED."displayName", + "userCreatedAt" = EXCLUDED."userCreatedAt", + "isAnonymous" = EXCLUDED."isAnonymous" + WHERE + EXCLUDED."sequenceId" > "PartialUsers"."sequenceId"; + `.trim(), + }, +} as const; diff --git a/packages/stack-shared/src/config/schema-fuzzer.test.ts b/packages/stack-shared/src/config/schema-fuzzer.test.ts index 364759545..da2bd9ed5 100644 --- a/packages/stack-shared/src/config/schema-fuzzer.test.ts +++ b/packages/stack-shared/src/config/schema-fuzzer.test.ts @@ -49,6 +49,27 @@ const branchSchemaFuzzerConfig = [{ }], }], }], + dbSync: [{ + externalDatabases: [{ + "some-external-db-id": [{ + type: ["neon", "postgres"] as const, + connectionString: [ + "postgres://user:password@host:port/database", + "some-connection-string", + ], + mappings: [{ + "some-mapping-id": [{ + sourceTables: [[["table1"], ["table2"]]], + targetTable: ["target_table"], + targetTableSchema: ["public"], + targetTablePrimaryKey: [[["id"]]] as const, + internalDbFetchQuery: ["SELECT * FROM table"], + externalDbUpdateQuery: ["UPDATE table SET ..."], + }] + }], + }], + }], + }], dataVault: [{ stores: [{ "some-store-id": [{ diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index 8835d5953..3081a19f0 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -8,13 +8,14 @@ import * as yup from "yup"; import { ALL_APPS } from "../apps/apps-config"; 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"; import { SUPPORTED_CURRENCIES } from "../utils/currency-constants"; import { StackAssertionError } from "../utils/errors"; import { allProviders } from "../utils/oauth"; import { DeepFilterUndefined, DeepMerge, DeepRequiredOrUndefined, filterUndefined, get, has, isObjectLike, mapValues, set, typedAssign, typedEntries, typedFromEntries } from "../utils/objects"; import { Result } from "../utils/results"; import { CollapseObjectUnion, Expand, IntersectAll, IsUnion, typeAssert, typeAssertExtends, typeAssertIs } from "../utils/types"; +import { DEFAULT_DB_SYNC_MAPPINGS } from "./db-sync-mappings"; import { Config, NormalizationError, NormalizesTo, assertNormalized, getInvalidConfigReason, normalize } from "./format"; export const configLevels = ['project', 'branch', 'environment', 'organization'] as const; @@ -200,6 +201,27 @@ export const branchConfigSchema = canNoLongerBeOverridden(projectConfigSchema, [ payments: branchPaymentsSchema, + dbSync: yupObject({ + externalDatabases: yupRecord( + userSpecifiedIdSchema("externalDatabaseId"), + yupObject({ + type: yupString().oneOf(['neon', 'postgres']).defined(), + connectionString: yupString().defined(), + mappings: yupRecord( + userSpecifiedIdSchema("mappingId"), + yupObject({ + sourceTables: yupArray(yupString()).optional(), + targetTable: yupString().defined(), + targetTableSchema: yupString().optional(), + targetTablePrimaryKey: yupTuple([yupString().defined()]).optional(), + internalDbFetchQuery: yupString().defined(), + externalDbUpdateQuery: yupString().optional(), + }) + ).default(() => DEFAULT_DB_SYNC_MAPPINGS as any), + }) + ), + }), + dataVault: yupObject({ stores: yupRecord( userSpecifiedIdSchema("storeId"), @@ -566,6 +588,15 @@ const organizationConfigDefaults = { } as const) }, + + dbSync: { + externalDatabases: (key: string) => ({ + type: undefined, + connectionString: undefined, + mappings: DEFAULT_DB_SYNC_MAPPINGS as any, + }), + }, + dataVault: { stores: (key: string) => ({ displayName: "Unnamed Vault", @@ -858,12 +889,9 @@ export async function getConfigOverrideErrors(schema: T return yupMixed(); } case "array": { - throw new StackAssertionError(`Arrays are not supported in config JSON files (besides tuples). Use a record instead.`, { schemaInfo, schema }); - - // This is how the implementation would look like, but we don't support arrays in config JSON files (besides tuples) - // const arraySchema = schema as yup.ArraySchema; - // const innerType = arraySchema.innerType; - // return yupArray(innerType ? getRestrictedSchema(path + ".[]", innerType as any) : undefined); + const arraySchema = schema as yup.ArraySchema; + const innerType = arraySchema.innerType; + return yupArray(innerType ? getRestrictedSchema(path + ".[]", innerType as any) : yupMixed()); } case "tuple": { return yupTuple(schemaInfo.items.map((s, index) => getRestrictedSchema(path + `[${index}]`, s)) as any); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 516cfb418..8d38dc3f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: '@types/node': specifier: 20.17.6 version: 20.17.6 + '@types/pg': + specifier: ^8.15.6 + version: 8.15.6 '@types/supertest': specifier: ^6.0.2 version: 6.0.2 @@ -173,7 +176,7 @@ importers: version: 1.2.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@sentry/nextjs': specifier: ^10.11.0 - version: 10.11.0(@opentelemetry/context-async-hooks@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.4.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.24.2)) + version: 10.11.0(@opentelemetry/context-async-hooks@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.4.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.25.11)) '@simplewebauthn/server': specifier: ^11.0.0 version: 11.0.0(encoding@0.1.13) @@ -315,7 +318,7 @@ importers: version: 5.0.7 tsup: specifier: ^8.3.0 - version: 8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.6)(tsx@4.15.5)(typescript@5.8.3)(yaml@2.4.5) + version: 8.4.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.6)(tsx@4.15.5)(typescript@5.8.3)(yaml@2.4.5) tsx: specifier: ^4.7.2 version: 4.15.5 @@ -540,9 +543,15 @@ importers: specifier: ^16.4.5 version: 16.4.5 devDependencies: + '@types/pg': + specifier: ^8.15.6 + version: 8.15.6 jose: specifier: ^5.6.3 version: 5.6.3 + pg: + specifier: ^8.16.3 + version: 8.16.3 apps/mock-oauth-server: dependencies: @@ -1284,10 +1293,10 @@ importers: version: link:../../packages/stack '@supabase/ssr': specifier: latest - version: 0.7.0(@supabase/supabase-js@2.84.0) + version: 0.8.0(@supabase/supabase-js@2.86.0) '@supabase/supabase-js': specifier: latest - version: 2.84.0 + version: 2.86.0 jose: specifier: ^5.2.2 version: 5.6.3 @@ -1395,7 +1404,7 @@ importers: devDependencies: '@quetzallabs/i18n': specifier: ^0.1.19 - version: 0.1.19(next@16.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.0.0))(react@19.0.0)) + version: 0.1.19(next@16.0.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.0.0))(react@19.0.0)) '@types/color': specifier: ^3.0.6 version: 3.0.6 @@ -1443,7 +1452,7 @@ importers: version: 3.4.14 tsup: specifier: ^8.0.2 - version: 8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.4.47)(tsx@4.16.2)(typescript@5.8.3)(yaml@2.8.0) + version: 8.4.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.4.47)(tsx@4.16.2)(typescript@5.8.3)(yaml@2.8.0) packages/react: dependencies: @@ -1513,7 +1522,7 @@ importers: devDependencies: '@quetzallabs/i18n': specifier: ^0.1.19 - version: 0.1.19(next@16.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)) + version: 0.1.19(next@16.0.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)) '@types/color': specifier: ^3.0.6 version: 3.0.6 @@ -1694,7 +1703,7 @@ importers: version: 3.4.14 tsup: specifier: ^8.0.2 - version: 8.1.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(postcss@8.4.47)(typescript@5.8.3) + version: 8.4.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.4.47)(tsx@4.15.5)(typescript@5.8.3)(yaml@2.8.0) packages/stack-sc: dependencies: @@ -1774,7 +1783,7 @@ importers: devDependencies: '@sentry/nextjs': specifier: ^10.11.0 - version: 10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.24.2)) + version: 10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.0.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.24.2)) '@simplewebauthn/types': specifier: ^11.0.0 version: 11.0.0 @@ -2076,7 +2085,7 @@ importers: version: 3.4.14 tsup: specifier: ^8.0.2 - version: 8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.4.47)(tsx@4.16.2)(typescript@5.8.3)(yaml@2.8.0) + version: 8.4.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.4.47)(tsx@4.16.2)(typescript@5.8.3)(yaml@2.8.0) packages: @@ -2156,7 +2165,6 @@ packages: '@assistant-ui/react-edge@0.2.12': resolution: {integrity: sha512-95Y912lW8ASMT52qZd6ZHRiF+T7WxbeJ1yb2z/I0lCKegPt0q3spGy92YnO7mwz0uJaNjqu4/oZZybYfeIDzJg==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: '@assistant-ui/react': '*' '@types/react': ^18.2.0 @@ -2462,10 +2470,6 @@ packages: resolution: {integrity: sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==} engines: {node: '>=6.9.0'} - '@babel/core@7.28.0': - resolution: {integrity: sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==} - engines: {node: '>=6.9.0'} - '@babel/core@7.28.5': resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} engines: {node: '>=6.9.0'} @@ -2478,18 +2482,10 @@ packages: resolution: {integrity: sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg==} engines: {node: '>=6.9.0'} - '@babel/generator@7.28.0': - resolution: {integrity: sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.28.5': resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} engines: {node: '>=6.9.0'} - '@babel/helper-annotate-as-pure@7.24.7': - resolution: {integrity: sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg==} - engines: {node: '>=6.9.0'} - '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} @@ -2502,12 +2498,6 @@ packages: resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} engines: {node: '>=6.9.0'} - '@babel/helper-create-class-features-plugin@7.25.0': - resolution: {integrity: sha512-GYM6BxeQsETc9mnct+nIIpf63SAyzvyYN7UB/IlTyd+MBg06afFGp0mIeUqGyWgS2mxad6vqbMrHVlaL3m70sQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-create-class-features-plugin@7.28.5': resolution: {integrity: sha512-q3WC4JfdODypvxArsJQROfupPBq9+lMwjKq7C33GhbFYJsufD0yd/ziwD+hJucLeWsnFPWZjsU2DNFqBPE7jwQ==} engines: {node: '>=6.9.0'} @@ -2529,10 +2519,6 @@ packages: resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} - '@babel/helper-member-expression-to-functions@7.24.8': - resolution: {integrity: sha512-LABppdt+Lp/RlBxqrh4qgf1oEH/WxdzQNDJIu5gC/W1GyvPVrOBiItmmM8wan2fm4oYqFuFfkXmlGpLQhPY8CA==} - engines: {node: '>=6.9.0'} - '@babel/helper-member-expression-to-functions@7.28.5': resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} engines: {node: '>=6.9.0'} @@ -2555,30 +2541,16 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-module-transforms@7.27.3': - resolution: {integrity: sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-module-transforms@7.28.3': resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-optimise-call-expression@7.24.7': - resolution: {integrity: sha512-jKiTsW2xmWwxT1ixIdfXUZp+P5yURx2suzLZr5Hi64rURpDYdMW0pv+Uf17EYk2Rd428Lx4tLsnjGJzYKDM/6A==} - engines: {node: '>=6.9.0'} - '@babel/helper-optimise-call-expression@7.27.1': resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} engines: {node: '>=6.9.0'} - '@babel/helper-plugin-utils@7.24.8': - resolution: {integrity: sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==} - engines: {node: '>=6.9.0'} - '@babel/helper-plugin-utils@7.27.1': resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} engines: {node: '>=6.9.0'} @@ -2589,22 +2561,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-replace-supers@7.25.0': - resolution: {integrity: sha512-q688zIvQVYtZu+i2PsdIu/uWGRpfxzr5WESsfpShfZECkO+d2o+WROWezCi/Q6kJ0tfPa5+pUGUlfx2HhrA3Bg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-replace-supers@7.27.1': resolution: {integrity: sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-skip-transparent-expression-wrappers@7.24.7': - resolution: {integrity: sha512-IO+DLT3LQUElMbpzlatRASEyQtfhSE0+m465v++3jyyXeBTBUjtVZg28/gHeV5mrTJqvEKhKroBGAvhW+qPHiQ==} - engines: {node: '>=6.9.0'} - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} engines: {node: '>=6.9.0'} @@ -2653,10 +2615,6 @@ packages: resolution: {integrity: sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.27.6': - resolution: {integrity: sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==} - engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.4': resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} engines: {node: '>=6.9.0'} @@ -3113,10 +3071,6 @@ packages: resolution: {integrity: sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.28.0': - resolution: {integrity: sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.28.5': resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} engines: {node: '>=6.9.0'} @@ -3137,10 +3091,6 @@ packages: resolution: {integrity: sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.1': - resolution: {integrity: sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==} - engines: {node: '>=6.9.0'} - '@babel/types@7.28.5': resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} @@ -4978,9 +4928,6 @@ packages: resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jridgewell/gen-mapping@0.3.12': - resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -5011,9 +4958,6 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@jridgewell/trace-mapping@0.3.29': - resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} - '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -5091,9 +5035,6 @@ packages: '@next/env@16.0.0': resolution: {integrity: sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA==} - '@next/env@16.0.3': - resolution: {integrity: sha512-IqgtY5Vwsm14mm/nmQaRMmywCU+yyMIYfk3/MHZ2ZTJvwVbBn3usZnjMi1GacrMVzVcAxJShTCpZlPs26EdEjQ==} - '@next/eslint-plugin-next@14.2.17': resolution: {integrity: sha512-fW6/u1jjlBQrMs1ExyINehaK3B+LEW5UqdF6QYL07QK+SECkX0hnEyPMaNKj0ZFzirQ9D8jLWQ00P8oua4yx9g==} @@ -5142,12 +5083,6 @@ packages: cpu: [arm64] os: [darwin] - '@next/swc-darwin-arm64@16.0.3': - resolution: {integrity: sha512-MOnbd92+OByu0p6QBAzq1ahVWzF6nyfiH07dQDez4/Nku7G249NjxDVyEfVhz8WkLiOEU+KFVnqtgcsfP2nLXg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - '@next/swc-darwin-x64@14.2.15': resolution: {integrity: sha512-5TGyjFcf8ampZP3e+FyCax5zFVHi+Oe7sZyaKOngsqyaNEpOgkKB3sqmymkZfowy3ufGA/tUgDPPxpQx931lHg==} engines: {node: '>= 10'} @@ -5184,12 +5119,6 @@ packages: cpu: [x64] os: [darwin] - '@next/swc-darwin-x64@16.0.3': - resolution: {integrity: sha512-i70C4O1VmbTivYdRlk+5lj9xRc2BlK3oUikt3yJeHT1unL4LsNtN7UiOhVanFdc7vDAgZn1tV/9mQwMkWOJvHg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - '@next/swc-linux-arm64-gnu@14.2.15': resolution: {integrity: sha512-3Bwv4oc08ONiQ3FiOLKT72Q+ndEMyLNsc/D3qnLMbtUYTQAmkx9E/JRu0DBpHxNddBmNT5hxz1mYBphJ3mfrrw==} engines: {node: '>= 10'} @@ -5232,13 +5161,6 @@ packages: os: [linux] libc: [glibc] - '@next/swc-linux-arm64-gnu@16.0.3': - resolution: {integrity: sha512-O88gCZ95sScwD00mn/AtalyCoykhhlokxH/wi1huFK+rmiP5LAYVs/i2ruk7xST6SuXN4NI5y4Xf5vepb2jf6A==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@next/swc-linux-arm64-musl@14.2.15': resolution: {integrity: sha512-k5xf/tg1FBv/M4CMd8S+JL3uV9BnnRmoe7F+GWC3DxkTCD9aewFRH1s5rJ1zkzDa+Do4zyN8qD0N8c84Hu96FQ==} engines: {node: '>= 10'} @@ -5281,13 +5203,6 @@ packages: os: [linux] libc: [musl] - '@next/swc-linux-arm64-musl@16.0.3': - resolution: {integrity: sha512-CEErFt78S/zYXzFIiv18iQCbRbLgBluS8z1TNDQoyPi8/Jr5qhR3e8XHAIxVxPBjDbEMITprqELVc5KTfFj0gg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - '@next/swc-linux-x64-gnu@14.2.15': resolution: {integrity: sha512-kE6q38hbrRbKEkkVn62reLXhThLRh6/TvgSP56GkFNhU22TbIrQDEMrO7j0IcQHcew2wfykq8lZyHFabz0oBrA==} engines: {node: '>= 10'} @@ -5330,13 +5245,6 @@ packages: os: [linux] libc: [glibc] - '@next/swc-linux-x64-gnu@16.0.3': - resolution: {integrity: sha512-Tc3i+nwt6mQ+Dwzcri/WNDj56iWdycGVh5YwwklleClzPzz7UpfaMw1ci7bLl6GRYMXhWDBfe707EXNjKtiswQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - '@next/swc-linux-x64-musl@14.2.15': resolution: {integrity: sha512-PZ5YE9ouy/IdO7QVJeIcyLn/Rc4ml9M2G4y3kCM9MNf1YKvFY4heg3pVa/jQbMro+tP6yc4G2o9LjAz1zxD7tQ==} engines: {node: '>= 10'} @@ -5379,13 +5287,6 @@ packages: os: [linux] libc: [musl] - '@next/swc-linux-x64-musl@16.0.3': - resolution: {integrity: sha512-zTh03Z/5PBBPdTurgEtr6nY0vI9KR9Ifp/jZCcHlODzwVOEKcKRBtQIGrkc7izFgOMuXDEJBmirwpGqdM/ZixA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - '@next/swc-win32-arm64-msvc@14.2.15': resolution: {integrity: sha512-2raR16703kBvYEQD9HNLyb0/394yfqzmIeyp2nDzcPV4yPjqNUG3ohX6jX00WryXz6s1FXpVhsCo3i+g4RUX+g==} engines: {node: '>= 10'} @@ -5422,12 +5323,6 @@ packages: cpu: [arm64] os: [win32] - '@next/swc-win32-arm64-msvc@16.0.3': - resolution: {integrity: sha512-Jc1EHxtZovcJcg5zU43X3tuqzl/sS+CmLgjRP28ZT4vk869Ncm2NoF8qSTaL99gh6uOzgM99Shct06pSO6kA6g==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - '@next/swc-win32-ia32-msvc@14.2.15': resolution: {integrity: sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ==} engines: {node: '>= 10'} @@ -5482,12 +5377,6 @@ packages: cpu: [x64] os: [win32] - '@next/swc-win32-x64-msvc@16.0.3': - resolution: {integrity: sha512-N7EJ6zbxgIYpI/sWNzpVKRMbfEGgsWuOIvzkML7wxAAZhPk1Msxuo/JDu1PKjWGrAoOLaZcIX5s+/pF5LIbBBg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - '@node-oauth/formats@1.0.0': resolution: {integrity: sha512-DwSbLtdC8zC5B5gTJkFzJj5s9vr9SGzOgQvV9nH7tUVuMSScg0EswAczhjIapOmH3Y8AyP7C4Jv7b8+QJObWZA==} @@ -7524,11 +7413,6 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.18.0': - resolution: {integrity: sha512-Tya6xypR10giZV1XzxmH5wr25VcZSncG0pZIjfePT0OVBvqNEurzValetGNarVrGiq66EBVAFn15iYX4w6FKgQ==} - cpu: [arm] - os: [android] - '@rollup/rollup-android-arm-eabi@4.24.4': resolution: {integrity: sha512-jfUJrFct/hTA0XDM5p/htWKoNNTbDLY0KRwEt6pyOA6k2fmk0WVwl65PdUdJZgzGEHWx+49LilkcSaumQRyNQw==} cpu: [arm] @@ -7544,11 +7428,6 @@ packages: cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.18.0': - resolution: {integrity: sha512-avCea0RAP03lTsDhEyfy+hpfr85KfyTctMADqHVhLAF3MlIkq83CP8UfAHUssgXTYd+6er6PaAhx/QGv4L1EiA==} - cpu: [arm64] - os: [android] - '@rollup/rollup-android-arm64@4.24.4': resolution: {integrity: sha512-j4nrEO6nHU1nZUuCfRKoCcvh7PIywQPUCBa2UsootTHvTHIoIu2BzueInGJhhvQO/2FTRdNYpf63xsgEqH9IhA==} cpu: [arm64] @@ -7564,11 +7443,6 @@ packages: cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.18.0': - resolution: {integrity: sha512-IWfdwU7KDSm07Ty0PuA/W2JYoZ4iTj3TUQjkVsO/6U+4I1jN5lcR71ZEvRh52sDOERdnNhhHU57UITXz5jC1/w==} - cpu: [arm64] - os: [darwin] - '@rollup/rollup-darwin-arm64@4.24.4': resolution: {integrity: sha512-GmU/QgGtBTeraKyldC7cDVVvAJEOr3dFLKneez/n7BvX57UdhOqDsVwzU7UOnYA7AAOt+Xb26lk79PldDHgMIQ==} cpu: [arm64] @@ -7584,11 +7458,6 @@ packages: cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.18.0': - resolution: {integrity: sha512-n2LMsUz7Ynu7DoQrSQkBf8iNrjOGyPLrdSg802vk6XT3FtsgX6JbE8IHRvposskFm9SNxzkLYGSq9QdpLYpRNA==} - cpu: [x64] - os: [darwin] - '@rollup/rollup-darwin-x64@4.24.4': resolution: {integrity: sha512-N6oDBiZCBKlwYcsEPXGDE4g9RoxZLK6vT98M8111cW7VsVJFpNEqvJeIPfsCzbf0XEakPslh72X0gnlMi4Ddgg==} cpu: [x64] @@ -7634,12 +7503,6 @@ packages: cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.18.0': - resolution: {integrity: sha512-C/zbRYRXFjWvz9Z4haRxcTdnkPt1BtCkz+7RtBSuNmKzMzp3ZxdM28Mpccn6pt28/UWUCTXa+b0Mx1k3g6NOMA==} - cpu: [arm] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-arm-gnueabihf@4.24.4': resolution: {integrity: sha512-10ICosOwYChROdQoQo589N5idQIisxjaFE/PAnX2i0Zr84mY0k9zul1ArH0rnJ/fpgiqfu13TFZR5A5YJLOYZA==} cpu: [arm] @@ -7658,12 +7521,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.18.0': - resolution: {integrity: sha512-l3m9ewPgjQSXrUMHg93vt0hYCGnrMOcUpTz6FLtbwljo2HluS4zTXFy2571YQbisTnfTKPZ01u/ukJdQTLGh9A==} - cpu: [arm] - os: [linux] - libc: [musl] - '@rollup/rollup-linux-arm-musleabihf@4.24.4': resolution: {integrity: sha512-ySAfWs69LYC7QhRDZNKqNhz2UKN8LDfbKSMAEtoEI0jitwfAG2iZwVqGACJT+kfYvvz3/JgsLlcBP+WWoKCLcw==} cpu: [arm] @@ -7682,12 +7539,6 @@ packages: os: [linux] libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.18.0': - resolution: {integrity: sha512-rJ5D47d8WD7J+7STKdCUAgmQk49xuFrRi9pZkWoRD1UeSMakbcepWXPF8ycChBoAqs1pb2wzvbY6Q33WmN2ftw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-arm64-gnu@4.24.4': resolution: {integrity: sha512-uHYJ0HNOI6pGEeZ/5mgm5arNVTI0nLlmrbdph+pGXpC9tFHFDQmDMOEqkmUObRfosJqpU8RliYoGz06qSdtcjg==} cpu: [arm64] @@ -7706,12 +7557,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.18.0': - resolution: {integrity: sha512-be6Yx37b24ZwxQ+wOQXXLZqpq4jTckJhtGlWGZs68TgdKXJgw54lUUoFYrg6Zs/kjzAQwEwYbp8JxZVzZLRepQ==} - cpu: [arm64] - os: [linux] - libc: [musl] - '@rollup/rollup-linux-arm64-musl@4.24.4': resolution: {integrity: sha512-38yiWLemQf7aLHDgTg85fh3hW9stJ0Muk7+s6tIkSUOMmi4Xbv5pH/5Bofnsb6spIwD5FJiR+jg71f0CH5OzoA==} cpu: [arm64] @@ -7742,12 +7587,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-powerpc64le-gnu@4.18.0': - resolution: {integrity: sha512-hNVMQK+qrA9Todu9+wqrXOHxFiD5YmdEi3paj6vP02Kx1hjd2LLYR2eaN7DsEshg09+9uzWi2W18MJDlG0cxJA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-powerpc64le-gnu@4.24.4': resolution: {integrity: sha512-q73XUPnkwt9ZNF2xRS4fvneSuaHw2BXuV5rI4cw0fWYVIWIBeDZX7c7FWhFQPNTnE24172K30I+dViWRVD9TwA==} cpu: [ppc64] @@ -7766,12 +7605,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-riscv64-gnu@4.18.0': - resolution: {integrity: sha512-ROCM7i+m1NfdrsmvwSzoxp9HFtmKGHEqu5NNDiZWQtXLA8S5HBCkVvKAxJ8U+CVctHwV2Gb5VUaK7UAkzhDjlg==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-riscv64-gnu@4.24.4': resolution: {integrity: sha512-Aie/TbmQi6UXokJqDZdmTJuZBCU3QBDA8oTKRGtd4ABi/nHgXICulfg1KI6n9/koDsiDbvHAiQO3YAUNa/7BCw==} cpu: [riscv64] @@ -7796,12 +7629,6 @@ packages: os: [linux] libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.18.0': - resolution: {integrity: sha512-0UyyRHyDN42QL+NbqevXIIUnKA47A+45WyasO+y2bGJ1mhQrfrtXUpTxCOrfxCR4esV3/RLYyucGVPiUsO8xjg==} - cpu: [s390x] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-s390x-gnu@4.24.4': resolution: {integrity: sha512-P8MPErVO/y8ohWSP9JY7lLQ8+YMHfTI4bAdtCi3pC2hTeqFJco2jYspzOzTUB8hwUWIIu1xwOrJE11nP+0JFAQ==} cpu: [s390x] @@ -7820,12 +7647,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.18.0': - resolution: {integrity: sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==} - cpu: [x64] - os: [linux] - libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.24.4': resolution: {integrity: sha512-K03TljaaoPK5FOyNMZAAEmhlyO49LaE4qCsr0lYHUKyb6QacTNF9pnfPpXnFlFD3TXuFbFbz7tJ51FujUXkXYA==} cpu: [x64] @@ -7844,12 +7665,6 @@ packages: os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.18.0': - resolution: {integrity: sha512-LKaqQL9osY/ir2geuLVvRRs+utWUNilzdE90TpyoX0eNqPzWjRm14oMEE+YLve4k/NAqCdPkGYDaDF5Sw+xBfg==} - cpu: [x64] - os: [linux] - libc: [musl] - '@rollup/rollup-linux-x64-musl@4.24.4': resolution: {integrity: sha512-VJYl4xSl/wqG2D5xTYncVWW+26ICV4wubwN9Gs5NrqhJtayikwCXzPL8GDsLnaLU3WwhQ8W02IinYSFJfyo34Q==} cpu: [x64] @@ -7873,11 +7688,6 @@ packages: cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.18.0': - resolution: {integrity: sha512-7J6TkZQFGo9qBKH0pk2cEVSRhJbL6MtfWxth7Y5YmZs57Pi+4x6c2dStAUvaQkHQLnEQv1jzBUW43GvZW8OFqA==} - cpu: [arm64] - os: [win32] - '@rollup/rollup-win32-arm64-msvc@4.24.4': resolution: {integrity: sha512-ku2GvtPwQfCqoPFIJCqZ8o7bJcj+Y54cZSr43hHca6jLwAiCbZdBUOrqE6y29QFajNAzzpIOwsckaTFmN6/8TA==} cpu: [arm64] @@ -7893,11 +7703,6 @@ packages: cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.18.0': - resolution: {integrity: sha512-Txjh+IxBPbkUB9+SXZMpv+b/vnTEtFyfWZgJ6iyCmt2tdx0OF5WhFowLmnh8ENGNpfUlUZkdI//4IEmhwPieNg==} - cpu: [ia32] - os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.24.4': resolution: {integrity: sha512-V3nCe+eTt/W6UYNr/wGvO1fLpHUrnlirlypZfKCT1fG6hWfqhPgQV/K/mRBXBpxc0eKLIF18pIOFVPh0mqHjlg==} cpu: [ia32] @@ -7913,11 +7718,6 @@ packages: cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.18.0': - resolution: {integrity: sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g==} - cpu: [x64] - os: [win32] - '@rollup/rollup-win32-x64-msvc@4.24.4': resolution: {integrity: sha512-LTw1Dfd0mBIEqUVCxbvTE/LLo+9ZxVC9k99v1v4ahg9Aak6FpqOfNu5kRkeTAn0wphoC4JU7No1/rL+bBCEwhg==} cpu: [x64] @@ -8144,7 +7944,6 @@ packages: '@simplewebauthn/types@11.0.0': resolution: {integrity: sha512-b2o0wC5u2rWts31dTgBkAtSNKGX0cvL6h8QedNsKmj8O4QoLFQFR3DBVBUlpyVEhYKA+mXGUaXbcOc4JdQ3HzA==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -8398,33 +8197,33 @@ packages: resolution: {integrity: sha512-SXuhqhuR5FXaYgKTXzZJeqtVA6JKb9IZWaGeEUxHHiOcFy2p51wccO72bYpXwoK4D5pzQOIYLTuAc7etxyMmwg==} engines: {node: '>=12.16'} - '@supabase/auth-js@2.84.0': - resolution: {integrity: sha512-J6XKbqqg1HQPMfYkAT9BrC8anPpAiifl7qoVLsYhQq5B/dnu/lxab1pabnxtJEsvYG5rwI5HEVEGXMjoQ6Wz2Q==} + '@supabase/auth-js@2.86.0': + resolution: {integrity: sha512-3xPqMvBWC6Haqpr6hEWmSUqDq+6SA1BAEdbiaHdAZM9QjZ5uiQJ+6iD9pZOzOa6MVXZh4GmwjhC9ObIG0K1NcA==} engines: {node: '>=20.0.0'} - '@supabase/functions-js@2.84.0': - resolution: {integrity: sha512-2oY5QBV4py/s64zMlhPEz+4RTdlwxzmfhM1k2xftD2v1DruRZKfoe7Yn9DCz1VondxX8evcvpc2udEIGzHI+VA==} + '@supabase/functions-js@2.86.0': + resolution: {integrity: sha512-AlOoVfeaq9XGlBFIyXTmb+y+CZzxNO4wWbfgRM6iPpNU5WCXKawtQYSnhivi3UVxS7GA0rWovY4d6cIAxZAojA==} engines: {node: '>=20.0.0'} - '@supabase/postgrest-js@2.84.0': - resolution: {integrity: sha512-oplc/3jfJeVW4F0J8wqywHkjIZvOVHtqzF0RESijepDAv5Dn/LThlGW1ftysoP4+PXVIrnghAbzPHo88fNomPQ==} + '@supabase/postgrest-js@2.86.0': + resolution: {integrity: sha512-QVf+wIXILcZJ7IhWhWn+ozdf8B+oO0Ulizh2AAPxD/6nQL+x3r9lJ47a+fpc/jvAOGXMbkeW534Kw6jz7e8iIA==} engines: {node: '>=20.0.0'} - '@supabase/realtime-js@2.84.0': - resolution: {integrity: sha512-ThqjxiCwWiZAroHnYPmnNl6tZk6jxGcG2a7Hp/3kcolPcMj89kWjUTA3cHmhdIWYsP84fHp8MAQjYWMLf7HEUg==} + '@supabase/realtime-js@2.86.0': + resolution: {integrity: sha512-dyS8bFoP29R/sj5zLi0AP3JfgG8ar1nuImcz5jxSx7UIW7fbFsXhUCVrSY2Ofo0+Ev6wiATiSdBOzBfWaiFyPA==} engines: {node: '>=20.0.0'} - '@supabase/ssr@0.7.0': - resolution: {integrity: sha512-G65t5EhLSJ5c8hTCcXifSL9Q/ZRXvqgXeNo+d3P56f4U1IxwTqjB64UfmfixvmMcjuxnq2yGqEWVJqUcO+AzAg==} + '@supabase/ssr@0.8.0': + resolution: {integrity: sha512-/PKk8kNFSs8QvvJ2vOww1mF5/c5W8y42duYtXvkOSe+yZKRgTTZywYG2l41pjhNomqESZCpZtXuWmYjFRMV+dw==} peerDependencies: - '@supabase/supabase-js': ^2.43.4 + '@supabase/supabase-js': ^2.76.1 - '@supabase/storage-js@2.84.0': - resolution: {integrity: sha512-vXvAJ1euCuhryOhC6j60dG8ky+lk0V06ubNo+CbhuoUv+sl39PyY0lc+k+qpQhTk/VcI6SiM0OECLN83+nyJ5A==} + '@supabase/storage-js@2.86.0': + resolution: {integrity: sha512-PM47jX/Mfobdtx7NNpoj9EvlrkapAVTQBZgGGslEXD6NS70EcGjhgRPBItwHdxZPM5GwqQ0cGMN06uhjeY2mHQ==} engines: {node: '>=20.0.0'} - '@supabase/supabase-js@2.84.0': - resolution: {integrity: sha512-byMqYBvb91sx2jcZsdp0qLpmd4Dioe80e4OU/UexXftCkpTcgrkoENXHf5dO8FCSai8SgNeq16BKg10QiDI6xg==} + '@supabase/supabase-js@2.86.0': + resolution: {integrity: sha512-BaC9sv5+HGNy1ulZwY8/Ev7EjfYYmWD4fOMw9bDBqTawEj6JHAiOHeTwXLRzVaeSay4p17xYLN2NSCoGgXMQnw==} engines: {node: '>=20.0.0'} '@swc/core-darwin-arm64@1.3.101': @@ -8613,17 +8412,9 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tanstack/query-core@5.81.5': - resolution: {integrity: sha512-ZJOgCy/z2qpZXWaj/oxvodDx07XcQa9BF92c0oINjHkoqUPsmm3uG08HpTaviviZ/N9eP1f9CM7mKSEkIo7O1Q==} - '@tanstack/query-core@5.90.7': resolution: {integrity: sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ==} - '@tanstack/react-query@5.81.5': - resolution: {integrity: sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw==} - peerDependencies: - react: ^18 || ^19 - '@tanstack/react-query@5.90.7': resolution: {integrity: sha512-wAHc/cgKzW7LZNFloThyHnV/AX9gTg3w5yAv0gvQHPZoCnepwqCMtzbuPbb2UvfvO32XZ46e8bPOYbfZhzVnnQ==} peerDependencies: @@ -8842,9 +8633,6 @@ packages: '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} - '@types/estree@1.0.5': - resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} - '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} @@ -8974,6 +8762,9 @@ packages: '@types/pg@8.15.4': resolution: {integrity: sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==} + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/phoenix@1.6.6': resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==} @@ -9929,12 +9720,6 @@ packages: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} - bundle-require@4.2.1: - resolution: {integrity: sha512-7Q/6vkyYAwOmQNRw75x+4yRtZCZJXUDmHHlFdkiV0wgv/reNjtJwpu1jPJ0w2kbEpIM0uoKI3S4/f39dU7AjSA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - peerDependencies: - esbuild: '>=0.17' - bundle-require@5.0.0: resolution: {integrity: sha512-GuziW3fSSmopcx4KRymQEJVbZUfqlCqcq7dvs6TYwKRZiegK/2buMxQTPs6MGlNv50wms1699qYO54R8XfRX4w==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -10763,15 +10548,6 @@ packages: supports-color: optional: true - debug@4.3.5: - resolution: {integrity: sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.3.7: resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} engines: {node: '>=6.0'} @@ -11754,22 +11530,6 @@ packages: picomatch: optional: true - fdir@6.4.3: - resolution: {integrity: sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fdir@6.4.6: - resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -12436,6 +12196,10 @@ packages: i18next@23.14.0: resolution: {integrity: sha512-Y5GL4OdA8IU2geRrt2+Uc1iIhsjICdHZzT9tNwQ3TVqdNzgxHToGCKf/TPRP80vTCAP6svg2WbbJL+Gx5MFQVA==} + iceberg-js@0.8.0: + resolution: {integrity: sha512-kmgmea2nguZEvRqW79gDqNXyxA3OS5WIgMVffrHpqXV4F/J4UmNIw2vstixioLTNSkd5rFB8G0s3Lwzogm6OFw==} + engines: {node: '>=20.0.0'} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -13037,7 +12801,6 @@ packages: keygrip@1.1.0: resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} engines: {node: '>= 0.6'} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -13853,9 +13616,6 @@ packages: ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -14064,27 +13824,6 @@ packages: sass: optional: true - next@16.0.3: - resolution: {integrity: sha512-Ka0/iNBblPFcIubTA1Jjh6gvwqfjrGq1Y2MTI5lbjeLIAfmC+p5bQmojpRZqgHHVu5cG4+qdIiwXiBSm/8lZ3w==} - engines: {node: '>=20.9.0'} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.51.1 - babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - babel-plugin-react-compiler: - optional: true - sass: - optional: true - nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -15529,11 +15268,6 @@ packages: robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} - rollup@4.18.0: - resolution: {integrity: sha512-QmJz14PX3rzbJCN1SG4Xe/bAAX2a6NpCP8ab2vfu2GiUr8AQcr2nCV/oEO3yneFarB67zk8ShlIyWb2LGTb3Sg==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - rollup@4.24.4: resolution: {integrity: sha512-vGorVWIsWfX3xbcyAS+I047kFKapHYivmkaT63Smj77XwvLSJos6M1xGqZnBPFQFBRZDOcG1QnYEIxAvTr/HjA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -15626,10 +15360,6 @@ packages: resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} engines: {node: '>= 10.13.0'} - schema-utils@4.3.2: - resolution: {integrity: sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==} - engines: {node: '>= 10.13.0'} - schema-utils@4.3.3: resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} @@ -15858,7 +15588,6 @@ packages: source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} - deprecated: The work that was done in this beta branch won't be included in future versions space-separated-tokens@1.1.5: resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==} @@ -16308,10 +16037,6 @@ packages: resolution: {integrity: sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==} engines: {node: '>=12.0.0'} - tinyglobby@0.2.12: - resolution: {integrity: sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==} - engines: {node: '>=12.0.0'} - tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -16426,25 +16151,6 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} - tsup@8.1.0: - resolution: {integrity: sha512-UFdfCAXukax+U6KzeTNO2kAARHcWxmKsnvSPXUcfA1D+kU05XDccCrkffCQpFaWDsZfV0jMyTsxU39VfCp6EOg==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - '@microsoft/api-extractor': ^7.36.0 - '@swc/core': ^1 - postcss: ^8.4.12 - typescript: '>=4.5.0' - peerDependenciesMeta: - '@microsoft/api-extractor': - optional: true - '@swc/core': - optional: true - postcss: - optional: true - typescript: - optional: true - tsup@8.3.5: resolution: {integrity: sha512-Tunf6r6m6tnZsG9GYWndg0z8dEV7fD733VBFzFJ5Vcm1FtlXB8xBD/rtrBi2a3YKEV7hHtxiZtW5EAVADoe1pA==} engines: {node: '>=18'} @@ -17110,10 +16816,6 @@ packages: engines: {node: '>= 10.13.0'} hasBin: true - webpack-sources@3.2.3: - resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} - engines: {node: '>=10.13.0'} - webpack-sources@3.3.3: resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} engines: {node: '>=10.13.0'} @@ -18432,26 +18134,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/core@7.28.0': - dependencies: - '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.0 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.27.3(@babel/core@7.28.0) - '@babel/helpers': 7.27.6 - '@babel/parser': 7.28.0 - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.0 - '@babel/types': 7.28.1 - convert-source-map: 2.0.0 - debug: 4.4.1 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/core@7.28.5': dependencies: '@babel/code-frame': 7.27.1 @@ -18488,14 +18170,6 @@ snapshots: '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.0.2 - '@babel/generator@7.28.0': - dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - '@jridgewell/gen-mapping': 0.3.12 - '@jridgewell/trace-mapping': 0.3.29 - jsesc: 3.0.2 - '@babel/generator@7.28.5': dependencies: '@babel/parser': 7.28.5 @@ -18504,10 +18178,6 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-annotate-as-pure@7.24.7': - dependencies: - '@babel/types': 7.28.5 - '@babel/helper-annotate-as-pure@7.27.3': dependencies: '@babel/types': 7.28.5 @@ -18528,19 +18198,6 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.25.0(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-annotate-as-pure': 7.24.7 - '@babel/helper-member-expression-to-functions': 7.24.8 - '@babel/helper-optimise-call-expression': 7.24.7 - '@babel/helper-replace-supers': 7.25.0(@babel/core@7.28.5) - '@babel/helper-skip-transparent-expression-wrappers': 7.24.7 - '@babel/traverse': 7.26.9 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/helper-create-class-features-plugin@7.28.5(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -18574,13 +18231,6 @@ snapshots: '@babel/helper-globals@7.28.0': {} - '@babel/helper-member-expression-to-functions@7.24.8': - dependencies: - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - transitivePeerDependencies: - - supports-color - '@babel/helper-member-expression-to-functions@7.28.5': dependencies: '@babel/traverse': 7.28.5 @@ -18618,15 +18268,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.27.3(@babel/core@7.28.0)': - dependencies: - '@babel/core': 7.28.0 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.5 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -18636,16 +18277,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-optimise-call-expression@7.24.7': - dependencies: - '@babel/types': 7.28.5 - '@babel/helper-optimise-call-expression@7.27.1': dependencies: '@babel/types': 7.28.5 - '@babel/helper-plugin-utils@7.24.8': {} - '@babel/helper-plugin-utils@7.27.1': {} '@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.28.5)': @@ -18657,15 +18292,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-replace-supers@7.25.0(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-member-expression-to-functions': 7.24.8 - '@babel/helper-optimise-call-expression': 7.24.7 - '@babel/traverse': 7.28.5 - transitivePeerDependencies: - - supports-color - '@babel/helper-replace-supers@7.27.1(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 @@ -18675,13 +18301,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.24.7': - dependencies: - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - transitivePeerDependencies: - - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.28.5 @@ -18720,11 +18339,6 @@ snapshots: '@babel/template': 7.25.9 '@babel/types': 7.26.0 - '@babel/helpers@7.27.6': - dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 - '@babel/helpers@7.28.4': dependencies: '@babel/template': 7.27.2 @@ -18781,9 +18395,9 @@ snapshots: '@babel/plugin-proposal-private-property-in-object@7.21.11(@babel/core@7.28.5)': dependencies: '@babel/core': 7.28.5 - '@babel/helper-annotate-as-pure': 7.24.7 - '@babel/helper-create-class-features-plugin': 7.25.0(@babel/core@7.28.5) - '@babel/helper-plugin-utils': 7.24.8 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.28.5) + '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.5) transitivePeerDependencies: - supports-color @@ -19257,18 +18871,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/traverse@7.28.0': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.28.5': dependencies: '@babel/code-frame': 7.27.1 @@ -19302,11 +18904,6 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@babel/types@7.28.1': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.28.5': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -20596,7 +20193,7 @@ snapshots: abort-controller: 3.0.0 debug: 4.4.3 source-map-support: 0.5.21 - undici: 6.19.8 + undici: 6.22.0 transitivePeerDependencies: - supports-color @@ -21016,11 +20613,6 @@ snapshots: '@types/yargs': 17.0.34 chalk: 4.1.2 - '@jridgewell/gen-mapping@0.3.12': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.29 - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -21055,11 +20647,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping@0.3.29': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -21204,8 +20791,6 @@ snapshots: '@next/env@16.0.0': {} - '@next/env@16.0.3': {} - '@next/eslint-plugin-next@14.2.17': dependencies: glob: 10.3.10 @@ -21240,9 +20825,6 @@ snapshots: '@next/swc-darwin-arm64@16.0.0': optional: true - '@next/swc-darwin-arm64@16.0.3': - optional: true - '@next/swc-darwin-x64@14.2.15': optional: true @@ -21261,9 +20843,6 @@ snapshots: '@next/swc-darwin-x64@16.0.0': optional: true - '@next/swc-darwin-x64@16.0.3': - optional: true - '@next/swc-linux-arm64-gnu@14.2.15': optional: true @@ -21282,9 +20861,6 @@ snapshots: '@next/swc-linux-arm64-gnu@16.0.0': optional: true - '@next/swc-linux-arm64-gnu@16.0.3': - optional: true - '@next/swc-linux-arm64-musl@14.2.15': optional: true @@ -21303,9 +20879,6 @@ snapshots: '@next/swc-linux-arm64-musl@16.0.0': optional: true - '@next/swc-linux-arm64-musl@16.0.3': - optional: true - '@next/swc-linux-x64-gnu@14.2.15': optional: true @@ -21324,9 +20897,6 @@ snapshots: '@next/swc-linux-x64-gnu@16.0.0': optional: true - '@next/swc-linux-x64-gnu@16.0.3': - optional: true - '@next/swc-linux-x64-musl@14.2.15': optional: true @@ -21345,9 +20915,6 @@ snapshots: '@next/swc-linux-x64-musl@16.0.0': optional: true - '@next/swc-linux-x64-musl@16.0.3': - optional: true - '@next/swc-win32-arm64-msvc@14.2.15': optional: true @@ -21366,9 +20933,6 @@ snapshots: '@next/swc-win32-arm64-msvc@16.0.0': optional: true - '@next/swc-win32-arm64-msvc@16.0.3': - optional: true - '@next/swc-win32-ia32-msvc@14.2.15': optional: true @@ -21396,9 +20960,6 @@ snapshots: '@next/swc-win32-x64-msvc@16.0.0': optional: true - '@next/swc-win32-x64-msvc@16.0.3': - optional: true - '@node-oauth/formats@1.0.0': {} '@node-oauth/oauth2-server@5.1.0': @@ -21941,7 +21502,7 @@ snapshots: - next - supports-color - '@quetzallabs/i18n@0.1.19(next@16.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))': + '@quetzallabs/i18n@0.1.19(next@16.0.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))': dependencies: '@babel/parser': 7.25.6 '@babel/traverse': 7.25.6 @@ -21949,7 +21510,7 @@ snapshots: dotenv: 10.0.0 i18next: 21.10.0 i18next-parser: 9.0.2 - next-intl: 3.19.1(next@16.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@18.3.1) + next-intl: 3.19.1(next@16.0.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@18.3.1) path: 0.12.7 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -21958,7 +21519,7 @@ snapshots: - next - supports-color - '@quetzallabs/i18n@0.1.19(next@16.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.0.0))(react@19.0.0))': + '@quetzallabs/i18n@0.1.19(next@16.0.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.0.0))(react@19.0.0))': dependencies: '@babel/parser': 7.25.6 '@babel/traverse': 7.25.6 @@ -21966,7 +21527,7 @@ snapshots: dotenv: 10.0.0 i18next: 21.10.0 i18next-parser: 9.0.2 - next-intl: 3.19.1(next@16.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.0.0))(react@19.0.0))(react@18.3.1) + next-intl: 3.19.1(next@16.0.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.0.0))(react@19.0.0))(react@18.3.1) path: 0.12.7 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -22232,7 +21793,7 @@ snapshots: '@radix-ui/react-compose-refs@1.0.0(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.0 + '@babel/runtime': 7.28.4 react: 18.3.1 '@radix-ui/react-compose-refs@1.1.0(@types/react@18.3.12)(react@19.0.0)': @@ -23444,7 +23005,7 @@ snapshots: '@radix-ui/react-slot@1.0.1(react@18.3.1)': dependencies: - '@babel/runtime': 7.26.0 + '@babel/runtime': 7.28.4 '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) react: 18.3.1 @@ -24266,10 +23827,10 @@ snapshots: '@rollup/pluginutils': 5.1.0(rollup@4.50.1) commondir: 1.0.1 estree-walker: 2.0.2 - fdir: 6.4.6(picomatch@4.0.2) + fdir: 6.5.0(picomatch@4.0.3) is-reference: 1.2.1 magic-string: 0.30.17 - picomatch: 4.0.2 + picomatch: 4.0.3 optionalDependencies: rollup: 4.50.1 @@ -24281,9 +23842,6 @@ snapshots: optionalDependencies: rollup: 4.50.1 - '@rollup/rollup-android-arm-eabi@4.18.0': - optional: true - '@rollup/rollup-android-arm-eabi@4.24.4': optional: true @@ -24293,9 +23851,6 @@ snapshots: '@rollup/rollup-android-arm-eabi@4.50.1': optional: true - '@rollup/rollup-android-arm64@4.18.0': - optional: true - '@rollup/rollup-android-arm64@4.24.4': optional: true @@ -24305,9 +23860,6 @@ snapshots: '@rollup/rollup-android-arm64@4.50.1': optional: true - '@rollup/rollup-darwin-arm64@4.18.0': - optional: true - '@rollup/rollup-darwin-arm64@4.24.4': optional: true @@ -24317,9 +23869,6 @@ snapshots: '@rollup/rollup-darwin-arm64@4.50.1': optional: true - '@rollup/rollup-darwin-x64@4.18.0': - optional: true - '@rollup/rollup-darwin-x64@4.24.4': optional: true @@ -24347,9 +23896,6 @@ snapshots: '@rollup/rollup-freebsd-x64@4.50.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.18.0': - optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.24.4': optional: true @@ -24359,9 +23905,6 @@ snapshots: '@rollup/rollup-linux-arm-gnueabihf@4.50.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.18.0': - optional: true - '@rollup/rollup-linux-arm-musleabihf@4.24.4': optional: true @@ -24371,9 +23914,6 @@ snapshots: '@rollup/rollup-linux-arm-musleabihf@4.50.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.18.0': - optional: true - '@rollup/rollup-linux-arm64-gnu@4.24.4': optional: true @@ -24383,9 +23923,6 @@ snapshots: '@rollup/rollup-linux-arm64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.18.0': - optional: true - '@rollup/rollup-linux-arm64-musl@4.24.4': optional: true @@ -24401,9 +23938,6 @@ snapshots: '@rollup/rollup-linux-loongarch64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-powerpc64le-gnu@4.18.0': - optional: true - '@rollup/rollup-linux-powerpc64le-gnu@4.24.4': optional: true @@ -24413,9 +23947,6 @@ snapshots: '@rollup/rollup-linux-ppc64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.18.0': - optional: true - '@rollup/rollup-linux-riscv64-gnu@4.24.4': optional: true @@ -24428,9 +23959,6 @@ snapshots: '@rollup/rollup-linux-riscv64-musl@4.50.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.18.0': - optional: true - '@rollup/rollup-linux-s390x-gnu@4.24.4': optional: true @@ -24440,9 +23968,6 @@ snapshots: '@rollup/rollup-linux-s390x-gnu@4.50.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.18.0': - optional: true - '@rollup/rollup-linux-x64-gnu@4.24.4': optional: true @@ -24452,9 +23977,6 @@ snapshots: '@rollup/rollup-linux-x64-gnu@4.50.1': optional: true - '@rollup/rollup-linux-x64-musl@4.18.0': - optional: true - '@rollup/rollup-linux-x64-musl@4.24.4': optional: true @@ -24467,9 +23989,6 @@ snapshots: '@rollup/rollup-openharmony-arm64@4.50.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.18.0': - optional: true - '@rollup/rollup-win32-arm64-msvc@4.24.4': optional: true @@ -24479,9 +23998,6 @@ snapshots: '@rollup/rollup-win32-arm64-msvc@4.50.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.18.0': - optional: true - '@rollup/rollup-win32-ia32-msvc@4.24.4': optional: true @@ -24491,9 +24007,6 @@ snapshots: '@rollup/rollup-win32-ia32-msvc@4.50.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.18.0': - optional: true - '@rollup/rollup-win32-x64-msvc@4.24.4': optional: true @@ -24553,7 +24066,7 @@ snapshots: '@sentry/bundler-plugin-core@4.3.0(encoding@0.1.13)': dependencies: - '@babel/core': 7.28.0 + '@babel/core': 7.28.5 '@sentry/babel-plugin-component-annotate': 4.3.0 '@sentry/cli': 2.53.0(encoding@0.1.13) dotenv: 16.4.7 @@ -24611,7 +24124,7 @@ snapshots: '@sentry/core@10.11.0': {} - '@sentry/nextjs@10.11.0(@opentelemetry/context-async-hooks@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.4.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.24.2))': + '@sentry/nextjs@10.11.0(@opentelemetry/context-async-hooks@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.4.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.25.11))': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.37.0 @@ -24623,7 +24136,7 @@ snapshots: '@sentry/opentelemetry': 10.11.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.37.0) '@sentry/react': 10.11.0(react@19.0.0) '@sentry/vercel-edge': 10.11.0 - '@sentry/webpack-plugin': 4.3.0(encoding@0.1.13)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.24.2)) + '@sentry/webpack-plugin': 4.3.0(encoding@0.1.13)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.25.11)) chalk: 3.0.0 next: 15.4.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) resolve: 1.22.8 @@ -24638,6 +24151,33 @@ snapshots: - supports-color - webpack + '@sentry/nextjs@10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.0.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.24.2))': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.37.0 + '@rollup/plugin-commonjs': 28.0.1(rollup@4.50.1) + '@sentry-internal/browser-utils': 10.11.0 + '@sentry/bundler-plugin-core': 4.3.0(encoding@0.1.13) + '@sentry/core': 10.11.0 + '@sentry/node': 10.11.0 + '@sentry/opentelemetry': 10.11.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.37.0) + '@sentry/react': 10.11.0(react@19.0.0) + '@sentry/vercel-edge': 10.11.0 + '@sentry/webpack-plugin': 4.3.0(encoding@0.1.13)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.24.2)) + chalk: 3.0.0 + next: 16.0.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + resolve: 1.22.8 + rollup: 4.50.1 + stacktrace-parser: 0.1.11 + transitivePeerDependencies: + - '@opentelemetry/context-async-hooks' + - '@opentelemetry/core' + - '@opentelemetry/sdk-trace-base' + - encoding + - react + - supports-color + - webpack + '@sentry/nextjs@10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.0.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.24.2))': dependencies: '@opentelemetry/api': 1.9.0 @@ -24665,33 +24205,6 @@ snapshots: - supports-color - webpack - '@sentry/nextjs@10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.24.2))': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/semantic-conventions': 1.37.0 - '@rollup/plugin-commonjs': 28.0.1(rollup@4.50.1) - '@sentry-internal/browser-utils': 10.11.0 - '@sentry/bundler-plugin-core': 4.3.0(encoding@0.1.13) - '@sentry/core': 10.11.0 - '@sentry/node': 10.11.0 - '@sentry/opentelemetry': 10.11.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.37.0) - '@sentry/react': 10.11.0(react@19.0.0) - '@sentry/vercel-edge': 10.11.0 - '@sentry/webpack-plugin': 4.3.0(encoding@0.1.13)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.24.2)) - chalk: 3.0.0 - next: 16.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - resolve: 1.22.8 - rollup: 4.50.1 - stacktrace-parser: 0.1.11 - transitivePeerDependencies: - - '@opentelemetry/context-async-hooks' - - '@opentelemetry/core' - - '@opentelemetry/sdk-trace-base' - - encoding - - react - - supports-color - - webpack - '@sentry/node-core@10.11.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.37.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -24793,6 +24306,16 @@ snapshots: - encoding - supports-color + '@sentry/webpack-plugin@4.3.0(encoding@0.1.13)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.25.11))': + dependencies: + '@sentry/bundler-plugin-core': 4.3.0(encoding@0.1.13) + unplugin: 1.0.1 + uuid: 9.0.1 + webpack: 5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.25.11) + transitivePeerDependencies: + - encoding + - supports-color + '@shikijs/core@3.14.0': dependencies: '@shikijs/types': 3.14.0 @@ -25268,19 +24791,19 @@ snapshots: '@stripe/stripe-js@7.7.0': {} - '@supabase/auth-js@2.84.0': + '@supabase/auth-js@2.86.0': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.84.0': + '@supabase/functions-js@2.86.0': dependencies: tslib: 2.8.1 - '@supabase/postgrest-js@2.84.0': + '@supabase/postgrest-js@2.86.0': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.84.0': + '@supabase/realtime-js@2.86.0': dependencies: '@types/phoenix': 1.6.6 '@types/ws': 8.18.1 @@ -25290,22 +24813,23 @@ snapshots: - bufferutil - utf-8-validate - '@supabase/ssr@0.7.0(@supabase/supabase-js@2.84.0)': + '@supabase/ssr@0.8.0(@supabase/supabase-js@2.86.0)': dependencies: - '@supabase/supabase-js': 2.84.0 + '@supabase/supabase-js': 2.86.0 cookie: 1.0.2 - '@supabase/storage-js@2.84.0': + '@supabase/storage-js@2.86.0': dependencies: + iceberg-js: 0.8.0 tslib: 2.8.1 - '@supabase/supabase-js@2.84.0': + '@supabase/supabase-js@2.86.0': dependencies: - '@supabase/auth-js': 2.84.0 - '@supabase/functions-js': 2.84.0 - '@supabase/postgrest-js': 2.84.0 - '@supabase/realtime-js': 2.84.0 - '@supabase/storage-js': 2.84.0 + '@supabase/auth-js': 2.86.0 + '@supabase/functions-js': 2.86.0 + '@supabase/postgrest-js': 2.86.0 + '@supabase/realtime-js': 2.86.0 + '@supabase/storage-js': 2.86.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -25455,15 +24979,8 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 3.4.18(tsx@4.19.3)(yaml@2.8.0) - '@tanstack/query-core@5.81.5': {} - '@tanstack/query-core@5.90.7': {} - '@tanstack/react-query@5.81.5(react@18.3.1)': - dependencies: - '@tanstack/query-core': 5.81.5 - react: 18.3.1 - '@tanstack/react-query@5.90.7(react@18.3.1)': dependencies: '@tanstack/query-core': 5.90.7 @@ -25753,8 +25270,6 @@ snapshots: dependencies: '@types/estree': 1.0.8 - '@types/estree@1.0.5': {} - '@types/estree@1.0.6': {} '@types/estree@1.0.8': {} @@ -25899,7 +25414,7 @@ snapshots: '@types/pg-pool@2.0.6': dependencies: - '@types/pg': 8.15.4 + '@types/pg': 8.15.6 '@types/pg@8.15.4': dependencies: @@ -25907,6 +25422,12 @@ snapshots: pg-protocol: 1.10.3 pg-types: 2.2.0 + '@types/pg@8.15.6': + dependencies: + '@types/node': 20.17.6 + pg-protocol: 1.10.3 + pg-types: 2.2.0 + '@types/phoenix@1.6.6': {} '@types/prop-types@15.7.13': {} @@ -27136,7 +26657,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.1 + debug: 4.4.3 http-errors: 2.0.0 iconv-lite: 0.6.3 on-finished: 2.4.1 @@ -27218,7 +26739,7 @@ snapshots: browserslist@4.23.1: dependencies: - caniuse-lite: 1.0.30001751 + caniuse-lite: 1.0.30001696 electron-to-chromium: 1.4.803 node-releases: 2.0.14 update-browserslist-db: 1.0.16(browserslist@4.23.1) @@ -27258,19 +26779,14 @@ snapshots: dependencies: run-applescript: 7.0.0 - bundle-require@4.2.1(esbuild@0.21.5): - dependencies: - esbuild: 0.21.5 - load-tsconfig: 0.2.5 - bundle-require@5.0.0(esbuild@0.24.2): dependencies: esbuild: 0.24.2 load-tsconfig: 0.2.5 - bundle-require@5.1.0(esbuild@0.25.3): + bundle-require@5.1.0(esbuild@0.25.11): dependencies: - esbuild: 0.25.3 + esbuild: 0.25.11 load-tsconfig: 0.2.5 busboy@1.6.0: @@ -28154,10 +27670,6 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.3.5: - dependencies: - ms: 2.1.2 - debug@4.3.7: dependencies: ms: 2.1.3 @@ -29543,7 +29055,7 @@ snapshots: react-native-is-edge-to-edge: 1.2.1(react-native@0.80.1(@babel/core@7.28.5)(@types/react@18.3.12)(react@19.0.0))(react@18.3.1) react-native-safe-area-context: 5.5.1(react-native@0.80.1(@babel/core@7.28.5)(@types/react@18.3.12)(react@19.0.0))(react@19.0.0) react-native-screens: 4.11.1(react-native@0.80.1(@babel/core@7.28.5)(@types/react@18.3.12)(react@19.0.0))(react@19.0.0) - schema-utils: 4.3.2 + schema-utils: 4.3.3 semver: 7.6.3 server-only: 0.0.1 transitivePeerDependencies: @@ -29740,14 +29252,6 @@ snapshots: optionalDependencies: picomatch: 4.0.2 - fdir@6.4.3(picomatch@4.0.2): - optionalDependencies: - picomatch: 4.0.2 - - fdir@6.4.6(picomatch@4.0.2): - optionalDependencies: - picomatch: 4.0.2 - fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -29796,7 +29300,7 @@ snapshots: finalhandler@2.1.0: dependencies: - debug: 4.4.1 + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -29915,7 +29419,7 @@ snapshots: freestyle-sandboxes@0.0.92(0ae31cf1c51f1760da555eb15aac1d28): dependencies: '@hey-api/client-fetch': 0.5.7 - '@tanstack/react-query': 5.81.5(react@18.3.1) + '@tanstack/react-query': 5.90.7(react@18.3.1) '@types/react': 18.3.12 expo-router: 4.0.21(f1d8b6ff2c809c739ebeb761bc7320ce) freestyle-sandboxes: 0.0.66(0ae31cf1c51f1760da555eb15aac1d28) @@ -30716,6 +30220,8 @@ snapshots: dependencies: '@babel/runtime': 7.26.0 + iceberg-js@0.8.0: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -31650,7 +31156,7 @@ snapshots: magic-string@0.30.8: dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.5.5 make-dir@3.1.0: dependencies: @@ -32452,8 +31958,6 @@ snapshots: ms@2.0.0: {} - ms@2.1.2: {} - ms@2.1.3: {} mute-stream@1.0.0: {} @@ -32496,19 +32000,19 @@ snapshots: react: 18.3.1 use-intl: 3.19.1(react@18.3.1) - next-intl@3.19.1(next@16.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@18.3.1): + next-intl@3.19.1(next@16.0.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@18.3.1): dependencies: '@formatjs/intl-localematcher': 0.5.4 negotiator: 0.6.4 - next: 16.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next: 16.0.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react: 18.3.1 use-intl: 3.19.1(react@18.3.1) - next-intl@3.19.1(next@16.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.0.0))(react@19.0.0))(react@18.3.1): + next-intl@3.19.1(next@16.0.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.0.0))(react@19.0.0))(react@18.3.1): dependencies: '@formatjs/intl-localematcher': 0.5.4 negotiator: 0.6.4 - next: 16.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.0.0))(react@19.0.0) + next: 16.0.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.0.0))(react@19.0.0) react: 18.3.1 use-intl: 3.19.1(react@18.3.1) @@ -32741,6 +32245,54 @@ snapshots: - '@babel/core' - babel-plugin-macros + next@16.0.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + '@next/env': 16.0.0 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001751 + postcss: 8.4.31 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + styled-jsx: 5.1.6(@babel/core@7.26.0)(react@19.0.0) + optionalDependencies: + '@next/swc-darwin-arm64': 16.0.0 + '@next/swc-darwin-x64': 16.0.0 + '@next/swc-linux-arm64-gnu': 16.0.0 + '@next/swc-linux-arm64-musl': 16.0.0 + '@next/swc-linux-x64-gnu': 16.0.0 + '@next/swc-linux-x64-musl': 16.0.0 + '@next/swc-win32-arm64-msvc': 16.0.0 + '@next/swc-win32-x64-msvc': 16.0.0 + '@opentelemetry/api': 1.9.0 + sharp: 0.34.4 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + next@16.0.0(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.0.0))(react@19.0.0): + dependencies: + '@next/env': 16.0.0 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001751 + postcss: 8.4.31 + react: 19.0.0 + react-dom: 19.2.0(react@19.0.0) + styled-jsx: 5.1.6(@babel/core@7.26.0)(react@19.0.0) + optionalDependencies: + '@next/swc-darwin-arm64': 16.0.0 + '@next/swc-darwin-x64': 16.0.0 + '@next/swc-linux-arm64-gnu': 16.0.0 + '@next/swc-linux-arm64-musl': 16.0.0 + '@next/swc-linux-x64-gnu': 16.0.0 + '@next/swc-linux-x64-musl': 16.0.0 + '@next/swc-win32-arm64-msvc': 16.0.0 + '@next/swc-win32-x64-msvc': 16.0.0 + '@opentelemetry/api': 1.9.0 + sharp: 0.34.4 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + next@16.0.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@next/env': 16.0.0 @@ -32765,54 +32317,6 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): - dependencies: - '@next/env': 16.0.3 - '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001751 - postcss: 8.4.31 - react: 19.0.0 - react-dom: 19.0.0(react@19.0.0) - styled-jsx: 5.1.6(@babel/core@7.26.0)(react@19.0.0) - optionalDependencies: - '@next/swc-darwin-arm64': 16.0.3 - '@next/swc-darwin-x64': 16.0.3 - '@next/swc-linux-arm64-gnu': 16.0.3 - '@next/swc-linux-arm64-musl': 16.0.3 - '@next/swc-linux-x64-gnu': 16.0.3 - '@next/swc-linux-x64-musl': 16.0.3 - '@next/swc-win32-arm64-msvc': 16.0.3 - '@next/swc-win32-x64-msvc': 16.0.3 - '@opentelemetry/api': 1.9.0 - sharp: 0.34.4 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - - next@16.0.3(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.0(react@19.0.0))(react@19.0.0): - dependencies: - '@next/env': 16.0.3 - '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001751 - postcss: 8.4.31 - react: 19.0.0 - react-dom: 19.2.0(react@19.0.0) - styled-jsx: 5.1.6(@babel/core@7.26.0)(react@19.0.0) - optionalDependencies: - '@next/swc-darwin-arm64': 16.0.3 - '@next/swc-darwin-x64': 16.0.3 - '@next/swc-linux-arm64-gnu': 16.0.3 - '@next/swc-linux-arm64-musl': 16.0.3 - '@next/swc-linux-x64-gnu': 16.0.3 - '@next/swc-linux-x64-musl': 16.0.3 - '@next/swc-win32-arm64-msvc': 16.0.3 - '@next/swc-win32-x64-msvc': 16.0.3 - '@opentelemetry/api': 1.9.0 - sharp: 0.34.4 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - nice-try@1.0.5: {} no-case@3.0.4: @@ -33427,13 +32931,6 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.6 - postcss-load-config@4.0.2(postcss@8.4.47): - dependencies: - lilconfig: 3.1.2 - yaml: 2.6.0 - optionalDependencies: - postcss: 8.4.47 - postcss-load-config@4.0.2(postcss@8.5.3): dependencies: lilconfig: 3.1.2 @@ -33450,6 +32947,15 @@ snapshots: tsx: 4.19.3 yaml: 2.8.0 + postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.4.47)(tsx@4.15.5)(yaml@2.8.0): + dependencies: + lilconfig: 3.1.2 + optionalDependencies: + jiti: 2.4.2 + postcss: 8.4.47 + tsx: 4.15.5 + yaml: 2.8.0 + postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.4.47)(tsx@4.16.2)(yaml@2.8.0): dependencies: lilconfig: 3.1.2 @@ -33858,7 +33364,7 @@ snapshots: react-helmet-async@1.3.0(react-dom@19.0.0(react@19.0.0))(react@18.3.1): dependencies: - '@babel/runtime': 7.26.0 + '@babel/runtime': 7.28.4 invariant: 2.2.4 prop-types: 15.8.1 react: 18.3.1 @@ -34565,28 +34071,6 @@ snapshots: robust-predicates@3.0.2: {} - rollup@4.18.0: - dependencies: - '@types/estree': 1.0.5 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.18.0 - '@rollup/rollup-android-arm64': 4.18.0 - '@rollup/rollup-darwin-arm64': 4.18.0 - '@rollup/rollup-darwin-x64': 4.18.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.18.0 - '@rollup/rollup-linux-arm-musleabihf': 4.18.0 - '@rollup/rollup-linux-arm64-gnu': 4.18.0 - '@rollup/rollup-linux-arm64-musl': 4.18.0 - '@rollup/rollup-linux-powerpc64le-gnu': 4.18.0 - '@rollup/rollup-linux-riscv64-gnu': 4.18.0 - '@rollup/rollup-linux-s390x-gnu': 4.18.0 - '@rollup/rollup-linux-x64-gnu': 4.18.0 - '@rollup/rollup-linux-x64-musl': 4.18.0 - '@rollup/rollup-win32-arm64-msvc': 4.18.0 - '@rollup/rollup-win32-ia32-msvc': 4.18.0 - '@rollup/rollup-win32-x64-msvc': 4.18.0 - fsevents: 2.3.3 - rollup@4.24.4: dependencies: '@types/estree': 1.0.6 @@ -34672,7 +34156,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.1 + debug: 4.4.3 depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -34743,13 +34227,6 @@ snapshots: ajv: 6.12.6 ajv-keywords: 3.5.2(ajv@6.12.6) - schema-utils@4.3.2: - dependencies: - '@types/json-schema': 7.0.15 - ajv: 8.17.1 - ajv-formats: 2.1.1(ajv@8.17.1) - ajv-keywords: 5.1.0(ajv@8.17.1) - schema-utils@4.3.3: dependencies: '@types/json-schema': 7.0.15 @@ -34820,7 +34297,7 @@ snapshots: send@1.2.0: dependencies: - debug: 4.4.1 + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -35601,6 +35078,18 @@ snapshots: '@swc/core': 1.3.101(@swc/helpers@0.5.15) esbuild: 0.24.2 + terser-webpack-plugin@5.3.14(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.25.11)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.25.11)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + serialize-javascript: 6.0.2 + terser: 5.44.0 + webpack: 5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.25.11) + optionalDependencies: + '@swc/core': 1.3.101(@swc/helpers@0.5.15) + esbuild: 0.25.11 + terser@5.44.0: dependencies: '@jridgewell/source-map': 0.3.11 @@ -35704,11 +35193,6 @@ snapshots: fdir: 6.4.2(picomatch@4.0.2) picomatch: 4.0.2 - tinyglobby@0.2.12: - dependencies: - fdir: 6.4.3(picomatch@4.0.2) - picomatch: 4.0.2 - tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -35803,86 +35287,6 @@ snapshots: tsscmp@1.0.6: {} - tsup@8.1.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(postcss@8.4.47)(typescript@5.8.3): - dependencies: - bundle-require: 4.2.1(esbuild@0.21.5) - cac: 6.7.14 - chokidar: 3.6.0 - debug: 4.3.5 - esbuild: 0.21.5 - execa: 5.1.1 - globby: 11.1.0 - joycon: 3.1.1 - postcss-load-config: 4.0.2(postcss@8.4.47) - resolve-from: 5.0.0 - rollup: 4.18.0 - source-map: 0.8.0-beta.0 - sucrase: 3.35.0 - tree-kill: 1.2.2 - optionalDependencies: - '@swc/core': 1.3.101(@swc/helpers@0.5.15) - postcss: 8.4.47 - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - - ts-node - - tsup@8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.4.47)(tsx@4.16.2)(typescript@5.8.3)(yaml@2.8.0): - dependencies: - bundle-require: 5.0.0(esbuild@0.24.2) - cac: 6.7.14 - chokidar: 4.0.1 - consola: 3.2.3 - debug: 4.3.7 - esbuild: 0.24.2 - joycon: 3.1.1 - picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.4.47)(tsx@4.16.2)(yaml@2.8.0) - resolve-from: 5.0.0 - rollup: 4.24.4 - source-map: 0.8.0-beta.0 - sucrase: 3.35.0 - tinyexec: 0.3.1 - tinyglobby: 0.2.10 - tree-kill: 1.2.2 - optionalDependencies: - '@swc/core': 1.3.101(@swc/helpers@0.5.15) - postcss: 8.4.47 - typescript: 5.8.3 - transitivePeerDependencies: - - jiti - - supports-color - - tsx - - yaml - - tsup@8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.6)(tsx@4.15.5)(typescript@5.8.3)(yaml@2.4.5): - dependencies: - bundle-require: 5.0.0(esbuild@0.24.2) - cac: 6.7.14 - chokidar: 4.0.1 - consola: 3.2.3 - debug: 4.3.7 - esbuild: 0.24.2 - joycon: 3.1.1 - picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.5.6)(tsx@4.15.5)(yaml@2.4.5) - resolve-from: 5.0.0 - rollup: 4.24.4 - source-map: 0.8.0-beta.0 - sucrase: 3.35.0 - tinyexec: 0.3.1 - tinyglobby: 0.2.10 - tree-kill: 1.2.2 - optionalDependencies: - '@swc/core': 1.3.101(@swc/helpers@0.5.15) - postcss: 8.5.6 - typescript: 5.8.3 - transitivePeerDependencies: - - jiti - - supports-color - - tsx - - yaml - tsup@8.3.5(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.3.3)(yaml@2.6.0): dependencies: bundle-require: 5.0.0(esbuild@0.24.2) @@ -35911,23 +35315,79 @@ snapshots: - tsx - yaml - tsup@8.4.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.2)(tsx@4.19.2)(typescript@5.8.3)(yaml@2.8.0): + tsup@8.4.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.4.47)(tsx@4.15.5)(typescript@5.8.3)(yaml@2.8.0): dependencies: - bundle-require: 5.1.0(esbuild@0.25.3) + bundle-require: 5.1.0(esbuild@0.25.11) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.0 - debug: 4.4.0 - esbuild: 0.25.3 + debug: 4.4.3 + esbuild: 0.25.11 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.4.47)(tsx@4.15.5)(yaml@2.8.0) + resolve-from: 5.0.0 + rollup: 4.50.1 + source-map: 0.8.0-beta.0 + sucrase: 3.35.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + '@swc/core': 1.3.101(@swc/helpers@0.5.15) + postcss: 8.4.47 + typescript: 5.8.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tsup@8.4.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.4.47)(tsx@4.16.2)(typescript@5.8.3)(yaml@2.8.0): + dependencies: + bundle-require: 5.1.0(esbuild@0.25.11) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.0 + debug: 4.4.3 + esbuild: 0.25.11 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.4.47)(tsx@4.16.2)(yaml@2.8.0) + resolve-from: 5.0.0 + rollup: 4.50.1 + source-map: 0.8.0-beta.0 + sucrase: 3.35.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + '@swc/core': 1.3.101(@swc/helpers@0.5.15) + postcss: 8.4.47 + typescript: 5.8.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tsup@8.4.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.2)(tsx@4.19.2)(typescript@5.8.3)(yaml@2.8.0): + dependencies: + bundle-require: 5.1.0(esbuild@0.25.11) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.0 + debug: 4.4.3 + esbuild: 0.25.11 joycon: 3.1.1 picocolors: 1.1.1 postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.5.2)(tsx@4.19.2)(yaml@2.8.0) resolve-from: 5.0.0 - rollup: 4.34.8 + rollup: 4.50.1 source-map: 0.8.0-beta.0 sucrase: 3.35.0 tinyexec: 0.3.2 - tinyglobby: 0.2.12 + tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: '@swc/core': 1.3.101(@swc/helpers@0.5.15) @@ -35939,23 +35399,51 @@ snapshots: - tsx - yaml - tsup@8.4.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.3.3)(yaml@2.8.0): + tsup@8.4.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.6)(tsx@4.15.5)(typescript@5.8.3)(yaml@2.4.5): dependencies: - bundle-require: 5.1.0(esbuild@0.25.3) + bundle-require: 5.1.0(esbuild@0.25.11) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.0 - debug: 4.4.0 - esbuild: 0.25.3 + debug: 4.4.3 + esbuild: 0.25.11 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.5.6)(tsx@4.15.5)(yaml@2.4.5) + resolve-from: 5.0.0 + rollup: 4.50.1 + source-map: 0.8.0-beta.0 + sucrase: 3.35.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + '@swc/core': 1.3.101(@swc/helpers@0.5.15) + postcss: 8.5.6 + typescript: 5.8.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tsup@8.4.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(typescript@5.3.3)(yaml@2.8.0): + dependencies: + bundle-require: 5.1.0(esbuild@0.25.11) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.0 + debug: 4.4.3 + esbuild: 0.25.11 joycon: 3.1.1 picocolors: 1.1.1 postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.5.6)(tsx@4.19.3)(yaml@2.8.0) resolve-from: 5.0.0 - rollup: 4.34.8 + rollup: 4.50.1 source-map: 0.8.0-beta.0 sucrase: 3.35.0 tinyexec: 0.3.2 - tinyglobby: 0.2.12 + tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: '@swc/core': 1.3.101(@swc/helpers@0.5.15) @@ -36202,7 +35690,7 @@ snapshots: dependencies: acorn: 8.15.0 chokidar: 3.6.0 - webpack-sources: 3.2.3 + webpack-sources: 3.3.3 webpack-virtual-modules: 0.5.0 update-browserslist-db@1.0.16(browserslist@4.23.1): @@ -36678,8 +36166,6 @@ snapshots: - bufferutil - utf-8-validate - webpack-sources@3.2.3: {} - webpack-sources@3.3.3: {} webpack-virtual-modules@0.5.0: {} @@ -36715,6 +36201,37 @@ snapshots: - esbuild - uglify-js + webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.25.11): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.15.0 + acorn-import-attributes: 1.9.5(acorn@8.15.0) + browserslist: 4.27.0 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.18.3 + es-module-lexer: 1.7.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.1 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.3.0 + terser-webpack-plugin: 5.3.14(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.25.11)(webpack@5.92.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(esbuild@0.25.11)) + watchpack: 2.4.4 + webpack-sources: 3.3.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 diff --git a/vercel.json b/vercel.json index ef5187b70..24d962b2f 100644 --- a/vercel.json +++ b/vercel.json @@ -1,4 +1,14 @@ { + "crons": [ + { + "path": "/api/latest/internal/external-db-sync/poller", + "schedule": "* * * * *" + }, + { + "path": "/api/latest/internal/external-db-sync/sequencer", + "schedule": "* * * * *" + } + ], "functions": { "**/*": { "maxDuration": 300