diff --git a/CLAUDE.md b/CLAUDE.md index 9b400c54..5ab36caa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -254,9 +254,12 @@ The SDK has built-in SSE support with automatic reconnection: ### Analytics Query Pattern The AnalyticsPlugin provides SQL query execution: -- Queries stored in `config/queries/.sql` +- Queries stored in `config/queries/` +- Query file naming determines execution context: + - `.sql` - Executes as service principal (shared cache) + - `.obo.sql` - Executes as user (OBO = On-Behalf-Of, per-user cache) - All queries should be parameterized (use placeholders) -- POST `/api/analytics/:query_key` - Execute query with parameters +- POST `/api/analytics/query/:query_key` - Execute query with parameters - Built-in caching with configurable TTL - Databricks SQL Warehouse connector for execution diff --git a/apps/dev-playground/client/src/routes/analytics.route.tsx b/apps/dev-playground/client/src/routes/analytics.route.tsx index a883206c..d1833107 100644 --- a/apps/dev-playground/client/src/routes/analytics.route.tsx +++ b/apps/dev-playground/client/src/routes/analytics.route.tsx @@ -69,7 +69,7 @@ function AnalyticsRoute() { data: untaggedAppsData, loading: untaggedAppsLoading, error: untaggedAppsError, - } = useAnalyticsQuery("untagged_apps", untaggedAppsParams, { asUser: true }); + } = useAnalyticsQuery("untagged_apps", untaggedAppsParams); const metrics = useMemo(() => { if (!summaryDataRaw || summaryDataRaw.length === 0) { diff --git a/apps/dev-playground/config/queries/untagged_apps.sql b/apps/dev-playground/config/queries/untagged_apps.obo.sql similarity index 100% rename from apps/dev-playground/config/queries/untagged_apps.sql rename to apps/dev-playground/config/queries/untagged_apps.obo.sql diff --git a/llms.txt b/llms.txt index b6484135..b4f576d1 100644 --- a/llms.txt +++ b/llms.txt @@ -431,9 +431,12 @@ WHERE workspace_id = :workspaceId HTTP endpoints exposed (mounted under `/api/analytics`): - `POST /api/analytics/query/:query_key` -- `POST /api/analytics/users/me/query/:query_key` - `GET /api/analytics/arrow-result/:jobId` -- `GET /api/analytics/users/me/arrow-result/:jobId` + +**Query file naming convention determines execution context:** + +- `config/queries/.sql` - Executes as service principal (shared cache) +- `config/queries/.obo.sql` - Executes as user (OBO = On-Behalf-Of, per-user cache) Formats: diff --git a/packages/appkit-ui/src/react/charts/create-chart.tsx b/packages/appkit-ui/src/react/charts/create-chart.tsx index 02233ffd..bca33194 100644 --- a/packages/appkit-ui/src/react/charts/create-chart.tsx +++ b/packages/appkit-ui/src/react/charts/create-chart.tsx @@ -27,7 +27,6 @@ export function createChart( parameters, format, transformer, - asUser, // Data props data, // Common props @@ -42,7 +41,6 @@ export function createChart( parameters?: Record; format?: string; transformer?: unknown; - asUser?: boolean; data?: unknown; height?: number; className?: string; @@ -58,7 +56,6 @@ export function createChart( parameters, format, transformer, - asUser, height, className, ariaLabel, diff --git a/packages/appkit-ui/src/react/charts/types.ts b/packages/appkit-ui/src/react/charts/types.ts index 41d97623..65804a74 100644 --- a/packages/appkit-ui/src/react/charts/types.ts +++ b/packages/appkit-ui/src/react/charts/types.ts @@ -85,12 +85,6 @@ export interface QueryProps extends ChartBaseProps { format?: DataFormat; /** Transform raw data before rendering */ transformer?: (data: T) => T; - /** - * Whether to execute the query as the current user - * @default false - */ - asUser?: boolean; - // Discriminator: cannot use direct data with query data?: never; } diff --git a/packages/appkit-ui/src/react/charts/wrapper.tsx b/packages/appkit-ui/src/react/charts/wrapper.tsx index de6bb455..2910ff9c 100644 --- a/packages/appkit-ui/src/react/charts/wrapper.tsx +++ b/packages/appkit-ui/src/react/charts/wrapper.tsx @@ -12,8 +12,6 @@ import { isArrowTable } from "./types"; // ============================================================================ interface ChartWrapperQueryProps { - /** Whether to execute the query as a user. Default is false. */ - asUser?: boolean; /** Analytics query key */ queryKey: string; /** Query parameters */ @@ -61,7 +59,6 @@ function QueryModeContent({ parameters, format, transformer, - asUser, height, className, ariaLabel, @@ -73,7 +70,6 @@ function QueryModeContent({ parameters, format, transformer, - asUser, }); if (loading) return ; @@ -184,7 +180,6 @@ export function ChartWrapper(props: ChartWrapperProps) { parameters={props.parameters} format={props.format} transformer={props.transformer} - asUser={props.asUser} height={height} className={className} ariaLabel={ariaLabel} diff --git a/packages/appkit-ui/src/react/hooks/types.ts b/packages/appkit-ui/src/react/hooks/types.ts index b9b023f4..5db725fc 100644 --- a/packages/appkit-ui/src/react/hooks/types.ts +++ b/packages/appkit-ui/src/react/hooks/types.ts @@ -41,9 +41,6 @@ export interface UseAnalyticsQueryOptions { /** Whether to automatically start the query when the hook is mounted. Default is true. */ autoStart?: boolean; - - /** Whether to execute the query as a user. Default is false. */ - asUser?: boolean; } /** Result state returned by useAnalyticsQuery */ diff --git a/packages/appkit-ui/src/react/hooks/use-analytics-query.ts b/packages/appkit-ui/src/react/hooks/use-analytics-query.ts index 54cc2349..24e03ea3 100644 --- a/packages/appkit-ui/src/react/hooks/use-analytics-query.ts +++ b/packages/appkit-ui/src/react/hooks/use-analytics-query.ts @@ -1,5 +1,5 @@ -import { ArrowClient, connectSSE } from "@/js"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ArrowClient, connectSSE } from "@/js"; import type { AnalyticsFormat, InferParams, @@ -30,6 +30,10 @@ function getArrowStreamUrl(id: string) { * - `format: "JSON"` (default): Returns typed array from QueryRegistry * - `format: "ARROW"`: Returns TypedArrowTable with row type preserved * + * Note: User context execution is determined by query file naming: + * - `queryKey.obo.sql`: Executes as user (OBO = on-behalf-of / user delegation) + * - `queryKey.sql`: Executes as service principal + * * @param queryKey - Analytics query identifier * @param parameters - Query parameters (type-safe based on QueryRegistry) * @param options - Analytics query settings including format @@ -59,12 +63,9 @@ export function useAnalyticsQuery< const format = options?.format ?? "JSON"; const maxParametersSize = options?.maxParametersSize ?? 100 * 1024; const autoStart = options?.autoStart ?? true; - const asUser = options?.asUser ?? false; const devMode = getDevMode(); - const urlSuffix = asUser - ? `/api/analytics/users/me/query/${encodeURIComponent(queryKey)}${devMode}` - : `/api/analytics/query/${encodeURIComponent(queryKey)}${devMode}`; + const urlSuffix = `/api/analytics/query/${encodeURIComponent(queryKey)}${devMode}`; type ResultType = InferResultByFormat; const [data, setData] = useState(null); diff --git a/packages/appkit-ui/src/react/hooks/use-chart-data.ts b/packages/appkit-ui/src/react/hooks/use-chart-data.ts index acb8f545..d8d0bd38 100644 --- a/packages/appkit-ui/src/react/hooks/use-chart-data.ts +++ b/packages/appkit-ui/src/react/hooks/use-chart-data.ts @@ -25,8 +25,6 @@ export interface UseChartDataOptions { format?: DataFormat; /** Transform data after fetching */ transformer?: (data: T) => T; - /** Whether to execute the query as the current user. @default false */ - asUser?: boolean; } export interface UseChartDataResult { @@ -104,13 +102,7 @@ function resolveFormat( * ``` */ export function useChartData(options: UseChartDataOptions): UseChartDataResult { - const { - queryKey, - parameters, - format = "auto", - transformer, - asUser = false, - } = options; + const { queryKey, parameters, format = "auto", transformer } = options; // Resolve the format to use const resolvedFormat = useMemo( @@ -128,7 +120,6 @@ export function useChartData(options: UseChartDataOptions): UseChartDataResult { } = useAnalyticsQuery(queryKey, parameters, { autoStart: true, format: resolvedFormat, - asUser, }); // Process and transform data diff --git a/packages/appkit-ui/src/react/table/table-wrapper.tsx b/packages/appkit-ui/src/react/table/table-wrapper.tsx index bbadd606..c4e28ff8 100644 --- a/packages/appkit-ui/src/react/table/table-wrapper.tsx +++ b/packages/appkit-ui/src/react/table/table-wrapper.tsx @@ -45,7 +45,6 @@ const CHECKBOX_COLUMN_WIDTH = 40; * @param props.queryKey - The query key to fetch the data * @param props.parameters - The parameters to pass to the query * @param props.transformer - Optional function to transform raw data before creating table - * @param props.asUser - Whether to execute the query as a user. Default is false. * @param props.children - Render function that receives the TanStack Table instance * @param props.className - Optional CSS class name for the wrapper * @param props.ariaLabel - Optional accessibility label @@ -60,7 +59,6 @@ export function TableWrapper( queryKey, parameters, transformer, - asUser = false, children, className, ariaLabel, @@ -78,7 +76,6 @@ export function TableWrapper( const { data, loading, error } = useAnalyticsQuery( queryKey, parameters, - { asUser }, ); useEffect(() => { diff --git a/packages/appkit-ui/src/react/table/types.ts b/packages/appkit-ui/src/react/table/types.ts index d6be90c3..366d29f8 100644 --- a/packages/appkit-ui/src/react/table/types.ts +++ b/packages/appkit-ui/src/react/table/types.ts @@ -12,8 +12,6 @@ export interface TableWrapperProps { parameters: Record; /** Optional function to transform raw data before creating table */ transformer?: (data: TRaw[]) => TProcessed[]; - /** Whether to execute the query as a user. Default is false. */ - asUser?: boolean; /** Render function that receives the TanStack Table instance */ children: (data: Table) => React.ReactNode; /** Optional CSS class name for the wrapper */ diff --git a/packages/appkit/src/analytics/analytics.ts b/packages/appkit/src/analytics/analytics.ts index 97bdec3c..885c4354 100644 --- a/packages/appkit/src/analytics/analytics.ts +++ b/packages/appkit/src/analytics/analytics.ts @@ -65,25 +65,6 @@ export class AnalyticsPlugin extends Plugin { await this._handleQueryRoute(req, res); }, }); - - // User context endpoints - use asUser(req) to execute with user's identity - this.route(router, { - name: "arrowAsUser", - method: "get", - path: "/users/me/arrow-result/:jobId", - handler: async (req: express.Request, res: express.Response) => { - await this.asUser(req)._handleArrowRoute(req, res); - }, - }); - - this.route(router, { - name: "queryAsUser", - method: "post", - path: "/users/me/query/:query_key", - handler: async (req: express.Request, res: express.Response) => { - await this.asUser(req)._handleQueryRoute(req, res); - }, - }); } /** @@ -149,38 +130,42 @@ export class AnalyticsPlugin extends Plugin { plugin: this.name, }); - const queryParameters = - format === "ARROW" - ? { - formatParameters: { - disposition: "EXTERNAL_LINKS", - format: "ARROW_STREAM", - }, - type: "arrow", - } - : { - type: "result", - }; - - // Get user key from current context (automatically includes user ID when in user context) - const userKey = getCurrentUserId(); - if (!query_key) { res.status(400).json({ error: "query_key is required" }); return; } - const query = await this.app.getAppQuery( + const queryResult = await this.app.getAppQuery( query_key, req, this.devFileReader, ); - if (!query) { + if (!queryResult) { res.status(404).json({ error: "Query not found" }); return; } + const { query, isAsUser } = queryResult; + + // get execution context - user-scoped if .obo.sql, otherwise service principal + const executor = isAsUser ? this.asUser(req) : this; + const userKey = getCurrentUserId(); + const executorKey = isAsUser ? userKey : "global"; + + const queryParameters = + format === "ARROW" + ? { + formatParameters: { + disposition: "EXTERNAL_LINKS", + format: "ARROW_STREAM", + }, + type: "arrow", + } + : { + type: "result", + }; + const hashedQuery = this.queryProcessor.hashQuery(query); const defaultConfig: PluginExecuteConfig = { @@ -193,7 +178,7 @@ export class AnalyticsPlugin extends Plugin { JSON.stringify(parameters), JSON.stringify(format), hashedQuery, - userKey, + executorKey, ], }, }; @@ -202,7 +187,7 @@ export class AnalyticsPlugin extends Plugin { default: defaultConfig, }; - await this.executeStream( + await executor.executeStream( res, async (signal) => { const processedParams = await this.queryProcessor.processQueryParams( @@ -210,7 +195,7 @@ export class AnalyticsPlugin extends Plugin { parameters, ); - const result = await this.query( + const result = await executor.query( query, processedParams, queryParameters.formatParameters, @@ -220,7 +205,7 @@ export class AnalyticsPlugin extends Plugin { return { type: queryParameters.type, ...result }; }, streamExecutionSettings, - userKey, + executorKey, ); } diff --git a/packages/appkit/src/analytics/tests/analytics.test.ts b/packages/appkit/src/analytics/tests/analytics.test.ts index cfae3f18..3755bbef 100644 --- a/packages/appkit/src/analytics/tests/analytics.test.ts +++ b/packages/appkit/src/analytics/tests/analytics.test.ts @@ -79,19 +79,29 @@ describe("Analytics Plugin", () => { }); describe("injectRoutes", () => { - test("should register POST routes", () => { + test("should register single POST route for queries", () => { const plugin = new AnalyticsPlugin(config); const { router } = createMockRouter(); plugin.injectRoutes(router); - expect(router.post).toHaveBeenCalledTimes(2); + // Only 1 POST route - asUser is determined by .obo.sql file convention + expect(router.post).toHaveBeenCalledTimes(1); expect(router.post).toHaveBeenCalledWith( "/query/:query_key", expect.any(Function), ); - expect(router.post).toHaveBeenCalledWith( - "/users/me/query/:query_key", + }); + + test("should register GET route for arrow results", () => { + const plugin = new AnalyticsPlugin(config); + const { router } = createMockRouter(); + + plugin.injectRoutes(router); + + expect(router.get).toHaveBeenCalledTimes(1); + expect(router.get).toHaveBeenCalledWith( + "/arrow-result/:jobId", expect.any(Function), ); }); @@ -117,19 +127,20 @@ describe("Analytics Plugin", () => { }); }); - test("/query/:query_key should execute as service account without user token", async () => { + test("/query/:query_key should execute as service principal for .sql files (isAsUser: false)", async () => { const plugin = new AnalyticsPlugin(config); const { router, getHandler } = createMockRouter(); - (plugin as any).app.getAppQuery = vi - .fn() - .mockResolvedValue("SELECT * FROM test"); + // Mock getAppQuery to return a regular .sql file (isAsUser: false) + (plugin as any).app.getAppQuery = vi.fn().mockResolvedValue({ + query: "SELECT * FROM test", + isAsUser: false, + }); let capturedWorkspaceClient: any; const executeMock = vi .fn() .mockImplementation((workspaceClient, ..._args) => { - // Capture the workspaceClient passed capturedWorkspaceClient = workspaceClient; return Promise.resolve({ result: { data: [{ id: 1, name: "test" }] }, @@ -148,10 +159,10 @@ describe("Analytics Plugin", () => { await handler(mockReq, mockRes); - // Verify service workspace client is used (from mocked ServiceContext) + // Verify service workspace client is used expect(capturedWorkspaceClient).toBeDefined(); - // Verify executeStatement is called + // Verify executeStatement is called with correct statement expect(executeMock).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ @@ -182,19 +193,20 @@ describe("Analytics Plugin", () => { expect(mockRes.end).toHaveBeenCalled(); }); - test("/users/me/query/:query_key should execute query with user workspace client", async () => { + test("/query/:query_key should execute as user for .obo.sql files (isAsUser: true)", async () => { const plugin = new AnalyticsPlugin(config); const { router, getHandler } = createMockRouter(); - (plugin as any).app.getAppQuery = vi - .fn() - .mockResolvedValue("SELECT * FROM users WHERE id = :user_id"); + // Mock getAppQuery to return an .obo.sql file (isAsUser: true) + (plugin as any).app.getAppQuery = vi.fn().mockResolvedValue({ + query: "SELECT * FROM users WHERE id = :user_id", + isAsUser: true, + }); let capturedWorkspaceClient: any; const executeMock = vi .fn() .mockImplementation((workspaceClient, ..._args: any[]) => { - // Capture the workspaceClient parameter capturedWorkspaceClient = workspaceClient; return Promise.resolve({ result: { data: [{ user_id: 123, name: "Alice" }] }, @@ -204,8 +216,8 @@ describe("Analytics Plugin", () => { plugin.injectRoutes(router); - const handler = getHandler("POST", "/users/me/query/:query_key"); - // The request needs both x-forwarded-access-token and x-forwarded-user headers + const handler = getHandler("POST", "/query/:query_key"); + // Request with user headers for .obo.sql queries const mockReq = createMockRequest({ params: { query_key: "user_profile" }, body: { parameters: { user_id: sql.number(123) } }, @@ -218,10 +230,10 @@ describe("Analytics Plugin", () => { await handler(mockReq, mockRes); - // Verify a workspace client is used (created via ServiceContext.createUserContext) + // Verify a workspace client is used expect(capturedWorkspaceClient).toBeDefined(); - // Verify the workspace client is passed to SQL connector + // Verify the query is executed with correct statement expect(executeMock).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ @@ -244,13 +256,63 @@ describe("Analytics Plugin", () => { expect(mockRes.end).toHaveBeenCalled(); }); - test("should return cached result on second request", async () => { + test("should use different cache keys for .sql vs .obo.sql queries", async () => { const plugin = new AnalyticsPlugin(config); const { router, getHandler } = createMockRouter(); - (plugin as any).app.getAppQuery = vi - .fn() - .mockResolvedValue("SELECT * FROM test WHERE foo = :foo"); + const getAppQueryMock = vi.fn(); + (plugin as any).app.getAppQuery = getAppQueryMock; + + const executeMock = vi.fn().mockResolvedValue({ + result: { data: [{ id: 1 }] }, + }); + (plugin as any).SQLClient.executeStatement = executeMock; + + plugin.injectRoutes(router); + const handler = getHandler("POST", "/query/:query_key"); + + // First request: .sql file (isAsUser: false) + getAppQueryMock.mockResolvedValueOnce({ + query: "SELECT 1", + isAsUser: false, + }); + + const mockReq1 = createMockRequest({ + params: { query_key: "test_query" }, + body: { parameters: {} }, + }); + const mockRes1 = createMockResponse(); + await handler(mockReq1, mockRes1); + + // Second request: .obo.sql file (isAsUser: true) + getAppQueryMock.mockResolvedValueOnce({ + query: "SELECT 1", + isAsUser: true, + }); + + const mockReq2 = createMockRequest({ + params: { query_key: "test_query" }, + body: { parameters: {} }, + headers: { + "x-forwarded-access-token": "user-token", + "x-forwarded-user": "user-1", + }, + }); + const mockRes2 = createMockResponse(); + await handler(mockReq2, mockRes2); + + // Both should execute (different cache keys due to isAsUser) + expect(executeMock).toHaveBeenCalledTimes(2); + }); + + test("should return cached result on second request for .sql files", async () => { + const plugin = new AnalyticsPlugin(config); + const { router, getHandler } = createMockRouter(); + + (plugin as any).app.getAppQuery = vi.fn().mockResolvedValue({ + query: "SELECT * FROM test WHERE foo = :foo", + isAsUser: false, + }); const executeMock = vi.fn().mockResolvedValue({ result: { data: [{ id: 1, name: "cached" }] }, @@ -277,13 +339,85 @@ describe("Analytics Plugin", () => { expect(mockRes2.write).toHaveBeenCalledWith("event: result\n"); }); - test("should cache user-scoped queries separately per user", async () => { + test("should share cache across users for .sql files (global cache)", async () => { const plugin = new AnalyticsPlugin(config); const { router, getHandler } = createMockRouter(); - (plugin as any).app.getAppQuery = vi - .fn() - .mockResolvedValue("SELECT * FROM users WHERE id = :user_id"); + // Mock returns .sql file (isAsUser: false) - should use global cache + (plugin as any).app.getAppQuery = vi.fn().mockResolvedValue({ + query: "SELECT * FROM shared_data", + isAsUser: false, + }); + + const executeMock = vi.fn().mockResolvedValue({ + result: { data: [{ id: 1, name: "shared" }] }, + }); + (plugin as any).SQLClient.executeStatement = executeMock; + + plugin.injectRoutes(router); + + const handler = getHandler("POST", "/query/:query_key"); + + // User 1's request + const mockReq1 = createMockRequest({ + params: { query_key: "shared_query" }, + body: { parameters: {} }, + headers: { + "x-forwarded-access-token": "user-token-1", + "x-forwarded-user": "user-1", + }, + }); + const mockRes1 = createMockResponse(); + await handler(mockReq1, mockRes1); + + // User 2's request - different user, but should use shared cache + const mockReq2 = createMockRequest({ + params: { query_key: "shared_query" }, + body: { parameters: {} }, + headers: { + "x-forwarded-access-token": "user-token-2", + "x-forwarded-user": "user-2", + }, + }); + const mockRes2 = createMockResponse(); + await handler(mockReq2, mockRes2); + + // User 3's request - also should use shared cache + const mockReq3 = createMockRequest({ + params: { query_key: "shared_query" }, + body: { parameters: {} }, + headers: { + "x-forwarded-access-token": "user-token-3", + "x-forwarded-user": "user-3", + }, + }); + const mockRes3 = createMockResponse(); + await handler(mockReq3, mockRes3); + + // Only 1 execution - cache is shared across all users for .sql files + expect(executeMock).toHaveBeenCalledTimes(1); + + // All users get the same cached result + expect(mockRes1.write).toHaveBeenCalledWith( + expect.stringContaining('"name":"shared"'), + ); + expect(mockRes2.write).toHaveBeenCalledWith( + expect.stringContaining('"name":"shared"'), + ); + expect(mockRes3.write).toHaveBeenCalledWith( + expect.stringContaining('"name":"shared"'), + ); + }); + + test("should cache user-scoped .obo.sql queries separately per user", async () => { + const plugin = new AnalyticsPlugin(config); + const { router, getHandler } = createMockRouter(); + + // Mock returns .obo.sql file (isAsUser: true) + (plugin as any).app.getAppQuery = vi.fn().mockResolvedValue({ + query: "SELECT * FROM users WHERE id = :user_id", + isAsUser: true, + }); const executeMock = vi .fn() @@ -297,7 +431,7 @@ describe("Analytics Plugin", () => { plugin.injectRoutes(router); - const handler = getHandler("POST", "/users/me/query/:query_key"); + const handler = getHandler("POST", "/query/:query_key"); // User 1's request const mockReq1 = createMockRequest({ @@ -353,9 +487,10 @@ describe("Analytics Plugin", () => { const plugin = new AnalyticsPlugin(config); const { router, getHandler } = createMockRouter(); - (plugin as any).app.getAppQuery = vi - .fn() - .mockResolvedValue("SELECT * FROM test"); + (plugin as any).app.getAppQuery = vi.fn().mockResolvedValue({ + query: "SELECT * FROM test", + isAsUser: false, + }); const executeMock = vi .fn() @@ -389,5 +524,29 @@ describe("Analytics Plugin", () => { expect.any(AbortSignal), ); }); + + test("should return 404 when query file is not found", async () => { + const plugin = new AnalyticsPlugin(config); + const { router, getHandler } = createMockRouter(); + + // Mock getAppQuery to return null (query not found) + (plugin as any).app.getAppQuery = vi.fn().mockResolvedValue(null); + + plugin.injectRoutes(router); + + const handler = getHandler("POST", "/query/:query_key"); + const mockReq = createMockRequest({ + params: { query_key: "nonexistent_query" }, + body: { parameters: {} }, + }); + const mockRes = createMockResponse(); + + await handler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(404); + expect(mockRes.json).toHaveBeenCalledWith({ + error: "Query not found", + }); + }); }); }); diff --git a/packages/appkit/src/app/index.ts b/packages/appkit/src/app/index.ts index 01ef4f6c..b171a4a6 100644 --- a/packages/appkit/src/app/index.ts +++ b/packages/appkit/src/app/index.ts @@ -13,6 +13,11 @@ interface DevFileReader { readFile(filePath: string, req: RequestLike): Promise; } +interface QueryResult { + query: string; + isAsUser: boolean; +} + export class AppManager { /** * Retrieves a query file by key from the queries directory @@ -27,7 +32,7 @@ export class AppManager { queryKey: string, req?: RequestLike, devFileReader?: DevFileReader, - ): Promise { + ): Promise { // Security: Sanitize query key to prevent path traversal if (!queryKey || !/^[a-zA-Z0-9_-]+$/.test(queryKey)) { logger.error( @@ -37,56 +42,92 @@ export class AppManager { return null; } - const queryFilePath = path.join( - process.cwd(), - "config/queries", - `${queryKey}.sql`, - ); + const queriesDir = path.resolve(process.cwd(), "config/queries"); + + // priority order: .obo.sql first (asUser), then .sql (default) + const oboFileName = `${queryKey}.obo.sql`; + const defaultFileName = `${queryKey}.sql`; + + let queryFileName: string | null = null; + let isAsUser: boolean = false; + + try { + const files = await fs.readdir(queriesDir); - // Security: Validate resolved path is within queries directory + // check for OBO query first + if (files.includes(oboFileName)) { + queryFileName = oboFileName; + isAsUser = true; + + // check for both files and warn if both are present + if (files.includes(defaultFileName)) { + logger.warn( + `Both ${oboFileName} and ${defaultFileName} found for query ${queryKey}. Using ${oboFileName}.`, + ); + } + // check for default query if OBO query is not present + } else if (files.includes(defaultFileName)) { + queryFileName = defaultFileName; + isAsUser = false; + } + } catch (error) { + logger.error( + `Failed to read queries directory: ${(error as Error).message}`, + ); + return null; + } + + if (!queryFileName) { + logger.error(`Query file not found: ${queryKey}`); + return null; + } + + const queryFilePath = path.join(queriesDir, queryFileName); + + // security: validate resolved path is within queries directory const resolvedPath = path.resolve(queryFilePath); - const queriesDir = path.resolve(process.cwd(), "config/queries"); + const resolvedQueriesDir = path.resolve(queriesDir); - if (!resolvedPath.startsWith(queriesDir)) { - logger.error("Invalid query path: path traversal detected"); + if (!resolvedPath.startsWith(resolvedQueriesDir)) { + logger.error(`Invalid query path: path traversal detected`); return null; } - // Check if we're in dev mode and should use WebSocket + // check if we're in dev mode and should use WebSocket const isDevMode = req?.query?.dev !== undefined; - if (isDevMode && devFileReader && req) { try { - // Read from local filesystem via WebSocket tunnel const relativePath = path.relative(process.cwd(), resolvedPath); - return await devFileReader.readFile(relativePath, req); + return { + query: await devFileReader.readFile(relativePath, req), + isAsUser, + }; } catch (error) { logger.error( - "Failed to read query %s from dev tunnel: %s", - queryKey, - (error as Error).message, + `Failed to read query from dev tunnel: ${(error as Error).message}`, ); return null; } } - // Production mode: read from server filesystem + // production mode: read from server filesystem try { const query = await fs.readFile(resolvedPath, "utf8"); - return query; + return { query, isAsUser }; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { - logger.debug("Query %s not found at path: %s", queryKey, resolvedPath); + logger.error( + `Failed to read query from server filesystem: ${(error as Error).message}`, + ); return null; } + logger.error( - "Failed to read query %s from server filesystem: %s", - queryKey, - (error as Error).message, + `Failed to read query from server filesystem: ${(error as Error).message}`, ); return null; } } } -export type { DevFileReader, RequestLike }; +export type { DevFileReader, QueryResult, RequestLike }; diff --git a/packages/appkit/src/type-generator/query-registry.ts b/packages/appkit/src/type-generator/query-registry.ts index 91f4de1f..dd2202a3 100644 --- a/packages/appkit/src/type-generator/query-registry.ts +++ b/packages/appkit/src/type-generator/query-registry.ts @@ -137,7 +137,8 @@ export async function generateQueriesFromDescribe( // process each query file for (let i = 0; i < queryFiles.length; i++) { const file = queryFiles[i]; - const queryName = path.basename(file, ".sql"); + const rawName = path.basename(file, ".sql"); + const queryName = normalizeQueryName(rawName); // read query file content const sql = fs.readFileSync(path.join(queryFolder, file), "utf8"); @@ -202,6 +203,15 @@ export async function generateQueriesFromDescribe( return querySchemas; } +/** + * Normalize query name by removing the .obo extension + * @param queryName - the query name to normalize + * @returns the normalized query name + */ +export function normalizeQueryName(fileName: string): string { + return fileName.replace(/\.obo$/, ""); +} + /** * Normalize SQL type name by removing parameters/generics * Examples: