Skip to content

Commit f10e0c9

Browse files
committed
More fixes
1 parent 32f4bf4 commit f10e0c9

7 files changed

Lines changed: 44 additions & 52 deletions

File tree

apps/backend/prisma/migrations/20260202000000_fix_trusted_domains_config/migration.sql

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,18 @@ ON /* SCHEMA_NAME_SENTINEL */."EnvironmentConfigOverride" ("temp_trusted_domains
2828
WHERE "temp_trusted_domains_checked" IS NOT TRUE;
2929
-- SPLIT_STATEMENT_SENTINEL
3030

31-
-- Process rows in batches
31+
-- Process rows in batches (outside transaction so each batch commits independently)
3232
-- SPLIT_STATEMENT_SENTINEL
3333
-- SINGLE_STATEMENT_SENTINEL
34+
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
3435
-- CONDITIONALLY_REPEAT_MIGRATION_SENTINEL
3536
WITH rows_to_check AS (
3637
-- Get unchecked rows
3738
SELECT "projectId", "branchId", "config"
38-
FROM "EnvironmentConfigOverride"
39+
FROM /* SCHEMA_NAME_SENTINEL */."EnvironmentConfigOverride"
3940
WHERE "temp_trusted_domains_checked" IS NOT TRUE
40-
LIMIT 10000
41+
-- Keep batch size small for consistent performance
42+
LIMIT 1000
4143
),
4244
matching_keys AS (
4345
-- Find all keys that look like "domains.trustedDomains.<id>.<property...>"
@@ -79,7 +81,7 @@ parents_to_add AS (
7981
),
8082
updated_with_keys AS (
8183
-- Update rows that need new parent keys
82-
UPDATE "EnvironmentConfigOverride" eco
84+
UPDATE /* SCHEMA_NAME_SENTINEL */."EnvironmentConfigOverride" eco
8385
SET
8486
"config" = eco."config" || pta.new_keys,
8587
"updatedAt" = NOW(),
@@ -91,7 +93,7 @@ updated_with_keys AS (
9193
),
9294
marked_as_checked AS (
9395
-- Mark all checked rows (including ones that didn't need fixing)
94-
UPDATE "EnvironmentConfigOverride" eco
96+
UPDATE /* SCHEMA_NAME_SENTINEL */."EnvironmentConfigOverride" eco
9597
SET "temp_trusted_domains_checked" = TRUE
9698
FROM rows_to_check rtc
9799
WHERE eco."projectId" = rtc."projectId"
@@ -107,7 +109,10 @@ SELECT COUNT(*) > 0 AS should_repeat_migration
107109
FROM rows_to_check;
108110
-- SPLIT_STATEMENT_SENTINEL
109111

110-
-- Clean up: drop temporary index
112+
-- Clean up: drop temporary index (outside transaction since CREATE was also outside)
113+
-- SPLIT_STATEMENT_SENTINEL
114+
-- SINGLE_STATEMENT_SENTINEL
115+
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
111116
DROP INDEX IF EXISTS "temp_eco_trusted_domains_checked_idx";
112117
-- SPLIT_STATEMENT_SENTINEL
113118

apps/backend/src/auto-migrations/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ export async function applyMigrations(options: {
132132
}
133133

134134
for (const statementRaw of migration.sql.split('SPLIT_STATEMENT_SENTINEL')) {
135-
const statement = statementRaw.replace('/* SCHEMA_NAME_SENTINEL */', sqlQuoteIdentToString(options.schema));
135+
const statement = statementRaw.replaceAll('/* SCHEMA_NAME_SENTINEL */', sqlQuoteIdentToString(options.schema));
136136
const runOutside = statement.includes('RUN_OUTSIDE_TRANSACTION_SENTINEL');
137137
const isSingleStatement = statement.includes('SINGLE_STATEMENT_SENTINEL');
138138
const isConditionallyRepeatMigration = statement.includes('CONDITIONALLY_REPEAT_MIGRATION_SENTINEL');

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
TrashIcon,
3030
} from "@phosphor-icons/react";
3131
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
32+
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
3233
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
3334
import { useCallback, useMemo, useState } from "react";
3435
import { AppEnabledGuard } from "../../app-enabled-guard";
@@ -151,7 +152,7 @@ function CreateFolderDialog({
151152
placeholder="My Queries"
152153
onKeyDown={(e) => {
153154
if (e.key === "Enter") {
154-
runAsynchronouslyWithAlert(handleCreate());
155+
runAsynchronouslyWithAlert(handleCreate);
155156
}
156157
}}
157158
/>
@@ -162,7 +163,7 @@ function CreateFolderDialog({
162163
<Button variant="secondary" onClick={() => onOpenChange(false)}>
163164
Cancel
164165
</Button>
165-
<Button onClick={handleCreate} disabled={!displayName.trim() || loading}>
166+
<Button onClick={() => runAsynchronouslyWithAlert(handleCreate)} disabled={!displayName.trim() || loading}>
166167
{loading ? "Creating..." : "Create"}
167168
</Button>
168169
</DialogFooter>
@@ -264,7 +265,7 @@ function SaveQueryDialog({
264265
<Button variant="secondary" onClick={() => onOpenChange(false)}>
265266
Cancel
266267
</Button>
267-
<Button onClick={handleSave} disabled={!canSave || loading}>
268+
<Button onClick={() => runAsynchronouslyWithAlert(handleSave)} disabled={!canSave || loading}>
268269
{loading ? "Saving..." : "Save"}
269270
</Button>
270271
</DialogFooter>
@@ -312,7 +313,7 @@ function DeleteConfirmDialog({
312313
<Button variant="secondary" onClick={() => onOpenChange(false)}>
313314
Cancel
314315
</Button>
315-
<Button variant="destructive" onClick={handleConfirm} disabled={loading}>
316+
<Button variant="destructive" onClick={() => runAsynchronouslyWithAlert(handleConfirm)} disabled={loading}>
316317
{loading ? "Deleting..." : "Delete"}
317318
</Button>
318319
</DialogFooter>
@@ -350,9 +351,9 @@ function QueriesContent() {
350351

351352
// Get folders and queries from environment config
352353
const folders = useMemo((): FolderWithId[] => {
353-
// Type assertion because config types may not be updated yet
354-
const analyticsConfig = (config as { analytics?: { queryFolders?: Record<string, ConfigFolder> } }).analytics ?? {};
355-
const queryFolders = analyticsConfig.queryFolders ?? {};
354+
const analyticsConfig = (config as { analytics?: { queryFolders?: Record<string, ConfigFolder> } }).analytics
355+
?? throwErr("Missing analytics config");
356+
const queryFolders = analyticsConfig.queryFolders ?? throwErr("Missing queryFolders in analytics config");
356357

357358
return Object.entries(queryFolders)
358359
.map(([id, folder]) => ({
@@ -404,7 +405,7 @@ function QueriesContent() {
404405
setSqlQuery(query.sqlQuery);
405406
setError(null);
406407
// Run the query immediately after selecting it
407-
runAsynchronouslyWithAlert(runQuery(query.sqlQuery));
408+
runAsynchronouslyWithAlert(() => runQuery(query.sqlQuery));
408409
};
409410

410411
const handleCreateFolder = async (displayName: string) => {
@@ -617,7 +618,7 @@ function QueriesContent() {
617618
onKeyDown={(e) => {
618619
if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && !loading) {
619620
e.preventDefault();
620-
runAsynchronouslyWithAlert(runQuery());
621+
runAsynchronouslyWithAlert(runQuery);
621622
}
622623
}}
623624
/>
@@ -628,7 +629,7 @@ function QueriesContent() {
628629
<div className="flex flex-col gap-2">
629630
<Button
630631
size="sm"
631-
onClick={() => runAsynchronouslyWithAlert(runQuery())}
632+
onClick={() => runAsynchronouslyWithAlert(runQuery)}
632633
disabled={!sqlQuery.trim() || loading}
633634
className="gap-1.5"
634635
>
@@ -643,7 +644,7 @@ function QueriesContent() {
643644
<Button
644645
variant="secondary"
645646
size="sm"
646-
onClick={() => runAsynchronouslyWithAlert(handleUpdateCurrentQuery())}
647+
onClick={() => runAsynchronouslyWithAlert(handleUpdateCurrentQuery)}
647648
disabled={!sqlQuery.trim()}
648649
className="gap-1.5"
649650
>

apps/dashboard/src/components/commands/run-query.tsx

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import { Textarea } from "@/components/ui/textarea";
2929
import { useDebouncedAction } from "@/hooks/use-debounced-action";
3030
import { useFromNow } from "@/hooks/use-from-now";
3131
import { useUpdateConfig } from "@/lib/config-update";
32-
import { cn } from "@/lib/utils";
3332
import {
3433
ArrowClockwiseIcon,
3534
CheckCircleIcon,
@@ -40,6 +39,7 @@ import {
4039
WarningCircleIcon
4140
} from "@phosphor-icons/react";
4241
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
42+
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
4343
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
4444
import { memo, useCallback, useMemo, useState } from "react";
4545
import { CmdKPreviewProps } from "../cmdk-commands";
@@ -162,6 +162,7 @@ function LoadingState() {
162162
}
163163

164164
// Save query dialog for the command palette
165+
// Note: This component requires adminApp to be non-null to avoid conditional hook calls
165166
function SaveQueryDialog({
166167
open,
167168
onOpenChange,
@@ -170,7 +171,7 @@ function SaveQueryDialog({
170171
}: {
171172
open: boolean,
172173
onOpenChange: (open: boolean) => void,
173-
adminApp: ReturnType<typeof useAdminAppIfExists>,
174+
adminApp: NonNullable<ReturnType<typeof useAdminAppIfExists>>,
174175
sqlQuery: string,
175176
}) {
176177
const updateConfig = useUpdateConfig();
@@ -183,13 +184,13 @@ function SaveQueryDialog({
183184
const [newFolderName, setNewFolderName] = useState("");
184185
const [creatingFolder, setCreatingFolder] = useState(false);
185186

186-
// Get folders from config
187-
const config = adminApp?.useProject().useConfig();
187+
// Get folders from config - hooks are now called unconditionally
188+
const config = adminApp.useProject().useConfig();
188189
const folders = useMemo((): FolderWithId[] => {
189-
if (!config) return [];
190190
// Type assertion because config types may not be updated yet
191-
const analyticsConfig = (config as { analytics?: { queryFolders?: Record<string, ConfigFolder> } }).analytics ?? {};
192-
const queryFolders = analyticsConfig.queryFolders ?? {};
191+
const analyticsConfig = (config as { analytics?: { queryFolders?: Record<string, ConfigFolder> } }).analytics
192+
?? throwErr("Missing analytics config");
193+
const queryFolders = analyticsConfig.queryFolders ?? throwErr("Missing queryFolders in analytics config");
193194

194195
return Object.entries(queryFolders)
195196
.map(([id, folder]) => ({
@@ -207,7 +208,7 @@ function SaveQueryDialog({
207208
}, [config]);
208209

209210
const handleSave = async () => {
210-
if (!adminApp || !displayName.trim() || !sqlQuery.trim() || !selectedFolderId) return;
211+
if (!displayName.trim() || !sqlQuery.trim() || !selectedFolderId) return;
211212
setLoading(true);
212213
try {
213214
const queryId = generateSecureRandomString();
@@ -234,7 +235,7 @@ function SaveQueryDialog({
234235
};
235236

236237
const handleCreateFolder = async () => {
237-
if (!adminApp || !newFolderName.trim()) return;
238+
if (!newFolderName.trim()) return;
238239
setCreatingFolder(true);
239240
try {
240241
const folderId = generateSecureRandomString();
@@ -260,8 +261,6 @@ function SaveQueryDialog({
260261

261262
const canSave = displayName.trim() && selectedFolderId && sqlQuery.trim();
262263

263-
if (!adminApp) return null;
264-
265264
return (
266265
<Dialog open={open} onOpenChange={onOpenChange}>
267266
<DialogContent className="sm:max-w-md">
@@ -311,7 +310,7 @@ function SaveQueryDialog({
311310
/>
312311
<Button
313312
size="sm"
314-
onClick={handleCreateFolder}
313+
onClick={() => runAsynchronouslyWithAlert(handleCreateFolder)}
315314
disabled={!newFolderName.trim() || creatingFolder}
316315
>
317316
{creatingFolder ? "..." : "Create"}
@@ -366,7 +365,7 @@ function SaveQueryDialog({
366365
<Button variant="secondary" onClick={() => onOpenChange(false)}>
367366
Cancel
368367
</Button>
369-
<Button onClick={handleSave} disabled={!canSave || loading}>
368+
<Button onClick={() => runAsynchronouslyWithAlert(handleSave)} disabled={!canSave || loading}>
370369
{loading ? "Saving..." : "Save"}
371370
</Button>
372371
</DialogFooter>
@@ -444,9 +443,7 @@ const RunQueryPreviewInner = memo(function RunQueryPreviewInner({
444443
};
445444

446445
const handleRetry = useCallback(() => {
447-
runQuery().catch(() => {
448-
// Error is already handled in runQuery
449-
});
446+
runAsynchronouslyWithAlert(runQuery);
450447
}, [runQuery]);
451448

452449
// No admin app available

apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -336,8 +336,8 @@ describe("analytics config - queries nested in folders", () => {
336336
});
337337

338338
// Verify folder is deleted (check override since rendered config applies defaults)
339-
const override = await getEnvironmentOverride(adminAccessToken);
340-
expect(override["analytics.queryFolders.cascade-folder"]).toBeNull();
339+
const override = await getConfig(adminAccessToken);
340+
expect(override.analytics.queryFolders["cascade-folder"]).toBeUndefined();
341341
});
342342
});
343343

apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -593,22 +593,8 @@ describe("domain config", () => {
593593
},
594594
});
595595

596-
expect(mixedFormatResponse.status).toBe(200);
597-
598-
const configResponse3 = await niceBackendFetch("/api/v1/internal/config", {
599-
method: "GET",
600-
accessType: "admin",
601-
headers: adminHeaders(adminAccessToken),
602-
});
603-
const config3 = JSON.parse(configResponse3.body.config_string);
604-
expect(config3.domains.trustedDomains).toMatchInlineSnapshot(`
605-
{
606-
"3": {
607-
"baseUrl": "http://nested.example.com",
608-
"handlerPath": "/nested",
609-
},
610-
}
611-
`);
596+
expect(mixedFormatResponse.status).toBe(400);
597+
expect(mixedFormatResponse.body).toContain("domains.trustedDomains");
612598
});
613599
});
614600

claude/CLAUDE-KNOWLEDGE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@ A: Use the shared `TextAreaField` component's `helperText` prop in `apps/dashboa
88

99
Q: Why did `pnpm typecheck` fail after deleting a Next.js route?
1010
A: The generated `.next/types/validator.ts` can keep stale imports for removed routes. Deleting that file (or regenerating Next build output) clears the outdated references so `pnpm typecheck` succeeds again.
11+
12+
Q: Why can auto-migrations time out and how should I mitigate it?
13+
A: Auto-migrations run each migration inside a Prisma interactive transaction with an 80s timeout. Long-running statements (even if marked RUN_OUTSIDE_TRANSACTION_SENTINEL) still consume that time, so keep each iteration small using CONDITIONALLY_REPEAT_MIGRATION_SENTINEL and reduce batch sizes (e.g., lower LIMIT) so each transaction finishes under 80s.

0 commit comments

Comments
 (0)