@vibedash/client 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Lazy-initialized DuckDB-WASM singleton.
3
+ *
4
+ * Only loaded when the first cached query is detected — dashboards that don't
5
+ * use caching never pay the bundle cost (~200KB JS + 4MB WASM).
6
+ */
7
+ interface DuckDBInstance {
8
+ query: (sql: string) => Promise<Record<string, unknown>[]>;
9
+ loadParquet: (name: string, url: string, forceReload?: boolean) => Promise<void>;
10
+ isTableLoaded: (name: string) => boolean;
11
+ }
12
+ export declare function getDuckDB(): Promise<DuckDBInstance>;
13
+ export {};
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Lazy-initialized DuckDB-WASM singleton.
3
+ *
4
+ * Only loaded when the first cached query is detected — dashboards that don't
5
+ * use caching never pay the bundle cost (~200KB JS + 4MB WASM).
6
+ */
7
+ let dbPromise = null;
8
+ export function getDuckDB() {
9
+ if (!dbPromise) {
10
+ dbPromise = initDuckDB();
11
+ }
12
+ return dbPromise;
13
+ }
14
+ async function initDuckDB() {
15
+ const duckdb = await import("@duckdb/duckdb-wasm");
16
+ // Use CDN-hosted bundles (browser caches these)
17
+ const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles();
18
+ const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES);
19
+ const worker = new Worker(bundle.mainWorker);
20
+ const logger = new duckdb.ConsoleLogger();
21
+ const db = new duckdb.AsyncDuckDB(logger, worker);
22
+ await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
23
+ const conn = await db.connect();
24
+ const loadedTables = new Set();
25
+ return {
26
+ async query(sql) {
27
+ const result = await conn.query(sql);
28
+ return result.toArray().map((row) => row.toJSON());
29
+ },
30
+ async loadParquet(name, url, forceReload = false) {
31
+ if (loadedTables.has(name) && !forceReload)
32
+ return;
33
+ // Fetch the Parquet file and register it
34
+ const response = await fetch(url);
35
+ const buffer = await response.arrayBuffer();
36
+ await db.registerFileBuffer(`${name}.parquet`, new Uint8Array(buffer));
37
+ // Drop existing table on force reload so we pick up the new data
38
+ if (forceReload && loadedTables.has(name)) {
39
+ await conn.query(`DROP TABLE IF EXISTS "${name}"`);
40
+ }
41
+ await conn.query(`CREATE TABLE IF NOT EXISTS "${name}" AS SELECT * FROM read_parquet('${name}.parquet')`);
42
+ loadedTables.add(name);
43
+ },
44
+ isTableLoaded(name) {
45
+ return loadedTables.has(name);
46
+ },
47
+ };
48
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { useQuery } from "./use-query";
2
- export { VibeDashProvider, useVibeDash } from "./provider";
2
+ export { VibeDashProvider, useVibeDash, useRefreshAll } from "./provider";
3
3
  export type { UseQueryOptions, UseQueryResult, VibeDashConfig } from "./types";
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export { useQuery } from "./use-query";
2
- export { VibeDashProvider, useVibeDash } from "./provider";
2
+ export { VibeDashProvider, useVibeDash, useRefreshAll } from "./provider";
@@ -1,5 +1,13 @@
1
1
  import type { VibeDashConfig } from "./types";
2
- export declare function VibeDashProvider({ apiUrl, projectSlug, apiKey, children, }: VibeDashConfig & {
2
+ type RefetchFn = () => Promise<void>;
3
+ interface VibeDashContextValue extends VibeDashConfig {
4
+ registerQuery: (name: string, fn: RefetchFn, cachedAt: Date | null) => void;
5
+ unregisterQuery: (name: string) => void;
6
+ refreshAll: () => Promise<void>;
7
+ }
8
+ export declare function VibeDashProvider({ apiUrl, projectSlug, dashboardSlug, apiKey, children, }: VibeDashConfig & {
3
9
  children: React.ReactNode;
4
10
  }): import("react/jsx-runtime").JSX.Element;
5
- export declare function useVibeDash(): VibeDashConfig;
11
+ export declare function useVibeDash(): VibeDashContextValue;
12
+ export declare function useRefreshAll(): () => Promise<void>;
13
+ export {};
package/dist/provider.js CHANGED
@@ -1,14 +1,103 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { createContext, useContext } from "react";
2
+ import { createContext, useContext, useCallback, useRef, useEffect, useState, } from "react";
3
3
  const VibeDashContext = createContext(null);
4
- export function VibeDashProvider({ apiUrl, projectSlug, apiKey, children, }) {
5
- return (_jsx(VibeDashContext.Provider, { value: { apiUrl, projectSlug, apiKey }, children: children }));
4
+ const POLL_INTERVAL_ACTIVE = 30000; // 30s when caching is on
5
+ const POLL_INTERVAL_IDLE = 300000; // 5min when caching is off
6
+ export function VibeDashProvider({ apiUrl, projectSlug, dashboardSlug, apiKey, children, }) {
7
+ const queryMap = useRef(new Map());
8
+ const [pollInterval, setPollInterval] = useState(POLL_INTERVAL_IDLE);
9
+ const registerQuery = useCallback((name, fn, cachedAt) => {
10
+ queryMap.current.set(name, { refetch: fn, cachedAt });
11
+ }, []);
12
+ const unregisterQuery = useCallback((name) => {
13
+ queryMap.current.delete(name);
14
+ }, []);
15
+ const refreshAll = useCallback(async () => {
16
+ const fns = Array.from(queryMap.current.values()).map((e) => e.refetch);
17
+ await Promise.allSettled(fns.map((fn) => fn()));
18
+ }, []);
19
+ // Auto-polling for live cache updates
20
+ useEffect(() => {
21
+ if (!dashboardSlug)
22
+ return;
23
+ let timeoutId;
24
+ let cancelled = false;
25
+ async function poll() {
26
+ if (cancelled)
27
+ return;
28
+ try {
29
+ const headers = {
30
+ "Content-Type": "application/json",
31
+ };
32
+ if (apiKey) {
33
+ headers["Authorization"] = `Bearer ${apiKey}`;
34
+ }
35
+ const res = await fetch(`${apiUrl}/query/status`, {
36
+ method: "POST",
37
+ headers,
38
+ credentials: apiKey ? "omit" : "include",
39
+ body: JSON.stringify({
40
+ project_slug: projectSlug,
41
+ dashboard_slug: dashboardSlug,
42
+ }),
43
+ });
44
+ if (!res.ok)
45
+ return;
46
+ const status = await res.json();
47
+ // Adjust poll interval based on cache mode
48
+ const newInterval = status.cache_mode && status.cache_mode !== "none"
49
+ ? POLL_INTERVAL_ACTIVE
50
+ : POLL_INTERVAL_IDLE;
51
+ setPollInterval(newInterval);
52
+ // Check for stale queries and refetch
53
+ if (status.queries) {
54
+ for (const [queryName, info] of Object.entries(status.queries)) {
55
+ const entry = queryMap.current.get(queryName);
56
+ if (!entry || !info.cached_at)
57
+ continue;
58
+ const serverCachedAt = new Date(info.cached_at);
59
+ const clientCachedAt = entry.cachedAt;
60
+ // Server has newer data — trigger refetch
61
+ if (!clientCachedAt ||
62
+ serverCachedAt.getTime() > clientCachedAt.getTime()) {
63
+ entry.refetch();
64
+ }
65
+ }
66
+ }
67
+ }
68
+ catch {
69
+ // Silently fail — will retry next interval
70
+ }
71
+ if (!cancelled) {
72
+ timeoutId = setTimeout(poll, pollInterval);
73
+ }
74
+ }
75
+ // Start first poll after a short delay (let queries mount first)
76
+ timeoutId = setTimeout(poll, 5000);
77
+ return () => {
78
+ cancelled = true;
79
+ clearTimeout(timeoutId);
80
+ };
81
+ }, [apiUrl, projectSlug, dashboardSlug, apiKey, pollInterval]);
82
+ return (_jsx(VibeDashContext.Provider, { value: {
83
+ apiUrl,
84
+ projectSlug,
85
+ dashboardSlug,
86
+ apiKey,
87
+ registerQuery,
88
+ unregisterQuery,
89
+ refreshAll,
90
+ }, children: children }));
6
91
  }
7
92
  export function useVibeDash() {
8
93
  const ctx = useContext(VibeDashContext);
9
94
  if (!ctx) {
10
95
  throw new Error("useVibeDash must be used within a <VibeDashProvider>. " +
11
- "Wrap your app in <VibeDashProvider apiUrl=\"...\" projectSlug=\"...\">.");
96
+ 'Wrap your app in <VibeDashProvider apiUrl="..." projectSlug="...">.');
12
97
  }
13
98
  return ctx;
14
99
  }
100
+ export function useRefreshAll() {
101
+ const { refreshAll } = useVibeDash();
102
+ return refreshAll;
103
+ }
package/dist/types.d.ts CHANGED
@@ -3,6 +3,8 @@ export interface VibeDashConfig {
3
3
  apiUrl: string;
4
4
  /** The project slug (e.g. "data-man") */
5
5
  projectSlug: string;
6
+ /** The dashboard slug — enables auto-polling for live cache updates */
7
+ dashboardSlug?: string;
6
8
  /** API key for authentication (used in local dev, read from env) */
7
9
  apiKey?: string;
8
10
  }
@@ -17,6 +19,10 @@ export interface UseQueryResult<T = Record<string, unknown>[]> {
17
19
  loading: boolean;
18
20
  /** Error message if the query failed */
19
21
  error: string | null;
20
- /** Re-run the query manually */
22
+ /** Re-run the query manually (forces cache refresh if cached) */
21
23
  refetch: () => Promise<void>;
24
+ /** When the cached data was last refreshed (undefined if not cached) */
25
+ cachedAt?: Date;
26
+ /** Run SQL locally against cached data via DuckDB-WASM (undefined if not cached) */
27
+ query?: (sql: string) => Promise<Record<string, unknown>[]>;
22
28
  }
@@ -2,13 +2,26 @@ import type { UseQueryOptions, UseQueryResult } from "./types";
2
2
  /**
3
3
  * Fetches data from a registered query by name.
4
4
  *
5
+ * If the dashboard has a cache TTL set in the Hub, data is served from a cached
6
+ * Parquet file and loaded into DuckDB-WASM in the browser. This enables instant
7
+ * client-side SQL filtering via the returned `query()` function.
8
+ *
9
+ * If no cache TTL is set, data comes from BigQuery live (existing behavior).
10
+ *
11
+ * Data stays fresh automatically — when the Hub refreshes the cache in the
12
+ * background, the provider detects the change and triggers a reload.
13
+ *
5
14
  * Usage:
6
15
  * ```tsx
7
- * const { data, loading, error } = useQuery("monthly_revenue");
8
- * ```
16
+ * const { data, query, loading, cachedAt } = useQuery("monthly_revenue");
17
+ *
18
+ * // All rows (works with or without cache)
19
+ * const allRows = data;
9
20
  *
10
- * The query must be registered at deploy time. The browser never sends raw SQL —
11
- * only the query name. Vibe Dash looks up the SQL server-side, runs it against
12
- * your BigQuery warehouse using stored credentials, and returns the results.
21
+ * // Filter locally (only when cached)
22
+ * if (query) {
23
+ * const filtered = await query("SELECT * FROM monthly_revenue WHERE region = 'US'");
24
+ * }
25
+ * ```
13
26
  */
14
27
  export declare function useQuery<T = Record<string, unknown>[]>(queryName: string, options?: UseQueryOptions): UseQueryResult<T>;
package/dist/use-query.js CHANGED
@@ -1,24 +1,41 @@
1
- import { useState, useEffect, useCallback } from "react";
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
2
  import { useVibeDash } from "./provider";
3
3
  /**
4
4
  * Fetches data from a registered query by name.
5
5
  *
6
+ * If the dashboard has a cache TTL set in the Hub, data is served from a cached
7
+ * Parquet file and loaded into DuckDB-WASM in the browser. This enables instant
8
+ * client-side SQL filtering via the returned `query()` function.
9
+ *
10
+ * If no cache TTL is set, data comes from BigQuery live (existing behavior).
11
+ *
12
+ * Data stays fresh automatically — when the Hub refreshes the cache in the
13
+ * background, the provider detects the change and triggers a reload.
14
+ *
6
15
  * Usage:
7
16
  * ```tsx
8
- * const { data, loading, error } = useQuery("monthly_revenue");
9
- * ```
17
+ * const { data, query, loading, cachedAt } = useQuery("monthly_revenue");
18
+ *
19
+ * // All rows (works with or without cache)
20
+ * const allRows = data;
10
21
  *
11
- * The query must be registered at deploy time. The browser never sends raw SQL —
12
- * only the query name. Vibe Dash looks up the SQL server-side, runs it against
13
- * your BigQuery warehouse using stored credentials, and returns the results.
22
+ * // Filter locally (only when cached)
23
+ * if (query) {
24
+ * const filtered = await query("SELECT * FROM monthly_revenue WHERE region = 'US'");
25
+ * }
26
+ * ```
14
27
  */
15
28
  export function useQuery(queryName, options) {
16
- const { apiUrl, projectSlug, apiKey } = useVibeDash();
29
+ const { apiUrl, projectSlug, apiKey, registerQuery, unregisterQuery } = useVibeDash();
17
30
  const [data, setData] = useState(null);
18
31
  const [loading, setLoading] = useState(false);
19
32
  const [error, setError] = useState(null);
33
+ const [cachedAt, setCachedAt] = useState(undefined);
34
+ // Store the query function in a ref so it's stable
35
+ const queryFnRef = useRef(undefined);
36
+ const [queryReady, setQueryReady] = useState(false);
20
37
  const enabled = options?.enabled !== false;
21
- const fetchData = useCallback(async () => {
38
+ const fetchData = useCallback(async (forceRefresh = false) => {
22
39
  setLoading(true);
23
40
  setError(null);
24
41
  try {
@@ -35,6 +52,7 @@ export function useQuery(queryName, options) {
35
52
  body: JSON.stringify({
36
53
  project_slug: projectSlug,
37
54
  query_name: queryName,
55
+ force_refresh: forceRefresh,
38
56
  }),
39
57
  });
40
58
  if (!res.ok) {
@@ -42,7 +60,30 @@ export function useQuery(queryName, options) {
42
60
  throw new Error(body.error || `Query failed with status ${res.status}`);
43
61
  }
44
62
  const body = await res.json();
45
- setData(body.data);
63
+ if (body.cached && body.parquet_url) {
64
+ // Cached response — load Parquet into DuckDB-WASM
65
+ const { getDuckDB } = await import("./duckdb-singleton");
66
+ const db = await getDuckDB();
67
+ // Force reload on refresh, otherwise reuse if already loaded
68
+ if (forceRefresh || !db.isTableLoaded(queryName)) {
69
+ await db.loadParquet(queryName, body.parquet_url, forceRefresh);
70
+ }
71
+ // Read all rows as default data
72
+ const allRows = await db.query(`SELECT * FROM "${queryName}"`);
73
+ setData(allRows);
74
+ const newCachedAt = new Date(body.cached_at);
75
+ setCachedAt(newCachedAt);
76
+ // Expose local SQL query function
77
+ queryFnRef.current = (sql) => db.query(sql);
78
+ setQueryReady(true);
79
+ }
80
+ else {
81
+ // Live response — standard JSON data
82
+ setData(body.data);
83
+ setCachedAt(undefined);
84
+ queryFnRef.current = undefined;
85
+ setQueryReady(false);
86
+ }
46
87
  }
47
88
  catch (err) {
48
89
  const message = err instanceof Error ? err.message : "Failed to fetch data";
@@ -51,11 +92,28 @@ export function useQuery(queryName, options) {
51
92
  finally {
52
93
  setLoading(false);
53
94
  }
54
- }, [apiUrl, projectSlug, queryName]);
95
+ }, [apiUrl, projectSlug, queryName, apiKey]);
96
+ // Register this query by name so the provider can:
97
+ // 1. Call refetch via useRefreshAll()
98
+ // 2. Detect stale data via auto-polling and trigger refetch
99
+ const refetchFn = useCallback(() => fetchData(true), [fetchData]);
100
+ useEffect(() => {
101
+ if (enabled) {
102
+ registerQuery(queryName, refetchFn, cachedAt ?? null);
103
+ return () => unregisterQuery(queryName);
104
+ }
105
+ }, [enabled, queryName, refetchFn, cachedAt, registerQuery, unregisterQuery]);
55
106
  useEffect(() => {
56
107
  if (enabled) {
57
108
  fetchData();
58
109
  }
59
110
  }, [enabled, fetchData]);
60
- return { data, loading, error, refetch: fetchData };
111
+ return {
112
+ data,
113
+ loading,
114
+ error,
115
+ cachedAt,
116
+ query: queryReady ? queryFnRef.current : undefined,
117
+ refetch: () => fetchData(true),
118
+ };
61
119
  }
package/package.json CHANGED
@@ -1,18 +1,27 @@
1
1
  {
2
2
  "name": "@vibedash/client",
3
- "version": "0.1.0",
4
- "description": "React hooks for Vibe Dash dashboards — useQuery() for data fetching",
3
+ "version": "0.3.0",
4
+ "description": "React hooks for Vibe Dash dashboards — useQuery() with optional DuckDB-WASM caching",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
- "files": ["dist"],
7
+ "files": [
8
+ "dist"
9
+ ],
8
10
  "scripts": {
9
11
  "build": "tsc",
10
12
  "dev": "tsc --watch"
11
13
  },
12
14
  "peerDependencies": {
15
+ "@duckdb/duckdb-wasm": ">=1.28.0",
13
16
  "react": ">=18"
14
17
  },
18
+ "peerDependenciesMeta": {
19
+ "@duckdb/duckdb-wasm": {
20
+ "optional": true
21
+ }
22
+ },
15
23
  "devDependencies": {
24
+ "@duckdb/duckdb-wasm": "^1.33.1-dev20.0",
16
25
  "@types/react": "^19",
17
26
  "react": "^19",
18
27
  "typescript": "^5"