fix tests

This commit is contained in:
Bilal Godil 2026-01-29 14:16:17 -08:00
parent 8cdd10785c
commit 871fe12f34
5 changed files with 133 additions and 37 deletions

View File

@ -11,6 +11,14 @@ import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { captureError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function assertUuid(value: unknown, label: string): asserts value is string {
if (typeof value !== "string" || value.trim().length === 0 || !UUID_REGEX.test(value)) {
throw new StatusError(500, `${label} must be a valid UUID. Received: ${JSON.stringify(value)}`);
}
}
// Assigns sequence IDs to rows that need them and queues sync requests for affected tenants.
// Processes up to 1000 rows at a time from each table.
async function backfillSequenceIds() {
@ -37,6 +45,7 @@ async function backfillSequenceIds() {
// Enqueue sync for each affected tenant
for (const { tenancyId } of projectUserTenants) {
assertUuid(tenancyId, "projectUserTenants.tenancyId");
await enqueueTenantSync(tenancyId);
}
@ -63,6 +72,7 @@ async function backfillSequenceIds() {
`;
for (const { tenancyId } of contactChannelTenants) {
assertUuid(tenancyId, "contactChannelTenants.tenancyId");
await enqueueTenantSync(tenancyId);
}
@ -87,6 +97,7 @@ async function backfillSequenceIds() {
`;
for (const { tenancyId } of deletedRowTenants) {
assertUuid(tenancyId, "deletedRowTenants.tenancyId");
await enqueueTenantSync(tenancyId);
}
}
@ -94,6 +105,7 @@ async function backfillSequenceIds() {
// Queues a sync request for a specific tenant if one isn't already pending.
// Prevents duplicate sync requests by checking for unfulfilled requests.
async function enqueueTenantSync(tenancyId: string) {
assertUuid(tenancyId, "tenancyId");
await globalPrismaClient.$executeRaw`
INSERT INTO "OutgoingRequest" ("id", "createdAt", "qstashOptions", "startedFulfillingAt")
SELECT
@ -101,7 +113,7 @@ async function enqueueTenantSync(tenancyId: string) {
NOW(),
json_build_object(
'url', '/api/latest/internal/external-db-sync/sync-engine',
'body', json_build_object('tenancyId', ${tenancyId})
'body', json_build_object('tenancyId', ${tenancyId}::uuid)
),
NULL
WHERE NOT EXISTS (
@ -173,4 +185,3 @@ export const GET = createSmartRouteHandler({
};
},
});

View File

@ -6,6 +6,62 @@ import { captureError, StackAssertionError, throwErr } from "@stackframe/stack-s
import { omit } from "@stackframe/stack-shared/dist/utils/objects";
import { Client } from 'pg';
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function assertNonEmptyString(value: unknown, label: string): asserts value is string {
if (typeof value !== "string" || value.trim().length === 0) {
throw new StackAssertionError(`${label} must be a non-empty string.`);
}
}
function assertUuid(value: unknown, label: string): asserts value is string {
assertNonEmptyString(value, label);
if (!UUID_REGEX.test(value)) {
throw new StackAssertionError(`${label} must be a valid UUID. Received: ${JSON.stringify(value)}`);
}
}
type PgErrorLike = {
code?: string;
constraint?: string;
message?: string;
};
function isDuplicateTypeError(error: unknown): error is PgErrorLike {
if (!error || typeof error !== "object") return false;
const pgError = error as PgErrorLike;
return pgError.code === "23505" && pgError.constraint === "pg_type_typname_nsp_index";
}
async function ensureExternalSchema(
externalClient: Client,
tableSchemaSql: string,
tableName: string,
) {
try {
await externalClient.query(tableSchemaSql);
} catch (error) {
if (!isDuplicateTypeError(error)) throw error;
// Concurrent CREATE TABLE can race and hit a duplicate type error.
// If the table now exists, we can safely continue.
const existsResult = await externalClient.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
);
`, [tableName]);
if (existsResult.rows[0]?.exists === true) {
return;
}
throw new StackAssertionError(
`Duplicate type error while creating table ${JSON.stringify(tableName)}, but table does not exist.`
);
}
}
async function pushRowsToExternalDb(
externalClient: Client,
tableName: string,
@ -14,6 +70,12 @@ async function pushRowsToExternalDb(
expectedTenancyId: string,
mappingId: string,
) {
assertNonEmptyString(tableName, "tableName");
assertNonEmptyString(mappingId, "mappingId");
assertUuid(expectedTenancyId, "expectedTenancyId");
if (!Array.isArray(newRows)) {
throw new StackAssertionError(`newRows must be an array for table ${JSON.stringify(tableName)}.`);
}
if (newRows.length === 0) return;
// Just for our own sanity, make sure that we have the right number of positional parameters
// The last parameter is mapping_name for metadata tracking
@ -73,12 +135,22 @@ async function syncMapping(
tenancyId: string,
dbType: 'postgres',
) {
assertNonEmptyString(mappingId, "mappingId");
assertNonEmptyString(mapping.targetTable, "mapping.targetTable");
assertUuid(tenancyId, "tenancyId");
const fetchQuery = mapping.internalDbFetchQuery;
const updateQuery = mapping.externalDbUpdateQueries[dbType];
const tableName = mapping.targetTable;
assertNonEmptyString(fetchQuery, "internalDbFetchQuery");
assertNonEmptyString(updateQuery, "externalDbUpdateQueries");
if (!fetchQuery.includes("$1") || !fetchQuery.includes("$2")) {
throw new StackAssertionError(
`internalDbFetchQuery must reference $1 (tenancyId) and $2 (lastSequenceId). Mapping: ${mappingId}`
);
}
const tableSchema = mapping.targetTableSchemas[dbType];
await externalClient.query(tableSchema);
await ensureExternalSchema(externalClient, tableSchema, tableName);
let lastSequenceId = -1;
const metadataResult = await externalClient.query(
@ -88,10 +160,19 @@ async function syncMapping(
if (metadataResult.rows.length > 0) {
lastSequenceId = Number(metadataResult.rows[0].last_synced_sequence_id);
}
if (!Number.isFinite(lastSequenceId)) {
throw new StackAssertionError(
`Invalid last_synced_sequence_id for mapping ${mappingId}: ${JSON.stringify(metadataResult.rows[0]?.last_synced_sequence_id)}`
);
}
const BATCH_LIMIT = 1000;
while (true) {
assertUuid(tenancyId, "tenancyId");
if (!Number.isFinite(lastSequenceId)) {
throw new StackAssertionError(`lastSequenceId must be a finite number for mapping ${mappingId}.`);
}
const rows = await internalPrisma.$queryRawUnsafe<any[]>(fetchQuery, tenancyId, lastSequenceId);
if (rows.length === 0) {
@ -132,6 +213,8 @@ async function syncDatabase(
internalPrisma: PrismaClientTransaction,
tenancyId: string,
) {
assertNonEmptyString(dbId, "dbId");
assertUuid(tenancyId, "tenancyId");
if (dbConfig.type !== 'postgres') {
throw new StackAssertionError(
`Unsupported database type '${dbConfig.type}' for external DB ${dbId}. Only 'postgres' is currently supported.`
@ -143,6 +226,7 @@ async function syncDatabase(
`Invalid configuration for external DB ${dbId}: 'connectionString' is missing.`
);
}
assertNonEmptyString(dbConfig.connectionString, `external DB ${dbId} connectionString`);
const externalClient = new Client({
connectionString: dbConfig.connectionString,
@ -176,6 +260,7 @@ async function syncDatabase(
export async function syncExternalDatabases(tenancy: Tenancy) {
assertUuid(tenancy?.id, "tenancy.id");
const externalDatabases = tenancy.config.dbSync.externalDatabases;
const internalPrisma = await getPrismaClientForTenancy(tenancy);

View File

@ -57,7 +57,7 @@ describe.sequential('External DB Sync - Advanced Tests', () => {
}
});
const userA = await User.create({ emailAddress: 'user-a@example.com' });
const userA = await User.create({ primary_email: 'user-a@example.com' });
await niceBackendFetch(`/api/v1/users/${userA.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -79,7 +79,7 @@ describe.sequential('External DB Sync - Advanced Tests', () => {
}
});
const userB = await User.create({ emailAddress: 'user-b@example.com' });
const userB = await User.create({ primary_email: 'user-b@example.com' });
await niceBackendFetch(`/api/v1/users/${userB.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -178,9 +178,9 @@ describe.sequential('External DB Sync - Advanced Tests', () => {
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' });
const user1 = await User.create({ primary_email: 'seq1@example.com' });
const user2 = await User.create({ primary_email: 'seq2@example.com' });
const user3 = await User.create({ primary_email: 'seq3@example.com' });
await niceBackendFetch(`/api/v1/users/${user1.userId}`, {
accessType: 'admin',
@ -219,7 +219,7 @@ describe.sequential('External DB Sync - Advanced Tests', () => {
const seq1 = Number(metadata1.rows[0].last_synced_sequence_id);
expect(seq1).toBeGreaterThan(0);
const user4 = await User.create({ emailAddress: 'seq4@example.com' });
const user4 = await User.create({ primary_email: 'seq4@example.com' });
await niceBackendFetch(`/api/v1/users/${user4.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -260,7 +260,7 @@ describe.sequential('External DB Sync - Advanced Tests', () => {
}
});
const user1 = await User.create({ emailAddress: 'user1@example.com' });
const user1 = await User.create({ primary_email: 'user1@example.com' });
await niceBackendFetch(`/api/v1/users/${user1.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -276,7 +276,7 @@ describe.sequential('External DB Sync - Advanced Tests', () => {
expect(res.rows[0].display_name).toBe('User 1');
const user1Id = res.rows[0].id;
const user2 = await User.create({ emailAddress: 'user2@example.com' });
const user2 = await User.create({ primary_email: 'user2@example.com' });
await niceBackendFetch(`/api/v1/users/${user2.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -316,7 +316,7 @@ describe.sequential('External DB Sync - Advanced Tests', () => {
});
const specialName = "O'Connor 🚀 用户 \"Test\"";
const user = await User.create({ emailAddress: 'special@example.com' });
const user = await User.create({ primary_email: 'special@example.com' });
await niceBackendFetch(`/api/v1/users/${user.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -456,9 +456,9 @@ describe.sequential('External DB Sync - Advanced Tests', () => {
}
});
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' });
const user1 = await User.create({ primary_email: 'seq1@example.com' });
const user2 = await User.create({ primary_email: 'seq2@example.com' });
const user3 = await User.create({ primary_email: 'seq3@example.com' });
await niceBackendFetch(`/api/v1/users/${user1.userId}`, {
accessType: 'admin',
@ -505,7 +505,7 @@ describe.sequential('External DB Sync - Advanced Tests', () => {
method: 'DELETE',
});
const user4 = await User.create({ emailAddress: 'seq4@example.com' });
const user4 = await User.create({ primary_email: 'seq4@example.com' });
await niceBackendFetch(`/api/v1/users/${user4.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -568,7 +568,7 @@ describe.sequential('External DB Sync - Advanced Tests', () => {
const superClient = dbManager.getClient(dbName);
const user = await User.create({ emailAddress: 'write-protect@example.com' });
const user = await User.create({ primary_email: 'write-protect@example.com' });
await niceBackendFetch(`/api/v1/users/${user.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -645,7 +645,7 @@ $$;`);
const client = dbManager.getClient(dbName);
const user = await User.create({ emailAddress: 'multi-update@example.com' });
const user = await User.create({ primary_email: 'multi-update@example.com' });
await niceBackendFetch(`/api/v1/users/${user.userId}`, {
accessType: 'admin',
@ -695,7 +695,7 @@ $$;`);
const client = dbManager.getClient(dbName);
const user = await User.create({ emailAddress: 'delete-before-sync@example.com' });
const user = await User.create({ primary_email: 'delete-before-sync@example.com' });
await niceBackendFetch(`/api/v1/users/${user.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -748,7 +748,7 @@ $$;`);
const client = dbManager.getClient(dbName);
const email = 'recreate-after-delete@example.com';
const firstUser = await User.create({ emailAddress: email });
const firstUser = await User.create({ primary_email: email });
await niceBackendFetch(`/api/v1/users/${firstUser.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -772,7 +772,7 @@ $$;`);
await waitForSyncedDeletion(client, email);
await verifyNotInExternalDb(client, email);
const secondUser = await User.create({ emailAddress: email });
const secondUser = await User.create({ primary_email: email });
await niceBackendFetch(`/api/v1/users/${secondUser.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -825,7 +825,7 @@ $$;`);
const client = dbManager.getClient(dbName);
const email = 'lifecycle-test@example.com';
const user1 = await User.create({ emailAddress: email });
const user1 = await User.create({ primary_email: email });
await niceBackendFetch(`/api/v1/users/${user1.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -875,7 +875,7 @@ $$;`);
res = await client.query(`SELECT * FROM "users" WHERE "primary_email" = $1`, [email]);
expect(res.rows.length).toBe(0);
const user2 = await User.create({ emailAddress: email });
const user2 = await User.create({ primary_email: email });
await niceBackendFetch(`/api/v1/users/${user2.userId}`, {
accessType: 'admin',
method: 'PATCH',

View File

@ -47,7 +47,7 @@ describe.sequential('External DB Sync - Basic Tests', () => {
const client = dbManager.getClient(dbName);
const user = await User.create({ emailAddress: 'insert-only@example.com' });
const user = await User.create({ primary_email: 'insert-only@example.com' });
await niceBackendFetch(`/api/v1/users/${user.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -80,7 +80,7 @@ describe.sequential('External DB Sync - Basic Tests', () => {
const client = dbManager.getClient(dbName);
const user = await User.create({ emailAddress: 'update-only@example.com' });
const user = await User.create({ primary_email: 'update-only@example.com' });
await niceBackendFetch(`/api/v1/users/${user.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -126,7 +126,7 @@ describe.sequential('External DB Sync - Basic Tests', () => {
const client = dbManager.getClient(dbName);
const user = await User.create({ emailAddress: 'delete-only@example.com' });
const user = await User.create({ primary_email: 'delete-only@example.com' });
await niceBackendFetch(`/api/v1/users/${user.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -184,7 +184,7 @@ describe.sequential('External DB Sync - Basic Tests', () => {
description: 'Testing that data only appears after sync is triggered'
});
const user = await User.create({ emailAddress: 'sync-verify@example.com' });
const user = await User.create({ primary_email: 'sync-verify@example.com' });
await niceBackendFetch(`/api/v1/users/${user.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -225,7 +225,7 @@ describe.sequential('External DB Sync - Basic Tests', () => {
const client = dbManager.getClient(dbName);
const user = await User.create({ emailAddress: 'crud-test@example.com' });
const user = await User.create({ primary_email: 'crud-test@example.com' });
await niceBackendFetch(`/api/v1/users/${user.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -274,7 +274,7 @@ describe.sequential('External DB Sync - Basic Tests', () => {
}
});
const user = await User.create({ emailAddress: 'auto-create@example.com' });
const user = await User.create({ primary_email: 'auto-create@example.com' });
await niceBackendFetch(`/api/v1/users/${user.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -320,7 +320,7 @@ describe.sequential('External DB Sync - Basic Tests', () => {
}
});
const user = await User.create({ emailAddress: 'resilience@example.com' });
const user = await User.create({ primary_email: 'resilience@example.com' });
await niceBackendFetch(`/api/v1/users/${user.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -357,7 +357,7 @@ describe.sequential('External DB Sync - Basic Tests', () => {
const client = dbManager.getClient(dbName);
const user = await User.create({ emailAddress: 'primary@example.com' });
const user = await User.create({ primary_email: 'primary@example.com' });
await niceBackendFetch(`/api/v1/users/${user.userId}`, {
accessType: 'admin',
method: 'PATCH',
@ -410,7 +410,7 @@ describe.sequential('External DB Sync - Basic Tests', () => {
const client = dbManager.getClient(dbName);
const user = await User.create({ emailAddress: 'update-test@example.com' });
const user = await User.create({ primary_email: 'update-test@example.com' });
await niceBackendFetch(`/api/v1/users/${user.userId}`, {
accessType: 'admin',
method: 'PATCH',

View File

@ -49,7 +49,7 @@ describe.sequential('External DB Sync - Race Condition Tests', () => {
});
const client = dbManager.getClient(dbName);
const user = await User.create({ emailAddress: 'parallel-sync@example.com' });
const user = await User.create({ primary_email: 'parallel-sync@example.com' });
await niceBackendFetch(`/api/v1/users/${user.userId}`, {
accessType: 'admin',
@ -97,7 +97,7 @@ describe.sequential('External DB Sync - Race Condition Tests', () => {
});
const client = dbManager.getClient(dbName);
const user = await User.create({ emailAddress: 'update-delete@example.com' });
const user = await User.create({ primary_email: 'update-delete@example.com' });
await niceBackendFetch(`/api/v1/users/${user.userId}`, {
accessType: 'admin',
@ -284,7 +284,7 @@ describe.sequential('External DB Sync - Race Condition Tests', () => {
});
const externalClient = dbManager.getClient(dbName);
const user = await User.create({ emailAddress: `${dbName}@example.com` });
const user = await User.create({ primary_email: `${dbName}@example.com` });
// Make sure the users row exists
await waitForTable(externalClient, 'users');
@ -570,8 +570,8 @@ describe.sequential('External DB Sync - Race Condition Tests', () => {
const externalClient = dbManager.getClient(dbName);
const user1 = await User.create({ emailAddress: 'row1@example.com' });
const user2 = await User.create({ emailAddress: 'row2@example.com' });
const user1 = await User.create({ primary_email: 'row1@example.com' });
const user2 = await User.create({ primary_email: 'row2@example.com' });
await waitForTable(externalClient, 'users');