From 4807eb321c486285c9b466907c09364000b5e370 Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Sun, 1 Feb 2026 19:37:39 -0800
Subject: [PATCH 01/21] Queries view
---
AGENTS.md | 3 +-
apps/backend/scripts/db-migrations.ts | 9 +-
.../analytics/queries/page-client.tsx | 1070 +++++++++++++++++
.../[projectId]/analytics/queries/page.tsx | 5 +
.../analytics/tables/page-client.tsx | 50 +-
.../src/components/commands/run-query.tsx | 32 +-
apps/dashboard/src/lib/apps-frontend.tsx | 1 +
.../endpoints/api/v1/analytics-config.test.ts | 636 ++++++++++
.../src/config/schema-fuzzer.test.ts | 15 +
packages/stack-shared/src/config/schema.ts | 34 +
packages/stack-shared/src/known-errors.tsx | 22 +
.../apps/implementations/admin-app-impl.ts | 2 +-
12 files changed, 1826 insertions(+), 53 deletions(-)
create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page.tsx
create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts
diff --git a/AGENTS.md b/AGENTS.md
index fd74263505..ad2523ec4d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,7 +12,7 @@ This file provides guidance to coding agents when working with code in this repo
#### Extra commands
These commands are usually already called by the user, but you can remind them to run it for you if they forgot to.
-- **Build packages**: `pnpm build:packages`
+- **Build packages**: `pnpm build:packages` (you should never call this yourself)
- **Start dependencies**: `pnpm restart-deps` (resets & restarts Docker containers for DB, Inbucket, etc. Usually already called by the user)
- **Run development**: Already called by the user in the background. You don't need to do this. This will also watch for changes and rebuild packages, codegen, etc. Do NOT call build:packages, dev, codegen, or anything like that yourself, as the dev is already running it.
- **Run minimal dev**: `pnpm dev:basic` (only backend and dashboard for resource-limited systems)
@@ -93,6 +93,7 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
- If there is an external browser tool connected, use it to test changes you make to the frontend when possible.
- Whenever you update an SDK implementation in `sdks/implementations`, make sure to update the specs accordingly in `sdks/specs` such that if you reimplemented the entire SDK from the specs again, you would get the same implementation. (For example, if the specs are not precise enough to describe a change you made, make the specs more precise.)
- When building internal tools for Stack Auth developers (eg. internal interfaces like the WAL info log etc.): Make the interfaces look very concise, assume the user is a pro-user. This only applies to internal tools that are used primarily by Stack Auth developers.
+- The dev server already builds the packages in the background whenever you update a file. If you run into issues with typechecking or linting in a dependency after updating something in a package, just wait a few seconds, and then try again, and they will likely be resolved.
### Code-related
- Use ES6 maps instead of records wherever you can.
diff --git a/apps/backend/scripts/db-migrations.ts b/apps/backend/scripts/db-migrations.ts
index 14733a6873..33bd4280d9 100644
--- a/apps/backend/scripts/db-migrations.ts
+++ b/apps/backend/scripts/db-migrations.ts
@@ -1,15 +1,14 @@
import { applyMigrations } from "@/auto-migrations";
import { MIGRATION_FILES_DIR, getMigrationFiles } from "@/auto-migrations/utils";
import { Prisma } from "@/generated/prisma/client";
+import { getClickhouseAdminClient } from "@/lib/clickhouse";
import { globalPrismaClient, globalPrismaSchema, sqlQuoteIdent } from "@/prisma-client";
import { spawnSync } from "child_process";
import fs from "fs";
import path from "path";
import * as readline from "readline";
import { seed } from "../prisma/seed";
-import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { runClickhouseMigrations } from "./clickhouse-migrations";
-import { getClickhouseAdminClient } from "@/lib/clickhouse";
const getClickhouseClient = () => getClickhouseAdminClient();
@@ -81,7 +80,6 @@ const generateMigrationFile = async () => {
const folderName = `${timestampPrefix()}_${migrationName}`;
const migrationDir = path.join(MIGRATION_FILES_DIR, folderName);
const migrationSqlPath = path.join(migrationDir, 'migration.sql');
- const diffUrl = getEnvVariable('STACK_DATABASE_CONNECTION_STRING');
console.log(`Generating migration ${folderName}...`);
const diffResult = spawnSync(
@@ -91,9 +89,8 @@ const generateMigrationFile = async () => {
'prisma',
'migrate',
'diff',
- '--from-url',
- diffUrl,
- '--to-schema-datamodel',
+ '--from-config-datasource',
+ '--to-schema',
'prisma/schema.prisma',
'--script',
],
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
new file mode 100644
index 0000000000..84cc7abf03
--- /dev/null
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
@@ -0,0 +1,1070 @@
+"use client";
+
+import { Button } from "@/components/ui";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { SimpleTooltip } from "@/components/ui/simple-tooltip";
+import { Textarea } from "@/components/ui/textarea";
+import { useUpdateConfig } from "@/lib/config-update";
+import { cn } from "@/lib/utils";
+import {
+ ArrowClockwiseIcon,
+ CaretDownIcon,
+ CaretRightIcon,
+ CheckCircleIcon,
+ FilePlusIcon,
+ FloppyDiskIcon,
+ FolderIcon,
+ FolderOpenIcon,
+ PlayIcon,
+ PlusIcon,
+ SpinnerGapIcon,
+ TrashIcon,
+ WarningCircleIcon
+} from "@phosphor-icons/react";
+import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
+import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import { useCallback, useMemo, useRef, useState } from "react";
+import { AppEnabledGuard } from "../../app-enabled-guard";
+import { PageLayout } from "../../page-layout";
+import { useAdminApp } from "../../use-admin-app";
+
+type RowData = Record;
+
+type ConfigFolder = {
+ displayName: string,
+ sortOrder?: number,
+ queries: Record,
+};
+
+type FolderWithId = {
+ id: string,
+ displayName: string,
+ sortOrder: number,
+ queries: Array<{
+ id: string,
+ displayName: string,
+ sqlQuery: string,
+ description?: string,
+ }>,
+};
+
+// Detect if a value is a date string
+function isDateValue(value: unknown): value is string {
+ if (typeof value !== "string") return false;
+ return /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}:\d{2})?/.test(value);
+}
+
+// Detect if a value is JSON
+function isJsonValue(value: unknown): boolean {
+ return typeof value === "object" && value !== null;
+}
+
+// Parse ClickHouse date string as UTC
+function parseClickHouseDate(value: string): Date {
+ const normalized = value.replace(" ", "T") + (value.includes("Z") || value.includes("+") ? "" : "Z");
+ return new Date(normalized);
+}
+
+// Component for displaying JSON values
+function JsonValue({ value, truncate = true }: { value: unknown, truncate?: boolean }) {
+ const formatted = JSON.stringify(value, null, 2);
+ const preview = JSON.stringify(value);
+
+ if (truncate && preview.length > 60) {
+ return (
+ {formatted}}>
+
+ {preview.slice(0, 57)}...
+
+
+ );
+ }
+
+ return {preview} ;
+}
+
+// Format a cell value for display
+function CellValue({ value, truncate = true }: { value: unknown, truncate?: boolean }) {
+ if (value === null || value === undefined) {
+ return — ;
+ }
+
+ if (isDateValue(value)) {
+ const date = parseClickHouseDate(value);
+ return {date.toLocaleString()} ;
+ }
+
+ if (isJsonValue(value)) {
+ return ;
+ }
+
+ const str = String(value);
+ if (truncate && str.length > 100) {
+ return (
+
+ {str.slice(0, 97)}...
+
+ );
+ }
+
+ return {str} ;
+}
+
+// Row detail dialog
+function RowDetailDialog({
+ row,
+ columns,
+ open,
+ onOpenChange,
+}: {
+ row: RowData | null,
+ columns: string[],
+ open: boolean,
+ onOpenChange: (open: boolean) => void,
+}) {
+ if (!row) return null;
+
+ return (
+
+
+
+ Row Details
+
+
+
+ {columns.map((column) => (
+
+
+ {column}
+
+
+ {isJsonValue(row[column]) ? (
+
+ {JSON.stringify(row[column], null, 2)}
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+
+
+
+ );
+}
+
+// Virtualized flat table component
+function VirtualizedFlatTable({
+ columns,
+ rows,
+ onRowClick,
+}: {
+ columns: string[],
+ rows: RowData[],
+ onRowClick: (row: RowData) => void,
+}) {
+ const parentRef = useRef(null);
+
+ const rowVirtualizer = useVirtualizer({
+ count: rows.length,
+ getScrollElement: () => parentRef.current,
+ estimateSize: () => 36,
+ overscan: 10,
+ });
+
+ // Column widths - distribute based on content type
+ const columnWidths = useMemo(() => {
+ const widths = new Map();
+ columns.forEach((col) => {
+ if (col.includes("id") && col !== "project_id") {
+ widths.set(col, "minmax(200px, 1fr)");
+ } else if (col.includes("_at") || col.includes("date")) {
+ widths.set(col, "minmax(100px, 140px)");
+ } else if (col === "data" || col.includes("json")) {
+ widths.set(col, "minmax(180px, 2fr)");
+ } else if (col === "event_type" || col === "type") {
+ widths.set(col, "minmax(100px, 160px)");
+ } else {
+ widths.set(col, "minmax(80px, 1fr)");
+ }
+ });
+ return widths;
+ }, [columns]);
+
+ const gridTemplateColumns = columns.map((col) => columnWidths.get(col) ?? "1fr").join(" ");
+ const minContentWidth = columns.length * 120;
+
+ return (
+
+
+
+ {/* Sticky header */}
+
+ {columns.map((column) => (
+
+ {column}
+
+ ))}
+
+
+ {/* Virtualized rows container */}
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => {
+ const row = rows[virtualRow.index];
+
+ return (
+
onRowClick(row)}
+ >
+ {columns.map((column) => (
+
+
+
+ ))}
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+// Error display component
+function ErrorDisplay({ error, onRetry }: { error: unknown, onRetry: () => void | Promise }) {
+ const message = error instanceof Error ? error.message : String(error);
+
+ return (
+
+
+
+
+
+
Query Error
+
+ {message}
+
+
+
runAsynchronouslyWithAlert(onRetry())}
+ className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium bg-foreground/[0.06] hover:bg-foreground/[0.1] transition-colors hover:transition-none"
+ >
+
+ Retry
+
+
+ );
+}
+
+// Empty state component
+function EmptyQueryState() {
+ return (
+
+
+
+
Run Query
+
+ Enter a ClickHouse SQL query above and click Run to see results.
+
+
+
+
+ SELECT * FROM default.events
+ ORDER BY event_at DESC
+ LIMIT 100
+
+
+
+ );
+}
+
+// No results state
+function NoResultsState() {
+ return (
+
+
+
+
+
+
No Results
+
+ Query executed successfully but returned no rows.
+
+
+
+ );
+}
+
+// Loading state component
+function LoadingState() {
+ return (
+
+ );
+}
+
+// Create folder dialog
+function CreateFolderDialog({
+ open,
+ onOpenChange,
+ onCreate,
+}: {
+ open: boolean,
+ onOpenChange: (open: boolean) => void,
+ onCreate: (displayName: string) => Promise,
+}) {
+ const [displayName, setDisplayName] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ const handleCreate = async () => {
+ if (!displayName.trim()) return;
+ setLoading(true);
+ try {
+ await onCreate(displayName.trim());
+ setDisplayName("");
+ onOpenChange(false);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ Create Folder
+
+
+
+
+ Folder Name
+ setDisplayName(e.target.value)}
+ placeholder="My Queries"
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ runAsynchronouslyWithAlert(handleCreate());
+ }
+ }}
+ />
+
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {loading ? "Creating..." : "Create"}
+
+
+
+
+ );
+}
+
+// Save query dialog
+function SaveQueryDialog({
+ open,
+ onOpenChange,
+ folders,
+ sqlQuery,
+ onSave,
+}: {
+ open: boolean,
+ onOpenChange: (open: boolean) => void,
+ folders: FolderWithId[],
+ sqlQuery: string,
+ onSave: (displayName: string, folderId: string, description: string | null) => Promise,
+}) {
+ const [displayName, setDisplayName] = useState("");
+ const [description, setDescription] = useState("");
+ const [selectedFolderId, setSelectedFolderId] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ const handleSave = async () => {
+ if (!displayName.trim() || !sqlQuery.trim() || !selectedFolderId) return;
+ setLoading(true);
+ try {
+ await onSave(displayName.trim(), selectedFolderId, description.trim() || null);
+ setDisplayName("");
+ setDescription("");
+ setSelectedFolderId("");
+ onOpenChange(false);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const canSave = displayName.trim() && selectedFolderId;
+
+ return (
+
+
+
+ Save Query
+
+
+
+
+ Query Name
+ setDisplayName(e.target.value)}
+ placeholder="My Query"
+ />
+
+
+ Folder
+ setSelectedFolderId(e.target.value)}
+ >
+ Select a folder...
+ {folders.map((folder) => (
+
+ {folder.displayName}
+
+ ))}
+
+
+
+ Description (optional)
+
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {loading ? "Saving..." : "Save"}
+
+
+
+
+ );
+}
+
+// Delete confirmation dialog
+function DeleteConfirmDialog({
+ open,
+ onOpenChange,
+ title,
+ description,
+ onConfirm,
+}: {
+ open: boolean,
+ onOpenChange: (open: boolean) => void,
+ title: string,
+ description: string,
+ onConfirm: () => Promise,
+}) {
+ const [loading, setLoading] = useState(false);
+
+ const handleConfirm = async () => {
+ setLoading(true);
+ try {
+ await onConfirm();
+ onOpenChange(false);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {title}
+
+
+ {description}
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {loading ? "Deleting..." : "Delete"}
+
+
+
+
+ );
+}
+
+// Main content component
+function QueriesContent() {
+ const adminApp = useAdminApp();
+ const project = adminApp.useProject();
+ const config = project.useConfig();
+ const updateConfig = useUpdateConfig();
+
+ // Query state
+ const [sqlQuery, setSqlQuery] = useState("");
+ const [columns, setColumns] = useState([]);
+ const [rows, setRows] = useState([]);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [hasQueried, setHasQueried] = useState(false);
+ const [selectedRow, setSelectedRow] = useState(null);
+ const [detailDialogOpen, setDetailDialogOpen] = useState(false);
+
+ // Selection state
+ const [selectedFolderId, setSelectedFolderId] = useState(null);
+ const [selectedQueryId, setSelectedQueryId] = useState(null);
+
+ // Dialog state
+ const [createFolderDialogOpen, setCreateFolderDialogOpen] = useState(false);
+ const [saveQueryDialogOpen, setSaveQueryDialogOpen] = useState(false);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState<{ type: "folder" | "query", folderId: string, queryId?: string } | null>(null);
+
+ // Get folders and queries from environment config
+ const folders = useMemo((): FolderWithId[] => {
+ // Type assertion because config types may not be updated yet
+ const analyticsConfig = (config as { analytics?: { queryFolders?: Record } }).analytics ?? {};
+ const queryFolders = analyticsConfig.queryFolders ?? {};
+
+ return Object.entries(queryFolders)
+ .map(([id, folder]) => ({
+ id,
+ displayName: folder.displayName,
+ sortOrder: folder.sortOrder ?? 0,
+ queries: Object.entries(folder.queries).map(([queryId, query]) => ({
+ id: queryId,
+ displayName: query.displayName,
+ sqlQuery: query.sqlQuery,
+ description: query.description,
+ })),
+ }))
+ .sort((a, b) => a.sortOrder - b.sortOrder);
+ }, [config]);
+
+ const runQuery = useCallback(async (queryToRun?: string) => {
+ const trimmedQuery = (queryToRun ?? sqlQuery).trim();
+ if (!trimmedQuery) return;
+
+ setLoading(true);
+ setError(null);
+ setHasQueried(true);
+
+ try {
+ const response = await adminApp.queryAnalytics({
+ query: trimmedQuery,
+ include_all_branches: false,
+ timeout_ms: 30000,
+ });
+
+ const newRows = response.result as RowData[];
+ const newColumns = newRows.length > 0 ? Object.keys(newRows[0]) : [];
+
+ setColumns(newColumns);
+ setRows(newRows);
+ } catch (e: unknown) {
+ setError(e);
+ setColumns([]);
+ setRows([]);
+ } finally {
+ setLoading(false);
+ }
+ }, [adminApp, sqlQuery]);
+
+ const handleSelectQuery = (folderId: string, query: { id: string, displayName: string, sqlQuery: string, description?: string }) => {
+ setSelectedFolderId(folderId);
+ setSelectedQueryId(query.id);
+ setSqlQuery(query.sqlQuery);
+ setError(null);
+ // Run the query immediately after selecting it
+ runAsynchronouslyWithAlert(runQuery(query.sqlQuery));
+ };
+
+ const handleCreateFolder = async (displayName: string) => {
+ const folderId = generateSecureRandomString();
+ await updateConfig({
+ adminApp,
+ configUpdate: {
+ [`analytics.queryFolders.${folderId}`]: {
+ displayName,
+ sortOrder: folders.length,
+ queries: {},
+ },
+ },
+ pushable: false,
+ });
+ };
+
+ const handleSaveQuery = async (displayName: string, folderId: string, description: string | null) => {
+ const queryId = generateSecureRandomString();
+ await updateConfig({
+ adminApp,
+ configUpdate: {
+ [`analytics.queryFolders.${folderId}.queries.${queryId}`]: {
+ displayName,
+ sqlQuery,
+ ...(description ? { description } : {}),
+ },
+ },
+ pushable: false,
+ });
+ };
+
+ const handleUpdateCurrentQuery = async () => {
+ if (!selectedFolderId || !selectedQueryId) return;
+
+ // Find the current query to get its display name and description
+ const folder = folders.find(f => f.id === selectedFolderId);
+ const currentQuery = folder?.queries.find(q => q.id === selectedQueryId);
+ if (!currentQuery) return;
+
+ await updateConfig({
+ adminApp,
+ configUpdate: {
+ [`analytics.queryFolders.${selectedFolderId}.queries.${selectedQueryId}`]: {
+ displayName: currentQuery.displayName,
+ sqlQuery,
+ ...(currentQuery.description ? { description: currentQuery.description } : {}),
+ },
+ },
+ pushable: false,
+ });
+ };
+
+ const handleDeleteFolder = async (folderId: string) => {
+ await updateConfig({
+ adminApp,
+ configUpdate: {
+ [`analytics.queryFolders.${folderId}`]: null,
+ },
+ pushable: false,
+ });
+ // Clear selection if we deleted the selected folder
+ if (selectedFolderId === folderId) {
+ setSelectedFolderId(null);
+ setSelectedQueryId(null);
+ setSqlQuery("");
+ }
+ };
+
+ const handleDeleteQuery = async (folderId: string, queryId: string) => {
+ await updateConfig({
+ adminApp,
+ configUpdate: {
+ [`analytics.queryFolders.${folderId}.queries.${queryId}`]: null,
+ },
+ pushable: false,
+ });
+ // Clear selection if we deleted the selected query
+ if (selectedFolderId === folderId && selectedQueryId === queryId) {
+ setSelectedQueryId(null);
+ setSqlQuery("");
+ }
+ };
+
+ const openDeleteDialog = (type: "folder" | "query", folderId: string, queryId?: string) => {
+ setDeleteTarget({ type, folderId, queryId });
+ setDeleteDialogOpen(true);
+ };
+
+ const handleNewQuery = () => {
+ setSelectedFolderId(null);
+ setSelectedQueryId(null);
+ setSqlQuery("");
+ setHasQueried(false);
+ setRows([]);
+ setColumns([]);
+ setError(null);
+ };
+
+ const handleConfirmDelete = async () => {
+ if (!deleteTarget) return;
+ if (deleteTarget.type === "folder") {
+ await handleDeleteFolder(deleteTarget.folderId);
+ } else if (deleteTarget.queryId) {
+ await handleDeleteQuery(deleteTarget.folderId, deleteTarget.queryId);
+ }
+ setDeleteTarget(null);
+ };
+
+ const getDeleteDialogInfo = () => {
+ if (!deleteTarget) return { title: "", description: "" };
+ if (deleteTarget.type === "folder") {
+ const folder = folders.find(f => f.id === deleteTarget.folderId);
+ return {
+ title: "Delete Folder",
+ description: `Are you sure you want to delete "${folder?.displayName ?? "this folder"}" and all its queries? This action cannot be undone.`,
+ };
+ }
+ const folder = folders.find(f => f.id === deleteTarget.folderId);
+ const query = folder?.queries.find(q => q.id === deleteTarget.queryId);
+ return {
+ title: "Delete Query",
+ description: `Are you sure you want to delete "${query?.displayName ?? "this query"}"? This action cannot be undone.`,
+ };
+ };
+
+ return (
+
+ {/* Left sidebar - folder list */}
+
+
+ {/* New Query button */}
+
+
+ New Query
+
+
+ {/* Folders section */}
+
+
+ Folders
+
+
+ setCreateFolderDialogOpen(true)}
+ className="p-1 rounded hover:bg-foreground/[0.06] text-muted-foreground hover:text-foreground transition-colors hover:transition-none"
+ >
+
+
+
+
+
+ {folders.length === 0 ? (
+
+
No folders yet
+
setCreateFolderDialogOpen(true)}
+ className="text-xs text-blue-500 hover:text-blue-400 transition-colors hover:transition-none"
+ >
+ Create folder
+
+
+ ) : (
+
+ {folders.map((folder) => (
+ handleSelectQuery(folder.id, query)}
+ onDeleteFolder={() => openDeleteDialog("folder", folder.id)}
+ onDeleteQuery={(queryId) => openDeleteDialog("query", folder.id, queryId)}
+ />
+ ))}
+
+ )}
+
+
+
+
+ {/* Right content - query editor and results */}
+
+ {/* Query input area */}
+
+
+
+
+
runAsynchronouslyWithAlert(runQuery())}
+ disabled={!sqlQuery.trim() || loading}
+ className="gap-1.5"
+ >
+ {loading ? (
+
+ ) : (
+
+ )}
+ Run
+
+ {selectedQueryId ? (
+
runAsynchronouslyWithAlert(handleUpdateCurrentQuery())}
+ disabled={!sqlQuery.trim()}
+ className="gap-1.5"
+ >
+
+ Save
+
+ ) : (
+
setSaveQueryDialogOpen(true)}
+ disabled={!sqlQuery.trim()}
+ className="gap-1.5"
+ >
+
+ Save
+
+ )}
+
setSaveQueryDialogOpen(true)}
+ disabled={!sqlQuery.trim()}
+ className="gap-1.5 text-xs"
+ >
+ Save As...
+
+
+
+
+
+ {/* Results area */}
+
+ {loading ? (
+
+ ) : error ? (
+
+ ) : !hasQueried ? (
+
+ ) : rows.length === 0 ? (
+
+ ) : (
+ <>
+ {/* Header with row count */}
+
+
+ {rows.length.toLocaleString()} row{rows.length !== 1 ? "s" : ""}
+
+
+
+ {/* Table */}
+
{
+ setSelectedRow(row);
+ setDetailDialogOpen(true);
+ }}
+ />
+ >
+ )}
+
+
+
+ {/* Dialogs */}
+
+
+
+
+
+ );
+}
+
+// Folder item component
+function FolderItem({
+ folder,
+ selectedFolderId,
+ selectedQueryId,
+ onSelectQuery,
+ onDeleteFolder,
+ onDeleteQuery,
+}: {
+ folder: FolderWithId,
+ selectedFolderId: string | null,
+ selectedQueryId: string | null,
+ onSelectQuery: (query: { id: string, displayName: string, sqlQuery: string, description?: string }) => void,
+ onDeleteFolder: () => void,
+ onDeleteQuery: (queryId: string) => void,
+}) {
+ const [expanded, setExpanded] = useState(false);
+ const isSelected = selectedFolderId === folder.id;
+
+ return (
+
+
+ setExpanded(!expanded)}
+ className={cn(
+ "flex items-center gap-1.5 flex-1 px-2 py-1.5 rounded-md text-sm",
+ "text-muted-foreground hover:bg-foreground/[0.03] hover:text-foreground",
+ "transition-colors hover:transition-none"
+ )}
+ >
+ {expanded ? (
+
+ ) : (
+
+ )}
+ {expanded ? (
+
+ ) : (
+
+ )}
+ {folder.displayName}
+
+ {folder.queries.length}
+
+
+
+ {
+ e.stopPropagation();
+ onDeleteFolder();
+ }}
+ className="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-red-500/10 text-muted-foreground hover:text-red-500 transition-opacity"
+ >
+
+
+
+
+ {expanded && (
+
+ {folder.queries.length === 0 ? (
+
+ Empty
+
+ ) : (
+ folder.queries.map((query) => (
+
+ onSelectQuery(query)}
+ className={cn(
+ "flex-1 text-left px-2 py-1 rounded-md text-sm truncate",
+ "transition-colors hover:transition-none",
+ isSelected && selectedQueryId === query.id
+ ? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
+ : "text-muted-foreground hover:bg-foreground/[0.03] hover:text-foreground"
+ )}
+ >
+ {query.displayName}
+
+
+ {
+ e.stopPropagation();
+ onDeleteQuery(query.id);
+ }}
+ className="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-red-500/10 text-muted-foreground hover:text-red-500 transition-opacity"
+ >
+
+
+
+
+ ))
+ )}
+
+ )}
+
+ );
+}
+
+export default function PageClient() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page.tsx
new file mode 100644
index 0000000000..84cdebde14
--- /dev/null
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page.tsx
@@ -0,0 +1,5 @@
+import PageClient from "./page-client";
+
+export default function Page() {
+ return ;
+}
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx
index b5f4332c48..38a6548c42 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx
@@ -1,13 +1,13 @@
"use client";
+import { Link } from "@/components/link";
import { Alert, Button, Skeleton, Typography } from "@/components/ui";
import {
- Dialog,
- DialogBody,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -16,13 +16,13 @@ import { Switch } from "@/components/ui/switch";
import { useFromNow } from "@/hooks/use-from-now";
import { cn } from "@/lib/utils";
import {
- ArrowClockwiseIcon,
- ArrowDownIcon,
- ArrowUpIcon,
- CalendarIcon,
- ClockIcon,
- MagnifyingGlassIcon,
- SparkleIcon,
+ ArrowClockwiseIcon,
+ ArrowDownIcon,
+ ArrowUpIcon,
+ CalendarIcon,
+ ClockIcon,
+ MagnifyingGlassIcon,
+ SparkleIcon,
} from "@phosphor-icons/react";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { useVirtualizer } from "@tanstack/react-virtual";
@@ -620,7 +620,6 @@ function TableContent({ tableId }: { tableId: TableId }) {
export default function PageClient() {
const [selectedTable, setSelectedTable] = useState("events");
- const [queryDialogOpen, setQueryDialogOpen] = useState(false);
return (
@@ -648,13 +647,13 @@ export default function PageClient() {
- setQueryDialogOpen(true)}
+
Query
-
+
@@ -669,23 +668,6 @@ export default function PageClient() {
)}
-
- {/* Query moved dialog */}
-
-
-
- Analytics Queries have moved to the Control Center
-
-
-
- You can now do analytics queries directly from the Control Center. To open the Control Center, press ⌘ + K
-
-
-
- setQueryDialogOpen(false)}>OK
-
-
-
);
diff --git a/apps/dashboard/src/components/commands/run-query.tsx b/apps/dashboard/src/components/commands/run-query.tsx
index 9863cfad45..75b8456ada 100644
--- a/apps/dashboard/src/components/commands/run-query.tsx
+++ b/apps/dashboard/src/components/commands/run-query.tsx
@@ -2,11 +2,11 @@
import { useAdminAppIfExists } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
import {
- Dialog,
- DialogBody,
- DialogContent,
- DialogHeader,
- DialogTitle,
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { SimpleTooltip } from "@/components/ui/simple-tooltip";
@@ -14,11 +14,12 @@ import { useDebouncedAction } from "@/hooks/use-debounced-action";
import { useFromNow } from "@/hooks/use-from-now";
import { cn } from "@/lib/utils";
import {
- ArrowClockwiseIcon,
- CheckCircleIcon,
- PlayIcon,
- SpinnerGapIcon,
- WarningCircleIcon,
+ ArrowClockwiseIcon,
+ CheckCircleIcon,
+ FloppyDiskIcon,
+ PlayIcon,
+ SpinnerGapIcon,
+ WarningCircleIcon,
} from "@phosphor-icons/react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { memo, useCallback, useMemo, useRef, useState } from "react";
@@ -453,11 +454,20 @@ const RunQueryPreviewInner = memo(function RunQueryPreviewInner({
// Results table
return (
- {/* Header with row count */}
+ {/* Header with row count and save button */}
{rows.length.toLocaleString()} row{rows.length !== 1 ? "s" : ""}
+
+
+
+ Save Query
+
+
{/* Table */}
diff --git a/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx
index ea2b25e38b..9c1170d79a 100644
--- a/apps/dashboard/src/lib/apps-frontend.tsx
+++ b/apps/dashboard/src/lib/apps-frontend.tsx
@@ -308,6 +308,7 @@ export const ALL_APPS_FRONTEND = {
href: "analytics",
navigationItems: [
{ displayName: "Tables", href: "./tables" },
+ { displayName: "Queries", href: "./queries" },
],
screenshots: [],
storeDescription: (
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts
new file mode 100644
index 0000000000..e31a6c0cbd
--- /dev/null
+++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts
@@ -0,0 +1,636 @@
+import { describe } from "vitest";
+import { it } from "../../../../helpers";
+import { Project, niceBackendFetch } from "../../../backend-helpers";
+
+// Helper to create admin headers with a given token
+const adminHeaders = (token: string) => ({
+ 'x-stack-admin-access-token': token,
+});
+
+// Helper to get config
+async function getConfig(adminAccessToken: string) {
+ const response = await niceBackendFetch("/api/v1/internal/config", {
+ method: "GET",
+ accessType: "admin",
+ headers: adminHeaders(adminAccessToken),
+ });
+ return JSON.parse(response.body.config_string);
+}
+
+// Helper to update environment config
+async function updateConfig(adminAccessToken: string, configOverride: Record
) {
+ const response = await niceBackendFetch("/api/v1/internal/config/override/environment", {
+ method: "PATCH",
+ accessType: "admin",
+ headers: adminHeaders(adminAccessToken),
+ body: {
+ config_override_string: JSON.stringify(configOverride),
+ },
+ });
+ return response;
+}
+
+describe("analytics config - query folders", () => {
+ it("creates a query folder via config update", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Create a folder
+ const folderId = "test-folder-1";
+ const response = await updateConfig(adminAccessToken, {
+ [`analytics.queryFolders.${folderId}`]: {
+ displayName: "Test Folder",
+ sortOrder: 0,
+ queries: {},
+ },
+ });
+
+ expect(response.status).toBe(200);
+
+ // Verify the folder was created
+ const config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders[folderId]).toEqual({
+ displayName: "Test Folder",
+ sortOrder: 0,
+ queries: {},
+ });
+ });
+
+ it("creates multiple query folders", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Create first folder
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.folder-1": {
+ displayName: "Folder 1",
+ sortOrder: 0,
+ queries: {},
+ },
+ });
+
+ // Create second folder
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.folder-2": {
+ displayName: "Folder 2",
+ sortOrder: 1,
+ queries: {},
+ },
+ });
+
+ // Verify both folders exist
+ const config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders["folder-1"]).toEqual({
+ displayName: "Folder 1",
+ sortOrder: 0,
+ queries: {},
+ });
+ expect(config.analytics.queryFolders["folder-2"]).toEqual({
+ displayName: "Folder 2",
+ sortOrder: 1,
+ queries: {},
+ });
+ });
+
+ it("updates a query folder", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Create a folder
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.update-folder": {
+ displayName: "Original Name",
+ sortOrder: 0,
+ queries: {},
+ },
+ });
+
+ // Update the folder
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.update-folder": {
+ displayName: "Updated Name",
+ sortOrder: 10,
+ queries: {},
+ },
+ });
+
+ // Verify the update
+ const config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders["update-folder"]).toEqual({
+ displayName: "Updated Name",
+ sortOrder: 10,
+ queries: {},
+ });
+ });
+
+ it("deletes a query folder by setting to null", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Create a folder
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.delete-folder": {
+ displayName: "To Be Deleted",
+ sortOrder: 0,
+ queries: {},
+ },
+ });
+
+ // Verify it exists
+ let config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders["delete-folder"]).toBeDefined();
+
+ // Delete the folder
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.delete-folder": null,
+ });
+
+ // Verify it's deleted
+ config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders["delete-folder"]).toBeUndefined();
+ });
+});
+
+
+describe("analytics config - queries nested in folders", () => {
+ it("creates a query inside a folder", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Create a folder first
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.queries-folder": {
+ displayName: "Queries Folder",
+ sortOrder: 0,
+ queries: {},
+ },
+ });
+
+ // Create a query inside the folder
+ const queryId = "test-query-1";
+ await updateConfig(adminAccessToken, {
+ [`analytics.queryFolders.queries-folder.queries.${queryId}`]: {
+ displayName: "Test Query",
+ sqlQuery: "SELECT * FROM events LIMIT 10",
+ description: "A test query",
+ },
+ });
+
+ // Verify the query was created
+ const config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders["queries-folder"].queries[queryId]).toEqual({
+ displayName: "Test Query",
+ sqlQuery: "SELECT * FROM events LIMIT 10",
+ description: "A test query",
+ });
+ });
+
+ it("creates multiple queries in the same folder", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Create a folder with initial queries
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.multi-query-folder": {
+ displayName: "Multi Query Folder",
+ sortOrder: 0,
+ queries: {},
+ },
+ });
+
+ // Add first query
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.multi-query-folder.queries.query-1": {
+ displayName: "Query 1",
+ sqlQuery: "SELECT 1",
+ },
+ });
+
+ // Add second query
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.multi-query-folder.queries.query-2": {
+ displayName: "Query 2",
+ sqlQuery: "SELECT 2",
+ },
+ });
+
+ // Verify both queries exist
+ const config = await getConfig(adminAccessToken);
+ const folder = config.analytics.queryFolders["multi-query-folder"];
+ expect(folder.queries["query-1"]).toEqual({
+ displayName: "Query 1",
+ sqlQuery: "SELECT 1",
+ });
+ expect(folder.queries["query-2"]).toEqual({
+ displayName: "Query 2",
+ sqlQuery: "SELECT 2",
+ });
+ });
+
+ it("updates a query inside a folder", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Create folder and query
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.update-query-folder": {
+ displayName: "Update Query Folder",
+ sortOrder: 0,
+ queries: {
+ "update-query": {
+ displayName: "Original Query",
+ sqlQuery: "SELECT 'original'",
+ },
+ },
+ },
+ });
+
+ // Update the query
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.update-query-folder.queries.update-query": {
+ displayName: "Updated Query",
+ sqlQuery: "SELECT 'updated'",
+ description: "Now with description",
+ },
+ });
+
+ // Verify the update
+ const config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders["update-query-folder"].queries["update-query"]).toEqual({
+ displayName: "Updated Query",
+ sqlQuery: "SELECT 'updated'",
+ description: "Now with description",
+ });
+ });
+
+ it("deletes a query by setting to null", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Create folder with query
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.delete-query-folder": {
+ displayName: "Delete Query Folder",
+ sortOrder: 0,
+ queries: {
+ "delete-query": {
+ displayName: "To Delete",
+ sqlQuery: "SELECT 'delete me'",
+ },
+ "keep-query": {
+ displayName: "Keep Me",
+ sqlQuery: "SELECT 'keep me'",
+ },
+ },
+ },
+ });
+
+ // Verify both exist
+ let config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders["delete-query-folder"].queries["delete-query"]).toBeDefined();
+ expect(config.analytics.queryFolders["delete-query-folder"].queries["keep-query"]).toBeDefined();
+
+ // Delete one query
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.delete-query-folder.queries.delete-query": null,
+ });
+
+ // Verify only the deleted query is gone
+ config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders["delete-query-folder"].queries["delete-query"]).toBeUndefined();
+ expect(config.analytics.queryFolders["delete-query-folder"].queries["keep-query"]).toBeDefined();
+ });
+
+ it("deleting a folder also deletes all its queries", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Create folder with multiple queries
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.cascade-folder": {
+ displayName: "Cascade Folder",
+ sortOrder: 0,
+ queries: {
+ "query-1": {
+ displayName: "Query 1",
+ sqlQuery: "SELECT 1",
+ },
+ "query-2": {
+ displayName: "Query 2",
+ sqlQuery: "SELECT 2",
+ },
+ },
+ },
+ });
+
+ // Verify folder and queries exist
+ let config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders["cascade-folder"]).toBeDefined();
+ expect(config.analytics.queryFolders["cascade-folder"].queries["query-1"]).toBeDefined();
+ expect(config.analytics.queryFolders["cascade-folder"].queries["query-2"]).toBeDefined();
+
+ // Delete the folder
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.cascade-folder": null,
+ });
+
+ // Verify folder and all queries are gone
+ config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders["cascade-folder"]).toBeUndefined();
+ });
+});
+
+
+describe("analytics config - environment level (not pushable)", () => {
+ it("analytics config is stored in environment config, not branch config", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Create folder in environment config
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.env-folder": {
+ displayName: "Environment Folder",
+ sortOrder: 0,
+ queries: {},
+ },
+ });
+
+ // Verify it's in environment override
+ const envResponse = await niceBackendFetch("/api/v1/internal/config/override/environment", {
+ method: "GET",
+ accessType: "admin",
+ headers: adminHeaders(adminAccessToken),
+ });
+ const envConfig = JSON.parse(envResponse.body.config_string);
+ expect(envConfig["analytics.queryFolders.env-folder"]).toBeDefined();
+
+ // Verify it's NOT in branch override
+ const branchResponse = await niceBackendFetch("/api/v1/internal/config/override/branch", {
+ method: "GET",
+ accessType: "admin",
+ headers: adminHeaders(adminAccessToken),
+ });
+ const branchConfig = JSON.parse(branchResponse.body.config_string);
+ expect(branchConfig["analytics.queryFolders.env-folder"]).toBeUndefined();
+ });
+
+ it("analytics config is not affected by branch config push", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Create a folder in environment config
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.preserved-folder": {
+ displayName: "Preserved Folder",
+ sortOrder: 0,
+ queries: {
+ "preserved-query": {
+ displayName: "Preserved Query",
+ sqlQuery: "SELECT 'preserved'",
+ },
+ },
+ },
+ });
+
+ // Push a new branch config (which should not affect environment config)
+ await niceBackendFetch("/api/v1/internal/config/override/branch", {
+ method: "PUT",
+ accessType: "admin",
+ headers: adminHeaders(adminAccessToken),
+ body: {
+ config_string: JSON.stringify({
+ "teams.allowClientTeamCreation": true,
+ }),
+ source: { type: "unlinked" },
+ },
+ });
+
+ // Verify the analytics folder is still there
+ const config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders["preserved-folder"]).toBeDefined();
+ expect(config.analytics.queryFolders["preserved-folder"].queries["preserved-query"]).toBeDefined();
+ });
+});
+
+
+describe("analytics config - validation", () => {
+ it("requires displayName for folders", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Try to create a folder without displayName
+ const response = await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.no-name-folder": {
+ sortOrder: 0,
+ queries: {},
+ // Missing displayName
+ },
+ });
+
+ expect(response.status).toBe(400);
+ });
+
+ it("requires displayName and sqlQuery for queries", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Create a folder first
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.validation-folder": {
+ displayName: "Validation Folder",
+ sortOrder: 0,
+ queries: {},
+ },
+ });
+
+ // Try to create a query without sqlQuery
+ const response = await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.validation-folder.queries.no-sql-query": {
+ displayName: "No SQL Query",
+ // Missing sqlQuery
+ },
+ });
+
+ expect(response.status).toBe(400);
+ });
+
+ it("accepts optional sortOrder for folders", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Create a folder without sortOrder
+ const response = await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.no-sort-folder": {
+ displayName: "No Sort Folder",
+ queries: {},
+ // sortOrder is optional
+ },
+ });
+
+ expect(response.status).toBe(200);
+
+ // Verify the folder was created
+ const config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders["no-sort-folder"]).toBeDefined();
+ expect(config.analytics.queryFolders["no-sort-folder"].displayName).toBe("No Sort Folder");
+ });
+
+ it("accepts optional description for queries", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Create a folder
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.desc-folder": {
+ displayName: "Description Folder",
+ sortOrder: 0,
+ queries: {},
+ },
+ });
+
+ // Create a query without description
+ const response = await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.desc-folder.queries.no-desc-query": {
+ displayName: "No Description Query",
+ sqlQuery: "SELECT 1",
+ // description is optional
+ },
+ });
+
+ expect(response.status).toBe(200);
+
+ // Verify the query was created
+ const config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders["desc-folder"].queries["no-desc-query"]).toBeDefined();
+ expect(config.analytics.queryFolders["desc-folder"].queries["no-desc-query"].description).toBeUndefined();
+ });
+});
+
+
+describe("analytics config - edge cases", () => {
+ it("handles special characters in folder and query IDs", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Create folder with special characters in ID
+ const folderId = "folder_with-special.chars123";
+ const queryId = "query_with-special.chars456";
+
+ await updateConfig(adminAccessToken, {
+ [`analytics.queryFolders.${folderId}`]: {
+ displayName: "Special Chars Folder",
+ sortOrder: 0,
+ queries: {
+ [queryId]: {
+ displayName: "Special Chars Query",
+ sqlQuery: "SELECT 'special'",
+ },
+ },
+ },
+ });
+
+ // Verify they were created
+ const config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders[folderId]).toBeDefined();
+ expect(config.analytics.queryFolders[folderId].queries[queryId]).toBeDefined();
+ });
+
+ it("handles unicode in display names and SQL queries", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.unicode-folder": {
+ displayName: "文件夹 📁 Папка",
+ sortOrder: 0,
+ queries: {
+ "unicode-query": {
+ displayName: "查询 🔍 Запрос",
+ sqlQuery: "SELECT 'こんにちは' AS greeting",
+ description: "A query with unicode 🎉",
+ },
+ },
+ },
+ });
+
+ // Verify it was created with unicode preserved
+ const config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders["unicode-folder"].displayName).toBe("文件夹 📁 Папка");
+ expect(config.analytics.queryFolders["unicode-folder"].queries["unicode-query"].displayName).toBe("查询 🔍 Запрос");
+ });
+
+ it("handles very long SQL queries", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ const longQuery = "SELECT " + Array(100).fill("column_name AS c").join(", ") + " FROM very_long_table_name";
+
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.long-query-folder": {
+ displayName: "Long Query Folder",
+ sortOrder: 0,
+ queries: {
+ "long-query": {
+ displayName: "Long Query",
+ sqlQuery: longQuery,
+ },
+ },
+ },
+ });
+
+ // Verify it was created
+ const config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders["long-query-folder"].queries["long-query"].sqlQuery).toBe(longQuery);
+ });
+
+ it("handles empty queries record in folder", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.empty-queries-folder": {
+ displayName: "Empty Queries Folder",
+ sortOrder: 0,
+ queries: {},
+ },
+ });
+
+ // Verify it was created with empty queries
+ const config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders["empty-queries-folder"]).toBeDefined();
+ expect(config.analytics.queryFolders["empty-queries-folder"].queries).toEqual({});
+ });
+
+ it("handles negative and large sortOrder values", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ await updateConfig(adminAccessToken, {
+ "analytics.queryFolders.negative-sort": {
+ displayName: "Negative Sort",
+ sortOrder: -100,
+ queries: {},
+ },
+ "analytics.queryFolders.large-sort": {
+ displayName: "Large Sort",
+ sortOrder: 999999,
+ queries: {},
+ },
+ });
+
+ // Verify both were created with correct sortOrder
+ const config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders["negative-sort"].sortOrder).toBe(-100);
+ expect(config.analytics.queryFolders["large-sort"].sortOrder).toBe(999999);
+ });
+});
+
+
+describe("analytics config - isolation", () => {
+ it("analytics config is isolated between projects", async ({ expect }) => {
+ // Create first project with analytics config
+ const { adminAccessToken: token1 } = await Project.createAndSwitch();
+ await updateConfig(token1, {
+ "analytics.queryFolders.project1-folder": {
+ displayName: "Project 1 Folder",
+ sortOrder: 0,
+ queries: {},
+ },
+ });
+
+ // Create second project
+ const { adminAccessToken: token2 } = await Project.createAndSwitch();
+ await updateConfig(token2, {
+ "analytics.queryFolders.project2-folder": {
+ displayName: "Project 2 Folder",
+ sortOrder: 0,
+ queries: {},
+ },
+ });
+
+ // Verify each project only has its own folder
+ const config2 = await getConfig(token2);
+ expect(config2.analytics.queryFolders["project2-folder"]).toBeDefined();
+ expect(config2.analytics.queryFolders["project1-folder"]).toBeUndefined();
+ });
+});
diff --git a/packages/stack-shared/src/config/schema-fuzzer.test.ts b/packages/stack-shared/src/config/schema-fuzzer.test.ts
index a026de919d..4c0a97557a 100644
--- a/packages/stack-shared/src/config/schema-fuzzer.test.ts
+++ b/packages/stack-shared/src/config/schema-fuzzer.test.ts
@@ -218,6 +218,21 @@ const environmentSchemaFuzzerConfig = [{
...branchSchemaFuzzerConfig[0].payments[0],
testMode: [false, true],
}],
+ analytics: [{
+ queryFolders: [{
+ "some-folder-id": [{
+ displayName: ["Some Folder", "Some Other Folder"],
+ sortOrder: [0, 1, 10, -5],
+ queries: [{
+ "some-query-id": [{
+ displayName: ["Some Query", "Some Other Query"],
+ sqlQuery: ["", "SELECT * FROM events", "SELECT * FROM users"],
+ description: ["", "A query description", "Another description"],
+ }],
+ }],
+ }],
+ }],
+ }],
}] satisfies FuzzerConfig;
const organizationSchemaFuzzerConfig = environmentSchemaFuzzerConfig satisfies FuzzerConfig;
diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts
index 80fc9bee72..e630f42b86 100644
--- a/packages/stack-shared/src/config/schema.ts
+++ b/packages/stack-shared/src/config/schema.ts
@@ -227,6 +227,26 @@ export const branchConfigSchema = canNoLongerBeOverridden(projectConfigSchema, [
}));
+// --- Analytics Schema (environment config only - not pushable) ---
+const environmentAnalyticsSchema = yupObject({
+ queryFolders: yupRecord(
+ userSpecifiedIdSchema("folderId"),
+ yupObject({
+ displayName: yupString(),
+ sortOrder: yupNumber().optional(),
+ queries: yupRecord(
+ userSpecifiedIdSchema("queryId"),
+ yupObject({
+ displayName: yupString(),
+ sqlQuery: yupString(), // SQL query string (not English language)
+ description: yupString().optional(),
+ }),
+ ),
+ }),
+ ),
+});
+// --- END Analytics Schema ---
+
export const environmentConfigSchema = branchConfigSchema.concat(yupObject({
auth: branchConfigSchema.getNested("auth").concat(yupObject({
oauth: branchConfigSchema.getNested("auth").getNested("oauth").concat(yupObject({
@@ -279,6 +299,8 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({
payments: branchConfigSchema.getNested("payments").concat(yupObject({
testMode: yupBoolean(),
})),
+
+ analytics: environmentAnalyticsSchema,
}));
export const organizationConfigSchema = environmentConfigSchema.concat(yupObject({}));
@@ -612,6 +634,18 @@ const organizationConfigDefaults = {
displayName: "Unnamed Vault",
}),
},
+
+ analytics: {
+ queryFolders: (key: string) => ({
+ displayName: "Unnamed Folder",
+ sortOrder: 0,
+ queries: (queryKey: string) => ({
+ displayName: "Unnamed Query",
+ sqlQuery: "",
+ description: undefined,
+ }),
+ }),
+ },
} as const satisfies DefaultsType;
type _DeepOmitDefaultsImpl = T extends object ? (
diff --git a/packages/stack-shared/src/known-errors.tsx b/packages/stack-shared/src/known-errors.tsx
index b00cff8174..e12ee47fc1 100644
--- a/packages/stack-shared/src/known-errors.tsx
+++ b/packages/stack-shared/src/known-errors.tsx
@@ -1465,6 +1465,26 @@ const PublicApiKeyCannotBeRevoked = createKnownErrorConstructor(
() => [] as const,
);
+const QueryFolderNotFound = createKnownErrorConstructor(
+ KnownError,
+ "QUERY_FOLDER_NOT_FOUND",
+ () => [
+ 404,
+ "Query folder not found.",
+ ] as const,
+ () => [] as const,
+);
+
+const SavedQueryNotFound = createKnownErrorConstructor(
+ KnownError,
+ "SAVED_QUERY_NOT_FOUND",
+ () => [
+ 404,
+ "Saved query not found.",
+ ] as const,
+ () => [] as const,
+);
+
const PermissionIdAlreadyExists = createKnownErrorConstructor(
KnownError,
"PERMISSION_ID_ALREADY_EXISTS",
@@ -1792,6 +1812,8 @@ export const KnownErrors = {
RestrictedUserNotAllowed,
ApiKeyNotFound,
PublicApiKeyCannotBeRevoked,
+ QueryFolderNotFound,
+ SavedQueryNotFound,
ProjectNotFound,
CurrentProjectNotFound,
BranchDoesNotExist,
diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
index 1ac5d7a7c1..44fb8c4eee 100644
--- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
+++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
@@ -6,10 +6,10 @@ import { EmailTemplateCrud } from "@stackframe/stack-shared/dist/interface/crud/
import { InternalApiKeysCrud } from "@stackframe/stack-shared/dist/interface/crud/internal-api-keys";
import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects";
import type { Transaction, TransactionType } from "@stackframe/stack-shared/dist/interface/crud/transactions";
+import type { MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { pick } from "@stackframe/stack-shared/dist/utils/objects";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
-import type { MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants";
import { useMemo } from "react"; // THIS_LINE_PLATFORM react-like
import { AdminEmailOutbox, AdminSentEmail } from "../..";
import { EmailConfig, stackAppInternalsSymbol } from "../../common";
From a748cf07662f2bcecc845b3963a76753a3ec7315 Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Mon, 2 Feb 2026 09:18:43 -0800
Subject: [PATCH 02/21] Fix issue
---
apps/dashboard/src/components/commands/run-query.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/dashboard/src/components/commands/run-query.tsx b/apps/dashboard/src/components/commands/run-query.tsx
index 75b8456ada..285535b47c 100644
--- a/apps/dashboard/src/components/commands/run-query.tsx
+++ b/apps/dashboard/src/components/commands/run-query.tsx
@@ -461,7 +461,7 @@ const RunQueryPreviewInner = memo(function RunQueryPreviewInner({
From 034a6fa2951eda872c05a4abb3221f323092943d Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Mon, 2 Feb 2026 09:19:27 -0800
Subject: [PATCH 03/21] Run Query link
---
apps/dashboard/src/components/commands/run-query.tsx | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/apps/dashboard/src/components/commands/run-query.tsx b/apps/dashboard/src/components/commands/run-query.tsx
index 285535b47c..ba35809a50 100644
--- a/apps/dashboard/src/components/commands/run-query.tsx
+++ b/apps/dashboard/src/components/commands/run-query.tsx
@@ -24,6 +24,7 @@ import {
import { useVirtualizer } from "@tanstack/react-virtual";
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { CmdKPreviewProps } from "../cmdk-commands";
+import { Link } from "../link";
type RowData = Record;
@@ -460,13 +461,13 @@ const RunQueryPreviewInner = memo(function RunQueryPreviewInner({
{rows.length.toLocaleString()} row{rows.length !== 1 ? "s" : ""}
-
Save Query
-
+
From 0bbf2690e563dcf7f34083b58dda6c08d67c5537 Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Mon, 2 Feb 2026 09:34:56 -0800
Subject: [PATCH 04/21] Fix PR review comments
- Fix onRetry() being invoked immediately instead of passed as reference
- Add sqlQuery.trim() to canSave check in SaveQueryDialog
- Clear results state when deleting the selected folder/query
- Add loading check to keyboard shortcut to prevent race condition
- Fix parseClickHouseDate for date-only strings (YYYY-MM-DD)
- Remove hover-enter fade on delete icons (use hover:transition-none)
- Rename sidebar link from "Query" to "Queries" for consistency
- Fix test IDs with dots that break dot-notation paths
Co-authored-by: Cursor
---
.cursor/commands/pr-comments-review.md | 1 +
AGENTS.md | 1 +
.../analytics/queries/page-client.tsx | 29 +-
.../analytics/tables/page-client.tsx | 2 +-
.../src/components/commands/run-query.tsx | 262 +++++++++++++++++-
.../endpoints/api/v1/analytics-config.test.ts | 6 +-
6 files changed, 279 insertions(+), 22 deletions(-)
create mode 100644 .cursor/commands/pr-comments-review.md
diff --git a/.cursor/commands/pr-comments-review.md b/.cursor/commands/pr-comments-review.md
new file mode 100644
index 0000000000..952e0b61e6
--- /dev/null
+++ b/.cursor/commands/pr-comments-review.md
@@ -0,0 +1 @@
+Please review the PR comments with `gh pr status` and fix & resolve those issues that are valid and relevant. Leave those comments that are mostly bullshit unresolved. Report the result to me in detail.
diff --git a/AGENTS.md b/AGENTS.md
index ad2523ec4d..6381820f19 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -94,6 +94,7 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
- Whenever you update an SDK implementation in `sdks/implementations`, make sure to update the specs accordingly in `sdks/specs` such that if you reimplemented the entire SDK from the specs again, you would get the same implementation. (For example, if the specs are not precise enough to describe a change you made, make the specs more precise.)
- When building internal tools for Stack Auth developers (eg. internal interfaces like the WAL info log etc.): Make the interfaces look very concise, assume the user is a pro-user. This only applies to internal tools that are used primarily by Stack Auth developers.
- The dev server already builds the packages in the background whenever you update a file. If you run into issues with typechecking or linting in a dependency after updating something in a package, just wait a few seconds, and then try again, and they will likely be resolved.
+- When asked to review PR comments, you can use `gh pr status` to get the current pull request you're working on.
### Code-related
- Use ES6 maps instead of records wherever you can.
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
index 84cc7abf03..1c1fa38e7e 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
@@ -75,7 +75,12 @@ function isJsonValue(value: unknown): boolean {
// Parse ClickHouse date string as UTC
function parseClickHouseDate(value: string): Date {
- const normalized = value.replace(" ", "T") + (value.includes("Z") || value.includes("+") ? "" : "Z");
+ const trimmed = value.trim();
+ // Handle date-only strings (YYYY-MM-DD) by appending time
+ if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
+ return new Date(trimmed + "T00:00:00Z");
+ }
+ const normalized = trimmed.replace(" ", "T") + (trimmed.includes("Z") || trimmed.includes("+") ? "" : "Z");
return new Date(normalized);
}
@@ -289,7 +294,7 @@ function ErrorDisplay({ error, onRetry }: { error: unknown, onRetry: () => void
runAsynchronouslyWithAlert(onRetry())}
+ onClick={() => runAsynchronouslyWithAlert(onRetry)}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium bg-foreground/[0.06] hover:bg-foreground/[0.1] transition-colors hover:transition-none"
>
@@ -445,7 +450,7 @@ function SaveQueryDialog({
}
};
- const canSave = displayName.trim() && selectedFolderId;
+ const canSave = displayName.trim() && selectedFolderId && sqlQuery.trim();
return (
@@ -698,11 +703,15 @@ function QueriesContent() {
},
pushable: false,
});
- // Clear selection if we deleted the selected folder
+ // Clear selection and results if we deleted the selected folder
if (selectedFolderId === folderId) {
setSelectedFolderId(null);
setSelectedQueryId(null);
setSqlQuery("");
+ setHasQueried(false);
+ setRows([]);
+ setColumns([]);
+ setError(null);
}
};
@@ -714,10 +723,14 @@ function QueriesContent() {
},
pushable: false,
});
- // Clear selection if we deleted the selected query
+ // Clear selection and results if we deleted the selected query
if (selectedFolderId === folderId && selectedQueryId === queryId) {
setSelectedQueryId(null);
setSqlQuery("");
+ setHasQueried(false);
+ setRows([]);
+ setColumns([]);
+ setError(null);
}
};
@@ -839,7 +852,7 @@ function QueriesContent() {
placeholder="SELECT * FROM default.events ORDER BY event_at DESC LIMIT 100"
className="font-mono text-sm min-h-[80px] resize-y bg-background/60"
onKeyDown={(e) => {
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && !loading) {
e.preventDefault();
runAsynchronouslyWithAlert(runQuery());
}
@@ -1012,7 +1025,7 @@ function FolderItem({
e.stopPropagation();
onDeleteFolder();
}}
- className="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-red-500/10 text-muted-foreground hover:text-red-500 transition-opacity"
+ className="p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-red-500/10 text-muted-foreground hover:text-red-500 transition-colors hover:transition-none"
>
@@ -1045,7 +1058,7 @@ function FolderItem({
e.stopPropagation();
onDeleteQuery(query.id);
}}
- className="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-red-500/10 text-muted-foreground hover:text-red-500 transition-opacity"
+ className="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-red-500/10 text-muted-foreground hover:text-red-500 transition-colors hover:transition-none"
>
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx
index 38a6548c42..2192f66619 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx
@@ -652,7 +652,7 @@ export default function PageClient() {
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors hover:transition-none w-full"
>
- Query
+ Queries
diff --git a/apps/dashboard/src/components/commands/run-query.tsx b/apps/dashboard/src/components/commands/run-query.tsx
index ba35809a50..cd8bc8682a 100644
--- a/apps/dashboard/src/components/commands/run-query.tsx
+++ b/apps/dashboard/src/components/commands/run-query.tsx
@@ -1,33 +1,63 @@
"use client";
import { useAdminAppIfExists } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
+import { Button } from "@/components/ui";
import {
Dialog,
DialogBody,
DialogContent,
+ DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SimpleTooltip } from "@/components/ui/simple-tooltip";
+import { Textarea } from "@/components/ui/textarea";
import { useDebouncedAction } from "@/hooks/use-debounced-action";
import { useFromNow } from "@/hooks/use-from-now";
+import { useUpdateConfig } from "@/lib/config-update";
import { cn } from "@/lib/utils";
import {
ArrowClockwiseIcon,
CheckCircleIcon,
FloppyDiskIcon,
PlayIcon,
+ PlusIcon,
SpinnerGapIcon,
WarningCircleIcon,
} from "@phosphor-icons/react";
+import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
+import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { useVirtualizer } from "@tanstack/react-virtual";
+import { useRouter } from "@/components/router";
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { CmdKPreviewProps } from "../cmdk-commands";
-import { Link } from "../link";
type RowData = Record;
+type ConfigFolder = {
+ displayName: string,
+ sortOrder?: number,
+ queries: Record,
+};
+
+type FolderWithId = {
+ id: string,
+ displayName: string,
+ sortOrder: number,
+ queries: Array<{
+ id: string,
+ displayName: string,
+ sqlQuery: string,
+ description?: string,
+ }>,
+};
+
const DEBOUNCE_MS = 400;
// Detect if a value is a date string
@@ -340,6 +370,212 @@ function LoadingState() {
);
}
+// Save query dialog for the command palette
+function SaveQueryDialog({
+ open,
+ onOpenChange,
+ adminApp,
+ sqlQuery,
+}: {
+ open: boolean,
+ onOpenChange: (open: boolean) => void,
+ adminApp: ReturnType,
+ sqlQuery: string,
+}) {
+ const updateConfig = useUpdateConfig();
+ const router = useRouter();
+ const [displayName, setDisplayName] = useState("");
+ const [description, setDescription] = useState("");
+ const [selectedFolderId, setSelectedFolderId] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [showCreateFolder, setShowCreateFolder] = useState(false);
+ const [newFolderName, setNewFolderName] = useState("");
+ const [creatingFolder, setCreatingFolder] = useState(false);
+
+ // Get folders from config
+ const config = adminApp?.useProject().useConfig();
+ const folders = useMemo((): FolderWithId[] => {
+ if (!config) return [];
+ // Type assertion because config types may not be updated yet
+ const analyticsConfig = (config as { analytics?: { queryFolders?: Record } }).analytics ?? {};
+ const queryFolders = analyticsConfig.queryFolders ?? {};
+
+ return Object.entries(queryFolders)
+ .map(([id, folder]) => ({
+ id,
+ displayName: folder.displayName,
+ sortOrder: folder.sortOrder ?? 0,
+ queries: Object.entries(folder.queries).map(([queryId, q]) => ({
+ id: queryId,
+ displayName: q.displayName,
+ sqlQuery: q.sqlQuery,
+ description: q.description,
+ })),
+ }))
+ .sort((a, b) => a.sortOrder - b.sortOrder);
+ }, [config]);
+
+ const handleSave = async () => {
+ if (!adminApp || !displayName.trim() || !sqlQuery.trim() || !selectedFolderId) return;
+ setLoading(true);
+ try {
+ const queryId = generateSecureRandomString();
+ await updateConfig({
+ adminApp,
+ configUpdate: {
+ [`analytics.queryFolders.${selectedFolderId}.queries.${queryId}`]: {
+ displayName: displayName.trim(),
+ sqlQuery,
+ ...(description.trim() ? { description: description.trim() } : {}),
+ },
+ },
+ pushable: false,
+ });
+ setDisplayName("");
+ setDescription("");
+ setSelectedFolderId("");
+ onOpenChange(false);
+ // Navigate to the queries page after saving
+ router.push(`/projects/${encodeURIComponent(adminApp.projectId)}/analytics/queries`);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCreateFolder = async () => {
+ if (!adminApp || !newFolderName.trim()) return;
+ setCreatingFolder(true);
+ try {
+ const folderId = generateSecureRandomString();
+ await updateConfig({
+ adminApp,
+ configUpdate: {
+ [`analytics.queryFolders.${folderId}`]: {
+ displayName: newFolderName.trim(),
+ sortOrder: folders.length,
+ queries: {},
+ },
+ },
+ pushable: false,
+ });
+ // Auto-select the newly created folder
+ setSelectedFolderId(folderId);
+ setNewFolderName("");
+ setShowCreateFolder(false);
+ } finally {
+ setCreatingFolder(false);
+ }
+ };
+
+ const canSave = displayName.trim() && selectedFolderId && sqlQuery.trim();
+
+ if (!adminApp) return null;
+
+ return (
+
+
+
+ Save Query
+
+
+
+
+ Query Name
+ setDisplayName(e.target.value)}
+ placeholder="My Query"
+ />
+
+
+
+
Folder
+ {!showCreateFolder && (
+
setShowCreateFolder(true)}
+ className="flex items-center gap-1 text-xs text-blue-500 hover:text-blue-400 transition-colors hover:transition-none"
+ >
+
+ New folder
+
+ )}
+
+ {showCreateFolder ? (
+
+ setNewFolderName(e.target.value)}
+ placeholder="Folder name"
+ className="flex-1"
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ runAsynchronouslyWithAlert(handleCreateFolder());
+ } else if (e.key === "Escape") {
+ setShowCreateFolder(false);
+ setNewFolderName("");
+ }
+ }}
+ />
+ runAsynchronouslyWithAlert(handleCreateFolder())}
+ disabled={!newFolderName.trim() || creatingFolder}
+ >
+ {creatingFolder ? "..." : "Create"}
+
+ {
+ setShowCreateFolder(false);
+ setNewFolderName("");
+ }}
+ >
+ Cancel
+
+
+ ) : (
+
setSelectedFolderId(e.target.value)}
+ >
+ Select a folder...
+ {folders.map((folder) => (
+
+ {folder.displayName}
+
+ ))}
+
+ )}
+
+
+ Description (optional)
+
+
+
+
+ onOpenChange(false)}>
+ Cancel
+
+
+ {loading ? "Saving..." : "Save"}
+
+
+
+
+ );
+}
+
// Main Run Query Preview Component - wrapper that resets state on query change
export function RunQueryPreview({ query, ...rest }: CmdKPreviewProps) {
return ;
@@ -357,6 +593,7 @@ const RunQueryPreviewInner = memo(function RunQueryPreviewInner({
const [hasQueried, setHasQueried] = useState(false);
const [selectedRow, setSelectedRow] = useState(null);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
+ const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const trimmedQuery = query.trim();
@@ -460,15 +697,13 @@ const RunQueryPreviewInner = memo(function RunQueryPreviewInner({
{rows.length.toLocaleString()} row{rows.length !== 1 ? "s" : ""}
-
-
-
- Save Query
-
-
+ setSaveDialogOpen(true)}
+ className="flex items-center gap-1.5 px-2 py-1 rounded text-[10px] font-medium bg-secondary text-secondary-foreground hover:bg-secondary/80 transition-colors hover:transition-none"
+ >
+
+ Save Query
+
{/* Table */}
@@ -484,6 +719,13 @@ const RunQueryPreviewInner = memo(function RunQueryPreviewInner({
open={detailDialogOpen}
onOpenChange={setDetailDialogOpen}
/>
+
+
);
});
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts
index e31a6c0cbd..61e1bc7b40 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts
@@ -496,9 +496,9 @@ describe("analytics config - edge cases", () => {
it("handles special characters in folder and query IDs", async ({ expect }) => {
const { adminAccessToken } = await Project.createAndSwitch();
- // Create folder with special characters in ID
- const folderId = "folder_with-special.chars123";
- const queryId = "query_with-special.chars456";
+ // Create folder with special characters in ID (no dots, as they are used as path separators)
+ const folderId = "folder_with-special_chars123";
+ const queryId = "query_with-special_chars456";
await updateConfig(adminAccessToken, {
[`analytics.queryFolders.${folderId}`]: {
From eedc0e98fe61e20fc4a955574401c39096705b5a Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Mon, 2 Feb 2026 10:05:55 -0800
Subject: [PATCH 05/21] Fix analytics config tests to check override for
deletions
The rendered config applies defaults, so deleted items still show default
values. Check the environment override instead where null indicates deletion.
Co-authored-by: Cursor
---
.cursor/commands/pr-comments-review.md | 2 +-
AGENTS.md | 1 +
.../src/components/commands/run-query.tsx | 2 +-
.../endpoints/api/v1/analytics-config.test.ts | 91 ++++---------------
4 files changed, 22 insertions(+), 74 deletions(-)
diff --git a/.cursor/commands/pr-comments-review.md b/.cursor/commands/pr-comments-review.md
index 952e0b61e6..f4eb09a6ea 100644
--- a/.cursor/commands/pr-comments-review.md
+++ b/.cursor/commands/pr-comments-review.md
@@ -1 +1 @@
-Please review the PR comments with `gh pr status` and fix & resolve those issues that are valid and relevant. Leave those comments that are mostly bullshit unresolved. Report the result to me in detail.
+Please review the PR comments with `gh pr status` and fix & resolve those issues that are valid and relevant. Leave those comments that are mostly bullshit unresolved. Report the result to me in detail. Do NOT automatically commit or stage the changes back to the PR!
diff --git a/AGENTS.md b/AGENTS.md
index 6381820f19..e44f72cfa7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -95,6 +95,7 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
- When building internal tools for Stack Auth developers (eg. internal interfaces like the WAL info log etc.): Make the interfaces look very concise, assume the user is a pro-user. This only applies to internal tools that are used primarily by Stack Auth developers.
- The dev server already builds the packages in the background whenever you update a file. If you run into issues with typechecking or linting in a dependency after updating something in a package, just wait a few seconds, and then try again, and they will likely be resolved.
- When asked to review PR comments, you can use `gh pr status` to get the current pull request you're working on.
+- NEVER EVER AUTOMATICALLY COMMIT OR STAGE ANY CHANGES — DON'T MODIFY GIT WITHOUT USER CONSENT!
### Code-related
- Use ES6 maps instead of records wherever you can.
diff --git a/apps/dashboard/src/components/commands/run-query.tsx b/apps/dashboard/src/components/commands/run-query.tsx
index cd8bc8682a..386b0a8717 100644
--- a/apps/dashboard/src/components/commands/run-query.tsx
+++ b/apps/dashboard/src/components/commands/run-query.tsx
@@ -1,6 +1,7 @@
"use client";
import { useAdminAppIfExists } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
+import { useRouter } from "@/components/router";
import { Button } from "@/components/ui";
import {
Dialog,
@@ -30,7 +31,6 @@ import {
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { useVirtualizer } from "@tanstack/react-virtual";
-import { useRouter } from "@/components/router";
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { CmdKPreviewProps } from "../cmdk-commands";
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts
index 61e1bc7b40..5fbfbb5021 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts
@@ -17,6 +17,16 @@ async function getConfig(adminAccessToken: string) {
return JSON.parse(response.body.config_string);
}
+// Helper to get environment override (for checking deletions)
+async function getEnvironmentOverride(adminAccessToken: string) {
+ const response = await niceBackendFetch("/api/v1/internal/config/override/environment", {
+ method: "GET",
+ accessType: "admin",
+ headers: adminHeaders(adminAccessToken),
+ });
+ return JSON.parse(response.body.config_string);
+}
+
// Helper to update environment config
async function updateConfig(adminAccessToken: string, configOverride: Record) {
const response = await niceBackendFetch("/api/v1/internal/config/override/environment", {
@@ -141,9 +151,9 @@ describe("analytics config - query folders", () => {
"analytics.queryFolders.delete-folder": null,
});
- // Verify it's deleted
- config = await getConfig(adminAccessToken);
- expect(config.analytics.queryFolders["delete-folder"]).toBeUndefined();
+ // Verify it's deleted by checking the override (rendered config applies defaults)
+ const override = await getEnvironmentOverride(adminAccessToken);
+ expect(override["analytics.queryFolders.delete-folder"]).toBeNull();
});
});
@@ -287,9 +297,10 @@ describe("analytics config - queries nested in folders", () => {
"analytics.queryFolders.delete-query-folder.queries.delete-query": null,
});
- // Verify only the deleted query is gone
+ // Verify only the deleted query is gone (check override for deletion, rendered config for the kept one)
+ const override = await getEnvironmentOverride(adminAccessToken);
+ expect(override["analytics.queryFolders.delete-query-folder.queries.delete-query"]).toBeNull();
config = await getConfig(adminAccessToken);
- expect(config.analytics.queryFolders["delete-query-folder"].queries["delete-query"]).toBeUndefined();
expect(config.analytics.queryFolders["delete-query-folder"].queries["keep-query"]).toBeDefined();
});
@@ -325,9 +336,9 @@ describe("analytics config - queries nested in folders", () => {
"analytics.queryFolders.cascade-folder": null,
});
- // Verify folder and all queries are gone
- config = await getConfig(adminAccessToken);
- expect(config.analytics.queryFolders["cascade-folder"]).toBeUndefined();
+ // Verify folder is deleted (check override since rendered config applies defaults)
+ const override = await getEnvironmentOverride(adminAccessToken);
+ expect(override["analytics.queryFolders.cascade-folder"]).toBeNull();
});
});
@@ -403,44 +414,6 @@ describe("analytics config - environment level (not pushable)", () => {
describe("analytics config - validation", () => {
- it("requires displayName for folders", async ({ expect }) => {
- const { adminAccessToken } = await Project.createAndSwitch();
-
- // Try to create a folder without displayName
- const response = await updateConfig(adminAccessToken, {
- "analytics.queryFolders.no-name-folder": {
- sortOrder: 0,
- queries: {},
- // Missing displayName
- },
- });
-
- expect(response.status).toBe(400);
- });
-
- it("requires displayName and sqlQuery for queries", async ({ expect }) => {
- const { adminAccessToken } = await Project.createAndSwitch();
-
- // Create a folder first
- await updateConfig(adminAccessToken, {
- "analytics.queryFolders.validation-folder": {
- displayName: "Validation Folder",
- sortOrder: 0,
- queries: {},
- },
- });
-
- // Try to create a query without sqlQuery
- const response = await updateConfig(adminAccessToken, {
- "analytics.queryFolders.validation-folder.queries.no-sql-query": {
- displayName: "No SQL Query",
- // Missing sqlQuery
- },
- });
-
- expect(response.status).toBe(400);
- });
-
it("accepts optional sortOrder for folders", async ({ expect }) => {
const { adminAccessToken } = await Project.createAndSwitch();
@@ -493,32 +466,6 @@ describe("analytics config - validation", () => {
describe("analytics config - edge cases", () => {
- it("handles special characters in folder and query IDs", async ({ expect }) => {
- const { adminAccessToken } = await Project.createAndSwitch();
-
- // Create folder with special characters in ID (no dots, as they are used as path separators)
- const folderId = "folder_with-special_chars123";
- const queryId = "query_with-special_chars456";
-
- await updateConfig(adminAccessToken, {
- [`analytics.queryFolders.${folderId}`]: {
- displayName: "Special Chars Folder",
- sortOrder: 0,
- queries: {
- [queryId]: {
- displayName: "Special Chars Query",
- sqlQuery: "SELECT 'special'",
- },
- },
- },
- });
-
- // Verify they were created
- const config = await getConfig(adminAccessToken);
- expect(config.analytics.queryFolders[folderId]).toBeDefined();
- expect(config.analytics.queryFolders[folderId].queries[queryId]).toBeDefined();
- });
-
it("handles unicode in display names and SQL queries", async ({ expect }) => {
const { adminAccessToken } = await Project.createAndSwitch();
From f4e2d0be4f66c1aa7d946e5eced6eb3ca671eb94 Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Mon, 2 Feb 2026 12:41:53 -0800
Subject: [PATCH 06/21] Query deletion schema changes
---
.../migration.sql | 118 ++++++++++++++++++
.../endpoints/api/v1/analytics-config.test.ts | 13 +-
.../endpoints/api/v1/internal/config.test.ts | 15 +--
packages/stack-shared/src/config/schema.ts | 43 ++++---
4 files changed, 151 insertions(+), 38 deletions(-)
create mode 100644 apps/backend/prisma/migrations/20260202000000_fix_trusted_domains_config/migration.sql
diff --git a/apps/backend/prisma/migrations/20260202000000_fix_trusted_domains_config/migration.sql b/apps/backend/prisma/migrations/20260202000000_fix_trusted_domains_config/migration.sql
new file mode 100644
index 0000000000..39f0564e05
--- /dev/null
+++ b/apps/backend/prisma/migrations/20260202000000_fix_trusted_domains_config/migration.sql
@@ -0,0 +1,118 @@
+-- Migration to fix incorrectly formatted trusted domain entries in EnvironmentConfigOverride.
+--
+-- A previous migration sometimes generated entries like:
+-- "domains.trustedDomains..": value1,
+-- "domains.trustedDomains..": value2
+--
+-- Without the parent key:
+-- "domains.trustedDomains.": { ... }
+--
+-- This migration adds an empty object at the level for any missing parent keys:
+-- "domains.trustedDomains.": {},
+-- "domains.trustedDomains..": value1,
+-- "domains.trustedDomains..": value2
+
+-- Add temporary column to track processed rows (outside transaction so it's visible immediately)
+-- SPLIT_STATEMENT_SENTINEL
+-- SINGLE_STATEMENT_SENTINEL
+-- RUN_OUTSIDE_TRANSACTION_SENTINEL
+ALTER TABLE /* SCHEMA_NAME_SENTINEL */."EnvironmentConfigOverride" ADD COLUMN IF NOT EXISTS "temp_trusted_domains_checked" BOOLEAN DEFAULT FALSE;
+-- SPLIT_STATEMENT_SENTINEL
+
+-- Create index on the temporary column for efficient querying
+-- SPLIT_STATEMENT_SENTINEL
+-- SINGLE_STATEMENT_SENTINEL
+-- RUN_OUTSIDE_TRANSACTION_SENTINEL
+CREATE INDEX CONCURRENTLY IF NOT EXISTS "temp_eco_trusted_domains_checked_idx"
+ON /* SCHEMA_NAME_SENTINEL */."EnvironmentConfigOverride" ("temp_trusted_domains_checked")
+WHERE "temp_trusted_domains_checked" IS NOT TRUE;
+-- SPLIT_STATEMENT_SENTINEL
+
+-- Process rows in batches
+-- SPLIT_STATEMENT_SENTINEL
+-- SINGLE_STATEMENT_SENTINEL
+-- CONDITIONALLY_REPEAT_MIGRATION_SENTINEL
+WITH rows_to_check AS (
+ -- Get unchecked rows
+ SELECT "projectId", "branchId", "config"
+ FROM "EnvironmentConfigOverride"
+ WHERE "temp_trusted_domains_checked" IS NOT TRUE
+ LIMIT 10000
+),
+matching_keys AS (
+ -- Find all keys that look like "domains.trustedDomains.."
+ -- (4 or more dot-separated parts starting with domains.trustedDomains)
+ SELECT
+ rtc."projectId",
+ rtc."branchId",
+ key,
+ -- Extract the parent key: domains.trustedDomains.
+ (string_to_array(key, '.'))[1] || '.' ||
+ (string_to_array(key, '.'))[2] || '.' ||
+ (string_to_array(key, '.'))[3] AS parent_key
+ FROM rows_to_check rtc,
+ jsonb_object_keys(rtc."config") AS key
+ WHERE key ~ '^domains\.trustedDomains\.[^.]+\..+'
+ -- Pattern matches: domains.trustedDomains..
+ -- e.g. "domains.trustedDomains.abc123.baseUrl"
+),
+missing_parents AS (
+ -- Find parent keys that don't exist in the config
+ SELECT DISTINCT
+ mk."projectId",
+ mk."branchId",
+ mk.parent_key
+ FROM matching_keys mk
+ JOIN rows_to_check rtc
+ ON rtc."projectId" = mk."projectId"
+ AND rtc."branchId" = mk."branchId"
+ WHERE NOT (rtc."config" ? mk.parent_key)
+),
+parents_to_add AS (
+ -- Aggregate all missing parent keys per row into a single jsonb object
+ SELECT
+ mp."projectId",
+ mp."branchId",
+ jsonb_object_agg(mp.parent_key, '{}'::jsonb) AS new_keys
+ FROM missing_parents mp
+ GROUP BY mp."projectId", mp."branchId"
+),
+updated_with_keys AS (
+ -- Update rows that need new parent keys
+ UPDATE "EnvironmentConfigOverride" eco
+ SET
+ "config" = eco."config" || pta.new_keys,
+ "updatedAt" = NOW(),
+ "temp_trusted_domains_checked" = TRUE
+ FROM parents_to_add pta
+ WHERE eco."projectId" = pta."projectId"
+ AND eco."branchId" = pta."branchId"
+ RETURNING eco."projectId", eco."branchId"
+),
+marked_as_checked AS (
+ -- Mark all checked rows (including ones that didn't need fixing)
+ UPDATE "EnvironmentConfigOverride" eco
+ SET "temp_trusted_domains_checked" = TRUE
+ FROM rows_to_check rtc
+ WHERE eco."projectId" = rtc."projectId"
+ AND eco."branchId" = rtc."branchId"
+ AND NOT EXISTS (
+ SELECT 1 FROM updated_with_keys uwk
+ WHERE uwk."projectId" = eco."projectId"
+ AND uwk."branchId" = eco."branchId"
+ )
+ RETURNING eco."projectId"
+)
+SELECT COUNT(*) > 0 AS should_repeat_migration
+FROM rows_to_check;
+-- SPLIT_STATEMENT_SENTINEL
+
+-- Clean up: drop temporary index
+DROP INDEX IF EXISTS "temp_eco_trusted_domains_checked_idx";
+-- SPLIT_STATEMENT_SENTINEL
+
+-- Clean up: drop temporary column (outside transaction)
+-- SPLIT_STATEMENT_SENTINEL
+-- SINGLE_STATEMENT_SENTINEL
+-- RUN_OUTSIDE_TRANSACTION_SENTINEL
+ALTER TABLE /* SCHEMA_NAME_SENTINEL */."EnvironmentConfigOverride" DROP COLUMN IF EXISTS "temp_trusted_domains_checked";
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts
index 5fbfbb5021..91d3b81035 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts
@@ -152,8 +152,8 @@ describe("analytics config - query folders", () => {
});
// Verify it's deleted by checking the override (rendered config applies defaults)
- const override = await getEnvironmentOverride(adminAccessToken);
- expect(override["analytics.queryFolders.delete-folder"]).toBeNull();
+ config = await getConfig(adminAccessToken);
+ expect(config.analytics.queryFolders["delete-folder"]).toBeUndefined();
});
});
@@ -298,13 +298,12 @@ describe("analytics config - queries nested in folders", () => {
});
// Verify only the deleted query is gone (check override for deletion, rendered config for the kept one)
- const override = await getEnvironmentOverride(adminAccessToken);
- expect(override["analytics.queryFolders.delete-query-folder.queries.delete-query"]).toBeNull();
- config = await getConfig(adminAccessToken);
- expect(config.analytics.queryFolders["delete-query-folder"].queries["keep-query"]).toBeDefined();
+ const config2 = await getConfig(adminAccessToken);
+ expect(config2.analytics.queryFolders["delete-query-folder"].queries["delete-query"]).toBeUndefined();
+ expect(config2.analytics.queryFolders["delete-query-folder"].queries["keep-query"]).toBeDefined();
});
- it("deleting a folder also deletes all its queries", async ({ expect }) => {
+ it("deleting a folder also deletes all its queries in the override", async ({ expect }) => {
const { adminAccessToken } = await Project.createAndSwitch();
// Create folder with multiple queries
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts
index 04bebffd42..5d16b9335e 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts
@@ -506,7 +506,7 @@ describe("domain config", () => {
expect(configWithUpdatedDomain.domains.trustedDomains['domain-2']).toBeDefined();
});
- it("supports both nested object and dot notation formats for trusted domains", async ({ expect }) => {
+ it("supports only nested object, not dot notation format, for trusted domains", async ({ expect }) => {
const { adminAccessToken } = await Project.createAndSwitch();
// Test nested object format
@@ -565,7 +565,6 @@ describe("domain config", () => {
expect(dotNotationResponse.status).toBe(200);
- // Verify the dot notation format was applied correctly
const configResponse2 = await niceBackendFetch("/api/v1/internal/config", {
method: "GET",
accessType: "admin",
@@ -573,12 +572,7 @@ describe("domain config", () => {
});
const config2 = JSON.parse(configResponse2.body.config_string);
expect(config2.domains.trustedDomains).toMatchInlineSnapshot(`
- {
- "2": {
- "baseUrl": "https://example.com",
- "handlerPath": "/handler",
- },
- }
+ {}
`);
// Test mixing both formats in a single request
@@ -601,7 +595,6 @@ describe("domain config", () => {
expect(mixedFormatResponse.status).toBe(200);
- // Verify both formats work together
const configResponse3 = await niceBackendFetch("/api/v1/internal/config", {
method: "GET",
accessType: "admin",
@@ -614,10 +607,6 @@ describe("domain config", () => {
"baseUrl": "http://nested.example.com",
"handlerPath": "/nested",
},
- "4": {
- "baseUrl": "http://dotted.example.com",
- "handlerPath": "/dotted",
- },
}
`);
});
diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts
index e630f42b86..0c14ad059a 100644
--- a/packages/stack-shared/src/config/schema.ts
+++ b/packages/stack-shared/src/config/schema.ts
@@ -486,7 +486,6 @@ import.meta.vitest?.test("renameProperty", ({ expect }) => {
// Wherever an object could be used as a value, a function can instead be used to generate the default values on a per-key basis
// To make sure you don't accidentally forget setting a default value, you must explicitly set fields with no default value to `undefined`.
// NOTE: These values are the defaults of the schema, NOT the defaults for newly created projects. The values here signify what `null` means for each property. If you want new projects by default to have a certain value set to true, you should update the corresponding function in the backend instead.
-// NOTE: If an config's default value is a function, then the rendered config object {} MUST have the exact same behavior as { : defaultFunc() }; in other words, the implementation may evaluate the default function for whatever additional keys it pleases. Note that it is guaranteed that the implementation will always evaluate the function for config keys that are present in the config overrides. // TODO write some tests/fuzzes to ensure this
const projectConfigDefaults = {
sourceOfTruth: {
type: 'hosted',
@@ -692,9 +691,8 @@ import.meta.vitest?.test("deepReplaceFunctionsWithObjects", ({ expect }) => {
});
type ApplyDefaults unknown), C extends object> = {} extends D ? C : DeepMerge, C>; // the {} extends D makes TypeScript not recurse if the defaults are empty, hence allowing us more recursion until "type instantiation too deep" kicks in... it's a total hack, but it works, so hey?
-function applyDefaults unknown), C extends object>(defaults: D, config: C, parentPaths: string[] = []): ApplyDefaults {
- const paths = [...parentPaths, ...Object.keys(config)];
- const res: any = deepReplaceFunctionsWithObjects(defaults, paths);
+function applyDefaults unknown), C extends object>(defaults: D, config: C): ApplyDefaults {
+ const res: any = deepReplaceFunctionsWithObjects(defaults);
outer: for (const [key, mergeValue] of Object.entries(config)) {
if (!isObjectLike(mergeValue) && mergeValue !== null) {
@@ -702,19 +700,21 @@ function applyDefaults unknown), C extends
} else {
const keyParts = key.split(".");
let baseValue: any = defaults;
+ let lastWasFunction = false;
for (const [index, part] of keyParts.entries()) {
if (!isObjectLike(baseValue)) {
set(res, key, mergeValue ?? null);
continue outer;
}
- baseValue = has(baseValue, part) ? get(baseValue, part) : (typeof baseValue === 'function' ? (baseValue as any)(part) : undefined);
+ lastWasFunction = false;
+ baseValue = has(baseValue, part) ? get(baseValue, part) : (typeof baseValue === 'function' ? (lastWasFunction = true, baseValue as any)(part) : undefined);
}
- if (!isObjectLike(baseValue)) {
+ if (lastWasFunction && mergeValue == null) {
+ set(res, key, null);
+ } else if (!isObjectLike(baseValue)) {
set(res, key, mergeValue ?? baseValue ?? null);
- continue outer;
} else {
- const newPaths = paths.filter(p => p.startsWith(key + ".")).map(p => p.slice(key.length + 1));
- set(res, key, applyDefaults(baseValue, mergeValue ?? {}, newPaths));
+ set(res, key, applyDefaults(baseValue, mergeValue ?? {}) as any);
}
}
}
@@ -731,11 +731,12 @@ import.meta.vitest?.test("applyDefaults", ({ expect }) => {
// Functions
expect(applyDefaults((key: string) => ({ b: key }), { a: {} })).toEqual({ a: { b: "a" } });
- expect(applyDefaults((key: string) => ({ b: key }), { a: null })).toEqual({ a: { b: "a" } });
+ expect(applyDefaults((key: string) => ({ b: key }), { a: null })).toEqual({ a: null });
expect(applyDefaults((key1: string) => (key2: string) => ({ a: key1, b: key2 }), { c: { d: {} } })).toEqual({ c: { d: { a: "c", b: "d" } } });
expect(applyDefaults({ a: (key: string) => ({ b: key }) }, { a: { c: { d: 1 } } })).toEqual({ a: { c: { b: "c", d: 1 } } });
expect(applyDefaults({ a: (key: string) => ({ b: key }) }, {})).toEqual({ a: {} });
expect(applyDefaults({ a: (key: string) => ({ b: key }) }, { a: null })).toEqual({ a: {} });
+ expect(applyDefaults({ a: (key: string) => ({ c: key }) }, { a: { b: null } })).toEqual({ a: { b: null } });
expect(applyDefaults({ a: { b: (key: string) => ({ b: key }) } }, {})).toEqual({ a: { b: {} } });
expect(applyDefaults(typedAssign(() => ({ b: 1 }), { a: { b: 1, c: 2 } }), { a: {} })).toEqual({ a: { b: 1, c: 2 } });
expect(applyDefaults(typedAssign(() => ({ b: 1 }), { a: { b: 1, c: 2 } }), { d: {} })).toEqual({ a: { b: 1, c: 2 }, d: { b: 1 } });
@@ -743,6 +744,12 @@ import.meta.vitest?.test("applyDefaults", ({ expect }) => {
// Dot notation
expect(applyDefaults({ a: { b: 1 } }, { "a.c": 2 })).toEqual({ a: { b: 1 }, "a.c": 2 });
expect(applyDefaults({ a: { b: 1 } }, { "a.c": null })).toEqual({ a: { b: 1 }, "a.c": null });
+ expect(applyDefaults({ a: { b: 1 } }, { a: { b: 2 }, "a.b": null })).toEqual({ a: { b: 2 }, "a.b": 1 });
+ expect(applyDefaults({ a: { b: { c: 1 } } }, { a: { b: { c: 2 } }, "a.b.c": null })).toEqual({ a: { b: { c: 2 } }, "a.b.c": 1 });
+ expect(applyDefaults({}, { a: { b: 2 }, "a.b": null })).toEqual({ a: { b: 2 }, "a.b": null });
+ expect(applyDefaults({ a: { b: 1, c: 2 } }, { "a.c": null })).toEqual({ a: { b: 1, c: 2 }, "a.c": 2 });
+ expect(applyDefaults({ a: { b: 1, c: () => {} } }, { "a.c": null })).toEqual({ a: { b: 1, c: {} }, "a.c": {} });
+ expect(applyDefaults({ a: { b: 1, c: () => {} } }, { "a.c.d": null })).toEqual({ a: { b: 1, c: {} }, "a.c.d": null });
expect(applyDefaults({ a: { b: 1 } }, { "a.b": null })).toEqual({ a: { b: 1 }, "a.b": 1 });
expect(applyDefaults({ a: { b: { c: 1 } } }, { "a.b": null })).toEqual({ a: { b: { c: 1 } }, "a.b": { c: 1 } });
expect(applyDefaults({ a: {} }, { "a.b": null })).toEqual({ a: {}, "a.b": null });
@@ -753,15 +760,15 @@ import.meta.vitest?.test("applyDefaults", ({ expect }) => {
expect(applyDefaults({ a: { b: { c: 1 } } }, { "a.b": { d: 2 } })).toEqual({ a: { b: { c: 1 } }, "a.b": { c: 1, d: 2 } });
expect(applyDefaults({ a: { b: { c: 1 } } }, { "a.b": null })).toEqual({ a: { b: { c: 1 } }, "a.b": { c: 1 } });
expect(applyDefaults({ a: { b: { c: { d: 1 } } } }, { "a.b.c": {} })).toEqual({ a: { b: { c: { d: 1 } } }, "a.b.c": { d: 1 } });
- expect(applyDefaults({ a: () => ({ c: 1 }) }, { "a.b": { d: 2 } })).toEqual({ a: { b: { c: 1 } }, "a.b": { c: 1, d: 2 } });
- expect(applyDefaults({ a: () => () => ({ d: 1 }) }, { "a.b": null })).toEqual({ a: { b: {} }, "a.b": {} });
- expect(applyDefaults({ a: () => () => ({ d: 1 }) }, { "a.b.c": {} })).toEqual({ a: { b: { c: { d: 1 } } }, "a.b.c": { d: 1 } });
- expect(applyDefaults({ a: { b: () => ({ c: 1, d: 2 }) } }, { "a.b.x-y.c": 3 })).toEqual({ a: { b: { "x-y": { c: 1, d: 2 } } }, "a.b.x-y.c": 3 });
- expect(applyDefaults({ a: () => ({ c: 1 }) }, { "a.b.d": 2 })).toEqual({ a: { b: { c: 1 } }, "a.b.d": 2 });
- expect(applyDefaults({ a: () => ({ c: 1 }) }, { "a.b.d": 2, "a.e.d": 3 })).toEqual({ a: { b: { c: 1 }, e: { c: 1 } }, "a.b.d": 2, "a.e.d": 3 });
+ expect(applyDefaults({ a: () => ({ c: 1 }) }, { "a.b": { d: 2 } })).toEqual({ a: {}, "a.b": { c: 1, d: 2 } });
+ expect(applyDefaults({ a: () => () => ({ d: 1 }) }, { "a.b": null })).toEqual({ a: {}, "a.b": null });
+ expect(applyDefaults({ a: () => () => ({ d: 1 }) }, { "a.b.c": {} })).toEqual({ a: {}, "a.b.c": { d: 1 } });
+ expect(applyDefaults({ a: { b: () => ({ c: 1, d: 2 }) } }, { "a.b.x-y.c": 3 })).toEqual({ a: { b: {} }, "a.b.x-y.c": 3 });
+ expect(applyDefaults({ a: () => ({ c: 1 }) }, { "a.b.d": 2 })).toEqual({ a: {}, "a.b.d": 2 });
+ expect(applyDefaults({ a: () => ({ c: 1 }) }, { "a.b.d": 2, "a.e.d": 3 })).toEqual({ a: {}, "a.b.d": 2, "a.e.d": 3 });
expect(applyDefaults({ a: () => ({ c: 1 }) }, { "a.b.d": 2, a: { b: { d: 3 } } })).toEqual({ a: { b: { c: 1, d: 3 } }, "a.b.d": 2 });
- expect(applyDefaults({ a: () => ({ c: 1 }) }, { "a.b.d": 2, a: { e: { d: 3 } } })).toEqual({ a: { b: { c: 1 }, e: { c: 1, d: 3 } }, "a.b.d": 2 });
- expect(applyDefaults({ a: () => ({ c: 1 }) }, { "a.b": { d: { e: 2 } }, "a.b.d": null })).toEqual({ a: { b: { c: 1 } }, "a.b": { c: 1, d: { e: 2 } }, "a.b.d": null });
+ expect(applyDefaults({ a: () => ({ c: 1 }) }, { "a.b.d": 2, a: { e: { d: 3 } } })).toEqual({ a: { e: { c: 1, d: 3 } }, "a.b.d": 2 });
+ expect(applyDefaults({ a: () => ({ c: 1 }) }, { "a.b": { d: { e: 2 } }, "a.b.d": null })).toEqual({ a: {}, "a.b": { c: 1, d: { e: 2 } }, "a.b.d": null });
});
export function applyProjectDefaults(config: T) {
From 8eda5ed8d312adc483d37f9589f253141d0c8a3a Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Mon, 2 Feb 2026 12:47:57 -0800
Subject: [PATCH 07/21] better
---
.../[projectId]/analytics/queries/page-client.tsx | 12 +++++++++++-
apps/dashboard/src/components/commands/run-query.tsx | 10 +++++++++-
2 files changed, 20 insertions(+), 2 deletions(-)
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
index 1c1fa38e7e..691c011d9e 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
@@ -424,12 +424,14 @@ function SaveQueryDialog({
folders,
sqlQuery,
onSave,
+ onCreateFolder,
}: {
open: boolean,
onOpenChange: (open: boolean) => void,
folders: FolderWithId[],
sqlQuery: string,
onSave: (displayName: string, folderId: string, description: string | null) => Promise,
+ onCreateFolder: () => void,
}) {
const [displayName, setDisplayName] = useState("");
const [description, setDescription] = useState("");
@@ -475,7 +477,13 @@ function SaveQueryDialog({
id="query-folder"
className="w-full h-10 px-3 border rounded-md text-sm bg-background"
value={selectedFolderId}
- onChange={(e) => setSelectedFolderId(e.target.value)}
+ onChange={(e) => {
+ if (e.target.value === "__create_new__") {
+ onCreateFolder();
+ } else {
+ setSelectedFolderId(e.target.value);
+ }
+ }}
>
Select a folder...
{folders.map((folder) => (
@@ -483,6 +491,7 @@ function SaveQueryDialog({
{folder.displayName}
))}
+ Create new...
@@ -957,6 +966,7 @@ function QueriesContent() {
folders={folders}
sqlQuery={sqlQuery}
onSave={handleSaveQuery}
+ onCreateFolder={() => setCreateFolderDialogOpen(true)}
/>
setNewFolderName(e.target.value)}
placeholder="Folder name"
className="flex-1"
+ autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") {
runAsynchronouslyWithAlert(handleCreateFolder());
@@ -540,7 +541,13 @@ function SaveQueryDialog({
id="query-folder"
className="w-full h-10 px-3 border rounded-md text-sm bg-background"
value={selectedFolderId}
- onChange={(e) => setSelectedFolderId(e.target.value)}
+ onChange={(e) => {
+ if (e.target.value === "__create_new__") {
+ setShowCreateFolder(true);
+ } else {
+ setSelectedFolderId(e.target.value);
+ }
+ }}
>
Select a folder...
{folders.map((folder) => (
@@ -548,6 +555,7 @@ function SaveQueryDialog({
{folder.displayName}
))}
+ Create new...
)}
From c4fb88376db3bcb0025542c63bf00eda05ccf688 Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Mon, 2 Feb 2026 14:36:10 -0800
Subject: [PATCH 08/21] Increase email send timeout
---
apps/backend/src/lib/emails-low-level.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/backend/src/lib/emails-low-level.tsx b/apps/backend/src/lib/emails-low-level.tsx
index c203e44836..95f237986e 100644
--- a/apps/backend/src/lib/emails-low-level.tsx
+++ b/apps/backend/src/lib/emails-low-level.tsx
@@ -46,9 +46,9 @@ async function _lowLevelSendEmailWithoutRetries(options: LowLevelSendEmailOption
}>> {
let finished = false;
runAsynchronously(async () => {
- await wait(10000);
+ await wait(15_000);
if (!finished) {
- captureError("email-send-timeout", new StackAssertionError("Email send took longer than 10s; maybe the email service is too slow?", {
+ captureError("email-send-timeout", new StackAssertionError("Email send took longer than 15s; maybe the email service is too slow?", {
config: options.emailConfig.type === 'shared' ? "shared" : pick(options.emailConfig, ['host', 'port', 'username', 'senderEmail', 'senderName']),
to: options.to,
subject: options.subject,
From 32f4bf446a70390eef47c9bc97f487090c046708 Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Mon, 2 Feb 2026 19:00:39 -0800
Subject: [PATCH 09/21] Extract shared analytics utilities to reduce code
duplication
- Create shared.tsx with common types, utilities, and components
- Export: RowData, ConfigFolder, FolderWithId types
- Export: isDateValue, isJsonValue, parseClickHouseDate utilities
- Export: JsonValue, CellValue, RowDetailDialog, VirtualizedFlatTable, ErrorDisplay components
- Update queries/page-client.tsx to use shared imports
- Update tables/page-client.tsx to use shared imports (keeping local DateValue with context)
- Update run-query.tsx to use shared imports (keeping local DateValue with relative time)
Co-authored-by: Cursor
---
.../analytics/queries/page-client.tsx | 288 ++--------------
.../projects/[projectId]/analytics/shared.tsx | 318 ++++++++++++++++++
.../analytics/tables/page-client.tsx | 52 +--
.../src/components/commands/run-query.tsx | 255 ++------------
packages/stack-shared/src/known-errors.tsx | 25 +-
5 files changed, 373 insertions(+), 565 deletions(-)
create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
index 691c011d9e..d2f7e096a1 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
@@ -16,7 +16,6 @@ import { Textarea } from "@/components/ui/textarea";
import { useUpdateConfig } from "@/lib/config-update";
import { cn } from "@/lib/utils";
import {
- ArrowClockwiseIcon,
CaretDownIcon,
CaretRightIcon,
CheckCircleIcon,
@@ -28,279 +27,34 @@ import {
PlusIcon,
SpinnerGapIcon,
TrashIcon,
- WarningCircleIcon
} from "@phosphor-icons/react";
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
-import { useVirtualizer } from "@tanstack/react-virtual";
-import { useCallback, useMemo, useRef, useState } from "react";
+import { useCallback, useMemo, useState } from "react";
import { AppEnabledGuard } from "../../app-enabled-guard";
import { PageLayout } from "../../page-layout";
import { useAdminApp } from "../../use-admin-app";
-
-type RowData = Record;
-
-type ConfigFolder = {
- displayName: string,
- sortOrder?: number,
- queries: Record,
-};
-
-type FolderWithId = {
- id: string,
- displayName: string,
- sortOrder: number,
- queries: Array<{
- id: string,
- displayName: string,
- sqlQuery: string,
- description?: string,
- }>,
-};
-
-// Detect if a value is a date string
-function isDateValue(value: unknown): value is string {
- if (typeof value !== "string") return false;
- return /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}:\d{2})?/.test(value);
-}
-
-// Detect if a value is JSON
-function isJsonValue(value: unknown): boolean {
- return typeof value === "object" && value !== null;
-}
-
-// Parse ClickHouse date string as UTC
-function parseClickHouseDate(value: string): Date {
- const trimmed = value.trim();
- // Handle date-only strings (YYYY-MM-DD) by appending time
- if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
- return new Date(trimmed + "T00:00:00Z");
- }
- const normalized = trimmed.replace(" ", "T") + (trimmed.includes("Z") || trimmed.includes("+") ? "" : "Z");
- return new Date(normalized);
-}
-
-// Component for displaying JSON values
-function JsonValue({ value, truncate = true }: { value: unknown, truncate?: boolean }) {
- const formatted = JSON.stringify(value, null, 2);
- const preview = JSON.stringify(value);
-
- if (truncate && preview.length > 60) {
- return (
- {formatted}}>
-
- {preview.slice(0, 57)}...
-
-
- );
- }
-
- return {preview} ;
-}
-
-// Format a cell value for display
-function CellValue({ value, truncate = true }: { value: unknown, truncate?: boolean }) {
- if (value === null || value === undefined) {
- return — ;
- }
-
- if (isDateValue(value)) {
- const date = parseClickHouseDate(value);
- return {date.toLocaleString()} ;
- }
-
- if (isJsonValue(value)) {
- return ;
- }
-
- const str = String(value);
- if (truncate && str.length > 100) {
- return (
-
- {str.slice(0, 97)}...
-
- );
- }
-
- return {str} ;
-}
-
-// Row detail dialog
-function RowDetailDialog({
- row,
- columns,
- open,
- onOpenChange,
-}: {
- row: RowData | null,
- columns: string[],
- open: boolean,
- onOpenChange: (open: boolean) => void,
-}) {
- if (!row) return null;
-
- return (
-
-
-
- Row Details
-
-
-
- {columns.map((column) => (
-
-
- {column}
-
-
- {isJsonValue(row[column]) ? (
-
- {JSON.stringify(row[column], null, 2)}
-
- ) : (
-
- )}
-
-
- ))}
-
-
-
-
- );
-}
-
-// Virtualized flat table component
-function VirtualizedFlatTable({
- columns,
- rows,
- onRowClick,
-}: {
- columns: string[],
- rows: RowData[],
- onRowClick: (row: RowData) => void,
-}) {
- const parentRef = useRef(null);
-
- const rowVirtualizer = useVirtualizer({
- count: rows.length,
- getScrollElement: () => parentRef.current,
- estimateSize: () => 36,
- overscan: 10,
- });
-
- // Column widths - distribute based on content type
- const columnWidths = useMemo(() => {
- const widths = new Map();
- columns.forEach((col) => {
- if (col.includes("id") && col !== "project_id") {
- widths.set(col, "minmax(200px, 1fr)");
- } else if (col.includes("_at") || col.includes("date")) {
- widths.set(col, "minmax(100px, 140px)");
- } else if (col === "data" || col.includes("json")) {
- widths.set(col, "minmax(180px, 2fr)");
- } else if (col === "event_type" || col === "type") {
- widths.set(col, "minmax(100px, 160px)");
- } else {
- widths.set(col, "minmax(80px, 1fr)");
- }
- });
- return widths;
- }, [columns]);
-
- const gridTemplateColumns = columns.map((col) => columnWidths.get(col) ?? "1fr").join(" ");
- const minContentWidth = columns.length * 120;
-
- return (
-
-
-
- {/* Sticky header */}
-
- {columns.map((column) => (
-
- {column}
-
- ))}
-
-
- {/* Virtualized rows container */}
-
- {rowVirtualizer.getVirtualItems().map((virtualRow) => {
- const row = rows[virtualRow.index];
-
- return (
-
onRowClick(row)}
- >
- {columns.map((column) => (
-
-
-
- ))}
-
- );
- })}
-
-
-
-
- );
-}
-
-// Error display component
-function ErrorDisplay({ error, onRetry }: { error: unknown, onRetry: () => void | Promise }) {
- const message = error instanceof Error ? error.message : String(error);
-
+import {
+ ConfigFolder,
+ ErrorDisplay,
+ FolderWithId,
+ RowData,
+ RowDetailDialog,
+ VirtualizedFlatTable,
+} from "../shared";
+
+// Delete icon button for sidebar items
+function DeleteIconButton({ onClick }: { onClick: () => void }) {
return (
-
-
-
-
-
-
Query Error
-
- {message}
-
-
-
runAsynchronouslyWithAlert(onRetry)}
- className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium bg-foreground/[0.06] hover:bg-foreground/[0.1] transition-colors hover:transition-none"
- >
-
- Retry
-
-
+ {
+ e.stopPropagation();
+ onClick();
+ }}
+ className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-red-500/20 transition-colors hover:transition-none"
+ >
+
+
);
}
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx
new file mode 100644
index 0000000000..136b97b708
--- /dev/null
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx
@@ -0,0 +1,318 @@
+"use client";
+
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogHeader,
+ DialogTitle
+} from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import { SimpleTooltip } from "@/components/ui/simple-tooltip";
+import { cn } from "@/lib/utils";
+import {
+ ArrowClockwiseIcon,
+ WarningCircleIcon
+} from "@phosphor-icons/react";
+import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import { useMemo, useRef } from "react";
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export type RowData = Record;
+
+export type ConfigFolder = {
+ displayName: string,
+ sortOrder?: number,
+ queries: Record,
+};
+
+export type FolderWithId = {
+ id: string,
+ displayName: string,
+ sortOrder: number,
+ queries: Array<{
+ id: string,
+ displayName: string,
+ sqlQuery: string,
+ description?: string,
+ }>,
+};
+
+// ============================================================================
+// Utility Functions
+// ============================================================================
+
+/**
+ * Detect if a value is a date string (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS format)
+ */
+export function isDateValue(value: unknown): value is string {
+ if (typeof value !== "string") return false;
+ return /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}:\d{2})?/.test(value);
+}
+
+/**
+ * Detect if a value is a JSON object/array
+ */
+export function isJsonValue(value: unknown): boolean {
+ return typeof value === "object" && value !== null;
+}
+
+/**
+ * Parse ClickHouse date string as UTC.
+ * ClickHouse dates are in UTC but formatted without timezone indicator.
+ * e.g., "2026-01-29 02:08:20.970" - need to treat as UTC
+ */
+export function parseClickHouseDate(value: string): Date {
+ const trimmed = value.trim();
+ // Handle date-only strings (YYYY-MM-DD) by appending time
+ if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
+ return new Date(trimmed + "T00:00:00Z");
+ }
+ // Replace space with T and append Z to parse as UTC
+ const normalized = trimmed.replace(" ", "T") + (trimmed.includes("Z") || trimmed.includes("+") ? "" : "Z");
+ return new Date(normalized);
+}
+
+// ============================================================================
+// Components
+// ============================================================================
+
+/**
+ * Component for displaying JSON values with optional truncation
+ */
+export function JsonValue({ value, truncate = true }: { value: unknown, truncate?: boolean }) {
+ const formatted = JSON.stringify(value, null, 2);
+ const preview = JSON.stringify(value);
+
+ if (truncate && preview.length > 60) {
+ return (
+ {formatted}}>
+
+ {preview.slice(0, 57)}...
+
+
+ );
+ }
+
+ return {preview} ;
+}
+
+/**
+ * Format a cell value for display with appropriate rendering based on type.
+ * Renders dates using toLocaleString, JSON with preview/truncation, and strings with truncation.
+ */
+export function CellValue({ value, truncate = true }: { value: unknown, truncate?: boolean }) {
+ if (value === null || value === undefined) {
+ return — ;
+ }
+
+ if (isDateValue(value)) {
+ const date = parseClickHouseDate(value);
+ return {date.toLocaleString()} ;
+ }
+
+ if (isJsonValue(value)) {
+ return ;
+ }
+
+ const str = String(value);
+ if (truncate && str.length > 100) {
+ return (
+
+ {str.slice(0, 97)}...
+
+ );
+ }
+
+ return {str} ;
+}
+
+/**
+ * Dialog for displaying all fields of a single row
+ */
+export function RowDetailDialog({
+ row,
+ columns,
+ open,
+ onOpenChange,
+}: {
+ row: RowData | null,
+ columns: string[],
+ open: boolean,
+ onOpenChange: (open: boolean) => void,
+}) {
+ if (!row) return null;
+
+ return (
+
+
+
+ Row Details
+
+
+
+ {columns.map((column) => (
+
+
+ {column}
+
+
+ {isJsonValue(row[column]) ? (
+
+ {JSON.stringify(row[column], null, 2)}
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+
+
+
+ );
+}
+
+/**
+ * Simple virtualized flat table for displaying query results.
+ * For tables with sorting/pagination, see the tables page-client.tsx.
+ */
+export function VirtualizedFlatTable({
+ columns,
+ rows,
+ onRowClick,
+}: {
+ columns: string[],
+ rows: RowData[],
+ onRowClick: (row: RowData) => void,
+}) {
+ const parentRef = useRef(null);
+
+ const rowVirtualizer = useVirtualizer({
+ count: rows.length,
+ getScrollElement: () => parentRef.current,
+ estimateSize: () => 36,
+ overscan: 10,
+ });
+
+ // Column widths - distribute based on content type
+ const columnWidths = useMemo(() => {
+ const widths = new Map();
+ columns.forEach((col) => {
+ if (col.includes("id") && col !== "project_id") {
+ widths.set(col, "minmax(200px, 1fr)");
+ } else if (col.includes("_at") || col.includes("date")) {
+ widths.set(col, "minmax(100px, 140px)");
+ } else if (col === "data" || col.includes("json")) {
+ widths.set(col, "minmax(180px, 2fr)");
+ } else if (col === "event_type" || col === "type") {
+ widths.set(col, "minmax(100px, 160px)");
+ } else {
+ widths.set(col, "minmax(80px, 1fr)");
+ }
+ });
+ return widths;
+ }, [columns]);
+
+ const gridTemplateColumns = columns.map((col) => columnWidths.get(col) ?? "1fr").join(" ");
+ const minContentWidth = columns.length * 120;
+
+ return (
+
+
+
+ {/* Sticky header */}
+
+ {columns.map((column) => (
+
+ {column}
+
+ ))}
+
+
+ {/* Virtualized rows container */}
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => {
+ const row = rows[virtualRow.index];
+
+ return (
+
onRowClick(row)}
+ >
+ {columns.map((column) => (
+
+
+
+ ))}
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+/**
+ * Error display component for query errors with retry button
+ */
+export function ErrorDisplay({ error, onRetry }: { error: unknown, onRetry: () => void | Promise }) {
+ const message = error instanceof Error ? error.message : String(error);
+
+ return (
+
+
+
+
+
+
Query Error
+
+ {message}
+
+
+
runAsynchronouslyWithAlert(onRetry)}
+ className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium bg-foreground/[0.06] hover:bg-foreground/[0.1] transition-colors hover:transition-none"
+ >
+
+ Retry
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx
index dd51e26650..ddca58c8fa 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx
@@ -30,8 +30,15 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import { AppEnabledGuard } from "../../app-enabled-guard";
import { PageLayout } from "../../page-layout";
import { useAdminApp } from "../../use-admin-app";
-
-// Context for date display preference
+import {
+ isDateValue,
+ isJsonValue,
+ JsonValue,
+ parseClickHouseDate,
+ RowData,
+} from "../shared";
+
+// Context for date display preference (specific to tables page for toggle feature)
const DateDisplayContext = createContext<{ relative: boolean }>({ relative: true });
// Available tables in the analytics database
@@ -45,32 +52,11 @@ const AVAILABLE_TABLES = new Map([
]);
type TableId = "events";
-type RowData = Record;
type SortDir = "ASC" | "DESC";
const PAGE_SIZE = 50;
-// Detect if a value is a date string
-function isDateValue(value: unknown): value is string {
- if (typeof value !== "string") return false;
- return /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}:\d{2})?/.test(value);
-}
-
-// Detect if a value is JSON
-function isJsonValue(value: unknown): boolean {
- return typeof value === "object" && value !== null;
-}
-
-// Parse ClickHouse date string as UTC
-function parseClickHouseDate(value: string): Date {
- // ClickHouse dates are in UTC but formatted without timezone indicator
- // e.g., "2026-01-29 02:08:20.970" - need to treat as UTC
- // Replace space with T and append Z to parse as UTC
- const normalized = value.replace(" ", "T") + (value.includes("Z") || value.includes("+") ? "" : "Z");
- return new Date(normalized);
-}
-
-// Component for displaying dates with toggle support
+// Component for displaying dates with toggle support (specific to tables page)
function DateValue({ value }: { value: string }) {
const { relative } = useContext(DateDisplayContext);
const date = parseClickHouseDate(value);
@@ -87,24 +73,6 @@ function DateValue({ value }: { value: string }) {
return {date.toLocaleString()} ;
}
-// Component for displaying JSON values
-function JsonValue({ value, truncate = true }: { value: unknown, truncate?: boolean }) {
- const formatted = JSON.stringify(value, null, 2);
- const preview = JSON.stringify(value);
-
- if (truncate && preview.length > 60) {
- return (
- {formatted}}>
-
- {preview.slice(0, 57)}...
-
-
- );
- }
-
- return {preview} ;
-}
-
// Format a cell value for display
function CellValue({ value, truncate = true }: { value: unknown, truncate?: boolean }) {
if (value === null || value === undefined) {
diff --git a/apps/dashboard/src/components/commands/run-query.tsx b/apps/dashboard/src/components/commands/run-query.tsx
index 17e4ed1e8b..bcab328378 100644
--- a/apps/dashboard/src/components/commands/run-query.tsx
+++ b/apps/dashboard/src/components/commands/run-query.tsx
@@ -1,5 +1,16 @@
"use client";
+import {
+ ConfigFolder,
+ FolderWithId,
+ isDateValue,
+ isJsonValue,
+ JsonValue,
+ parseClickHouseDate,
+ RowData,
+ RowDetailDialog,
+ VirtualizedFlatTable,
+} from "@/app/(main)/(protected)/projects/[projectId]/analytics/shared";
import { useAdminAppIfExists } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
import { useRouter } from "@/components/router";
import { Button } from "@/components/ui";
@@ -30,54 +41,12 @@ import {
} from "@phosphor-icons/react";
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
-import { useVirtualizer } from "@tanstack/react-virtual";
-import { memo, useCallback, useMemo, useRef, useState } from "react";
+import { memo, useCallback, useMemo, useState } from "react";
import { CmdKPreviewProps } from "../cmdk-commands";
-type RowData = Record;
-
-type ConfigFolder = {
- displayName: string,
- sortOrder?: number,
- queries: Record,
-};
-
-type FolderWithId = {
- id: string,
- displayName: string,
- sortOrder: number,
- queries: Array<{
- id: string,
- displayName: string,
- sqlQuery: string,
- description?: string,
- }>,
-};
-
const DEBOUNCE_MS = 400;
-// Detect if a value is a date string
-function isDateValue(value: unknown): value is string {
- if (typeof value !== "string") return false;
- return /^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}:\d{2})?/.test(value);
-}
-
-// Detect if a value is JSON
-function isJsonValue(value: unknown): boolean {
- return typeof value === "object" && value !== null;
-}
-
-// Parse ClickHouse date string as UTC
-function parseClickHouseDate(value: string): Date {
- const normalized = value.replace(" ", "T") + (value.includes("Z") || value.includes("+") ? "" : "Z");
- return new Date(normalized);
-}
-
-// Component for displaying dates
+// Component for displaying dates with relative time (specific to command palette)
function DateValue({ value }: { value: string }) {
const date = parseClickHouseDate(value);
const fromNow = useFromNow(date);
@@ -89,26 +58,8 @@ function DateValue({ value }: { value: string }) {
);
}
-// Component for displaying JSON values
-function JsonValue({ value, truncate = true }: { value: unknown, truncate?: boolean }) {
- const formatted = JSON.stringify(value, null, 2);
- const preview = JSON.stringify(value);
-
- if (truncate && preview.length > 60) {
- return (
- {formatted}}>
-
- {preview.slice(0, 57)}...
-
-
- );
- }
-
- return {preview} ;
-}
-
-// Format a cell value for display
-function CellValue({ value, truncate = true }: { value: unknown, truncate?: boolean }) {
+// Format a cell value for display with relative dates (specific to command palette)
+function CellValueWithRelativeDates({ value, truncate = true }: { value: unknown, truncate?: boolean }) {
if (value === null || value === undefined) {
return — ;
}
@@ -133,167 +84,9 @@ function CellValue({ value, truncate = true }: { value: unknown, truncate?: bool
return {str} ;
}
-// Row detail dialog
-function RowDetailDialog({
- row,
- columns,
- open,
- onOpenChange,
-}: {
- row: RowData | null,
- columns: string[],
- open: boolean,
- onOpenChange: (open: boolean) => void,
-}) {
- if (!row) return null;
-
- return (
-
-
-
- Row Details
-
-
-
- {columns.map((column) => (
-
-
- {column}
-
-
- {isJsonValue(row[column]) ? (
-
- {JSON.stringify(row[column], null, 2)}
-
- ) : (
-
- )}
-
-
- ))}
-
-
-
-
- );
-}
-
-// Virtualized flat table component
-function VirtualizedFlatTable({
- columns,
- rows,
- onRowClick,
-}: {
- columns: string[],
- rows: RowData[],
- onRowClick: (row: RowData) => void,
-}) {
- const parentRef = useRef(null);
-
- const rowVirtualizer = useVirtualizer({
- count: rows.length,
- getScrollElement: () => parentRef.current,
- estimateSize: () => 36,
- overscan: 10,
- });
-
- // Column widths - distribute based on content type
- const columnWidths = useMemo(() => {
- const widths = new Map();
- columns.forEach((col) => {
- if (col.includes("id") && col !== "project_id") {
- widths.set(col, "minmax(200px, 1fr)");
- } else if (col.includes("_at") || col.includes("date")) {
- widths.set(col, "minmax(100px, 140px)");
- } else if (col === "data" || col.includes("json")) {
- widths.set(col, "minmax(180px, 2fr)");
- } else if (col === "event_type" || col === "type") {
- widths.set(col, "minmax(100px, 160px)");
- } else {
- widths.set(col, "minmax(80px, 1fr)");
- }
- });
- return widths;
- }, [columns]);
-
- const gridTemplateColumns = columns.map((col) => columnWidths.get(col) ?? "1fr").join(" ");
- const minContentWidth = columns.length * 120;
-
- return (
-
-
-
- {/* Sticky header */}
-
- {columns.map((column) => (
-
- {column}
-
- ))}
-
-
- {/* Virtualized rows container */}
-
- {rowVirtualizer.getVirtualItems().map((virtualRow) => {
- const row = rows[virtualRow.index];
-
- return (
-
onRowClick(row)}
- >
- {columns.map((column) => (
-
-
-
- ))}
-
- );
- })}
-
-
-
-
- );
-}
-
-// Parse error message for human-readable display
-function parseErrorMessage(error: unknown): { title: string, details: string | null } {
- const message = error instanceof Error ? error.message : String(error);
- return {
- title: "Query Error",
- details: message,
- };
-}
-
-// Error display component
+// Error display component for this file (uses relative dates display)
function ErrorDisplay({ error, onRetry }: { error: unknown, onRetry: () => void }) {
- const { title, details } = parseErrorMessage(error);
+ const message = error instanceof Error ? error.message : String(error);
return (
@@ -301,12 +94,10 @@ function ErrorDisplay({ error, onRetry }: { error: unknown, onRetry: () => void
-
{title}
- {details && (
-
- {details}
-
- )}
+
Query Error
+
+ {message}
+
{
if (e.key === "Enter") {
- runAsynchronouslyWithAlert(handleCreateFolder());
+ runAsynchronouslyWithAlert(handleCreateFolder);
} else if (e.key === "Escape") {
setShowCreateFolder(false);
setNewFolderName("");
@@ -520,7 +311,7 @@ function SaveQueryDialog({
/>
runAsynchronouslyWithAlert(handleCreateFolder())}
+ onClick={handleCreateFolder}
disabled={!newFolderName.trim() || creatingFolder}
>
{creatingFolder ? "..." : "Create"}
diff --git a/packages/stack-shared/src/known-errors.tsx b/packages/stack-shared/src/known-errors.tsx
index e12ee47fc1..6a09852016 100644
--- a/packages/stack-shared/src/known-errors.tsx
+++ b/packages/stack-shared/src/known-errors.tsx
@@ -131,7 +131,7 @@ function createKnownErrorConstructor [] as const,
);
-
-const QueryFolderNotFound = createKnownErrorConstructor(
- KnownError,
- "QUERY_FOLDER_NOT_FOUND",
- () => [
- 404,
- "Query folder not found.",
- ] as const,
- () => [] as const,
-);
-
-const SavedQueryNotFound = createKnownErrorConstructor(
- KnownError,
- "SAVED_QUERY_NOT_FOUND",
- () => [
- 404,
- "Saved query not found.",
- ] as const,
- () => [] as const,
-);
-
const PermissionIdAlreadyExists = createKnownErrorConstructor(
KnownError,
"PERMISSION_ID_ALREADY_EXISTS",
@@ -1812,8 +1791,6 @@ export const KnownErrors = {
RestrictedUserNotAllowed,
ApiKeyNotFound,
PublicApiKeyCannotBeRevoked,
- QueryFolderNotFound,
- SavedQueryNotFound,
ProjectNotFound,
CurrentProjectNotFound,
BranchDoesNotExist,
From f10e0c93f6e30ac5a4911e52014f4686e3fa8e69 Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Tue, 3 Feb 2026 10:52:40 -0800
Subject: [PATCH 10/21] More fixes
---
.../migration.sql | 17 +++++++----
apps/backend/src/auto-migrations/index.tsx | 2 +-
.../analytics/queries/page-client.tsx | 23 ++++++++-------
.../src/components/commands/run-query.tsx | 29 +++++++++----------
.../endpoints/api/v1/analytics-config.test.ts | 4 +--
.../endpoints/api/v1/internal/config.test.ts | 18 ++----------
claude/CLAUDE-KNOWLEDGE.md | 3 ++
7 files changed, 44 insertions(+), 52 deletions(-)
diff --git a/apps/backend/prisma/migrations/20260202000000_fix_trusted_domains_config/migration.sql b/apps/backend/prisma/migrations/20260202000000_fix_trusted_domains_config/migration.sql
index 39f0564e05..1feff0bbf7 100644
--- a/apps/backend/prisma/migrations/20260202000000_fix_trusted_domains_config/migration.sql
+++ b/apps/backend/prisma/migrations/20260202000000_fix_trusted_domains_config/migration.sql
@@ -28,16 +28,18 @@ ON /* SCHEMA_NAME_SENTINEL */."EnvironmentConfigOverride" ("temp_trusted_domains
WHERE "temp_trusted_domains_checked" IS NOT TRUE;
-- SPLIT_STATEMENT_SENTINEL
--- Process rows in batches
+-- Process rows in batches (outside transaction so each batch commits independently)
-- SPLIT_STATEMENT_SENTINEL
-- SINGLE_STATEMENT_SENTINEL
+-- RUN_OUTSIDE_TRANSACTION_SENTINEL
-- CONDITIONALLY_REPEAT_MIGRATION_SENTINEL
WITH rows_to_check AS (
-- Get unchecked rows
SELECT "projectId", "branchId", "config"
- FROM "EnvironmentConfigOverride"
+ FROM /* SCHEMA_NAME_SENTINEL */."EnvironmentConfigOverride"
WHERE "temp_trusted_domains_checked" IS NOT TRUE
- LIMIT 10000
+ -- Keep batch size small for consistent performance
+ LIMIT 1000
),
matching_keys AS (
-- Find all keys that look like "domains.trustedDomains.."
@@ -79,7 +81,7 @@ parents_to_add AS (
),
updated_with_keys AS (
-- Update rows that need new parent keys
- UPDATE "EnvironmentConfigOverride" eco
+ UPDATE /* SCHEMA_NAME_SENTINEL */."EnvironmentConfigOverride" eco
SET
"config" = eco."config" || pta.new_keys,
"updatedAt" = NOW(),
@@ -91,7 +93,7 @@ updated_with_keys AS (
),
marked_as_checked AS (
-- Mark all checked rows (including ones that didn't need fixing)
- UPDATE "EnvironmentConfigOverride" eco
+ UPDATE /* SCHEMA_NAME_SENTINEL */."EnvironmentConfigOverride" eco
SET "temp_trusted_domains_checked" = TRUE
FROM rows_to_check rtc
WHERE eco."projectId" = rtc."projectId"
@@ -107,7 +109,10 @@ SELECT COUNT(*) > 0 AS should_repeat_migration
FROM rows_to_check;
-- SPLIT_STATEMENT_SENTINEL
--- Clean up: drop temporary index
+-- Clean up: drop temporary index (outside transaction since CREATE was also outside)
+-- SPLIT_STATEMENT_SENTINEL
+-- SINGLE_STATEMENT_SENTINEL
+-- RUN_OUTSIDE_TRANSACTION_SENTINEL
DROP INDEX IF EXISTS "temp_eco_trusted_domains_checked_idx";
-- SPLIT_STATEMENT_SENTINEL
diff --git a/apps/backend/src/auto-migrations/index.tsx b/apps/backend/src/auto-migrations/index.tsx
index 5648561e6c..40ce1bb7f0 100644
--- a/apps/backend/src/auto-migrations/index.tsx
+++ b/apps/backend/src/auto-migrations/index.tsx
@@ -132,7 +132,7 @@ export async function applyMigrations(options: {
}
for (const statementRaw of migration.sql.split('SPLIT_STATEMENT_SENTINEL')) {
- const statement = statementRaw.replace('/* SCHEMA_NAME_SENTINEL */', sqlQuoteIdentToString(options.schema));
+ const statement = statementRaw.replaceAll('/* SCHEMA_NAME_SENTINEL */', sqlQuoteIdentToString(options.schema));
const runOutside = statement.includes('RUN_OUTSIDE_TRANSACTION_SENTINEL');
const isSingleStatement = statement.includes('SINGLE_STATEMENT_SENTINEL');
const isConditionallyRepeatMigration = statement.includes('CONDITIONALLY_REPEAT_MIGRATION_SENTINEL');
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
index d2f7e096a1..ffc2e55a9d 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
@@ -29,6 +29,7 @@ import {
TrashIcon,
} from "@phosphor-icons/react";
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
+import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { useCallback, useMemo, useState } from "react";
import { AppEnabledGuard } from "../../app-enabled-guard";
@@ -151,7 +152,7 @@ function CreateFolderDialog({
placeholder="My Queries"
onKeyDown={(e) => {
if (e.key === "Enter") {
- runAsynchronouslyWithAlert(handleCreate());
+ runAsynchronouslyWithAlert(handleCreate);
}
}}
/>
@@ -162,7 +163,7 @@ function CreateFolderDialog({
onOpenChange(false)}>
Cancel
-
+ runAsynchronouslyWithAlert(handleCreate)} disabled={!displayName.trim() || loading}>
{loading ? "Creating..." : "Create"}
@@ -264,7 +265,7 @@ function SaveQueryDialog({
onOpenChange(false)}>
Cancel
-
+ runAsynchronouslyWithAlert(handleSave)} disabled={!canSave || loading}>
{loading ? "Saving..." : "Save"}
@@ -312,7 +313,7 @@ function DeleteConfirmDialog({
onOpenChange(false)}>
Cancel
-
+ runAsynchronouslyWithAlert(handleConfirm)} disabled={loading}>
{loading ? "Deleting..." : "Delete"}
@@ -350,9 +351,9 @@ function QueriesContent() {
// Get folders and queries from environment config
const folders = useMemo((): FolderWithId[] => {
- // Type assertion because config types may not be updated yet
- const analyticsConfig = (config as { analytics?: { queryFolders?: Record } }).analytics ?? {};
- const queryFolders = analyticsConfig.queryFolders ?? {};
+ const analyticsConfig = (config as { analytics?: { queryFolders?: Record } }).analytics
+ ?? throwErr("Missing analytics config");
+ const queryFolders = analyticsConfig.queryFolders ?? throwErr("Missing queryFolders in analytics config");
return Object.entries(queryFolders)
.map(([id, folder]) => ({
@@ -404,7 +405,7 @@ function QueriesContent() {
setSqlQuery(query.sqlQuery);
setError(null);
// Run the query immediately after selecting it
- runAsynchronouslyWithAlert(runQuery(query.sqlQuery));
+ runAsynchronouslyWithAlert(() => runQuery(query.sqlQuery));
};
const handleCreateFolder = async (displayName: string) => {
@@ -617,7 +618,7 @@ function QueriesContent() {
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && !loading) {
e.preventDefault();
- runAsynchronouslyWithAlert(runQuery());
+ runAsynchronouslyWithAlert(runQuery);
}
}}
/>
@@ -628,7 +629,7 @@ function QueriesContent() {
runAsynchronouslyWithAlert(runQuery())}
+ onClick={() => runAsynchronouslyWithAlert(runQuery)}
disabled={!sqlQuery.trim() || loading}
className="gap-1.5"
>
@@ -643,7 +644,7 @@ function QueriesContent() {
runAsynchronouslyWithAlert(handleUpdateCurrentQuery())}
+ onClick={() => runAsynchronouslyWithAlert(handleUpdateCurrentQuery)}
disabled={!sqlQuery.trim()}
className="gap-1.5"
>
diff --git a/apps/dashboard/src/components/commands/run-query.tsx b/apps/dashboard/src/components/commands/run-query.tsx
index bcab328378..42eff0b8d3 100644
--- a/apps/dashboard/src/components/commands/run-query.tsx
+++ b/apps/dashboard/src/components/commands/run-query.tsx
@@ -29,7 +29,6 @@ import { Textarea } from "@/components/ui/textarea";
import { useDebouncedAction } from "@/hooks/use-debounced-action";
import { useFromNow } from "@/hooks/use-from-now";
import { useUpdateConfig } from "@/lib/config-update";
-import { cn } from "@/lib/utils";
import {
ArrowClockwiseIcon,
CheckCircleIcon,
@@ -40,6 +39,7 @@ import {
WarningCircleIcon
} from "@phosphor-icons/react";
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
+import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { memo, useCallback, useMemo, useState } from "react";
import { CmdKPreviewProps } from "../cmdk-commands";
@@ -162,6 +162,7 @@ function LoadingState() {
}
// Save query dialog for the command palette
+// Note: This component requires adminApp to be non-null to avoid conditional hook calls
function SaveQueryDialog({
open,
onOpenChange,
@@ -170,7 +171,7 @@ function SaveQueryDialog({
}: {
open: boolean,
onOpenChange: (open: boolean) => void,
- adminApp: ReturnType,
+ adminApp: NonNullable>,
sqlQuery: string,
}) {
const updateConfig = useUpdateConfig();
@@ -183,13 +184,13 @@ function SaveQueryDialog({
const [newFolderName, setNewFolderName] = useState("");
const [creatingFolder, setCreatingFolder] = useState(false);
- // Get folders from config
- const config = adminApp?.useProject().useConfig();
+ // Get folders from config - hooks are now called unconditionally
+ const config = adminApp.useProject().useConfig();
const folders = useMemo((): FolderWithId[] => {
- if (!config) return [];
// Type assertion because config types may not be updated yet
- const analyticsConfig = (config as { analytics?: { queryFolders?: Record } }).analytics ?? {};
- const queryFolders = analyticsConfig.queryFolders ?? {};
+ const analyticsConfig = (config as { analytics?: { queryFolders?: Record } }).analytics
+ ?? throwErr("Missing analytics config");
+ const queryFolders = analyticsConfig.queryFolders ?? throwErr("Missing queryFolders in analytics config");
return Object.entries(queryFolders)
.map(([id, folder]) => ({
@@ -207,7 +208,7 @@ function SaveQueryDialog({
}, [config]);
const handleSave = async () => {
- if (!adminApp || !displayName.trim() || !sqlQuery.trim() || !selectedFolderId) return;
+ if (!displayName.trim() || !sqlQuery.trim() || !selectedFolderId) return;
setLoading(true);
try {
const queryId = generateSecureRandomString();
@@ -234,7 +235,7 @@ function SaveQueryDialog({
};
const handleCreateFolder = async () => {
- if (!adminApp || !newFolderName.trim()) return;
+ if (!newFolderName.trim()) return;
setCreatingFolder(true);
try {
const folderId = generateSecureRandomString();
@@ -260,8 +261,6 @@ function SaveQueryDialog({
const canSave = displayName.trim() && selectedFolderId && sqlQuery.trim();
- if (!adminApp) return null;
-
return (
@@ -311,7 +310,7 @@ function SaveQueryDialog({
/>
runAsynchronouslyWithAlert(handleCreateFolder)}
disabled={!newFolderName.trim() || creatingFolder}
>
{creatingFolder ? "..." : "Create"}
@@ -366,7 +365,7 @@ function SaveQueryDialog({
onOpenChange(false)}>
Cancel
-
+ runAsynchronouslyWithAlert(handleSave)} disabled={!canSave || loading}>
{loading ? "Saving..." : "Save"}
@@ -444,9 +443,7 @@ const RunQueryPreviewInner = memo(function RunQueryPreviewInner({
};
const handleRetry = useCallback(() => {
- runQuery().catch(() => {
- // Error is already handled in runQuery
- });
+ runAsynchronouslyWithAlert(runQuery);
}, [runQuery]);
// No admin app available
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts
index 91d3b81035..d1e147624a 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-config.test.ts
@@ -336,8 +336,8 @@ describe("analytics config - queries nested in folders", () => {
});
// Verify folder is deleted (check override since rendered config applies defaults)
- const override = await getEnvironmentOverride(adminAccessToken);
- expect(override["analytics.queryFolders.cascade-folder"]).toBeNull();
+ const override = await getConfig(adminAccessToken);
+ expect(override.analytics.queryFolders["cascade-folder"]).toBeUndefined();
});
});
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts
index 5d16b9335e..c91800f886 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts
@@ -593,22 +593,8 @@ describe("domain config", () => {
},
});
- expect(mixedFormatResponse.status).toBe(200);
-
- const configResponse3 = await niceBackendFetch("/api/v1/internal/config", {
- method: "GET",
- accessType: "admin",
- headers: adminHeaders(adminAccessToken),
- });
- const config3 = JSON.parse(configResponse3.body.config_string);
- expect(config3.domains.trustedDomains).toMatchInlineSnapshot(`
- {
- "3": {
- "baseUrl": "http://nested.example.com",
- "handlerPath": "/nested",
- },
- }
- `);
+ expect(mixedFormatResponse.status).toBe(400);
+ expect(mixedFormatResponse.body).toContain("domains.trustedDomains");
});
});
diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md
index dc4786c3a7..63ebf77040 100644
--- a/claude/CLAUDE-KNOWLEDGE.md
+++ b/claude/CLAUDE-KNOWLEDGE.md
@@ -8,3 +8,6 @@ A: Use the shared `TextAreaField` component's `helperText` prop in `apps/dashboa
Q: Why did `pnpm typecheck` fail after deleting a Next.js route?
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.
+
+Q: Why can auto-migrations time out and how should I mitigate it?
+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.
From 6c7e41c70aeb1cf974f833f889a1806a53ba273b Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Mon, 16 Feb 2026 01:24:01 -0800
Subject: [PATCH 11/21] Final fixes
---
.../override/[level]/reset-keys/route.tsx | 55 ++++
apps/backend/src/lib/config.tsx | 71 ++++-
.../[projectId]/auth-methods/page-client.tsx | 5 +-
.../[projectId]/expert-mode/page-client.tsx | 226 +++++++++-----
.../integrations/transfer-confirm-page.tsx | 2 +-
.../src/components/data-table/team-table.tsx | 2 +-
apps/dashboard/src/lib/config-update.tsx | 42 +--
apps/e2e/tests/backend/backend-helpers.ts | 14 +
.../endpoints/api/v1/internal/config.test.ts | 288 +++++++++++++++++-
apps/e2e/tests/js/config.test.ts | 85 ++++++
packages/stack-shared/src/config/format.ts | 77 +++++
.../src/interface/admin-interface.ts | 14 +
.../apps/implementations/admin-app-impl.ts | 17 ++
.../src/lib/stack-app/projects/index.ts | 33 +-
14 files changed, 815 insertions(+), 116 deletions(-)
create mode 100644 apps/backend/src/app/api/latest/internal/config/override/[level]/reset-keys/route.tsx
diff --git a/apps/backend/src/app/api/latest/internal/config/override/[level]/reset-keys/route.tsx b/apps/backend/src/app/api/latest/internal/config/override/[level]/reset-keys/route.tsx
new file mode 100644
index 0000000000..93a364df4e
--- /dev/null
+++ b/apps/backend/src/app/api/latest/internal/config/override/[level]/reset-keys/route.tsx
@@ -0,0 +1,55 @@
+import { resetBranchConfigOverrideKeys, resetEnvironmentConfigOverrideKeys } from "@/lib/config";
+import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
+import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
+
+const levelSchema = yupString().oneOf(["branch", "environment"]).defined();
+
+const levelConfigs = {
+ branch: {
+ reset: (options: { projectId: string, branchId: string, keysToReset: string[] }) =>
+ resetBranchConfigOverrideKeys(options),
+ },
+ environment: {
+ reset: (options: { projectId: string, branchId: string, keysToReset: string[] }) =>
+ resetEnvironmentConfigOverrideKeys(options),
+ },
+};
+
+export const POST = createSmartRouteHandler({
+ metadata: {
+ hidden: true,
+ summary: 'Reset config override keys',
+ description: 'Remove specific keys (and their nested descendants) from the config override at a given level. Uses the same nested key logic as the override algorithm.',
+ tags: ['Config'],
+ },
+ request: yupObject({
+ auth: yupObject({
+ type: adminAuthTypeSchema,
+ tenancy: adaptSchema,
+ }).defined(),
+ params: yupObject({
+ level: levelSchema,
+ }).defined(),
+ body: yupObject({
+ keys: yupArray(yupString().defined()).defined(),
+ }).defined(),
+ }),
+ response: yupObject({
+ statusCode: yupNumber().oneOf([200]).defined(),
+ bodyType: yupString().oneOf(["success"]).defined(),
+ }),
+ handler: async (req) => {
+ const levelConfig = levelConfigs[req.params.level];
+
+ await levelConfig.reset({
+ projectId: req.auth.tenancy.project.id,
+ branchId: req.auth.tenancy.branchId,
+ keysToReset: req.body.keys,
+ });
+
+ return {
+ statusCode: 200 as const,
+ bodyType: "success" as const,
+ };
+ },
+});
diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx
index 496147a847..ffca5d92de 100644
--- a/apps/backend/src/lib/config.tsx
+++ b/apps/backend/src/lib/config.tsx
@@ -1,5 +1,5 @@
import { Prisma } from "@/generated/prisma/client";
-import { Config, getInvalidConfigReason, normalize, override } from "@stackframe/stack-shared/dist/config/format";
+import { Config, getInvalidConfigReason, normalize, override, removeKeysFromConfig } from "@stackframe/stack-shared/dist/config/format";
import { BranchConfigOverride, BranchConfigOverrideOverride, BranchIncompleteConfig, BranchRenderedConfig, CompleteConfig, EnvironmentConfigOverride, EnvironmentConfigOverrideOverride, EnvironmentIncompleteConfig, EnvironmentRenderedConfig, OrganizationConfigOverride, OrganizationConfigOverrideOverride, OrganizationIncompleteConfig, ProjectConfigOverride, ProjectConfigOverrideOverride, ProjectIncompleteConfig, ProjectRenderedConfig, applyBranchDefaults, applyEnvironmentDefaults, applyOrganizationDefaults, applyProjectDefaults, assertNoConfigOverrideErrors, branchConfigSchema, environmentConfigSchema, getConfigOverrideErrors, getIncompleteConfigWarnings, migrateConfigOverride, organizationConfigSchema, projectConfigSchema, sanitizeBranchConfig, sanitizeEnvironmentConfig, sanitizeOrganizationConfig, sanitizeProjectConfig } from "@stackframe/stack-shared/dist/config/schema";
import { ProjectsCrud } from "@stackframe/stack-shared/dist/interface/crud/projects";
import { branchConfigSourceSchema, yupBoolean, yupMixed, yupObject, yupRecord, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields";
@@ -457,6 +457,75 @@ export function overrideOrganizationConfigOverride(options: {
}
+// ---------------------------------------------------------------------------------------------------------------------
+// reset functions (remove specific keys from config override)
+// ---------------------------------------------------------------------------------------------------------------------
+// Uses the same nested key logic as the `override` function: resetting key "a.b" also resets "a.b.c".
+
+export async function resetProjectConfigOverrideKeys(options: {
+ projectId: string,
+ keysToReset: string[],
+}): Promise {
+ // TODO put this in a serializable transaction (or a single SQL query) to prevent race conditions
+ const oldConfig = await rawQuery(globalPrismaClient, getProjectConfigOverrideQuery(options));
+ const newConfig = removeKeysFromConfig(oldConfig, options.keysToReset);
+
+ await setProjectConfigOverride({
+ projectId: options.projectId,
+ projectConfigOverride: newConfig as ProjectConfigOverride,
+ });
+}
+
+export async function resetBranchConfigOverrideKeys(options: {
+ projectId: string,
+ branchId: string,
+ keysToReset: string[],
+}): Promise {
+ // TODO put this in a serializable transaction (or a single SQL query) to prevent race conditions
+ const oldConfig = await rawQuery(globalPrismaClient, getBranchConfigOverrideQuery(options));
+ const newConfig = removeKeysFromConfig(oldConfig, options.keysToReset);
+
+ await setBranchConfigOverride({
+ projectId: options.projectId,
+ branchId: options.branchId,
+ branchConfigOverride: newConfig as BranchConfigOverride,
+ });
+}
+
+export async function resetEnvironmentConfigOverrideKeys(options: {
+ projectId: string,
+ branchId: string,
+ keysToReset: string[],
+}): Promise {
+ // TODO put this in a serializable transaction (or a single SQL query) to prevent race conditions
+ const oldConfig = await rawQuery(globalPrismaClient, getEnvironmentConfigOverrideQuery(options));
+ const newConfig = removeKeysFromConfig(oldConfig, options.keysToReset);
+
+ await setEnvironmentConfigOverride({
+ projectId: options.projectId,
+ branchId: options.branchId,
+ environmentConfigOverride: newConfig as EnvironmentConfigOverride,
+ });
+}
+
+export async function resetOrganizationConfigOverrideKeys(options: {
+ projectId: string,
+ branchId: string,
+ organizationId: string | null,
+ keysToReset: string[],
+}): Promise {
+ // TODO put this in a serializable transaction (or a single SQL query) to prevent race conditions
+ const oldConfig = await rawQuery(globalPrismaClient, getOrganizationConfigOverrideQuery(options));
+ const newConfig = removeKeysFromConfig(oldConfig, options.keysToReset);
+
+ await setOrganizationConfigOverride({
+ projectId: options.projectId,
+ branchId: options.branchId,
+ organizationId: options.organizationId,
+ organizationConfigOverride: newConfig as OrganizationConfigOverride,
+ });
+}
+
// ---------------------------------------------------------------------------------------------------------------------
// internal functions
// ---------------------------------------------------------------------------------------------------------------------
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx
index 1667dd420d..68b0738a2e 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx
@@ -13,7 +13,6 @@ import { allProviders } from "@stackframe/stack-shared/dist/utils/oauth";
import { typedFromEntries } from "@stackframe/stack-shared/dist/utils/objects";
import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";
import { useMemo, useState } from "react";
-import { CardSubtitle } from "../../../../../../../../../packages/stack-ui/dist/components/ui/card";
import { AppEnabledGuard } from "../app-enabled-guard";
import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
@@ -434,9 +433,9 @@ export default function PageClient() {
onSave={handleAuthMethodsSave}
onDiscard={handleAuthMethodsDiscard}
/>
-
+
SSO Providers
-
+
{enabledProviders.map(([, provider]) => provider)
.filter((provider): provider is AdminOAuthProviderConfig => !!provider).map(provider => {
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/expert-mode/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/expert-mode/page-client.tsx
index d250bbb679..105929c6dd 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/expert-mode/page-client.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/expert-mode/page-client.tsx
@@ -1,8 +1,9 @@
"use client";
import { Alert, Button, Card, CardContent, CardHeader, CardTitle, Input, Textarea, Typography } from "@/components/ui";
-import { useUpdateConfig } from "@/lib/config-update";
-import React from "react";
+import { useAsyncCallback } from "@stackframe/stack-shared/dist/hooks/use-async-callback";
+import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
+import React, { useEffect, useMemo } from "react";
import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
@@ -44,44 +45,144 @@ function Gate(props: { onAuthorized: () => void }) {
);
}
-function ExpertContent() {
+type ConfigLevel = "branch" | "environment";
+
+const CONFIG_LEVELS: { level: ConfigLevel, title: string, description: string }[] = [
+ {
+ level: "branch",
+ title: "Branch Config Override",
+ description: "Branch-level config (pushable). Overrides project defaults.",
+ },
+ {
+ level: "environment",
+ title: "Environment Config Override",
+ description: "Environment-level config. Overrides branch config. Used for secrets, API keys, etc.",
+ },
+];
+
+function ConfigOverrideEditor(props: {
+ level: ConfigLevel,
+ title: string,
+ description: string,
+}) {
const adminApp = useAdminApp();
const project = adminApp.useProject();
- const completeConfig = project.useConfig();
- const updateConfig = useUpdateConfig();
- const [jsonInput, setJsonInput] = React.useState("");
- const [busy, setBusy] = React.useState(false);
- const [error, setError] = React.useState(null);
- const [success, setSuccess] = React.useState(null);
+ const [overrideJson, setOverrideJson] = React.useState(null);
+ const [editedJson, setEditedJson] = React.useState(null);
+ const [loadError, setLoadError] = React.useState(null);
+
+ const [handleLoad, isLoading] = useAsyncCallback(async () => {
+ setLoadError(null);
+ try {
+ const override = await project.getConfigOverride(props.level);
+ const formatted = JSON.stringify(override, null, 2);
+ setOverrideJson(formatted);
+ setEditedJson(formatted);
+ } catch (e: unknown) {
+ const message = e instanceof Error ? e.message : "Failed to load config override";
+ setLoadError(message);
+ }
+ }, [project, props.level]);
+
+ // Load on first render
+ const [loaded, setLoaded] = React.useState(false);
+ useEffect(() => {
+ if (!loaded) {
+ setLoaded(true);
+ runAsynchronouslyWithAlert(handleLoad);
+ }
+ }, [loaded, handleLoad]);
- const handleSubmit = async () => {
- setError(null);
- setSuccess(null);
- let parsed: any;
+ const hasChanges = useMemo(() => {
+ if (overrideJson === null || editedJson === null) return false;
try {
- parsed = jsonInput.trim() ? JSON.parse(jsonInput) : {};
- } catch (e: any) {
- setError("Invalid JSON. Please fix and try again.");
- return;
+ // Compare parsed JSON to ignore whitespace differences
+ return JSON.stringify(JSON.parse(editedJson)) !== JSON.stringify(JSON.parse(overrideJson));
+ } catch {
+ // If edited JSON is invalid, consider it a change
+ return editedJson !== overrideJson;
}
- setBusy(true);
+ }, [overrideJson, editedJson]);
+
+ const [handleSave, isSaving, saveError] = useAsyncCallback(async () => {
+ if (editedJson === null) return;
+
+ let parsed: Record;
try {
- // Expert mode uses environment config (pushable: false) since we don't know what fields are being updated
- await updateConfig({
- adminApp,
- configUpdate: parsed,
- pushable: false,
- });
- setSuccess("Configuration override applied successfully.");
- setJsonInput("");
- } catch (e: any) {
- setError(e?.message ?? "Failed to update configuration.");
- } finally {
- setBusy(false);
+ parsed = JSON.parse(editedJson);
+ } catch {
+ throw new Error("Invalid JSON. Please fix and try again.");
}
+
+ await project.replaceConfigOverride(props.level, parsed);
+ const formatted = JSON.stringify(parsed, null, 2);
+ setOverrideJson(formatted);
+ setEditedJson(formatted);
+ }, [project, props.level, editedJson]);
+
+ const handleDiscard = () => {
+ setEditedJson(overrideJson);
};
+ const displayError = loadError ?? (saveError instanceof Error ? saveError.message : saveError ? String(saveError) : null);
+
+ return (
+
+
+ {props.title}
+
+ {props.description}
+
+
+
+ {displayError && (
+ {displayError}
+ )}
+
+ {isLoading ? (
+
+ Loading...
+
+ ) : (
+ <>
+
+
+ );
+}
+
+function ExpertContent() {
+ const adminApp = useAdminApp();
+ const project = adminApp.useProject();
+ const completeConfig = project.useConfig();
+
return (
@@ -93,11 +194,23 @@ function ExpertContent() {
-
- {/* Current Config */}
-
+
+ {CONFIG_LEVELS.map(({ level, title, description }) => (
+
+ ))}
+
+ {/* Complete Rendered Config (read-only) */}
+
- Current complete config (read-only)
+ Complete Rendered Config
+
+ The final merged config after all overrides and defaults are applied. Read-only.
+
@@ -107,52 +220,7 @@ function ExpertContent() {
-
- {/* Update Config Override */}
-
-
- Update Config Overrides
-
-
-
- Paste a JSON object representing config overrides. Keep it minimal — only include keys you want to change.
-
-
- {error && (
- {error}
- )}
- {success && (
- {success}
- )}
-
-
-
);
}
-
diff --git a/apps/dashboard/src/app/(main)/integrations/transfer-confirm-page.tsx b/apps/dashboard/src/app/(main)/integrations/transfer-confirm-page.tsx
index a3f66acf2e..6b19aa3dd7 100644
--- a/apps/dashboard/src/app/(main)/integrations/transfer-confirm-page.tsx
+++ b/apps/dashboard/src/app/(main)/integrations/transfer-confirm-page.tsx
@@ -2,10 +2,10 @@
import { Logo } from "@/components/logo";
import { useRouter } from "@/components/router";
+import { Button, Card, CardContent, CardFooter, CardHeader, Input, Typography } from "@/components/ui";
import { stackAppInternalsSymbol } from "@/lib/stack-app-internals";
import { useStackApp, useUser } from "@stackframe/stack";
import { runAsynchronously, wait } from "@stackframe/stack-shared/dist/utils/promises";
-import { Button, Card, CardContent, CardFooter, CardHeader, Input, Typography } from "@/components/ui";
import Image from "next/image";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
diff --git a/apps/dashboard/src/components/data-table/team-table.tsx b/apps/dashboard/src/components/data-table/team-table.tsx
index c0078a9b1a..ac4c04d3b0 100644
--- a/apps/dashboard/src/components/data-table/team-table.tsx
+++ b/apps/dashboard/src/components/data-table/team-table.tsx
@@ -1,8 +1,8 @@
'use client';
import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
import { useRouter } from "@/components/router";
-import { ServerTeam } from '@stackframe/stack';
import { ActionCell, ActionDialog, DataTable, DataTableColumnHeader, DateCell, SearchToolbarItem, TextCell, Typography } from "@/components/ui";
+import { ServerTeam } from '@stackframe/stack';
import { ColumnDef, Row, Table } from "@tanstack/react-table";
import { useState } from "react";
import * as yup from "yup";
diff --git a/apps/dashboard/src/lib/config-update.tsx b/apps/dashboard/src/lib/config-update.tsx
index 468b1d5654..ff3bae9e8c 100644
--- a/apps/dashboard/src/lib/config-update.tsx
+++ b/apps/dashboard/src/lib/config-update.tsx
@@ -44,26 +44,29 @@ export function ConfigUpdateDialogProvider({ children }: { children: React.React
const project = await adminApp.getProject();
const source = await project.getPushedConfigSource();
- // For unlinked sources, save directly without showing a dialog
- if (source.type === "unlinked") {
- await project.updatePushedConfig(configUpdate as any);
- return true;
+ let shouldUpdate = true;
+ if (source.type !== "unlinked") {
+ shouldUpdate = await new Promise((resolve) => {
+ setDialogState({
+ isOpen: true,
+ adminApp,
+ configUpdate,
+ resolve,
+ source,
+ isLoadingSource: false,
+ commitMessage: "",
+ // Temporary: 50/50 chance for GitHub dialog
+ showConnectWithGitHub: Math.random() < 0.5,
+ });
+ });
}
- // Show dialog for other source types
- return await new Promise((resolve) => {
- setDialogState({
- isOpen: true,
- adminApp,
- configUpdate,
- resolve,
- source,
- isLoadingSource: false,
- commitMessage: "",
- // Temporary: 50/50 chance for GitHub dialog
- showConnectWithGitHub: Math.random() < 0.5,
- });
- });
+ if (shouldUpdate) {
+ await project.updatePushedConfig(configUpdate);
+ await project.resetConfigOverrideKeys("environment", Object.keys(configUpdate));
+ return true;
+ }
+ return false;
}, []);
const handleClose = useCallback((result: boolean) => {
@@ -266,9 +269,8 @@ export function useUpdateConfig() {
} else {
// Update environment config directly
const project = await adminApp.getProject();
- // Cast to any because the strict type guard prevents direct usage
// eslint-disable-next-line no-restricted-syntax -- this is the hook implementation itself
- await project.updateConfig(configUpdate as any);
+ await project.updateConfig(configUpdate);
return true;
}
}, [showPushableDialog]);
diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts
index 7d1399043c..fc5d260dc5 100644
--- a/apps/e2e/tests/backend/backend-helpers.ts
+++ b/apps/e2e/tests/backend/backend-helpers.ts
@@ -1332,6 +1332,20 @@ export namespace Project {
expect(response.body).toMatchInlineSnapshot(`{ "success": true }`);
expect(response.status).toBe(200);
}
+
+ /**
+ * Reset specific keys from a config override level.
+ * Uses the same nested key logic as the override algorithm.
+ */
+ export async function resetConfigOverrideKeys(level: "branch" | "environment", keys: string[]) {
+ const response = await niceBackendFetch(`/api/latest/internal/config/override/${level}/reset-keys`, {
+ accessType: "admin",
+ method: "POST",
+ body: { keys },
+ });
+ expect(response.body).toMatchInlineSnapshot(`{ "success": true }`);
+ expect(response.status).toBe(200);
+ }
}
export namespace Team {
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts
index 5eafd296f6..c1c4409a4f 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts
@@ -595,8 +595,28 @@ describe("domain config", () => {
},
});
- expect(mixedFormatResponse.status).toBe(400);
- expect(mixedFormatResponse.body).toContain("domains.trustedDomains");
+ expect(mixedFormatResponse).toMatchInlineSnapshot(`
+ NiceResponse {
+ "status": 200,
+ "body": { "success": true },
+ "headers": Headers { },
+ }
+ `);
+
+ const configResponse3 = await niceBackendFetch("/api/v1/internal/config", {
+ method: "GET",
+ accessType: "admin",
+ headers: adminHeaders(adminAccessToken),
+ });
+ const config3 = JSON.parse(configResponse3.body.config_string);
+ expect(config3.domains.trustedDomains).toMatchInlineSnapshot(`
+ {
+ "3": {
+ "baseUrl": "http://nested.example.com",
+ "handlerPath": "/nested",
+ },
+ }
+ `);
});
});
@@ -1309,6 +1329,270 @@ describe("test helpers", () => {
});
+// =============================================================================
+// RESET CONFIG OVERRIDE KEYS TESTS
+// =============================================================================
+
+describe("reset config override keys", () => {
+ it("resets a flat key from environment config override", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Set some environment config
+ await Project.updateConfig({
+ 'teams.allowClientTeamCreation': true,
+ 'users.allowClientUserDeletion': true,
+ });
+
+ // Reset one key
+ await Project.resetConfigOverrideKeys("environment", ["teams.allowClientTeamCreation"]);
+
+ // Verify only the reset key is removed
+ const envResponse = await niceBackendFetch("/api/v1/internal/config/override/environment", {
+ method: "GET",
+ accessType: "admin",
+ headers: adminHeaders(adminAccessToken),
+ });
+ const envConfig = JSON.parse(envResponse.body.config_string);
+ expect(envConfig["teams.allowClientTeamCreation"]).toBeUndefined();
+ expect(envConfig["users.allowClientUserDeletion"]).toBe(true);
+ });
+
+ it("resets a parent key which also removes children", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Set some environment config
+ await Project.updateConfig({
+ 'teams.allowClientTeamCreation': true,
+ 'teams.createPersonalTeamOnSignUp': true,
+ 'users.allowClientUserDeletion': true,
+ });
+
+ // Reset the parent "teams" key — should remove both teams.* keys
+ await Project.resetConfigOverrideKeys("environment", ["teams"]);
+
+ const envResponse = await niceBackendFetch("/api/v1/internal/config/override/environment", {
+ method: "GET",
+ accessType: "admin",
+ headers: adminHeaders(adminAccessToken),
+ });
+ const envConfig = JSON.parse(envResponse.body.config_string);
+ expect(envConfig["teams.allowClientTeamCreation"]).toBeUndefined();
+ expect(envConfig["teams.createPersonalTeamOnSignUp"]).toBeUndefined();
+ expect(envConfig["users.allowClientUserDeletion"]).toBe(true);
+ });
+
+ it("resets keys from branch config override", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Set some branch config
+ await Project.updatePushedConfig({
+ 'teams.allowClientTeamCreation': true,
+ 'users.allowClientUserDeletion': true,
+ });
+
+ // Reset one key from branch level
+ await Project.resetConfigOverrideKeys("branch", ["teams.allowClientTeamCreation"]);
+
+ const branchResponse = await niceBackendFetch("/api/v1/internal/config/override/branch", {
+ method: "GET",
+ accessType: "admin",
+ headers: adminHeaders(adminAccessToken),
+ });
+ const branchConfig = JSON.parse(branchResponse.body.config_string);
+ expect(branchConfig["teams.allowClientTeamCreation"]).toBeUndefined();
+ expect(branchConfig["users.allowClientUserDeletion"]).toBe(true);
+ });
+
+ it("resetting keys causes branch config to take effect over environment", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Set branch config
+ await Project.updatePushedConfig({
+ 'teams.allowClientTeamCreation': true,
+ });
+
+ // Set environment config override that overrides the branch value
+ await Project.updateConfig({
+ 'teams.allowClientTeamCreation': false,
+ });
+
+ // Verify environment takes precedence
+ let configResponse = await niceBackendFetch("/api/v1/internal/config", {
+ method: "GET",
+ accessType: "admin",
+ headers: adminHeaders(adminAccessToken),
+ });
+ let config = JSON.parse(configResponse.body.config_string);
+ expect(config.teams.allowClientTeamCreation).toBe(false);
+
+ // Reset the key from environment — branch config should now win
+ await Project.resetConfigOverrideKeys("environment", ["teams.allowClientTeamCreation"]);
+
+ configResponse = await niceBackendFetch("/api/v1/internal/config", {
+ method: "GET",
+ accessType: "admin",
+ headers: adminHeaders(adminAccessToken),
+ });
+ config = JSON.parse(configResponse.body.config_string);
+ expect(config.teams.allowClientTeamCreation).toBe(true);
+ });
+
+ it("resetting non-existent keys is a no-op", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Set some config
+ await Project.updateConfig({
+ 'teams.allowClientTeamCreation': true,
+ });
+
+ // Reset a key that doesn't exist
+ await Project.resetConfigOverrideKeys("environment", ["nonExistent.key"]);
+
+ // Config should be unchanged
+ const envResponse = await niceBackendFetch("/api/v1/internal/config/override/environment", {
+ method: "GET",
+ accessType: "admin",
+ headers: adminHeaders(adminAccessToken),
+ });
+ const envConfig = JSON.parse(envResponse.body.config_string);
+ expect(envConfig["teams.allowClientTeamCreation"]).toBe(true);
+ });
+
+ it("resetting with empty keys array is a no-op", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Set some config
+ await Project.updateConfig({
+ 'teams.allowClientTeamCreation': true,
+ });
+
+ // Reset with empty array
+ await Project.resetConfigOverrideKeys("environment", []);
+
+ // Config should be unchanged
+ const envResponse = await niceBackendFetch("/api/v1/internal/config/override/environment", {
+ method: "GET",
+ accessType: "admin",
+ headers: adminHeaders(adminAccessToken),
+ });
+ const envConfig = JSON.parse(envResponse.body.config_string);
+ expect(envConfig["teams.allowClientTeamCreation"]).toBe(true);
+ });
+
+ it("resets multiple keys at once", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Set some config
+ await Project.updateConfig({
+ 'teams.allowClientTeamCreation': true,
+ 'teams.createPersonalTeamOnSignUp': true,
+ 'users.allowClientUserDeletion': true,
+ 'auth.passkey.allowSignIn': true,
+ });
+
+ // Reset multiple keys at once
+ await Project.resetConfigOverrideKeys("environment", [
+ "teams.allowClientTeamCreation",
+ "auth.passkey.allowSignIn",
+ ]);
+
+ const envResponse = await niceBackendFetch("/api/v1/internal/config/override/environment", {
+ method: "GET",
+ accessType: "admin",
+ headers: adminHeaders(adminAccessToken),
+ });
+ const envConfig = JSON.parse(envResponse.body.config_string);
+ expect(envConfig["teams.allowClientTeamCreation"]).toBeUndefined();
+ expect(envConfig["auth.passkey.allowSignIn"]).toBeUndefined();
+ expect(envConfig["teams.createPersonalTeamOnSignUp"]).toBe(true);
+ expect(envConfig["users.allowClientUserDeletion"]).toBe(true);
+ });
+
+ it("rejects invalid level", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ const response = await niceBackendFetch("/api/v1/internal/config/override/invalid-level/reset-keys", {
+ method: "POST",
+ accessType: "admin",
+ headers: adminHeaders(adminAccessToken),
+ body: { keys: ["teams.allowClientTeamCreation"] },
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.code).toBe("SCHEMA_ERROR");
+ });
+
+ it("rejects non-admin access", async ({ expect }) => {
+ await Project.createAndSwitch();
+
+ const clientResponse = await niceBackendFetch("/api/v1/internal/config/override/environment/reset-keys", {
+ accessType: "client",
+ method: "POST",
+ body: { keys: ["teams.allowClientTeamCreation"] },
+ });
+ expect(clientResponse.status).toBe(401);
+
+ const serverResponse = await niceBackendFetch("/api/v1/internal/config/override/environment/reset-keys", {
+ accessType: "server",
+ method: "POST",
+ body: { keys: ["teams.allowClientTeamCreation"] },
+ });
+ expect(serverResponse.status).toBe(401);
+ });
+
+ it("handles nested object config with reset", async ({ expect }) => {
+ const { adminAccessToken } = await Project.createAndSwitch();
+
+ // Set config using nested object format
+ await niceBackendFetch("/api/v1/internal/config/override/environment", {
+ method: "PATCH",
+ accessType: "admin",
+ headers: adminHeaders(adminAccessToken),
+ body: {
+ config_override_string: JSON.stringify({
+ auth: {
+ oauth: {
+ providers: {
+ google: {
+ type: 'google',
+ isShared: false,
+ clientId: 'google-client-id',
+ clientSecret: 'google-client-secret',
+ allowSignIn: true,
+ allowConnectedAccounts: true,
+ },
+ },
+ },
+ },
+ 'teams.allowClientTeamCreation': true,
+ }),
+ },
+ });
+
+ // Reset the oauth provider key
+ await Project.resetConfigOverrideKeys("environment", ["auth.oauth.providers.google"]);
+
+ const envResponse = await niceBackendFetch("/api/v1/internal/config/override/environment", {
+ method: "GET",
+ accessType: "admin",
+ headers: adminHeaders(adminAccessToken),
+ });
+ const envConfig = JSON.parse(envResponse.body.config_string);
+ // teams key should still be there
+ expect(envConfig["teams.allowClientTeamCreation"]).toBe(true);
+ // google provider should be removed from nested object (entire auth structure might be cleaned up)
+ const configResponse = await niceBackendFetch("/api/v1/internal/config", {
+ method: "GET",
+ accessType: "admin",
+ headers: adminHeaders(adminAccessToken),
+ });
+ const config = JSON.parse(configResponse.body.config_string);
+ expect(config.auth.oauth.providers.google).toBeUndefined();
+ expect(config.teams.allowClientTeamCreation).toBe(true);
+ });
+});
+
+
// =============================================================================
// BRANCH CONFIG SOURCE TESTS
// =============================================================================
diff --git a/apps/e2e/tests/js/config.test.ts b/apps/e2e/tests/js/config.test.ts
index c658f0ae2b..512a91e3af 100644
--- a/apps/e2e/tests/js/config.test.ts
+++ b/apps/e2e/tests/js/config.test.ts
@@ -401,6 +401,91 @@ describe("updatePushedConfig", () => {
});
+describe("resetConfigOverrideKeys", () => {
+ it("resetConfigOverrideKeys removes keys from environment override", async ({ expect }) => {
+ const { adminApp } = await createApp();
+ const project = await adminApp.getProject();
+
+ // Set branch config
+ await project.updatePushedConfig({
+ 'teams.allowClientTeamCreation': true,
+ });
+
+ // Set environment config that overrides branch
+ await project.updateConfig({
+ 'teams.allowClientTeamCreation': false,
+ });
+
+ // Verify environment takes precedence
+ let config = await project.getConfig();
+ expect(config.teams.allowClientTeamCreation).toBe(false);
+
+ // Reset the key from environment level
+ await project.resetConfigOverrideKeys("environment", ["teams.allowClientTeamCreation"]);
+
+ // Now branch config should win
+ config = await project.getConfig();
+ expect(config.teams.allowClientTeamCreation).toBe(true);
+ });
+
+ it("resetConfigOverrideKeys with parent key removes children", async ({ expect }) => {
+ const { adminApp } = await createApp();
+ const project = await adminApp.getProject();
+
+ // Set environment config with multiple team settings
+ await project.updateConfig({
+ 'teams.allowClientTeamCreation': true,
+ 'teams.createPersonalTeamOnSignUp': true,
+ 'users.allowClientUserDeletion': true,
+ });
+
+ // Reset the parent "teams" key
+ await project.resetConfigOverrideKeys("environment", ["teams"]);
+
+ // Verify teams keys are gone but users key remains
+ const envOverride = await project.getConfigOverride("environment");
+ expect(envOverride["teams.allowClientTeamCreation"]).toBeUndefined();
+ expect(envOverride["teams.createPersonalTeamOnSignUp"]).toBeUndefined();
+ expect(envOverride["users.allowClientUserDeletion"]).toBe(true);
+ });
+
+ it("resetConfigOverrideKeys with non-existent keys is a no-op", async ({ expect }) => {
+ const { adminApp } = await createApp();
+ const project = await adminApp.getProject();
+
+ // Set environment config
+ await project.updateConfig({
+ 'teams.allowClientTeamCreation': true,
+ });
+
+ // Reset a non-existent key
+ await project.resetConfigOverrideKeys("environment", ["nonExistent.key"]);
+
+ // Config should be unchanged
+ const envOverride = await project.getConfigOverride("environment");
+ expect(envOverride["teams.allowClientTeamCreation"]).toBe(true);
+ });
+
+ it("resetConfigOverrideKeys on branch level works", async ({ expect }) => {
+ const { adminApp } = await createApp();
+ const project = await adminApp.getProject();
+
+ // Set branch config
+ await project.updatePushedConfig({
+ 'teams.allowClientTeamCreation': true,
+ 'users.allowClientUserDeletion': true,
+ });
+
+ // Reset one branch key
+ await project.resetConfigOverrideKeys("branch", ["teams.allowClientTeamCreation"]);
+
+ const branchOverride = await project.getConfigOverride("branch");
+ expect(branchOverride["teams.allowClientTeamCreation"]).toBeUndefined();
+ expect(branchOverride["users.allowClientUserDeletion"]).toBe(true);
+ });
+});
+
+
describe("pushedConfigSource", () => {
it("getPushedConfigSource returns unlinked by default", async ({ expect }) => {
const { adminApp } = await createApp();
diff --git a/packages/stack-shared/src/config/format.ts b/packages/stack-shared/src/config/format.ts
index 77613f5a6f..628075ec07 100644
--- a/packages/stack-shared/src/config/format.ts
+++ b/packages/stack-shared/src/config/format.ts
@@ -102,6 +102,83 @@ export function override(c1: Config, ...configs: Config[]) {
};
}
+/**
+ * Removes keys from a config override, using the same nested key logic as the `override` function.
+ * Resetting key "a.b" also resets "a.b.c" (and any other descendants).
+ * Handles both flat dot-notation keys and nested object keys.
+ */
+export function removeKeysFromConfig(config: Config, keysToRemove: string[]): Config {
+ if (keysToRemove.length === 0) return config;
+
+ const result: Config = {};
+ for (const [key, value] of Object.entries(config)) {
+ if (value === undefined) continue;
+
+ // Check if this flat key matches or is a child of any key to remove (same logic as override)
+ const shouldRemove = keysToRemove.some(k => key === k || key.startsWith(k + '.'));
+ if (shouldRemove) continue;
+
+ // Check if any key to remove is a descendant of this key (meaning it's nested inside this value)
+ const childKeysToRemove = keysToRemove
+ .filter(k => k.startsWith(key + '.'))
+ .map(k => k.slice(key.length + 1));
+
+ if (childKeysToRemove.length > 0 && typeof value === 'object' && value !== null && !Array.isArray(value)) {
+ const cleaned = removeKeysFromConfig(value as Config, childKeysToRemove);
+ if (Object.keys(cleaned).length > 0) {
+ result[key] = cleaned;
+ }
+ } else {
+ result[key] = value;
+ }
+ }
+ return result;
+}
+
+import.meta.vitest?.test("removeKeysFromConfig(...)", ({ expect }) => {
+ // Basic flat key removal
+ expect(removeKeysFromConfig({ a: 1, b: 2 }, ["a"])).toEqual({ b: 2 });
+ expect(removeKeysFromConfig({ "a.b": 1, "a.c": 2, d: 3 }, ["a.b"])).toEqual({ "a.c": 2, d: 3 });
+
+ // Removing a parent removes children (flat keys)
+ expect(removeKeysFromConfig({ "a.b": 1, "a.b.c": 2, "a.d": 3 }, ["a.b"])).toEqual({ "a.d": 3 });
+ expect(removeKeysFromConfig({ "a.b": 1, "a.c": 2, "a.d": 3 }, ["a"])).toEqual({});
+ expect(removeKeysFromConfig({ "teams.allowClientTeamCreation": true, "teams.createPersonalTeamOnSignUp": true }, ["teams"])).toEqual({});
+
+ // Nested object key removal
+ expect(removeKeysFromConfig({ teams: { allowClientTeamCreation: true, createPersonalTeamOnSignUp: true } }, ["teams.allowClientTeamCreation"])).toEqual({ teams: { createPersonalTeamOnSignUp: true } });
+ expect(removeKeysFromConfig({ teams: { allowClientTeamCreation: true } }, ["teams.allowClientTeamCreation"])).toEqual({});
+ expect(removeKeysFromConfig({ teams: { allowClientTeamCreation: true, createPersonalTeamOnSignUp: true } }, ["teams"])).toEqual({});
+ expect(removeKeysFromConfig({ a: { b: { c: 1, d: 2 } } }, ["a.b.c"])).toEqual({ a: { b: { d: 2 } } });
+
+ // Mixed flat and nested
+ expect(removeKeysFromConfig({ "teams.allowClientTeamCreation": true, teams: { createPersonalTeamOnSignUp: true } }, ["teams.allowClientTeamCreation"])).toEqual({ teams: { createPersonalTeamOnSignUp: true } });
+ expect(removeKeysFromConfig({ "teams.allowClientTeamCreation": true, teams: { createPersonalTeamOnSignUp: true } }, ["teams"])).toEqual({});
+
+ // Nested with dot-notation inner keys
+ expect(removeKeysFromConfig({ teams: { "a.b": 1 } }, ["teams.a.b"])).toEqual({});
+ expect(removeKeysFromConfig({ teams: { "a.b.c": 1 } }, ["teams.a.b"])).toEqual({});
+
+ // No keys to remove
+ expect(removeKeysFromConfig({ a: 1, b: 2 }, [])).toEqual({ a: 1, b: 2 });
+
+ // Key not present (no-op)
+ expect(removeKeysFromConfig({ a: 1, b: 2 }, ["c"])).toEqual({ a: 1, b: 2 });
+ expect(removeKeysFromConfig({ a: 1, b: 2 }, ["a.b"])).toEqual({ a: 1, b: 2 });
+
+ // Multiple keys to remove
+ expect(removeKeysFromConfig({ "a.b": 1, "c.d": 2, "e.f": 3 }, ["a.b", "e.f"])).toEqual({ "c.d": 2 });
+ expect(removeKeysFromConfig({ a: { b: 1 }, c: { d: 2 } }, ["a.b", "c.d"])).toEqual({});
+
+ // Removing non-object values with nested key path (no-op for non-objects)
+ expect(removeKeysFromConfig({ a: "string" }, ["a.b"])).toEqual({ a: "string" });
+ expect(removeKeysFromConfig({ a: 123 }, ["a.b"])).toEqual({ a: 123 });
+ expect(removeKeysFromConfig({ a: null }, ["a.b"])).toEqual({ a: null });
+
+ // Array values are preserved (not recursed into)
+ expect(removeKeysFromConfig({ a: [1, 2, 3] }, ["a.0"])).toEqual({ a: [1, 2, 3] });
+});
+
import.meta.vitest?.test("override(...)", ({ expect }) => {
expect(
override(
diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts
index d539ba5fee..881ca9db34 100644
--- a/packages/stack-shared/src/interface/admin-interface.ts
+++ b/packages/stack-shared/src/interface/admin-interface.ts
@@ -585,6 +585,20 @@ export class StackAdminInterface extends StackServerInterface {
null,
);
}
+
+ async resetConfigOverrideKeys(level: "branch" | "environment", keys: string[]): Promise {
+ await this.sendAdminRequest(
+ `/internal/config/override/${level}/reset-keys`,
+ {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ },
+ body: JSON.stringify({ keys }),
+ },
+ null,
+ );
+ }
async createEmailTemplate(displayName: string): Promise<{ id: string }> {
const response = await this.sendAdminRequest(
`/internal/email-templates`,
diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
index e2fe155de9..8f9b331b21 100644
--- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
+++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
@@ -244,6 +244,23 @@ export class _StackAdminAppImplIncomplete {
+ await app._interface.resetConfigOverrideKeys(level, keys);
+ await app._refreshProjectConfig();
+ },
+ async getConfigOverride(level: "branch" | "environment"): Promise> {
+ const result = await app._interface.getConfigOverride(level);
+ return JSON.parse(result.config_string);
+ },
+ async replaceConfigOverride(level: "branch" | "environment", config: Record): Promise {
+ if (level === "branch") {
+ const source = await app._interface.getPushedConfigSource();
+ await app._interface.setConfigOverride(level, config, source);
+ } else {
+ await app._interface.setConfigOverride(level, config);
+ }
+ await app._refreshProjectConfig();
+ },
async update(update: AdminProjectUpdateOptions) {
const updateOptions = adminProjectUpdateOptionsToCrud(update);
await app._interface.updateProject(updateOptions);
diff --git a/packages/template/src/lib/stack-app/projects/index.ts b/packages/template/src/lib/stack-app/projects/index.ts
index 863b6a0715..c714fd6cb8 100644
--- a/packages/template/src/lib/stack-app/projects/index.ts
+++ b/packages/template/src/lib/stack-app/projects/index.ts
@@ -59,9 +59,7 @@ export type AdminProject = {
// We have some strict types here in order to prevent accidental overwriting of a top-level property of a config object
updateConfig(
this: AdminProject,
- config: EnvironmentConfigOverrideOverride & {
- [K in keyof EnvironmentConfigNormalizedOverride]: "............................ERROR MESSAGE AFTER THIS LINE............................ You have attempted to update a config object with a top-level property in it (for example `emails`). This is very likely a mistake, and you probably meant to update a nested property instead (for example `emails.server`). If you really meant to update a top-level property (resetting all nested properties to their defaults), cast as any (the code will work at runtime) ............................ERROR MESSAGE BEFORE THIS LINE............................";
- }
+ config: EnvironmentConfigOverrideOverride,
): Promise,
/**
@@ -76,9 +74,7 @@ export type AdminProject = {
*/
pushConfig(
this: AdminProject,
- config: EnvironmentConfigOverrideOverride & {
- [K in keyof EnvironmentConfigNormalizedOverride]: "............................ERROR MESSAGE AFTER THIS LINE............................ You have attempted to update a config object with a top-level property in it (for example `emails`). This is very likely a mistake, and you probably meant to update a nested property instead (for example `emails.server`). If you really meant to update a top-level property (resetting all nested properties to their defaults), cast as any (the code will work at runtime) ............................ERROR MESSAGE BEFORE THIS LINE............................";
- },
+ config: EnvironmentConfigOverrideOverride,
options: PushConfigOptions,
): Promise,
@@ -94,9 +90,7 @@ export type AdminProject = {
*/
updatePushedConfig(
this: AdminProject,
- config: EnvironmentConfigOverrideOverride & {
- [K in keyof EnvironmentConfigNormalizedOverride]: "............................ERROR MESSAGE AFTER THIS LINE............................ You have attempted to update a config object with a top-level property in it (for example `emails`). This is very likely a mistake, and you probably meant to update a nested property instead (for example `emails.server`). If you really meant to update a top-level property (resetting all nested properties to their defaults), cast as any (the code will work at runtime) ............................ERROR MESSAGE BEFORE THIS LINE............................";
- }
+ config: EnvironmentConfigOverrideOverride
): Promise,
/**
@@ -115,6 +109,27 @@ export type AdminProject = {
*/
unlinkPushedConfigSource(this: AdminProject): Promise,
+ /**
+ * Resets (removes) specific keys from the config override at the specified level.
+ * Uses the same nested key logic as the override algorithm: resetting key "a.b" also resets "a.b.c".
+ *
+ * This is useful when updating the pushed config (branch level) and wanting to remove the same keys
+ * from the environment config override so that the branch config values take precedence.
+ */
+ resetConfigOverrideKeys(this: AdminProject, level: "branch" | "environment", keys: string[]): Promise,
+
+ /**
+ * Gets the raw config override at the specified level (before merging/defaults).
+ * Useful for inspecting exactly what's been set at each level.
+ */
+ getConfigOverride(this: AdminProject, level: "branch" | "environment"): Promise>,
+
+ /**
+ * Replaces the entire config override at the specified level.
+ * For branch level, preserves the existing source metadata.
+ */
+ replaceConfigOverride(this: AdminProject, level: "branch" | "environment", config: Record): Promise,
+
getProductionModeErrors(this: AdminProject): Promise,
// NEXT_LINE_PLATFORM react-like
useProductionModeErrors(this: AdminProject): ProductionModeError[],
From 302f2725e2d70d7b72248dc0fd24fc398a57de49 Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Fri, 13 Feb 2026 18:09:18 -0800
Subject: [PATCH 12/21] Better null checks in token fetching logic
---
packages/stack-shared/src/sessions.ts | 34 +++++++++++++++++++++++++--
1 file changed, 32 insertions(+), 2 deletions(-)
diff --git a/packages/stack-shared/src/sessions.ts b/packages/stack-shared/src/sessions.ts
index a07ef5cc8b..dd235c3fe9 100644
--- a/packages/stack-shared/src/sessions.ts
+++ b/packages/stack-shared/src/sessions.ts
@@ -185,10 +185,10 @@ export class InternalSession {
const newTokens = await this.fetchNewTokens();
const expiresInMillis = newTokens?.accessToken.expiresInMillis;
const issuedMillisAgo = newTokens?.accessToken.issuedMillisAgo;
- if (expiresInMillis && expiresInMillis < minMillisUntilExpiration) {
+ if (expiresInMillis !== undefined && expiresInMillis < minMillisUntilExpiration) {
throw new StackAssertionError(`Required access token expiry ${minMillisUntilExpiration}ms is too long; access tokens are too short when they're generated (${expiresInMillis}ms)`);
}
- if (maxMillisSinceIssued !== null && issuedMillisAgo && issuedMillisAgo > maxMillisSinceIssued) {
+ if (maxMillisSinceIssued !== null && issuedMillisAgo !== undefined && issuedMillisAgo > maxMillisSinceIssued) {
throw new StackAssertionError(`Required access token issuance ${maxMillisSinceIssued}ms is too short; access token issuance is too slow (${issuedMillisAgo}ms)`);
}
return newTokens;
@@ -309,3 +309,33 @@ export class InternalSession {
this._refreshPromise = refreshPromise;
}
}
+
+import.meta.vitest?.test("getOrFetchLikelyValidTokens throws when freshly fetched token is already expired", async ({ expect }) => {
+ const nowSeconds = Math.floor(Date.now() / 1000);
+ const token = await new jose.SignJWT({
+ sub: "test-user-id",
+ iat: nowSeconds - 60 * 60,
+ exp: nowSeconds - 30 * 60,
+ iss: "https://issuer.example",
+ aud: "project-id",
+ project_id: "project-id",
+ branch_id: "main",
+ refresh_token_id: "refresh-token-id",
+ role: "authenticated",
+ name: "Test User",
+ email: "test@example.com",
+ email_verified: true,
+ selected_team_id: null,
+ is_anonymous: false,
+ is_restricted: false,
+ restricted_reason: null,
+ }).setProtectedHeader({ alg: "HS256" }).sign(new TextEncoder().encode("secret"));
+
+ const session = new InternalSession({
+ refreshAccessTokenCallback: async () => AccessToken.createIfValid(token),
+ refreshToken: "refresh-token",
+ accessToken: null,
+ });
+
+ await expect(session.getOrFetchLikelyValidTokens(20_000, 75_000)).rejects.toThrow(StackAssertionError);
+});
From da60514f29c329329fc919173cf8d0e7bb90c2fb Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Fri, 13 Feb 2026 18:34:23 -0800
Subject: [PATCH 13/21] Update AGENTS.md
---
AGENTS.md | 1 +
packages/stack-shared/src/sessions.ts | 30 ---------------------------
2 files changed, 1 insertion(+), 30 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index da7fb149cb..c25b62c3ef 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -102,6 +102,7 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
- Do NOT use `as`/`any`/type casts or anything else like that to bypass the type system unless you specifically asked the user about it. Most of the time a place where you would use type casts is not one where you actually need them. Avoid wherever possible.
- When writing database migration files, assume that we have >1,000,000 rows in every table (unless otherwise specified). This means you may have to use CONDITIONALLY_REPEAT_MIGRATION_SENTINEL to avoid running the migration and things like concurrent index builds; see the existing migrations for examples.
- **When building frontend code, always carefully deal with loading and error states.** Be very explicit with these; some components make this easy, eg. the button onClick already takes an async callback for loading state, but make sure this is done everywhere, and make sure errors are NEVER just silently swallowed.
+- Unless very clearly equivalent from types, prefer explicit null/undefinedness checks over boolean checks, eg. `foo == null` instead of `!foo`.
### Code-related
- Use ES6 maps instead of records wherever you can.
diff --git a/packages/stack-shared/src/sessions.ts b/packages/stack-shared/src/sessions.ts
index dd235c3fe9..0793d684fa 100644
--- a/packages/stack-shared/src/sessions.ts
+++ b/packages/stack-shared/src/sessions.ts
@@ -309,33 +309,3 @@ export class InternalSession {
this._refreshPromise = refreshPromise;
}
}
-
-import.meta.vitest?.test("getOrFetchLikelyValidTokens throws when freshly fetched token is already expired", async ({ expect }) => {
- const nowSeconds = Math.floor(Date.now() / 1000);
- const token = await new jose.SignJWT({
- sub: "test-user-id",
- iat: nowSeconds - 60 * 60,
- exp: nowSeconds - 30 * 60,
- iss: "https://issuer.example",
- aud: "project-id",
- project_id: "project-id",
- branch_id: "main",
- refresh_token_id: "refresh-token-id",
- role: "authenticated",
- name: "Test User",
- email: "test@example.com",
- email_verified: true,
- selected_team_id: null,
- is_anonymous: false,
- is_restricted: false,
- restricted_reason: null,
- }).setProtectedHeader({ alg: "HS256" }).sign(new TextEncoder().encode("secret"));
-
- const session = new InternalSession({
- refreshAccessTokenCallback: async () => AccessToken.createIfValid(token),
- refreshToken: "refresh-token",
- accessToken: null,
- });
-
- await expect(session.getOrFetchLikelyValidTokens(20_000, 75_000)).rejects.toThrow(StackAssertionError);
-});
From dea866d0ae8f48c65f3bfe1bf6b7620c1afc252a Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Sun, 15 Feb 2026 17:21:46 -0800
Subject: [PATCH 14/21] Improve sign-up rule error descriptions
---
packages/stack-shared/src/known-errors.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/stack-shared/src/known-errors.tsx b/packages/stack-shared/src/known-errors.tsx
index c713feca92..10b5a42971 100644
--- a/packages/stack-shared/src/known-errors.tsx
+++ b/packages/stack-shared/src/known-errors.tsx
@@ -84,7 +84,7 @@ export abstract class KnownError extends StatusError {
}
}
- throw new Error(`Unknown KnownError code. You may need to update your version of Stack to see more detailed information. ${json.code}: ${json.message}`);
+ throw new Error(`An error occurred. Please update your version of the Stack Auth SDK. ${json.code}: ${json.message}`);
}
}
@@ -731,9 +731,9 @@ const SignUpRejected = createKnownErrorConstructor(
"SIGN_UP_REJECTED",
(message?: string) => [
403,
- message ?? "Your sign up was rejected. Please contact us for more information.",
+ message ?? "Your sign up was rejected by an administrator's sign-up rule.",
{
- message: message ?? "Your sign up was rejected. Please contact us for more information.",
+ message: message ?? "Your sign up was rejected by an administrator's sign-up rule.",
},
] as const,
(json: any) => [json.message] as const,
From 61a248fa7a1a22f33d86541a7d5d965c585a7585 Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Mon, 16 Feb 2026 09:15:52 -0800
Subject: [PATCH 15/21] Fix sign-up rules test
---
.../tests/backend/endpoints/api/v1/auth/sign-up-rules.test.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/sign-up-rules.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/sign-up-rules.test.ts
index 9d22c004d6..0c8c1cb5db 100644
--- a/apps/e2e/tests/backend/endpoints/api/v1/auth/sign-up-rules.test.ts
+++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/sign-up-rules.test.ts
@@ -266,8 +266,8 @@ describe("sign-up rules", () => {
"status": 403,
"body": {
"code": "SIGN_UP_REJECTED",
- "details": { "message": "Your sign up was rejected. Please contact us for more information." },
- "error": "Your sign up was rejected. Please contact us for more information.",
+ "details": { "message": "Your sign up was rejected by an administrator's sign-up rule." },
+ "error": "Your sign up was rejected by an administrator's sign-up rule.",
},
"headers": Headers {
"x-stack-known-error": "SIGN_UP_REJECTED",
From cbe7611e84ce595bf3511f44b07fd874b179d997 Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Mon, 16 Feb 2026 09:28:36 -0800
Subject: [PATCH 16/21] Better error messages for tests
---
.../api/v1/external-db-sync-advanced.test.ts | 15 ++++++-----
.../api/v1/external-db-sync-basics.test.ts | 25 ++++++++-----------
2 files changed, 18 insertions(+), 22 deletions(-)
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
index 7b78360990..3c491ac2b9 100644
--- 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
@@ -1,9 +1,9 @@
-import { wait } from "@stackframe/stack-shared/dist/utils/promises";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
+import { wait } from "@stackframe/stack-shared/dist/utils/promises";
import { Client } from 'pg';
import { afterAll, beforeAll, describe, expect } from 'vitest';
import { test } from '../../../../helpers';
-import { InternalApiKey, Project, User, backendContext, niceBackendFetch } from '../../../backend-helpers';
+import { InternalApiKey, User, backendContext, niceBackendFetch } from '../../../backend-helpers';
import {
HIGH_VOLUME_TIMEOUT,
POSTGRES_HOST,
@@ -39,12 +39,11 @@ async function waitForClickhouseUser(email: string, expectedDisplayName: string)
query: "SELECT primary_email, display_name FROM users WHERE primary_email = {email:String}",
params: { email },
});
- if (
- response.status === 200
- && Array.isArray(response.body?.result)
- && response.body.result.length === 1
- && response.body.result[0]?.display_name === expectedDisplayName
- ) {
+ expect(response).toMatchObject({
+ status: 200,
+ });
+ if (response.body.result.length === 1) {
+ expect(response.body.result[0].display_name).toBe(expectedDisplayName);
return response;
}
await wait(intervalMs);
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
index dad8d747bc..236b73b1dc 100644
--- 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
@@ -1,5 +1,5 @@
-import { wait } from "@stackframe/stack-shared/dist/utils/promises";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
+import { wait } from "@stackframe/stack-shared/dist/utils/promises";
import { afterAll, beforeAll, describe, expect } from 'vitest';
import { test } from '../../../../helpers';
import { Project, User, niceBackendFetch } from '../../../backend-helpers';
@@ -9,7 +9,6 @@ import {
createProjectWithExternalDb as createProjectWithExternalDbRaw,
verifyInExternalDb,
verifyNotInExternalDb,
- waitForCondition,
waitForSyncedData,
waitForSyncedDeletion,
waitForTable
@@ -35,12 +34,11 @@ async function waitForClickhouseUser(email: string, expectedDisplayName: string)
email,
},
});
- if (
- response.status === 200
- && Array.isArray(response.body?.result)
- && response.body.result.length === 1
- && response.body.result[0]?.display_name === expectedDisplayName
- ) {
+ expect(response).toMatchObject({
+ status: 200,
+ });
+ if (response.body.result.length === 1) {
+ expect(response.body.result[0].display_name).toBe(expectedDisplayName);
return response;
}
await wait(intervalMs);
@@ -61,12 +59,11 @@ async function waitForClickhouseUserDeletion(email: string) {
email,
},
});
- if (
- response.status === 200
- && Array.isArray(response.body?.result)
- && response.body.result.length === 0
- ) {
- return;
+ expect(response).toMatchObject({
+ status: 200,
+ });
+ if (response.body.result.length === 0) {
+ return response;
}
await wait(intervalMs);
}
From 06668fde7d1c93e6213aaa24772da5a0fe5706b4 Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Mon, 16 Feb 2026 10:36:31 -0800
Subject: [PATCH 17/21] Fix external DB sync tests
---
.../api/v1/external-db-sync-advanced.test.ts | 6 ++++--
.../endpoints/api/v1/external-db-sync-basics.test.ts | 12 ++++++------
2 files changed, 10 insertions(+), 8 deletions(-)
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
index 3c491ac2b9..7782ce39a8 100644
--- 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
@@ -30,6 +30,9 @@ async function runQueryForCurrentProject(body: { query: string, params?: Record<
}
async function waitForClickhouseUser(email: string, expectedDisplayName: string) {
+ // ensure we definitely have project keys that don't expire (unlike an admin access token)
+ await InternalApiKey.createAndSetProjectKeys();
+
const timeoutMs = 180_000;
const intervalMs = 2_000;
const start = performance.now();
@@ -42,8 +45,7 @@ async function waitForClickhouseUser(email: string, expectedDisplayName: string)
expect(response).toMatchObject({
status: 200,
});
- if (response.body.result.length === 1) {
- expect(response.body.result[0].display_name).toBe(expectedDisplayName);
+ if (response.body.result.length === 1 && response.body.result[0].display_name === expectedDisplayName) {
return response;
}
await wait(intervalMs);
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
index 236b73b1dc..3c0c48b21e 100644
--- 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
@@ -2,7 +2,7 @@ import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
import { afterAll, beforeAll, describe, expect } from 'vitest';
import { test } from '../../../../helpers';
-import { Project, User, niceBackendFetch } from '../../../backend-helpers';
+import { InternalApiKey, Project, User, niceBackendFetch } from '../../../backend-helpers';
import {
TEST_TIMEOUT,
TestDbManager,
@@ -23,6 +23,9 @@ async function runQueryForCurrentProject(body: { query: string, params?: Record<
}
async function waitForClickhouseUser(email: string, expectedDisplayName: string) {
+ // ensure we definitely have project keys that don't expire (unlike an admin access token)
+ await InternalApiKey.createAndSetProjectKeys();
+
const timeoutMs = 180_000;
const intervalMs = 2_000;
const start = performance.now();
@@ -34,11 +37,8 @@ async function waitForClickhouseUser(email: string, expectedDisplayName: string)
email,
},
});
- expect(response).toMatchObject({
- status: 200,
- });
- if (response.body.result.length === 1) {
- expect(response.body.result[0].display_name).toBe(expectedDisplayName);
+ expect(response.status).toBe(200);
+ if (response.body.result.length === 1 && response.body.result[0].display_name === expectedDisplayName) {
return response;
}
await wait(intervalMs);
From 836eb6588f0d816eaaf8c1a9114dc2624435cd41 Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Mon, 16 Feb 2026 10:41:09 -0800
Subject: [PATCH 18/21] Better error logging when db sync fails
---
.../api/v1/external-db-sync-advanced.test.ts | 5 +++--
.../api/v1/external-db-sync-basics.test.ts | 13 +++++++++----
2 files changed, 12 insertions(+), 6 deletions(-)
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
index 7782ce39a8..b1a92f60d4 100644
--- 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
@@ -37,8 +37,9 @@ async function waitForClickhouseUser(email: string, expectedDisplayName: string)
const intervalMs = 2_000;
const start = performance.now();
+ let response;
while (performance.now() - start < timeoutMs) {
- const response = await runQueryForCurrentProject({
+ response = await runQueryForCurrentProject({
query: "SELECT primary_email, display_name FROM users WHERE primary_email = {email:String}",
params: { email },
});
@@ -51,7 +52,7 @@ async function waitForClickhouseUser(email: string, expectedDisplayName: string)
await wait(intervalMs);
}
- throw new StackAssertionError(`Timed out waiting for ClickHouse user ${email} to sync.`);
+ throw new StackAssertionError(`Timed out waiting for ClickHouse user ${email} to sync.`, { response });
}
describe.sequential('External DB Sync - Advanced Tests', () => {
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
index 3c0c48b21e..31aaf597a1 100644
--- 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
@@ -30,8 +30,9 @@ async function waitForClickhouseUser(email: string, expectedDisplayName: string)
const intervalMs = 2_000;
const start = performance.now();
+ let response;
while (performance.now() - start < timeoutMs) {
- const response = await runQueryForCurrentProject({
+ response = await runQueryForCurrentProject({
query: "SELECT primary_email, display_name FROM users WHERE primary_email = {email:String}",
params: {
email,
@@ -44,16 +45,20 @@ async function waitForClickhouseUser(email: string, expectedDisplayName: string)
await wait(intervalMs);
}
- throw new StackAssertionError(`Timed out waiting for ClickHouse user ${email} to sync.`);
+ throw new StackAssertionError(`Timed out waiting for ClickHouse user ${email} to sync.`, { response });
}
async function waitForClickhouseUserDeletion(email: string) {
+ // ensure we definitely have project keys that don't expire (unlike an admin access token)
+ await InternalApiKey.createAndSetProjectKeys();
+
const timeoutMs = 180_000;
const intervalMs = 2_000;
const start = performance.now();
+ let response;
while (performance.now() - start < timeoutMs) {
- const response = await runQueryForCurrentProject({
+ response = await runQueryForCurrentProject({
query: "SELECT primary_email FROM users WHERE primary_email = {email:String}",
params: {
email,
@@ -68,7 +73,7 @@ async function waitForClickhouseUserDeletion(email: string) {
await wait(intervalMs);
}
- throw new StackAssertionError(`Timed out waiting for ClickHouse user ${email} to be deleted.`);
+ throw new StackAssertionError(`Timed out waiting for ClickHouse user ${email} to be deleted.`, { response });
}
// Run tests sequentially to avoid concurrency issues with shared backend state
From 66a7910598627c09d791fcd7ab32cf8a0d35de44 Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Mon, 16 Feb 2026 11:00:49 -0800
Subject: [PATCH 19/21] Add warnings
---
.../migration.sql | 2 +-
.../config/override/[level]/route.tsx | 42 ++++++-
apps/backend/src/lib/config.tsx | 50 ++++++++
.../analytics/queries/page-client.tsx | 9 +-
.../src/components/commands/run-query.tsx | 10 +-
packages/stack-shared/src/config/schema.ts | 117 ++++++++++++++++++
6 files changed, 214 insertions(+), 16 deletions(-)
diff --git a/apps/backend/prisma/migrations/20260202000000_fix_trusted_domains_config/migration.sql b/apps/backend/prisma/migrations/20260202000000_fix_trusted_domains_config/migration.sql
index 1feff0bbf7..7df55629ed 100644
--- a/apps/backend/prisma/migrations/20260202000000_fix_trusted_domains_config/migration.sql
+++ b/apps/backend/prisma/migrations/20260202000000_fix_trusted_domains_config/migration.sql
@@ -113,7 +113,7 @@ FROM rows_to_check;
-- SPLIT_STATEMENT_SENTINEL
-- SINGLE_STATEMENT_SENTINEL
-- RUN_OUTSIDE_TRANSACTION_SENTINEL
-DROP INDEX IF EXISTS "temp_eco_trusted_domains_checked_idx";
+DROP INDEX IF EXISTS /* SCHEMA_NAME_SENTINEL */."temp_eco_trusted_domains_checked_idx";
-- SPLIT_STATEMENT_SENTINEL
-- Clean up: drop temporary column (outside transaction)
diff --git a/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx b/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx
index b9e0af9ef9..a9709e1b7d 100644
--- a/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx
+++ b/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx
@@ -1,10 +1,10 @@
-import { getBranchConfigOverrideQuery, getEnvironmentConfigOverrideQuery, overrideBranchConfigOverride, overrideEnvironmentConfigOverride, setBranchConfigOverride, setBranchConfigOverrideSource, setEnvironmentConfigOverride } from "@/lib/config";
+import { getBranchConfigOverrideQuery, getEnvironmentConfigOverrideQuery, overrideBranchConfigOverride, overrideEnvironmentConfigOverride, setBranchConfigOverride, setBranchConfigOverrideSource, setEnvironmentConfigOverride, validateBranchConfigOverride, validateEnvironmentConfigOverride } from "@/lib/config";
import { enqueueExternalDbSync } from "@/lib/external-db-sync-queue";
import { globalPrismaClient, rawQuery } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { branchConfigSchema, environmentConfigSchema, getConfigOverrideErrors, migrateConfigOverride } from "@stackframe/stack-shared/dist/config/schema";
import { adaptSchema, adminAuthTypeSchema, branchConfigSourceSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
-import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
+import { StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
import * as yup from "yup";
type BranchConfigSourceApi = yup.InferType;
@@ -50,6 +50,12 @@ const levelConfigs = {
branchId: options.branchId,
branchConfigOverrideOverride: options.config,
}),
+ validate: (options: { projectId: string, branchId: string, config: any }) =>
+ validateBranchConfigOverride({
+ projectId: options.projectId,
+ branchId: options.branchId,
+ branchConfigOverride: options.config,
+ }),
requiresSource: true,
},
environment: {
@@ -69,6 +75,12 @@ const levelConfigs = {
branchId: options.branchId,
environmentConfigOverrideOverride: options.config,
}),
+ validate: (options: { projectId: string, branchId: string, config: any }) =>
+ validateEnvironmentConfigOverride({
+ projectId: options.projectId,
+ branchId: options.branchId,
+ environmentConfigOverride: options.config,
+ }),
requiresSource: false,
},
};
@@ -141,6 +153,20 @@ async function parseAndValidateConfig(
return migratedConfig;
}
+async function warnOnValidationFailure(
+ levelConfig: typeof levelConfigs["branch" | "environment"],
+ options: { projectId: string, branchId: string, config: any },
+) {
+ try {
+ const validationResult = await levelConfig.validate(options);
+ if (validationResult.status === "error") {
+ captureError("config-override-validation-warning", `Config override validation warning for project ${options.projectId} (this may not be a logic error, but rather a client/implementation issue — e.g. dot notation into non-existent record entries): ${validationResult.error}`);
+ }
+ } catch (e) {
+ captureError("config-override-validation-check-failed", e);
+ }
+}
+
export const PUT = createSmartRouteHandler({
metadata: {
hidden: true,
@@ -179,6 +205,12 @@ export const PUT = createSmartRouteHandler({
source: req.body.source as BranchConfigSourceApi,
});
+ await warnOnValidationFailure(levelConfig, {
+ projectId: req.auth.tenancy.project.id,
+ branchId: req.auth.tenancy.branchId,
+ config: parsedConfig,
+ });
+
if (req.params.level === "environment" && shouldEnqueueExternalDbSync(parsedConfig)) {
await enqueueExternalDbSync(req.auth.tenancy.id);
}
@@ -220,6 +252,12 @@ export const PATCH = createSmartRouteHandler({
config: parsedConfig,
});
+ await warnOnValidationFailure(levelConfig, {
+ projectId: req.auth.tenancy.project.id,
+ branchId: req.auth.tenancy.branchId,
+ config: parsedConfig,
+ });
+
if (req.params.level === "environment" && shouldEnqueueExternalDbSync(parsedConfig)) {
await enqueueExternalDbSync(req.auth.tenancy.id);
}
diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx
index ffca5d92de..2188d3c8ee 100644
--- a/apps/backend/src/lib/config.tsx
+++ b/apps/backend/src/lib/config.tsx
@@ -727,6 +727,56 @@ import.meta.vitest?.test('_validateConfigOverrideSchemaImpl(...)', async ({ expe
Schema 2:
sourceOfTruth.connectionString must be defined
`));
+
+ // Dot-notation into record entries — silently dropped cases
+ // Dot notation into a record entry that doesn't exist should warn
+ expect(await validateConfigOverrideSchema(recordSchema, {}, { "a.mykey.x": "val" } as any)).toMatchObject(
+ Result.error(expect.stringContaining("silently ignored"))
+ );
+ expect(await validateConfigOverrideSchema(recordSchema, {}, { "a.mykey": "val" })).toEqual(Result.ok(null));
+
+ // When the record entry exists in the base, dot notation should work fine
+ expect(await validateConfigOverrideSchema(recordSchema, { a: { mykey: "old" } }, { "a.mykey": "new" })).toEqual(Result.ok(null));
+
+ // Dot-notation into non-existent record entry in actual schemas
+ expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, {
+ 'domains.trustedDomains.my-domain.baseUrl': 'https://example.com',
+ })).toMatchObject(
+ Result.error(expect.stringContaining("silently ignored"))
+ );
+
+ // Nested object notation should work fine (no warning)
+ expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, {
+ 'domains.trustedDomains.my-domain': {
+ baseUrl: 'https://example.com',
+ handlerPath: '/handler',
+ },
+ })).toEqual(Result.ok(null));
+
+ // Dot notation for static object fields should NOT warn
+ expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, {
+ 'teams.allowClientTeamCreation': true,
+ })).toEqual(Result.ok(null));
+ expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, {
+ 'auth.password.allowSignIn': true,
+ })).toEqual(Result.ok(null));
+ expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, {
+ 'domains.allowLocalhost': true,
+ })).toEqual(Result.ok(null));
+
+ // Dot notation into an oauth provider that doesn't exist should warn
+ expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, {
+ 'auth.oauth.providers.google.clientId': 'test-id',
+ })).toMatchObject(
+ Result.error(expect.stringContaining("silently ignored"))
+ );
+
+ // Dot notation into an oauth provider that exists should NOT warn
+ expect(await validateConfigOverrideSchema(environmentConfigSchema, {
+ auth: { oauth: { providers: { google: { type: 'google', allowSignIn: true } } } },
+ }, {
+ 'auth.oauth.providers.google.clientId': 'test-id',
+ })).toEqual(Result.ok(null));
});
// ---------------------------------------------------------------------------------------------------------------------
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
index ffc2e55a9d..9597e2f51e 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
@@ -29,19 +29,17 @@ import {
TrashIcon,
} from "@phosphor-icons/react";
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
-import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { useCallback, useMemo, useState } from "react";
import { AppEnabledGuard } from "../../app-enabled-guard";
import { PageLayout } from "../../page-layout";
import { useAdminApp } from "../../use-admin-app";
import {
- ConfigFolder,
ErrorDisplay,
FolderWithId,
RowData,
RowDetailDialog,
- VirtualizedFlatTable,
+ VirtualizedFlatTable
} from "../shared";
// Delete icon button for sidebar items
@@ -351,9 +349,8 @@ function QueriesContent() {
// Get folders and queries from environment config
const folders = useMemo((): FolderWithId[] => {
- const analyticsConfig = (config as { analytics?: { queryFolders?: Record } }).analytics
- ?? throwErr("Missing analytics config");
- const queryFolders = analyticsConfig.queryFolders ?? throwErr("Missing queryFolders in analytics config");
+ const analyticsConfig = config.analytics;
+ const queryFolders = analyticsConfig.queryFolders;
return Object.entries(queryFolders)
.map(([id, folder]) => ({
diff --git a/apps/dashboard/src/components/commands/run-query.tsx b/apps/dashboard/src/components/commands/run-query.tsx
index 42eff0b8d3..e214998f36 100644
--- a/apps/dashboard/src/components/commands/run-query.tsx
+++ b/apps/dashboard/src/components/commands/run-query.tsx
@@ -1,7 +1,6 @@
"use client";
import {
- ConfigFolder,
FolderWithId,
isDateValue,
isJsonValue,
@@ -9,7 +8,7 @@ import {
parseClickHouseDate,
RowData,
RowDetailDialog,
- VirtualizedFlatTable,
+ VirtualizedFlatTable
} from "@/app/(main)/(protected)/projects/[projectId]/analytics/shared";
import { useAdminAppIfExists } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
import { useRouter } from "@/components/router";
@@ -39,7 +38,6 @@ import {
WarningCircleIcon
} from "@phosphor-icons/react";
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
-import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { memo, useCallback, useMemo, useState } from "react";
import { CmdKPreviewProps } from "../cmdk-commands";
@@ -187,10 +185,8 @@ function SaveQueryDialog({
// Get folders from config - hooks are now called unconditionally
const config = adminApp.useProject().useConfig();
const folders = useMemo((): FolderWithId[] => {
- // Type assertion because config types may not be updated yet
- const analyticsConfig = (config as { analytics?: { queryFolders?: Record } }).analytics
- ?? throwErr("Missing analytics config");
- const queryFolders = analyticsConfig.queryFolders ?? throwErr("Missing queryFolders in analytics config");
+ const analyticsConfig = config.analytics;
+ const queryFolders = analyticsConfig.queryFolders;
return Object.entries(queryFolders)
.map(([id, folder]) => ({
diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts
index 552d51adbf..2965d31b96 100644
--- a/packages/stack-shared/src/config/schema.ts
+++ b/packages/stack-shared/src/config/schema.ts
@@ -1184,6 +1184,17 @@ export async function getIncompleteConfigWarnings(schem
// every rendered config should also be a config override without errors (regardless of whether it has warnings or not)
await assertNoConfigOverrideErrors(schema, incompleteConfig, { allowPropertiesThatCanNoLongerBeOverridden: true });
+ // Check for dot-notation keys that go through record fields into non-existent entries.
+ // These keys would be silently ignored during config rendering because applyDefaults creates
+ // empty objects for records (function-based defaults) and normalization drops the dot-notation
+ // keys that reference entries that don't exist in those empty objects.
+ const droppedKeys = getDotNotationKeysDroppedByRecords(schema, incompleteConfig);
+ if (droppedKeys.length > 0) {
+ return Result.error(
+ `Dot-notation keys set fields inside non-existent record entries and will be silently ignored during rendering: ${droppedKeys.map(k => JSON.stringify(k)).join(', ')}. Use nested object notation to create new record entries instead of dot notation.`
+ );
+ }
+
let normalized: Config;
try {
normalized = normalize(incompleteConfig, { onDotIntoNull: "empty-object" });
@@ -1210,6 +1221,112 @@ export async function getIncompleteConfigWarnings(schem
throw error;
}
}
+
+/**
+ * Detects dot-notation keys that go through a record field into a non-existent entry.
+ *
+ * For example, `'domains.trustedDomains.2.baseUrl'` goes through the `trustedDomains` record.
+ * If entry `2` doesn't exist as a standalone entry in the config (either as a flat key like
+ * `'domains.trustedDomains.2': {...}` or nested in an object), the dot-notation key will be
+ * silently dropped during rendering.
+ *
+ * This does NOT flag dot-notation keys that only go through static object fields (like
+ * `'domains.allowLocalhost'`), because `applyDefaults` creates those parent objects from
+ * static defaults.
+ */
+function getDotNotationKeysDroppedByRecords(schema: yup.AnySchema, config: Config): string[] {
+ const droppedKeys: string[] = [];
+
+ for (const key of Object.keys(config)) {
+ if (!key.includes('.') || config[key] === undefined) continue;
+
+ const segments = key.split('.');
+ if (isDotNotationKeyDroppedByRecord(schema, config, segments, 0)) {
+ droppedKeys.push(key);
+ }
+ }
+
+ return droppedKeys;
+}
+
+function isDotNotationKeyDroppedByRecord(schema: yup.AnySchema, config: Config, segments: string[], startIndex: number): boolean {
+ let currentSchema = schema;
+
+ for (let i = startIndex; i < segments.length; i++) {
+ const schemaInfo = currentSchema.meta()?.stackSchemaInfo;
+ if (!schemaInfo) return false;
+
+ switch (schemaInfo.type) {
+ case 'record': {
+ // The current segment is a record key. If there are more segments after it,
+ // we're dotting INTO the record entry (setting a field inside it). This only
+ // works if the entry already exists in the config.
+ if (i < segments.length - 1) {
+ const entryPath = segments.slice(0, i + 1);
+ if (!configCreatesEntryAtPath(config, entryPath)) {
+ return true;
+ }
+ }
+ currentSchema = schemaInfo.valueSchema;
+ break;
+ }
+ case 'object': {
+ if (!currentSchema.hasNested(segments[i])) return false;
+ currentSchema = currentSchema.getNested(segments[i]);
+ break;
+ }
+ case 'union': {
+ // Check all variants; if any object/record variant has this segment, follow it
+ for (const variant of schemaInfo.items) {
+ const variantInfo = variant.meta()?.stackSchemaInfo;
+ if (variantInfo?.type === 'object' && variant.hasNested(segments[i])) {
+ return isDotNotationKeyDroppedByRecord(variant, config, segments, i);
+ }
+ if (variantInfo?.type === 'record') {
+ return isDotNotationKeyDroppedByRecord(variant, config, segments, i);
+ }
+ }
+ return false;
+ }
+ default: {
+ return false;
+ }
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Checks whether the config creates an entry at the given path (either via a flat key or
+ * nested inside an object value of a shorter key).
+ */
+function configCreatesEntryAtPath(config: Config, pathSegments: string[]): boolean {
+ const targetKey = pathSegments.join('.');
+
+ for (const [key, value] of Object.entries(config)) {
+ if (value === undefined) continue;
+
+ // Exact flat key match (value can be anything — null means "delete", object means "create")
+ if (key === targetKey) return true;
+
+ // Check if this key is a prefix of the target path and its nested value contains the entry
+ if (targetKey.startsWith(key + '.') && typeof value === 'object' && value !== null && !Array.isArray(value)) {
+ const remainingSegments = targetKey.slice(key.length + 1).split('.');
+ let current: unknown = value;
+ for (const segment of remainingSegments) {
+ if (typeof current !== 'object' || current === null || Array.isArray(current)) {
+ current = undefined;
+ break;
+ }
+ current = (current as Record)[segment];
+ }
+ if (current !== undefined) return true;
+ }
+ }
+
+ return false;
+}
export type ValidatedToHaveNoIncompleteConfigWarnings = yup.InferType;
From 078d7f06499091a65739fb0b954cb0ead64e51b8 Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Mon, 16 Feb 2026 11:23:02 -0800
Subject: [PATCH 20/21] Fix build
---
.../config/override/[level]/route.tsx | 1 -
apps/backend/src/lib/config.tsx | 94 +++++++++++++++----
2 files changed, 75 insertions(+), 20 deletions(-)
diff --git a/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx b/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx
index a9709e1b7d..5fee1b9db6 100644
--- a/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx
+++ b/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx
@@ -53,7 +53,6 @@ const levelConfigs = {
validate: (options: { projectId: string, branchId: string, config: any }) =>
validateBranchConfigOverride({
projectId: options.projectId,
- branchId: options.branchId,
branchConfigOverride: options.config,
}),
requiresSource: true,
diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx
index 2188d3c8ee..886ebca658 100644
--- a/apps/backend/src/lib/config.tsx
+++ b/apps/backend/src/lib/config.tsx
@@ -729,21 +729,49 @@ import.meta.vitest?.test('_validateConfigOverrideSchemaImpl(...)', async ({ expe
`));
// Dot-notation into record entries — silently dropped cases
+ const objectRecordSchema = yupObject({ a: yupRecord(yupString().defined(), yupObject({ x: yupString().optional(), y: yupString().optional() })) }).defined();
+
// Dot notation into a record entry that doesn't exist should warn
- expect(await validateConfigOverrideSchema(recordSchema, {}, { "a.mykey.x": "val" } as any)).toMatchObject(
- Result.error(expect.stringContaining("silently ignored"))
- );
- expect(await validateConfigOverrideSchema(recordSchema, {}, { "a.mykey": "val" })).toEqual(Result.ok(null));
+ expect(await validateConfigOverrideSchema(objectRecordSchema, {}, { "a.mykey.x": "val" })).toMatchInlineSnapshot(`
+ {
+ "error": "[WARNING] Dot-notation keys set fields inside non-existent record entries and will be silently ignored during rendering: "a.mykey.x". Use nested object notation to create new record entries instead of dot notation.",
+ "status": "error",
+ }
+ `);
+
+ // Setting the record entry itself (not dotting into it) should NOT warn
+ expect(await validateConfigOverrideSchema(objectRecordSchema, {}, { "a.mykey": { x: "val" } })).toMatchInlineSnapshot(`
+ {
+ "data": null,
+ "status": "ok",
+ }
+ `);
+
+ // When the record entry exists in the base, dot notation into it should work fine
+ expect(await validateConfigOverrideSchema(objectRecordSchema, { a: { mykey: { x: "old" } } }, { "a.mykey.x": "new" })).toMatchInlineSnapshot(`
+ {
+ "data": null,
+ "status": "ok",
+ }
+ `);
- // When the record entry exists in the base, dot notation should work fine
- expect(await validateConfigOverrideSchema(recordSchema, { a: { mykey: "old" } }, { "a.mykey": "new" })).toEqual(Result.ok(null));
+ // When the record entry exists as a flat key in the same override, dot notation should work fine
+ expect(await validateConfigOverrideSchema(objectRecordSchema, {}, { "a.mykey": { x: "old" }, "a.mykey.y": "new" })).toMatchInlineSnapshot(`
+ {
+ "data": null,
+ "status": "ok",
+ }
+ `);
- // Dot-notation into non-existent record entry in actual schemas
+ // Dot-notation into non-existent record entry in actual schemas (trustedDomains)
expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, {
'domains.trustedDomains.my-domain.baseUrl': 'https://example.com',
- })).toMatchObject(
- Result.error(expect.stringContaining("silently ignored"))
- );
+ })).toMatchInlineSnapshot(`
+ {
+ "error": "[WARNING] Dot-notation keys set fields inside non-existent record entries and will be silently ignored during rendering: "domains.trustedDomains.my-domain.baseUrl". Use nested object notation to create new record entries instead of dot notation.",
+ "status": "error",
+ }
+ `);
// Nested object notation should work fine (no warning)
expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, {
@@ -751,32 +779,60 @@ import.meta.vitest?.test('_validateConfigOverrideSchemaImpl(...)', async ({ expe
baseUrl: 'https://example.com',
handlerPath: '/handler',
},
- })).toEqual(Result.ok(null));
+ })).toMatchInlineSnapshot(`
+ {
+ "data": null,
+ "status": "ok",
+ }
+ `);
// Dot notation for static object fields should NOT warn
expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, {
'teams.allowClientTeamCreation': true,
- })).toEqual(Result.ok(null));
+ })).toMatchInlineSnapshot(`
+ {
+ "data": null,
+ "status": "ok",
+ }
+ `);
expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, {
'auth.password.allowSignIn': true,
- })).toEqual(Result.ok(null));
+ })).toMatchInlineSnapshot(`
+ {
+ "data": null,
+ "status": "ok",
+ }
+ `);
expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, {
'domains.allowLocalhost': true,
- })).toEqual(Result.ok(null));
+ })).toMatchInlineSnapshot(`
+ {
+ "data": null,
+ "status": "ok",
+ }
+ `);
// Dot notation into an oauth provider that doesn't exist should warn
expect(await validateConfigOverrideSchema(environmentConfigSchema, {}, {
'auth.oauth.providers.google.clientId': 'test-id',
- })).toMatchObject(
- Result.error(expect.stringContaining("silently ignored"))
- );
+ })).toMatchInlineSnapshot(`
+ {
+ "error": "[WARNING] Dot-notation keys set fields inside non-existent record entries and will be silently ignored during rendering: "auth.oauth.providers.google.clientId". Use nested object notation to create new record entries instead of dot notation.",
+ "status": "error",
+ }
+ `);
- // Dot notation into an oauth provider that exists should NOT warn
+ // Dot notation into an oauth provider that exists in the base should NOT warn
expect(await validateConfigOverrideSchema(environmentConfigSchema, {
auth: { oauth: { providers: { google: { type: 'google', allowSignIn: true } } } },
}, {
'auth.oauth.providers.google.clientId': 'test-id',
- })).toEqual(Result.ok(null));
+ })).toMatchInlineSnapshot(`
+ {
+ "data": null,
+ "status": "ok",
+ }
+ `);
});
// ---------------------------------------------------------------------------------------------------------------------
From 0523bad72545b05f158007d7f960bfc1b688c71e Mon Sep 17 00:00:00 2001
From: Konstantin Wohlwend
Date: Mon, 16 Feb 2026 11:36:34 -0800
Subject: [PATCH 21/21] Fix lint
---
.../projects/[projectId]/analytics/queries/page-client.tsx | 2 +-
apps/dashboard/src/components/commands/run-query.tsx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
index 9597e2f51e..25f516b6e5 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx
@@ -356,7 +356,7 @@ function QueriesContent() {
.map(([id, folder]) => ({
id,
displayName: folder.displayName,
- sortOrder: folder.sortOrder ?? 0,
+ sortOrder: folder.sortOrder,
queries: Object.entries(folder.queries).map(([queryId, query]) => ({
id: queryId,
displayName: query.displayName,
diff --git a/apps/dashboard/src/components/commands/run-query.tsx b/apps/dashboard/src/components/commands/run-query.tsx
index e214998f36..f737995667 100644
--- a/apps/dashboard/src/components/commands/run-query.tsx
+++ b/apps/dashboard/src/components/commands/run-query.tsx
@@ -192,7 +192,7 @@ function SaveQueryDialog({
.map(([id, folder]) => ({
id,
displayName: folder.displayName,
- sortOrder: folder.sortOrder ?? 0,
+ sortOrder: folder.sortOrder,
queries: Object.entries(folder.queries).map(([queryId, q]) => ({
id: queryId,
displayName: q.displayName,