Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
59d70a9
Add INLINE + ARROW_STREAM format support for analytics plugin
jamesbroadhead Apr 3, 2026
b1566ea
Add tests for ARROW_STREAM and ARROW format parameter handling
jamesbroadhead Apr 3, 2026
dbe8ea3
fix: propagate ARROW_STREAM format to UI layer and typegen
jamesbroadhead Apr 3, 2026
8fe05d8
fix: default analytics format to ARROW_STREAM for broadest warehouse …
jamesbroadhead Apr 3, 2026
4725c97
feat: automatic format fallback for warehouse compatibility
jamesbroadhead Apr 3, 2026
a4ad7b0
fix: handle ARROW_STREAM + INLINE data in _transformDataArray
jamesbroadhead Apr 14, 2026
1e17f5f
feat: decode inline Arrow IPC attachments from serverless warehouses
jamesbroadhead Apr 14, 2026
055cd41
test: add 147 tests for service-context, stream-registry, genie conne…
jamesbroadhead Apr 15, 2026
a003274
refactor: use API enum names (JSON_ARRAY, ARROW_STREAM) and simplify …
jamesbroadhead Apr 16, 2026
2351f38
fix: address ACE multi-model review findings
jamesbroadhead Apr 27, 2026
997d6a7
fix: keep ARROW_STREAM contract consistent across INLINE/EXTERNAL_LINKS
jamesbroadhead Apr 27, 2026
694feed
test: drop unrelated files-plugin upload tests from this PR
jamesbroadhead Apr 27, 2026
e1e9017
fix: handle ARROW_STREAM attachment in type generator
jamesbroadhead Apr 28, 2026
003a309
fix: address ACE iter-2 review findings
jamesbroadhead Apr 28, 2026
cf50679
fix: synthesize empty Arrow IPC for empty ARROW_STREAM responses
jamesbroadhead Apr 28, 2026
aef9042
fix: align SSE event size with inline Arrow attachment cap (8 MiB)
jamesbroadhead Apr 28, 2026
6e8b12e
refactor: proposal — stash inline Arrow IPC, serve via /arrow-result
jamesbroadhead Apr 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ describe("isQueryProps", () => {
const props = {
queryKey: "test_query",
parameters: { limit: 100 },
format: "json" as const,
format: "json_array" as const,
};

expect(isQueryProps(props as any)).toBe(true);
Expand Down
6 changes: 3 additions & 3 deletions packages/appkit-ui/src/react/charts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Table } from "apache-arrow";
// ============================================================================

/** Supported data formats for analytics queries */
export type DataFormat = "json" | "arrow" | "auto";
export type DataFormat = "json_array" | "arrow_stream" | "auto";

/** Chart orientation */
export type Orientation = "vertical" | "horizontal";
Expand Down Expand Up @@ -77,8 +77,8 @@ export interface QueryProps extends ChartBaseProps {
parameters?: Record<string, unknown>;
/**
* Data format to use
* - "json": Use JSON format (smaller payloads, simpler)
* - "arrow": Use Arrow format (faster for large datasets)
* - "json_array": Use JSON format (smaller payloads, simpler)
* - "arrow_stream": Use Arrow format (faster for large datasets)
* - "auto": Automatically select based on expected data size
* @default "auto"
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";

// Capture the onMessage handler so tests can drive SSE messages directly.
let lastConnectArgs: any = null;
const mockProcessArrowBuffer = vi.fn();
const mockFetchArrow = vi.fn();

vi.mock("@/js", () => ({
connectSSE: vi.fn((args: any) => {
lastConnectArgs = args;
return () => {};
}),
ArrowClient: {
fetchArrow: (...args: unknown[]) => mockFetchArrow(...args),
processArrowBuffer: (...args: unknown[]) => mockProcessArrowBuffer(...args),
},
}));

// useQueryHMR is a no-op shim for tests; mock to avoid HMR side effects.
vi.mock("../use-query-hmr", () => ({
useQueryHMR: vi.fn(),
}));

import { useAnalyticsQuery } from "../use-analytics-query";

describe("useAnalyticsQuery", () => {
beforeEach(() => {
vi.clearAllMocks();
lastConnectArgs = null;
});

test("fetches Arrow IPC via /arrow-result for type:arrow (covers both inline-stash and external-link paths)", async () => {
const fakeTable = { numRows: 0, schema: { fields: [] } };
mockFetchArrow.mockResolvedValueOnce(new Uint8Array([1, 2, 3]));
mockProcessArrowBuffer.mockResolvedValueOnce(fakeTable);

const { result } = renderHook(() =>
useAnalyticsQuery("q", null, { format: "ARROW_STREAM" }),
);

// Server emits the same {type:"arrow", statement_id} shape regardless of
// whether the bytes came from the warehouse (EXTERNAL_LINKS) or were
// stashed locally (INLINE).
await lastConnectArgs.onMessage({
data: JSON.stringify({ type: "arrow", statement_id: "inline-abc" }),
});

await waitFor(() => {
expect(result.current.data).toBe(fakeTable);
});
expect(mockFetchArrow).toHaveBeenCalledTimes(1);
expect(mockFetchArrow.mock.calls[0][0]).toBe(
"/api/analytics/arrow-result/inline-abc",
);
});

test("still handles type:result rows for JSON_ARRAY", async () => {
const { result } = renderHook(() =>
useAnalyticsQuery("q", null, { format: "JSON_ARRAY" }),
);

await lastConnectArgs.onMessage({
data: JSON.stringify({
type: "result",
data: [{ id: 1 }, { id: 2 }],
}),
});

await waitFor(() => {
expect(result.current.data).toEqual([{ id: 1 }, { id: 2 }]);
});
expect(mockProcessArrowBuffer).not.toHaveBeenCalled();
});

test("surfaces an error when /arrow-result fetch fails", async () => {
mockFetchArrow.mockRejectedValueOnce(new Error("HTTP 404"));

const { result } = renderHook(() =>
useAnalyticsQuery("q", null, { format: "ARROW_STREAM" }),
);

await lastConnectArgs.onMessage({
data: JSON.stringify({ type: "arrow", statement_id: "inline-stale" }),
});

await waitFor(() => {
expect(result.current.error).toBe(
"Unable to load data, please try again",
);
});
expect(result.current.loading).toBe(false);
});
});
40 changes: 20 additions & 20 deletions packages/appkit-ui/src/react/hooks/__tests__/use-chart-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ describe("useChartData", () => {
});

describe("format selection", () => {
test("uses JSON format when explicitly specified", () => {
test("uses JSON_ARRAY format when explicitly specified", () => {
mockUseAnalyticsQuery.mockReturnValue({
data: [],
loading: false,
Expand All @@ -82,18 +82,18 @@ describe("useChartData", () => {
renderHook(() =>
useChartData({
queryKey: "test",
format: "json",
format: "json_array",
}),
);

expect(mockUseAnalyticsQuery).toHaveBeenCalledWith(
"test",
undefined,
expect.objectContaining({ format: "JSON" }),
expect.objectContaining({ format: "JSON_ARRAY" }),
);
});

test("uses ARROW format when explicitly specified", () => {
test("uses ARROW_STREAM format when explicitly specified", () => {
mockUseAnalyticsQuery.mockReturnValue({
data: [],
loading: false,
Expand All @@ -103,18 +103,18 @@ describe("useChartData", () => {
renderHook(() =>
useChartData({
queryKey: "test",
format: "arrow",
format: "arrow_stream",
}),
);

expect(mockUseAnalyticsQuery).toHaveBeenCalledWith(
"test",
undefined,
expect.objectContaining({ format: "ARROW" }),
expect.objectContaining({ format: "ARROW_STREAM" }),
);
});

test("auto-selects ARROW for large limit", () => {
test("auto-selects ARROW_STREAM for large limit", () => {
mockUseAnalyticsQuery.mockReturnValue({
data: [],
loading: false,
Expand All @@ -132,11 +132,11 @@ describe("useChartData", () => {
expect(mockUseAnalyticsQuery).toHaveBeenCalledWith(
"test",
{ limit: 1000 },
expect.objectContaining({ format: "ARROW" }),
expect.objectContaining({ format: "ARROW_STREAM" }),
);
});

test("auto-selects ARROW for date range queries", () => {
test("auto-selects ARROW_STREAM for date range queries", () => {
mockUseAnalyticsQuery.mockReturnValue({
data: [],
loading: false,
Expand All @@ -157,7 +157,7 @@ describe("useChartData", () => {
expect(mockUseAnalyticsQuery).toHaveBeenCalledWith(
"test",
expect.objectContaining({ startDate: "2025-01-01" }),
expect.objectContaining({ format: "ARROW" }),
expect.objectContaining({ format: "ARROW_STREAM" }),
);
});

Expand All @@ -179,7 +179,7 @@ describe("useChartData", () => {
expect(mockUseAnalyticsQuery).toHaveBeenCalledWith(
"test",
expect.anything(),
expect.objectContaining({ format: "JSON" }),
expect.objectContaining({ format: "JSON_ARRAY" }),
);
});

Expand All @@ -201,11 +201,11 @@ describe("useChartData", () => {
expect(mockUseAnalyticsQuery).toHaveBeenCalledWith(
"test",
expect.anything(),
expect.objectContaining({ format: "ARROW" }),
expect.objectContaining({ format: "ARROW_STREAM" }),
);
});

test("auto-selects JSON by default when no heuristics match", () => {
test("auto-selects JSON_ARRAY by default when no heuristics match", () => {
mockUseAnalyticsQuery.mockReturnValue({
data: [],
loading: false,
Expand All @@ -223,11 +223,11 @@ describe("useChartData", () => {
expect(mockUseAnalyticsQuery).toHaveBeenCalledWith(
"test",
{ limit: 100 },
expect.objectContaining({ format: "JSON" }),
expect.objectContaining({ format: "JSON_ARRAY" }),
);
});

test("defaults to auto format (JSON) when format is not specified", () => {
test("defaults to auto format (JSON_ARRAY) when format is not specified", () => {
mockUseAnalyticsQuery.mockReturnValue({
data: [],
loading: false,
Expand All @@ -243,7 +243,7 @@ describe("useChartData", () => {
expect(mockUseAnalyticsQuery).toHaveBeenCalledWith(
"test",
undefined,
expect.objectContaining({ format: "JSON" }),
expect.objectContaining({ format: "JSON_ARRAY" }),
);
});
});
Expand Down Expand Up @@ -353,29 +353,29 @@ describe("useChartData", () => {
expect(result.current.isArrow).toBe(false);
});

test("isArrow reflects requested ARROW format when data is null", () => {
test("isArrow reflects requested ARROW_STREAM format when data is null", () => {
mockUseAnalyticsQuery.mockReturnValue({
data: null,
loading: true,
error: null,
});

const { result } = renderHook(() =>
useChartData({ queryKey: "test", format: "arrow" }),
useChartData({ queryKey: "test", format: "arrow_stream" }),
);

expect(result.current.isArrow).toBe(true);
});

test("isArrow reflects requested JSON format when data is null", () => {
test("isArrow reflects requested JSON_ARRAY format when data is null", () => {
mockUseAnalyticsQuery.mockReturnValue({
data: null,
loading: true,
error: null,
});

const { result } = renderHook(() =>
useChartData({ queryKey: "test", format: "json" }),
useChartData({ queryKey: "test", format: "json_array" }),
);

expect(result.current.isArrow).toBe(false);
Expand Down
12 changes: 8 additions & 4 deletions packages/appkit-ui/src/react/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Table } from "apache-arrow";
// ============================================================================

/** Supported response formats for analytics queries */
export type AnalyticsFormat = "JSON" | "ARROW";
export type AnalyticsFormat = "JSON_ARRAY" | "ARROW_STREAM";

/**
* Typed Arrow Table - preserves row type information for type inference.
Expand All @@ -32,8 +32,10 @@ export interface TypedArrowTable<
// ============================================================================

/** Options for configuring an analytics SSE query */
export interface UseAnalyticsQueryOptions<F extends AnalyticsFormat = "JSON"> {
/** Response format - "JSON" returns typed arrays, "ARROW" returns TypedArrowTable */
export interface UseAnalyticsQueryOptions<
F extends AnalyticsFormat = "JSON_ARRAY",
> {
/** Response format - "JSON_ARRAY" (default) returns typed arrays, "ARROW_STREAM" uses Arrow (inline or external links) */
format?: F;

/** Maximum size of serialized parameters in bytes */
Expand Down Expand Up @@ -120,7 +122,9 @@ export type InferResultByFormat<
T,
K,
F extends AnalyticsFormat,
> = F extends "ARROW" ? TypedArrowTable<InferRowType<K>> : InferResult<T, K>;
> = F extends "ARROW_STREAM"
? TypedArrowTable<InferRowType<K>>
: InferResult<T, K>;

/**
* Infers parameters type from QueryRegistry[K]["parameters"]
Expand Down
18 changes: 10 additions & 8 deletions packages/appkit-ui/src/react/hooks/use-analytics-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ function getArrowStreamUrl(id: string) {
* Integration hook between client and analytics plugin.
*
* The return type is automatically inferred based on the format:
* - `format: "JSON"` (default): Returns typed array from QueryRegistry
* - `format: "ARROW"`: Returns TypedArrowTable with row type preserved
* - `format: "JSON_ARRAY"` (default): Returns typed array from QueryRegistry
* - `format: "ARROW_STREAM"`: 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)
Expand All @@ -39,28 +39,28 @@ function getArrowStreamUrl(id: string) {
* @param options - Analytics query settings including format
* @returns Query result state with format-appropriate data type
*
* @example JSON format (default)
* @example JSON_ARRAY format (default)
* ```typescript
* const { data } = useAnalyticsQuery("spend_data", params);
* // data: Array<{ group_key: string; cost_usd: number; ... }> | null
* ```
*
* @example Arrow format
* @example ARROW_STREAM format
* ```typescript
* const { data } = useAnalyticsQuery("spend_data", params, { format: "ARROW" });
* const { data } = useAnalyticsQuery("spend_data", params, { format: "ARROW_STREAM" });
* // data: TypedArrowTable<{ group_key: string; cost_usd: number; ... }> | null
* ```
*/
export function useAnalyticsQuery<
T = unknown,
K extends QueryKey = QueryKey,
F extends AnalyticsFormat = "JSON",
F extends AnalyticsFormat = "JSON_ARRAY",
>(
queryKey: K,
parameters?: InferParams<K> | null,
options: UseAnalyticsQueryOptions<F> = {} as UseAnalyticsQueryOptions<F>,
): UseAnalyticsQueryResult<InferResultByFormat<T, K, F>> {
const format = options?.format ?? "JSON";
const format = options?.format ?? "JSON_ARRAY";
const maxParametersSize = options?.maxParametersSize ?? 100 * 1024;
const autoStart = options?.autoStart ?? true;

Expand Down Expand Up @@ -129,7 +129,9 @@ export function useAnalyticsQuery<
return;
}

// success - Arrow format
// success - Arrow format. The server delivers Arrow IPC bytes via
// /arrow-result/:jobId for both INLINE (stashed server-side) and
// EXTERNAL_LINKS (forwarded from the warehouse) responses.
if (parsed.type === "arrow") {
try {
const arrowData = await ArrowClient.fetchArrow(
Expand Down
Loading
Loading