Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
840a67b
refactor: rename AnalyticsFormat values to API enum names
jamesbroadhead Apr 29, 2026
09392bb
refactor(analytics): accept legacy "JSON"/"ARROW" format aliases
jamesbroadhead May 11, 2026
08c5486
feat: decode inline Arrow IPC + warehouse-compat fallback
jamesbroadhead Apr 29, 2026
2ef0c65
fix: address ACE multi-model review findings
jamesbroadhead May 12, 2026
2b22d56
chore(shared): align zod with appkit's 4.3.6
jamesbroadhead May 12, 2026
a37c100
Merge remote-tracking branch 'origin/main' into rebase/329-on-new-main
jamesbroadhead May 12, 2026
90ecd8a
chore: regenerate pnpm-lock.yaml after zod restoration
jamesbroadhead May 12, 2026
3d54009
style: consolidate normalizeAnalyticsFormat into the types import block
jamesbroadhead May 12, 2026
e6c2aae
fix: restore logger.error in executeStatement catch block
jamesbroadhead May 12, 2026
a7434f6
refactor(analytics): stash inline Arrow server-side, drop arrow_inlin…
jamesbroadhead May 12, 2026
f34e18e
fix: address ACE multi-model review on the inline-stash redesign
jamesbroadhead May 12, 2026
09f801f
docs(stash): correct maxBytes comment after switch to reject-on-full
jamesbroadhead May 12, 2026
698a264
style: drop unused imports and tidy stash test types
jamesbroadhead May 12, 2026
f5b9604
Merge origin/main into stack/arrow-3-inline-arrow-fix
jamesbroadhead May 15, 2026
8f0e31c
test(analytics): cover stash-full fallback to EXTERNAL_LINKS
jamesbroadhead May 15, 2026
0f5022f
feat(appkit): retry JSON_ARRAY as ARROW_STREAM on inline-arrow-only w…
jamesbroadhead May 15, 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
30 changes: 23 additions & 7 deletions docs/docs/api/appkit/Class.ExecutionError.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ throw new ExecutionError("Statement was canceled");
new ExecutionError(message: string, options?: {
cause?: Error;
context?: Record<string, unknown>;
errorCode?: string;
}): ExecutionError;
```

Expand All @@ -30,15 +31,16 @@ new ExecutionError(message: string, options?: {
| Parameter | Type |
| ------ | ------ |
| `message` | `string` |
| `options?` | \{ `cause?`: `Error`; `context?`: `Record`\<`string`, `unknown`\>; \} |
| `options?` | \{ `cause?`: `Error`; `context?`: `Record`\<`string`, `unknown`\>; `errorCode?`: `string`; \} |
| `options.cause?` | `Error` |
| `options.context?` | `Record`\<`string`, `unknown`\> |
| `options.errorCode?` | `string` |

#### Returns

`ExecutionError`

#### Inherited from
#### Overrides

[`AppKitError`](Class.AppKitError.md).[`constructor`](Class.AppKitError.md#constructor)

Expand Down Expand Up @@ -86,6 +88,19 @@ Additional context for the error

***

### errorCode?

```ts
readonly optional errorCode: string;
```

Structured error code from the upstream source (typically the warehouse's
`error_code` for statement-level failures, or the SDK's `ApiError.errorCode`
for HTTP failures). Preserved through wrapping so callers can branch on a
stable identifier without substring-matching the message.

***

### isRetryable

```ts
Expand Down Expand Up @@ -202,16 +217,17 @@ Create an execution error for closed/expired results
### statementFailed()

```ts
static statementFailed(errorMessage?: string): ExecutionError;
static statementFailed(errorMessage?: string, errorCode?: string): ExecutionError;
```

Create an execution error for statement failure
Create an execution error for statement failure.

#### Parameters

| Parameter | Type |
| ------ | ------ |
| `errorMessage?` | `string` |
| Parameter | Type | Description |
| ------ | ------ | ------ |
| `errorMessage?` | `string` | Human-readable error from the warehouse / SDK. |
| `errorCode?` | `string` | Structured code (e.g. "INVALID_PARAMETER_VALUE") to preserve through wrapping. Optional. |

#### Returns

Expand Down
6 changes: 5 additions & 1 deletion packages/appkit-ui/src/js/sse/connect-sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ export async function connectSSE<Payload = unknown>(
lastEventId: initialLastEventId = null,
retryDelay = 2000,
maxRetries = 3,
maxBufferSize = 1024 * 1024, // 1MB
// 1 MiB — matches the server's `streamDefaults.maxEventSize`. SSE
// carries only short JSON control messages; bulk Arrow payloads flow
// over plain HTTP via `/api/analytics/arrow-result/:jobId`, so this
// buffer never needs to hold multi-MiB attachments.
maxBufferSize = 1 * 1024 * 1024,
timeout = 300000, // 5 minutes
onError,
} = options;
Expand Down
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
22 changes: 18 additions & 4 deletions packages/appkit-ui/src/react/charts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,22 @@ import type { Table } from "apache-arrow";
// Data Format Types
// ============================================================================

/** Supported data formats for analytics queries */
export type DataFormat = "json" | "arrow" | "auto";
/**
* Supported data formats for analytics queries.
*
* "json" and "arrow" are legacy aliases kept for backwards compatibility
* with appkit-ui < 0.33.0 — safe to remove once no consumer is on a
* pre-0.33.0 version. resolveFormat() normalizes them to their canonical
* equivalents before any downstream code reads the value.
*/
export type DataFormat =
| "json_array"
| "arrow_stream"
| "auto"
/** @deprecated Use "json_array". Safe to remove once no consumer is on appkit-ui < 0.33.0. */
| "json"
/** @deprecated Use "arrow_stream". Safe to remove once no consumer is on appkit-ui < 0.33.0. */
| "arrow";

/** Chart orientation */
export type Orientation = "vertical" | "horizontal";
Expand Down Expand Up @@ -77,8 +91,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
@@ -1,50 +1,184 @@
import { renderHook } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";

// Mock connectSSE so the hook does not attempt a real network request.
const mockConnectSSE = vi.fn().mockImplementation((_opts: unknown) => {
// Return a never-resolving promise; tests don't need the result.
return new Promise<void>(() => {});
import { renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";

let lastConnectArgs: any = null;
const mockProcessArrowBuffer = vi.fn();
const mockFetchArrow = vi.fn();
const mockConnectSSE = vi.fn((args: any) => {
lastConnectArgs = args;
return () => {};
});

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

// Stub useQueryHMR so we don't pull in import.meta.hot wiring.
vi.mock("../use-query-hmr", () => ({
useQueryHMR: () => {},
useQueryHMR: vi.fn(),
}));

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

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

test("fetches an arrow message (warehouse statement id) via /arrow-result", async () => {
const fakeTable = { numRows: 1, schema: { fields: [] } };
const fakeBytes = new Uint8Array([1, 2, 3]);
mockFetchArrow.mockResolvedValueOnce(fakeBytes);
mockProcessArrowBuffer.mockResolvedValueOnce(fakeTable);

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

await lastConnectArgs.onMessage({
data: JSON.stringify({ type: "arrow", statement_id: "stmt-warehouse-1" }),
});

await waitFor(() => {
expect(result.current.data).toBe(fakeTable);
});

expect(mockFetchArrow).toHaveBeenCalledTimes(1);
expect(mockFetchArrow).toHaveBeenCalledWith(
"/api/analytics/arrow-result/stmt-warehouse-1",
);
expect(mockProcessArrowBuffer).toHaveBeenCalledWith(fakeBytes);
});

test("fetches an arrow message with synthetic inline- id through the same /arrow-result path", async () => {
// The client must treat inline and external-links responses uniformly —
// it never decodes base64 locally. The /arrow-result route on the
// server is the only place that knows which path the bytes came from.
const fakeTable = { numRows: 1, schema: { fields: [] } };
const fakeBytes = new Uint8Array([1, 2, 3, 4, 5]);
mockFetchArrow.mockResolvedValueOnce(fakeBytes);
mockProcessArrowBuffer.mockResolvedValueOnce(fakeTable);

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

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

await waitFor(() => {
expect(result.current.data).toBe(fakeTable);
});

expect(mockFetchArrow).toHaveBeenCalledTimes(1);
expect(mockFetchArrow).toHaveBeenCalledWith(
"/api/analytics/arrow-result/inline-abc-xyz",
);
});

test("surfaces an error when the arrow fetch fails", async () => {
mockFetchArrow.mockRejectedValueOnce(new Error("network"));

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

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

await waitFor(() => {
expect(result.current.error).toBe(
"Unable to load data, please try again",
);
});
expect(result.current.loading).toBe(false);
});

test("rejects the retired arrow_inline message type as schema-invalid", async () => {
// arrow_inline was the prior wire shape. The discriminated union no
// longer accepts it, so it falls through to the generic error/code
// branch — but critically, it must NEVER trigger ArrowClient calls.
const { result } = renderHook(() =>
useAnalyticsQuery("q", null, { format: "ARROW_STREAM" }),
);

await lastConnectArgs.onMessage({
data: JSON.stringify({ type: "arrow_inline", attachment: "AQID" }),
});

await waitFor(() => {
expect(
result.current.loading ||
result.current.error ||
result.current.data === null,
).toBeTruthy();
});
expect(mockProcessArrowBuffer).not.toHaveBeenCalled();
expect(mockFetchArrow).not.toHaveBeenCalled();
});

test("normalizes an empty result message (no data field) to []", async () => {
// The wire schema makes `data` optional — empty result sets may omit
// it. The hook must surface that as an explicit empty array rather
// than `undefined`, so callers can rely on `data` being either null
// (no message yet) or a value of the inferred result type.
const { result } = renderHook(() =>
useAnalyticsQuery("q", null, { format: "JSON_ARRAY" }),
);

await lastConnectArgs.onMessage({
data: JSON.stringify({ type: "result" }),
});

await waitFor(() => {
expect(result.current.data).toEqual([]);
});
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});

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();
expect(mockFetchArrow).not.toHaveBeenCalled();
});

test("does not refetch when params object is structurally equal across renders", () => {
// Each render passes a fresh object literal — the common footgun.
const { rerender } = renderHook(
({ limit }: { limit: number }) =>
// biome-ignore lint/suspicious/noExplicitAny: typed registry not available in tests
useAnalyticsQuery("test_query" as any, { limit } as any),
{ initialProps: { limit: 10 } },
);

// Initial render triggers exactly one connection.
expect(mockConnectSSE).toHaveBeenCalledTimes(1);

// Re-render with structurally-equal-but-new-reference params.
rerender({ limit: 10 });
rerender({ limit: 10 });
rerender({ limit: 10 });

// Should NOT have refetched — the hook stabilized the params reference.
expect(mockConnectSSE).toHaveBeenCalledTimes(1);
});

Expand Down
Loading
Loading