From 78a3a5f443d663645676d5b1e34c5fd9d7645fed Mon Sep 17 00:00:00 2001 From: nams1570 Date: Wed, 18 Mar 2026 14:31:29 -0700 Subject: [PATCH 1/6] fix: unbounded outputData array in script This kept slowly growing the old heap leading to a fatal error. I removed it but tried to keep the same functionality for the two flags that depended on it via checkpointed writes and comparisons --- .../scripts/verify-data-integrity/api.ts | 81 ++++++++++++++++--- .../scripts/verify-data-integrity/index.ts | 19 ++--- 2 files changed, 75 insertions(+), 25 deletions(-) diff --git a/apps/backend/scripts/verify-data-integrity/api.ts b/apps/backend/scripts/verify-data-integrity/api.ts index 17ffac578e..3109d1b7ab 100644 --- a/apps/backend/scripts/verify-data-integrity/api.ts +++ b/apps/backend/scripts/verify-data-integrity/api.ts @@ -1,3 +1,4 @@ +import fs from "fs"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { deepPlainEquals, filterUndefined } from "@stackframe/stack-shared/dist/utils/objects"; @@ -16,17 +17,55 @@ export type ExpectStatusCode = ( request: RequestInit, ) => Promise; +/** + * Reads an output file that may be in either format: + * - Legacy: a single JSON object keyed by endpoint (`OutputData`) + * - JSONL: one JSON object per line, each `{ endpoint, output }` + */ +export function loadOutputData(filePath: string): OutputData { + const content = fs.readFileSync(filePath, "utf8").trim(); + if (!content) return {}; + + const firstLine = content.split("\n")[0]; + try { + const parsed = JSON.parse(firstLine); + if ("endpoint" in parsed && "output" in parsed) { + const data: OutputData = {}; + for (const line of content.split("\n")) { + if (!line.trim()) continue; + const { endpoint, output } = JSON.parse(line); + if (!(endpoint in data)) data[endpoint] = []; + data[endpoint].push(output); + } + return data; + } + } catch { + // Not JSONL — fall through to legacy parse + } + + return JSON.parse(content); +} + export function createApiHelpers(options: { - currentOutputData: OutputData, targetOutputData?: OutputData, + /** + * When set, each API response is streamed to this file as JSONL + * (one `{ endpoint, output }` object per line). This avoids + * accumulating all responses in memory. + */ + outputFilePath?: string, }) { - const { currentOutputData, targetOutputData } = options; + const { targetOutputData, outputFilePath } = options; + const outputCountByEndpoint = new Map(); + + if (outputFilePath) { + fs.writeFileSync(outputFilePath, ""); + } function appendOutputData(endpoint: string, output: EndpointOutput) { - if (!(endpoint in currentOutputData)) { - currentOutputData[endpoint] = []; - } - const newLength = currentOutputData[endpoint].push(output); + const count = (outputCountByEndpoint.get(endpoint) ?? 0) + 1; + outputCountByEndpoint.set(endpoint, count); + if (targetOutputData) { if (!(endpoint in targetOutputData)) { throw new StackAssertionError(deindent` @@ -34,22 +73,40 @@ export function createApiHelpers(options: { Expected ${endpoint} to be in targetOutputData, but it is not. `, { endpoint }); } - if (targetOutputData[endpoint].length < newLength) { + if (targetOutputData[endpoint].length < count) { throw new StackAssertionError(deindent` Output data mismatch for endpoint ${endpoint}: - Expected ${targetOutputData[endpoint].length} outputs but got at least ${newLength}. + Expected ${targetOutputData[endpoint].length} outputs but got at least ${count}. `, { endpoint }); } - if (!(deepPlainEquals(targetOutputData[endpoint][newLength - 1], output))) { + if (!(deepPlainEquals(targetOutputData[endpoint][count - 1], output))) { throw new StackAssertionError(deindent` Output data mismatch for endpoint ${endpoint}: - Expected output[${JSON.stringify(endpoint)}][${newLength - 1}] to be: - ${JSON.stringify(targetOutputData[endpoint][newLength - 1], null, 2)} + Expected output[${JSON.stringify(endpoint)}][${count - 1}] to be: + ${JSON.stringify(targetOutputData[endpoint][count - 1], null, 2)} but got: ${JSON.stringify(output, null, 2)}. `, { endpoint }); } } + + if (outputFilePath) { + fs.appendFileSync(outputFilePath, JSON.stringify({ endpoint, output }) + "\n"); + } + } + + function verifyOutputCompleteness() { + // targetOutputData is old output file. + if (!targetOutputData) return; + for (const [endpoint, expectedOutputs] of Object.entries(targetOutputData)) { + const actualCount = outputCountByEndpoint.get(endpoint) ?? 0; + if (actualCount !== expectedOutputs.length) { + throw new StackAssertionError(deindent` + Output data mismatch for endpoint ${endpoint}: + Expected ${expectedOutputs.length} outputs but got ${actualCount}. + `, { endpoint, expectedCount: expectedOutputs.length, actualCount }); + } + } } const expectStatusCode: ExpectStatusCode = async (expectedStatusCode, endpoint, request) => { @@ -87,6 +144,6 @@ export function createApiHelpers(options: { return { appendOutputData, expectStatusCode, + verifyOutputCompleteness, }; } - diff --git a/apps/backend/scripts/verify-data-integrity/index.ts b/apps/backend/scripts/verify-data-integrity/index.ts index 3ab0ed2f0c..01fc4504eb 100644 --- a/apps/backend/scripts/verify-data-integrity/index.ts +++ b/apps/backend/scripts/verify-data-integrity/index.ts @@ -3,12 +3,12 @@ import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; import type { OrganizationRenderedConfig } from "@stackframe/stack-shared/dist/config/schema"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; -import { deepPlainEquals, omit } from "@stackframe/stack-shared/dist/utils/objects"; +import { omit } from "@stackframe/stack-shared/dist/utils/objects"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; import fs from "fs"; -import { createApiHelpers, type OutputData } from "./api"; +import { createApiHelpers, loadOutputData, type OutputData } from "./api"; import { createPaymentsVerifier } from "./payments-verifier"; import { createRecurse } from "./recurse"; import { verifyStripePayoutIntegrity } from "./stripe-payout-integrity"; @@ -19,7 +19,6 @@ const STRIPE_SECRET_KEY = getEnvVariable("STACK_STRIPE_SECRET_KEY", ""); const USE_MOCK_STRIPE_API = STRIPE_SECRET_KEY === "sk_test_mockstripekey"; let targetOutputData: OutputData | undefined = undefined; -const currentOutputData: OutputData = {}; async function main() { console.log(); @@ -83,7 +82,6 @@ async function main() { const maxUsersPerProject = maxUsersPerProjectFlag ? parseInt(maxUsersPerProjectFlag.split("=")[1], 10) : Infinity; - const { recurse, collectedErrors } = createRecurse({ noBail }); if (noBail) { @@ -102,7 +100,7 @@ async function main() { throw new Error(`Cannot verify output: ${OUTPUT_FILE_PATH} does not exist`); } try { - targetOutputData = JSON.parse(fs.readFileSync(OUTPUT_FILE_PATH, "utf8")); + targetOutputData = loadOutputData(OUTPUT_FILE_PATH); // TODO next-release these are hacks for the migration, delete them if (targetOutputData) { @@ -125,9 +123,9 @@ async function main() { } } - const { expectStatusCode } = createApiHelpers({ - currentOutputData, + const { expectStatusCode, verifyOutputCompleteness } = createApiHelpers({ targetOutputData, + outputFilePath: shouldSaveOutput ? OUTPUT_FILE_PATH : undefined, }); const projects = await prismaClient.project.findMany({ @@ -335,13 +333,8 @@ async function main() { }); } - if (targetOutputData && !deepPlainEquals(currentOutputData, targetOutputData)) { - throw new StackAssertionError(deindent` - Output data mismatch between final and target output data. - `); - } + verifyOutputCompleteness(); if (shouldSaveOutput) { - fs.writeFileSync(OUTPUT_FILE_PATH, JSON.stringify(currentOutputData, null, 2)); console.log(`Output saved to ${OUTPUT_FILE_PATH}`); } From ad431858e2dfcb69e676147cb8ff2450126a21ef Mon Sep 17 00:00:00 2001 From: nams1570 Date: Wed, 18 Mar 2026 15:38:27 -0700 Subject: [PATCH 2/6] fix: allUsers array as another potential src for memory leak we used to read allusers for each project into memory. For a project with lots of users, this array could grow unbounded. --- .../scripts/verify-data-integrity/api.ts | 2 +- .../scripts/verify-data-integrity/index.ts | 216 +++++++++--------- 2 files changed, 110 insertions(+), 108 deletions(-) diff --git a/apps/backend/scripts/verify-data-integrity/api.ts b/apps/backend/scripts/verify-data-integrity/api.ts index 3109d1b7ab..f920501c8a 100644 --- a/apps/backend/scripts/verify-data-integrity/api.ts +++ b/apps/backend/scripts/verify-data-integrity/api.ts @@ -96,7 +96,7 @@ export function createApiHelpers(options: { } function verifyOutputCompleteness() { - // targetOutputData is old output file. + // targetOutputData is old output file. if (!targetOutputData) return; for (const [endpoint, expectedOutputs] of Object.entries(targetOutputData)) { const actualCount = outputCountByEndpoint.get(endpoint) ?? 0; diff --git a/apps/backend/scripts/verify-data-integrity/index.ts b/apps/backend/scripts/verify-data-integrity/index.ts index 01fc4504eb..22ab75c58e 100644 --- a/apps/backend/scripts/verify-data-integrity/index.ts +++ b/apps/backend/scripts/verify-data-integrity/index.ts @@ -103,19 +103,17 @@ async function main() { targetOutputData = loadOutputData(OUTPUT_FILE_PATH); // TODO next-release these are hacks for the migration, delete them - if (targetOutputData) { - targetOutputData["/api/v1/internal/projects/current"] = targetOutputData["/api/v1/internal/projects/current"].map(output => { - if ("config" in output.responseJson) { - delete output.responseJson.config.id; - output.responseJson.config.oauth_providers = output.responseJson.config.oauth_providers - // `any` because this is historical output JSON from disk. - // We intentionally keep this "migration hack" untyped. - .filter((provider: any) => provider.enabled) - .map((provider: any) => omit(provider, ["enabled"])); - } - return output; - }); - } + targetOutputData["/api/v1/internal/projects/current"] = targetOutputData["/api/v1/internal/projects/current"].map(output => { + if ("config" in output.responseJson) { + delete output.responseJson.config.id; + output.responseJson.config.oauth_providers = output.responseJson.config.oauth_providers + // `any` because this is historical output JSON from disk. + // We intentionally keep this "migration hack" untyped. + .filter((provider: any) => provider.enabled) + .map((provider: any) => omit(provider, ["enabled"])); + } + return output; + }); console.log(`Loaded previous output data for verification`); } catch (error) { @@ -189,30 +187,6 @@ async function main() { ]); void currentProject; - // Fetch users with pagination - const PAGE_LIMIT = 1000; - const allUsers: any[] = []; - let cursor: string | undefined = undefined; - while (allUsers.length < maxUsersPerProject) { - const remainingToFetch = maxUsersPerProject - allUsers.length; - const limit = Math.min(PAGE_LIMIT, remainingToFetch); - const cursorParam: string = cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""; - const usersPage = await expectStatusCode(200, `/api/v1/users?limit=${limit}${cursorParam}`, { - method: "GET", - headers: { - "x-stack-project-id": projectId, - "x-stack-access-type": "admin", - "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), - }, - }); - allUsers.push(...usersPage.items); - if (!usersPage.pagination?.next_cursor) { - break; - } - cursor = usersPage.pagination.next_cursor; - } - const users = { items: allUsers.slice(0, maxUsersPerProject) }; - const tenancy = await getSoleTenancyFromProjectBranch(projectId, DEFAULT_BRANCH_ID, true); const paymentsConfig = tenancy ? (tenancy.config as OrganizationRenderedConfig).payments : undefined; const paymentsVerifier = tenancy && paymentsConfig @@ -239,86 +213,114 @@ async function main() { const verifiedTeams = new Set(); if (!skipUsers) { - for (let j = 0; j < users.items.length; j++) { - const user = users.items[j]; - await recurse(`[user ${j + 1}/${users.items.length}] ${user.display_name ?? user.primary_email}`, async (recurse) => { - // get user individually - await expectStatusCode(200, `/api/v1/users/${user.id}`, { - method: "GET", - headers: { - "x-stack-project-id": projectId, - "x-stack-access-type": "admin", - "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), - }, - }); + let userCount = 0; + if (tenancy) { + // TS thinks this could be undefined (no default in switch), ESLint disagrees + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const prisma = (await getPrismaClientForTenancy(tenancy))!; + userCount = await prisma.projectUser.count({ where: { tenancyId: tenancy.id } }); + } - // list project permissions - const projectPermissions = await expectStatusCode(200, `/api/v1/project-permissions?user_id=${user.id}`, { - method: "GET", - headers: { - "x-stack-project-id": projectId, - "x-stack-access-type": "admin", - "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), - }, - }); - for (const projectPermission of projectPermissions.items) { - // `any` because these endpoint response types aren't imported here, - // and this script is intentionally tolerant of response shape changes. - if (!projectPermissionDefinitions.items.some((p: any) => p.id === projectPermission.id)) { - throw new StackAssertionError(deindent` - Project permission ${projectPermission.id} not found in project permission definitions. - `); - } - } - - // list teams - const teams = await expectStatusCode(200, `/api/v1/teams?user_id=${user.id}`, { - method: "GET", - headers: { - "x-stack-project-id": projectId, - "x-stack-access-type": "admin", - "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), - }, - }); + // Process users page-by-page to avoid holding all users in memory at once + const PAGE_LIMIT = 1000; + let userCursor: string | undefined = undefined; + let usersProcessed = 0; + let hasMore = true; - for (const team of teams.items) { - await recurse(`[team ${team.id}] ${team.name}`, async (recurse) => { - // list team permissions - const teamPermissions = await expectStatusCode(200, `/api/v1/team-permissions?team_id=${team.id}`, { - method: "GET", - headers: { - "x-stack-project-id": projectId, - "x-stack-access-type": "admin", - "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), - }, - }); - for (const teamPermission of teamPermissions.items) { - // `any` because these endpoint response types aren't imported here, - // and this script is intentionally tolerant of response shape changes. - if (!teamPermissionDefinitions.items.some((p: any) => p.id === teamPermission.id)) { - throw new StackAssertionError(deindent` - Team permission ${teamPermission.id} not found in team permission definitions. + while (hasMore && usersProcessed < maxUsersPerProject) { + const remainingToFetch = maxUsersPerProject - usersProcessed; + const limit = Math.min(PAGE_LIMIT, remainingToFetch); + const cursorParam: string = userCursor ? `&cursor=${encodeURIComponent(userCursor)}` : ""; + const usersPage = await expectStatusCode(200, `/api/v1/users?limit=${limit}${cursorParam}`, { + method: "GET", + headers: { + "x-stack-project-id": projectId, + "x-stack-access-type": "admin", + "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), + }, + }); + + for (const user of usersPage.items) { + if (usersProcessed >= maxUsersPerProject) break; + usersProcessed++; + await recurse(`[user ${usersProcessed}/${userCount}] ${user.display_name ?? user.primary_email}`, async (recurse) => { + await expectStatusCode(200, `/api/v1/users/${user.id}`, { + method: "GET", + headers: { + "x-stack-project-id": projectId, + "x-stack-access-type": "admin", + "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), + }, + }); + + const projectPermissions = await expectStatusCode(200, `/api/v1/project-permissions?user_id=${user.id}`, { + method: "GET", + headers: { + "x-stack-project-id": projectId, + "x-stack-access-type": "admin", + "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), + }, + }); + for (const projectPermission of projectPermissions.items) { + // `any` because these endpoint response types aren't imported here, + // and this script is intentionally tolerant of response shape changes. + if (!projectPermissionDefinitions.items.some((p: any) => p.id === projectPermission.id)) { + throw new StackAssertionError(deindent` + Project permission ${projectPermission.id} not found in project permission definitions. `); - } } + } + + const teams = await expectStatusCode(200, `/api/v1/teams?user_id=${user.id}`, { + method: "GET", + headers: { + "x-stack-project-id": projectId, + "x-stack-access-type": "admin", + "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), + }, }); - if (paymentsVerifier && !verifiedTeams.has(team.id)) { + for (const team of teams.items) { + await recurse(`[team ${team.id}] ${team.name}`, async (recurse) => { + const teamPermissions = await expectStatusCode(200, `/api/v1/team-permissions?team_id=${team.id}`, { + method: "GET", + headers: { + "x-stack-project-id": projectId, + "x-stack-access-type": "admin", + "x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), + }, + }); + for (const teamPermission of teamPermissions.items) { + // `any` because these endpoint response types aren't imported here, + // and this script is intentionally tolerant of response shape changes. + if (!teamPermissionDefinitions.items.some((p: any) => p.id === teamPermission.id)) { + throw new StackAssertionError(deindent` + Team permission ${teamPermission.id} not found in team permission definitions. + `); + } + } + }); + + if (paymentsVerifier && !verifiedTeams.has(team.id)) { + await paymentsVerifier.verifyCustomerPayments({ + customerType: "team", + customerId: team.id, + }); + verifiedTeams.add(team.id); + } + } + + if (paymentsVerifier) { await paymentsVerifier.verifyCustomerPayments({ - customerType: "team", - customerId: team.id, + customerType: "user", + customerId: user.id, }); - verifiedTeams.add(team.id); } - } + }); + } - if (paymentsVerifier) { - await paymentsVerifier.verifyCustomerPayments({ - customerType: "user", - customerId: user.id, - }); - } - }); + hasMore = !!usersPage.pagination?.next_cursor; + userCursor = usersPage.pagination?.next_cursor ?? undefined; } if (paymentsVerifier) { From d45f88ab4c8287bbb926585abda69a32fbd765d6 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Wed, 18 Mar 2026 15:59:26 -0700 Subject: [PATCH 3/6] chore/fix: style updates, safety guards Can't pass verify-output and save-output together else file will be overwritten. --- .../scripts/verify-data-integrity/api.ts | 51 ++++++++++++------- .../scripts/verify-data-integrity/index.ts | 34 ++++++++----- 2 files changed, 53 insertions(+), 32 deletions(-) diff --git a/apps/backend/scripts/verify-data-integrity/api.ts b/apps/backend/scripts/verify-data-integrity/api.ts index f920501c8a..3e23de9957 100644 --- a/apps/backend/scripts/verify-data-integrity/api.ts +++ b/apps/backend/scripts/verify-data-integrity/api.ts @@ -9,7 +9,7 @@ export type EndpointOutput = { responseJson: any, }; -export type OutputData = Record; +export type OutputData = Map; export type ExpectStatusCode = ( expectedStatusCode: number, @@ -19,23 +19,23 @@ export type ExpectStatusCode = ( /** * Reads an output file that may be in either format: - * - Legacy: a single JSON object keyed by endpoint (`OutputData`) + * - Legacy: a single JSON object keyed by endpoint. This was old * - JSONL: one JSON object per line, each `{ endpoint, output }` */ export function loadOutputData(filePath: string): OutputData { const content = fs.readFileSync(filePath, "utf8").trim(); - if (!content) return {}; + const data: OutputData = new Map(); + if (!content) return data; const firstLine = content.split("\n")[0]; try { const parsed = JSON.parse(firstLine); if ("endpoint" in parsed && "output" in parsed) { - const data: OutputData = {}; for (const line of content.split("\n")) { if (!line.trim()) continue; const { endpoint, output } = JSON.parse(line); - if (!(endpoint in data)) data[endpoint] = []; - data[endpoint].push(output); + if (!data.has(endpoint)) data.set(endpoint, []); + data.get(endpoint)!.push(output); } return data; } @@ -43,7 +43,11 @@ export function loadOutputData(filePath: string): OutputData { // Not JSONL — fall through to legacy parse } - return JSON.parse(content); + const legacy = JSON.parse(content) as Record; + for (const [endpoint, outputs] of Object.entries(legacy)) { + data.set(endpoint, outputs); + } + return data; } export function createApiHelpers(options: { @@ -51,15 +55,17 @@ export function createApiHelpers(options: { /** * When set, each API response is streamed to this file as JSONL * (one `{ endpoint, output }` object per line). This avoids - * accumulating all responses in memory. + * accumulating all responses in memory. Writes go to a temporary + * file first; call `finalizeOutput()` to rename it to the final path. */ outputFilePath?: string, }) { const { targetOutputData, outputFilePath } = options; const outputCountByEndpoint = new Map(); + const tmpFilePath = outputFilePath ? `${outputFilePath}.tmp` : undefined; - if (outputFilePath) { - fs.writeFileSync(outputFilePath, ""); + if (tmpFilePath) { + fs.writeFileSync(tmpFilePath, ""); } function appendOutputData(endpoint: string, output: EndpointOutput) { @@ -67,38 +73,38 @@ export function createApiHelpers(options: { outputCountByEndpoint.set(endpoint, count); if (targetOutputData) { - if (!(endpoint in targetOutputData)) { + const targetEndpointOutputs = targetOutputData.get(endpoint); + if (!targetEndpointOutputs) { throw new StackAssertionError(deindent` Output data mismatch for endpoint ${endpoint}: Expected ${endpoint} to be in targetOutputData, but it is not. `, { endpoint }); } - if (targetOutputData[endpoint].length < count) { + if (targetEndpointOutputs.length < count) { throw new StackAssertionError(deindent` Output data mismatch for endpoint ${endpoint}: - Expected ${targetOutputData[endpoint].length} outputs but got at least ${count}. + Expected ${targetEndpointOutputs.length} outputs but got at least ${count}. `, { endpoint }); } - if (!(deepPlainEquals(targetOutputData[endpoint][count - 1], output))) { + if (!(deepPlainEquals(targetEndpointOutputs[count - 1], output))) { throw new StackAssertionError(deindent` Output data mismatch for endpoint ${endpoint}: Expected output[${JSON.stringify(endpoint)}][${count - 1}] to be: - ${JSON.stringify(targetOutputData[endpoint][count - 1], null, 2)} + ${JSON.stringify(targetEndpointOutputs[count - 1], null, 2)} but got: ${JSON.stringify(output, null, 2)}. `, { endpoint }); } } - if (outputFilePath) { - fs.appendFileSync(outputFilePath, JSON.stringify({ endpoint, output }) + "\n"); + if (tmpFilePath) { + fs.appendFileSync(tmpFilePath, JSON.stringify({ endpoint, output }) + "\n"); } } function verifyOutputCompleteness() { - // targetOutputData is old output file. if (!targetOutputData) return; - for (const [endpoint, expectedOutputs] of Object.entries(targetOutputData)) { + for (const [endpoint, expectedOutputs] of targetOutputData) { const actualCount = outputCountByEndpoint.get(endpoint) ?? 0; if (actualCount !== expectedOutputs.length) { throw new StackAssertionError(deindent` @@ -109,6 +115,12 @@ export function createApiHelpers(options: { } } + function finalizeOutput() { + if (tmpFilePath && outputFilePath) { + fs.renameSync(tmpFilePath, outputFilePath); + } + } + const expectStatusCode: ExpectStatusCode = async (expectedStatusCode, endpoint, request) => { const apiUrl = new URL(getEnvVariable("NEXT_PUBLIC_STACK_API_URL")); const response = await fetch(new URL(endpoint, apiUrl), { @@ -145,5 +157,6 @@ export function createApiHelpers(options: { appendOutputData, expectStatusCode, verifyOutputCompleteness, + finalizeOutput, }; } diff --git a/apps/backend/scripts/verify-data-integrity/index.ts b/apps/backend/scripts/verify-data-integrity/index.ts index 22ab75c58e..960b16711d 100644 --- a/apps/backend/scripts/verify-data-integrity/index.ts +++ b/apps/backend/scripts/verify-data-integrity/index.ts @@ -84,6 +84,10 @@ async function main() { : Infinity; const { recurse, collectedErrors } = createRecurse({ noBail }); + if (shouldSaveOutput && shouldVerifyOutput) { + throw new Error("Cannot use --save-output and --verify-output at the same time."); + } + if (noBail) { console.log(`Running in no-bail mode: will continue on errors and report all at the end.`); } @@ -103,17 +107,20 @@ async function main() { targetOutputData = loadOutputData(OUTPUT_FILE_PATH); // TODO next-release these are hacks for the migration, delete them - targetOutputData["/api/v1/internal/projects/current"] = targetOutputData["/api/v1/internal/projects/current"].map(output => { - if ("config" in output.responseJson) { - delete output.responseJson.config.id; - output.responseJson.config.oauth_providers = output.responseJson.config.oauth_providers - // `any` because this is historical output JSON from disk. - // We intentionally keep this "migration hack" untyped. - .filter((provider: any) => provider.enabled) - .map((provider: any) => omit(provider, ["enabled"])); - } - return output; - }); + const projectCurrentOutputs = targetOutputData.get("/api/v1/internal/projects/current"); + if (projectCurrentOutputs) { + targetOutputData.set("/api/v1/internal/projects/current", projectCurrentOutputs.map(output => { + if ("config" in output.responseJson) { + delete output.responseJson.config.id; + output.responseJson.config.oauth_providers = output.responseJson.config.oauth_providers + // `any` because this is historical output JSON from disk. + // We intentionally keep this "migration hack" untyped. + .filter((provider: any) => provider.enabled) + .map((provider: any) => omit(provider, ["enabled"])); + } + return output; + })); + } console.log(`Loaded previous output data for verification`); } catch (error) { @@ -121,7 +128,7 @@ async function main() { } } - const { expectStatusCode, verifyOutputCompleteness } = createApiHelpers({ + const { expectStatusCode, verifyOutputCompleteness, finalizeOutput } = createApiHelpers({ targetOutputData, outputFilePath: shouldSaveOutput ? OUTPUT_FILE_PATH : undefined, }); @@ -306,7 +313,7 @@ async function main() { customerType: "team", customerId: team.id, }); - verifiedTeams.add(team.id); + verifiedTeams.add(team.id); } } @@ -337,6 +344,7 @@ async function main() { verifyOutputCompleteness(); if (shouldSaveOutput) { + finalizeOutput(); console.log(`Output saved to ${OUTPUT_FILE_PATH}`); } From f27a340d6cd7217bbb53c6c50d6e949212f42e3c Mon Sep 17 00:00:00 2001 From: nams1570 Date: Wed, 18 Mar 2026 16:02:48 -0700 Subject: [PATCH 4/6] chore: split lines in way that works on fs --- apps/backend/scripts/verify-data-integrity/api.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/backend/scripts/verify-data-integrity/api.ts b/apps/backend/scripts/verify-data-integrity/api.ts index 3e23de9957..30f4087cb3 100644 --- a/apps/backend/scripts/verify-data-integrity/api.ts +++ b/apps/backend/scripts/verify-data-integrity/api.ts @@ -27,11 +27,12 @@ export function loadOutputData(filePath: string): OutputData { const data: OutputData = new Map(); if (!content) return data; - const firstLine = content.split("\n")[0]; + const lines = content.split(/\r?\n/); + const firstLine = lines[0]; try { const parsed = JSON.parse(firstLine); if ("endpoint" in parsed && "output" in parsed) { - for (const line of content.split("\n")) { + for (const line of lines) { if (!line.trim()) continue; const { endpoint, output } = JSON.parse(line); if (!data.has(endpoint)) data.set(endpoint, []); From 671e337fe814a680ee6a954467335964cac4810b Mon Sep 17 00:00:00 2001 From: nams1570 Date: Wed, 18 Mar 2026 16:10:21 -0700 Subject: [PATCH 5/6] chore: defensive type validations --- apps/backend/scripts/verify-data-integrity/api.ts | 2 +- apps/backend/scripts/verify-data-integrity/index.ts | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/backend/scripts/verify-data-integrity/api.ts b/apps/backend/scripts/verify-data-integrity/api.ts index 30f4087cb3..ad5f5945c8 100644 --- a/apps/backend/scripts/verify-data-integrity/api.ts +++ b/apps/backend/scripts/verify-data-integrity/api.ts @@ -31,7 +31,7 @@ export function loadOutputData(filePath: string): OutputData { const firstLine = lines[0]; try { const parsed = JSON.parse(firstLine); - if ("endpoint" in parsed && "output" in parsed) { + if (typeof parsed === "object" && parsed !== null && "endpoint" in parsed && "output" in parsed) { for (const line of lines) { if (!line.trim()) continue; const { endpoint, output } = JSON.parse(line); diff --git a/apps/backend/scripts/verify-data-integrity/index.ts b/apps/backend/scripts/verify-data-integrity/index.ts index 960b16711d..536f4286f4 100644 --- a/apps/backend/scripts/verify-data-integrity/index.ts +++ b/apps/backend/scripts/verify-data-integrity/index.ts @@ -220,13 +220,9 @@ async function main() { const verifiedTeams = new Set(); if (!skipUsers) { - let userCount = 0; - if (tenancy) { - // TS thinks this could be undefined (no default in switch), ESLint disagrees - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - const prisma = (await getPrismaClientForTenancy(tenancy))!; - userCount = await prisma.projectUser.count({ where: { tenancyId: tenancy.id } }); - } + const userCount = tenancy + ? await (await getPrismaClientForTenancy(tenancy)).projectUser.count({ where: { tenancyId: tenancy.id } }) + : 0; // Process users page-by-page to avoid holding all users in memory at once const PAGE_LIMIT = 1000; From 8773d6103fddf3392b0d356c28abc12a645d109d Mon Sep 17 00:00:00 2001 From: nams1570 Date: Wed, 18 Mar 2026 16:21:43 -0700 Subject: [PATCH 6/6] chore: progress bar for users shows effective cap This was the old behavior --- apps/backend/scripts/verify-data-integrity/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/scripts/verify-data-integrity/index.ts b/apps/backend/scripts/verify-data-integrity/index.ts index 536f4286f4..ea1f01d703 100644 --- a/apps/backend/scripts/verify-data-integrity/index.ts +++ b/apps/backend/scripts/verify-data-integrity/index.ts @@ -246,7 +246,7 @@ async function main() { for (const user of usersPage.items) { if (usersProcessed >= maxUsersPerProject) break; usersProcessed++; - await recurse(`[user ${usersProcessed}/${userCount}] ${user.display_name ?? user.primary_email}`, async (recurse) => { + await recurse(`[user ${usersProcessed}/${Math.min(userCount, maxUsersPerProject)}] ${user.display_name ?? user.primary_email}`, async (recurse) => { await expectStatusCode(200, `/api/v1/users/${user.id}`, { method: "GET", headers: {