@definite-app/data-apps 1.0.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.
Files changed (32) hide show
  1. package/CLAUDE.md +686 -0
  2. package/LICENSE +201 -0
  3. package/README.md +643 -0
  4. package/build.mjs +459 -0
  5. package/examples/_refined_demo/app.json +15 -0
  6. package/examples/_refined_demo/data/sample.parquet +0 -0
  7. package/examples/_refined_demo/gen_preview_data.py +59 -0
  8. package/examples/_refined_demo/preview-data.json +13 -0
  9. package/examples/_refined_demo/src/App.tsx +188 -0
  10. package/examples/_refined_demo/src/main.tsx +12 -0
  11. package/examples/loan-portfolio/app.json +31 -0
  12. package/examples/loan-portfolio/data/loan_book.parquet +0 -0
  13. package/examples/loan-portfolio/gen_preview_data.py +454 -0
  14. package/examples/loan-portfolio/preview-data.json +84 -0
  15. package/examples/loan-portfolio/src/App.tsx +1103 -0
  16. package/examples/loan-portfolio/src/main.tsx +12 -0
  17. package/examples/revenue-explorer/app.json +23 -0
  18. package/examples/revenue-explorer/data/transactions.parquet +0 -0
  19. package/examples/revenue-explorer/gen_preview_data.py +129 -0
  20. package/examples/revenue-explorer/preview-data.json +49 -0
  21. package/examples/revenue-explorer/src/App.tsx +527 -0
  22. package/examples/revenue-explorer/src/main.tsx +12 -0
  23. package/package.json +55 -0
  24. package/preview.mjs +35 -0
  25. package/runtime/definite-runtime.tsx +5934 -0
  26. package/scripts/headless-smoke.mjs +196 -0
  27. package/templates/blank/app.json +15 -0
  28. package/templates/blank/src/App.tsx +41 -0
  29. package/templates/blank/src/main.tsx +12 -0
  30. package/templates/refined/app.json +15 -0
  31. package/templates/refined/src/App.tsx +198 -0
  32. package/templates/refined/src/main.tsx +12 -0
package/README.md ADDED
@@ -0,0 +1,643 @@
1
+ # Definite Data Apps
2
+
3
+ Build interactive React applications that run inside [Definite](https://www.definite.app/) Docs. Data apps compile to a single HTML file with client-side DuckDB WASM, Perspective.js, and a built-in component library.
4
+
5
+ ## How data flows
6
+
7
+ ```
8
+ app.json Definite platform Browser DuckDB WASM App.tsx
9
+ (manifest) --> (server-side fetch) --> (local tables) --> (client-side SQL)
10
+ ```
11
+
12
+ 1. **app.json** declares every data resource the app needs. Each resource has a key, a `kind`, and a `source`.
13
+ 2. **The Definite platform** reads the manifest and fetches data server-side (from DuckLake, Cube, GCS, etc.). The app never talks to the warehouse directly.
14
+ 3. **The runtime** loads fetched data into a **browser-side DuckDB WASM** instance as local tables.
15
+ 4. **App.tsx** queries those local tables via `useSqlQuery(dataset, sql, deps)`. These SQL queries run in the browser, not against the server.
16
+
17
+ Column names in your `useSqlQuery` SQL must match the aliases in your `app.json` SQL. If `app.json` has `SELECT foo AS myColumn`, the local table has a column called `myColumn`.
18
+
19
+ ## Quick start
20
+
21
+ ```bash
22
+ git clone https://github.com/definite-app/definite-data-apps.git
23
+ cd definite-data-apps
24
+ make setup # npm install
25
+ make new-app NAME=my-app # blank template (one KPI)
26
+ # or:
27
+ make new-app NAME=my-app TEMPLATE=refined # sidebar shell + drill drawer
28
+ # Edit examples/my-app/app.json (declare your data resources)
29
+ # Edit examples/my-app/src/App.tsx (build your UI)
30
+ make build NAME=my-app # build to dist/index.html
31
+ ```
32
+
33
+ Two bundled templates live under `templates/`:
34
+
35
+ | Template | When to use |
36
+ |---|---|
37
+ | `blank` | Single-view dashboards, embedded tiles, anything that doesn't need a navigation rail. Built on `AppShell`. |
38
+ | `refined` | Multi-view analytics apps. Sidebar with nav + date range + 10-filter accordion, KPI cards with sparklines, drill drawer, cache popover, optional AI follow-up chat. Built on `ShellLayout` + `Sidebar`. |
39
+
40
+ To preview bundled examples with mock data:
41
+
42
+ ```bash
43
+ make preview NAME=revenue-explorer # blank-style
44
+ make preview NAME=loan-portfolio # refined-style reference app
45
+ open examples/<name>/dist/index.html
46
+ ```
47
+
48
+ ## Directory structure
49
+
50
+ ```
51
+ examples/my-app/
52
+ app.json # Manifest: declares all data resources
53
+ preview-data.json # Optional: mock data for local preview
54
+ src/
55
+ main.tsx # Entry point (boilerplate, don't edit)
56
+ App.tsx # Your UI code (imports from "@definite/runtime")
57
+ dist/
58
+ index.html # Built artifact (generated by build.mjs)
59
+ ```
60
+
61
+ Deploy `dist/index.html` by uploading it to Definite Drive and creating a Doc with an HTML tile:
62
+
63
+ ```yaml
64
+ version: 1
65
+ schemaVersion: "2025-01"
66
+ kind: dashboard
67
+ metadata:
68
+ name: "My App"
69
+ datasets: {}
70
+ layout:
71
+ columns: 36
72
+ tiles:
73
+ - id: app
74
+ x: 0
75
+ y: 0
76
+ w: 36
77
+ h: 22
78
+ type: html
79
+ fullScreen: true
80
+ driveFile: "apps-v2/my-app/dist/index.html"
81
+ ```
82
+
83
+ ## Manifest (app.json)
84
+
85
+ The manifest declares what data the app needs. The platform fetches it server-side and the runtime loads it into the browser.
86
+
87
+ ```json
88
+ {
89
+ "version": 2,
90
+ "name": "Revenue Explorer",
91
+ "entry": "src/main.tsx",
92
+ "resources": {
93
+ "transactions": {
94
+ "kind": "dataset",
95
+ "source": {
96
+ "type": "sql",
97
+ "sql": "SELECT id AS transactionId, STRFTIME(created_at, '%Y-%m-%d') AS transactionDate, amount::DOUBLE AS amount FROM LAKE.SCHEMA.transactions LIMIT 200000"
98
+ },
99
+ "public": false
100
+ },
101
+ "branches": {
102
+ "kind": "json",
103
+ "source": {
104
+ "type": "sql",
105
+ "sql": "SELECT branch_id AS branchId, branch_name AS branchName FROM LAKE.SCHEMA.branches ORDER BY 2 LIMIT 3000"
106
+ },
107
+ "snapshot": {
108
+ "format": "json",
109
+ "drivePath": "apps-v2/my-app/snapshots/branches.json"
110
+ },
111
+ "public": true
112
+ }
113
+ }
114
+ }
115
+ ```
116
+
117
+ ### Resource kinds
118
+
119
+ | Kind | Hook | Use for |
120
+ |------|------|---------|
121
+ | `dataset` | `useDataset(key)` | Data loaded into browser DuckDB WASM as a local table |
122
+ | `json` | `useJsonResource(key)` | Small lookup lists returned as plain arrays |
123
+
124
+ ### Source types
125
+
126
+ | Type | Description |
127
+ |------|-------------|
128
+ | `sql` | SQL query executed server-side against DuckLake. **Recommended for most cases.** |
129
+ | `duckdbFile` | A `.duckdb` file downloaded from Drive/GCS and attached locally |
130
+ | `cube` | Cube semantic model query. **Not recommended** (column names are Cube titles with spaces). |
131
+
132
+ ### Snapshots
133
+
134
+ For public embeds, add a `snapshot` block to pre-cache data:
135
+
136
+ ```json
137
+ "snapshot": {
138
+ "format": "json",
139
+ "drivePath": "apps-v2/my-app/snapshots/branches.json"
140
+ }
141
+ ```
142
+
143
+ ## Runtime hooks
144
+
145
+ ### `useDataset(key, opts?)`
146
+
147
+ Loads a `kind: "dataset"` resource into browser DuckDB WASM. Returns a `DatasetHandle` with:
148
+
149
+ - `tableRef`: the table reference string for use in SQL (e.g., `memory.main.transactions`)
150
+ - `db`, `conn`: the DuckDB WASM database and connection
151
+ - `perspectiveTable`: table name for Perspective viewer
152
+ - `loading`, `error`: loading state
153
+ - `cache`: cache metadata (source, load time, TTL)
154
+ - `refresh()`: hard refresh (bypasses IndexedDB cache)
155
+
156
+ ```tsx
157
+ const data = useDataset("transactions");
158
+
159
+ if (data.loading) return <LoadingState message="Loading..." />;
160
+ if (data.error) return <ErrorState title="Error" message={data.error} />;
161
+ ```
162
+
163
+ ### `useSqlQuery(dataset, sql, deps?)`
164
+
165
+ Runs client-side SQL against a loaded dataset's DuckDB WASM instance.
166
+
167
+ ```tsx
168
+ const result = useSqlQuery(
169
+ data,
170
+ data.tableRef
171
+ ? `SELECT branchName, SUM(amount)::INTEGER AS total FROM ${data.tableRef} GROUP BY 1`
172
+ : "",
173
+ [],
174
+ );
175
+ // result.data = [{ branchName: "Austin", total: 2685 }, ...]
176
+ ```
177
+
178
+ ### `useJsonResource(key, opts?)`
179
+
180
+ Loads a `kind: "json"` resource. Returns `{ data, loading, error, cache, refresh }`.
181
+
182
+ ```tsx
183
+ const branches = useJsonResource<{ branchId: string; branchName: string }>("branches");
184
+ // branches.data = [{ branchId: "AUS", branchName: "Austin" }, ...]
185
+ ```
186
+
187
+ ### `useTheme()`
188
+
189
+ Returns `{ theme, toggleTheme }` for dark/light mode support. Pass `theme` and `toggleTheme` to `AppShell`.
190
+
191
+ ### `usePerspective(dataset)`
192
+
193
+ Initializes a Perspective client connected to the dataset's DuckDB instance. Returns `{ client, perspectiveTable, loading, error }` for use with `PerspectivePanel`.
194
+
195
+ ## Component reference
196
+
197
+ The runtime includes a complete component library. Import from `@definite/runtime`.
198
+
199
+ ### Layout
200
+
201
+ #### `AppShell`
202
+
203
+ Page wrapper with title, subtitle, theme toggle, and optional meta slot.
204
+
205
+ | Prop | Type | Description |
206
+ |------|------|-------------|
207
+ | `title` | `string` | Page title |
208
+ | `subtitle` | `string?` | Subtitle below title |
209
+ | `theme` | `PerspectiveTheme` | Current theme from `useTheme()` |
210
+ | `onToggleTheme` | `() => void` | Toggle function from `useTheme()` |
211
+ | `meta` | `ReactNode?` | Content below subtitle (e.g., `ResourceCacheBadge`) |
212
+ | `children` | `ReactNode` | Page content |
213
+
214
+ #### `Card`
215
+
216
+ Container with optional title and right-aligned header content.
217
+
218
+ | Prop | Type | Description |
219
+ |------|------|-------------|
220
+ | `title` | `string?` | Card header title |
221
+ | `headerRight` | `ReactNode?` | Right-aligned header content |
222
+ | `noPadding` | `boolean?` | Remove inner padding (for tables, charts) |
223
+ | `children` | `ReactNode` | Card body |
224
+
225
+ #### `TabGroup`
226
+
227
+ Tab bar with accent underline.
228
+
229
+ | Prop | Type | Description |
230
+ |------|------|-------------|
231
+ | `tabs` | `string[]` | Tab labels. **Must be a plain string array, NOT `{key, label}` objects.** |
232
+ | `activeTab` | `string` | Currently active tab |
233
+ | `onTabChange` | `(tab: string) => void` | Tab change handler |
234
+ | `children` | `ReactNode?` | Content below tabs |
235
+
236
+ ### Data display
237
+
238
+ #### `KpiCard`
239
+
240
+ Metric card with hover lift, accent top-line, and shimmer loading state.
241
+
242
+ | Prop | Type | Description |
243
+ |------|------|-------------|
244
+ | `title` | `string` | Metric label |
245
+ | `value` | `unknown` | Metric value |
246
+ | `format` | `"number" \| "currency" \| "percent"` | **Required.** `number`: commas, no decimals. `currency`: USD. `percent`: appends "%" with 1 decimal. |
247
+ | `loading` | `boolean?` | Show shimmer placeholder |
248
+ | `detail` | `ReactNode?` | Content below value (badges, comparison deltas) |
249
+
250
+ Pre-formatted strings (e.g., `"53.6s"`) pass through as-is. NaN/Infinity display as a dash.
251
+
252
+ #### `DataTable`
253
+
254
+ Simple table with row hover.
255
+
256
+ | Prop | Type | Description |
257
+ |------|------|-------------|
258
+ | `columns` | `{ key: string; label: string }[]` | Column definitions |
259
+ | `rows` | `Record<string, unknown>[]` | Row data |
260
+ | `emptyState` | `string?` | Empty state message |
261
+
262
+ #### `ReportTable`
263
+
264
+ Rich management report table with grouped column headers, colored bands, section dividers, subtotal/total rows, and per-cell conditional styling.
265
+
266
+ | Prop | Type | Description |
267
+ |------|------|-------------|
268
+ | `headerGroups` | `{ label, colSpan?, color?, subHeaders[] }[]` | Grouped headers with optional color bands |
269
+ | `rows` | `{ type?, indent?, cells }[]` | Rows with type: `data`, `section`, `subtotal`, or `total` |
270
+ | `emptyState` | `string?` | Empty state message |
271
+
272
+ #### `Badge`
273
+
274
+ Status indicator with colored dot.
275
+
276
+ | Prop | Type | Description |
277
+ |------|------|-------------|
278
+ | `variant` | `"default" \| "success" \| "warning" \| "error" \| "info"` | Color scheme |
279
+ | `dot` | `boolean?` | Show/hide the dot (default: true) |
280
+ | `children` | `ReactNode` | Badge text |
281
+
282
+ ### Charts
283
+
284
+ #### `EChart`
285
+
286
+ Apache ECharts wrapper. Handles init/dispose lifecycle, theme switching, and resize.
287
+
288
+ | Prop | Type | Description |
289
+ |------|------|-------------|
290
+ | `option` | `Record<string, unknown>` | Full ECharts option spec |
291
+ | `height` | `number?` | Chart height in pixels |
292
+ | `theme` | `PerspectiveTheme?` | Theme for auto-switching |
293
+ | `onClick` | `(params) => void` | Click handler for drill-down |
294
+
295
+ **Critical**: EChart serializes options through `JSON.stringify`. **Functions are silently stripped.** Do not use `formatter`, `valueFormatter`, or callbacks. Use ECharts string templates instead (e.g., `"{value}%"`).
296
+
297
+ #### `PerspectivePanel`
298
+
299
+ Wrapper for [Perspective.js](https://perspective.finos.org/) viewer. Supports Datagrid, Y Bar, Y Line, Y Area, X Bar, Y Scatter, Heatmap, Treemap, and Sunburst.
300
+
301
+ | Prop | Type | Description |
302
+ |------|------|-------------|
303
+ | `client` | `any` | Perspective client from `usePerspective()` |
304
+ | `table` | `string` | Table name from dataset's `perspectiveTable` |
305
+ | `theme` | `PerspectiveTheme` | Current theme |
306
+ | `config` | `Record<string, unknown>?` | Perspective viewer config (plugin, columns, group_by, etc.) |
307
+ | `onSelect` | `(row) => void` | Click handler for drill-down (`perspective-select` event) |
308
+
309
+ Example:
310
+
311
+ ```tsx
312
+ const perspective = usePerspective(data);
313
+
314
+ <PerspectivePanel
315
+ client={perspective.client}
316
+ table={data.perspectiveTable}
317
+ theme={theme}
318
+ config={{
319
+ plugin: "Y Bar",
320
+ columns: ["amount"],
321
+ group_by: ["branchName"],
322
+ aggregates: { amount: "sum" },
323
+ sort: [["amount", "desc"]],
324
+ }}
325
+ onSelect={(row) => {
326
+ if (!row) return;
327
+ setFilter(prev => prev === row.branchName ? "" : String(row.branchName));
328
+ }}
329
+ />
330
+ ```
331
+
332
+ ### Inputs
333
+
334
+ #### `Select`
335
+
336
+ Single-select dropdown.
337
+
338
+ | Prop | Type | Description |
339
+ |------|------|-------------|
340
+ | `options` | `{ value: string; label: string }[]` | Options |
341
+ | `value` | `string` | Selected value |
342
+ | `onChange` | `(value: string) => void` | Change handler |
343
+ | `placeholder` | `string?` | Placeholder text |
344
+ | `label` | `ReactNode?` | Label above dropdown |
345
+
346
+ #### `MultiSelect<T>`
347
+
348
+ Searchable checkbox dropdown.
349
+
350
+ | Prop | Type | Description |
351
+ |------|------|-------------|
352
+ | `options` | `T[]` | All options |
353
+ | `selected` | `T[]` | Selected items |
354
+ | `onChange` | `(selected: T[]) => void` | Change handler |
355
+ | `labelKey` | `string` | Key for display label |
356
+ | `valueKey` | `string` | Key for value |
357
+ | `label` | `ReactNode?` | Label above dropdown |
358
+ | `placeholder` | `string?` | Placeholder text |
359
+
360
+ #### `FilterPills`
361
+
362
+ Single-select horizontal toggle group.
363
+
364
+ | Prop | Type | Description |
365
+ |------|------|-------------|
366
+ | `options` | `{ value: string; label: string }[]` | Options |
367
+ | `value` | `string` | Selected value |
368
+ | `onChange` | `(value: string) => void` | Change handler |
369
+ | `label` | `ReactNode?` | Label above pills |
370
+
371
+ #### `TextInput`
372
+
373
+ Text input with focus ring and optional icon.
374
+
375
+ | Prop | Type | Description |
376
+ |------|------|-------------|
377
+ | `value` | `string` | Current value |
378
+ | `onChange` | `(value: string) => void` | Change handler |
379
+ | `placeholder` | `string?` | Placeholder |
380
+ | `label` | `ReactNode?` | Label |
381
+ | `icon` | `ReactNode?` | Icon on left |
382
+
383
+ #### `DateInput`
384
+
385
+ Native date picker with design system styling.
386
+
387
+ | Prop | Type | Description |
388
+ |------|------|-------------|
389
+ | `value` | `string` | Date string (YYYY-MM-DD) |
390
+ | `onChange` | `(value: string) => void` | Change handler |
391
+ | `label` | `ReactNode?` | Label |
392
+ | `max` | `string?` | Max date |
393
+ | `min` | `string?` | Min date |
394
+
395
+ ### Feedback
396
+
397
+ #### `LoadingState`
398
+
399
+ Full-page pulsing dots animation.
400
+
401
+ | Prop | Type | Description |
402
+ |------|------|-------------|
403
+ | `message` | `string?` | Loading message (default: "Loading...") |
404
+
405
+ #### `ErrorState`
406
+
407
+ Full-page error with red left bar.
408
+
409
+ | Prop | Type | Description |
410
+ |------|------|-------------|
411
+ | `title` | `string` | Error title |
412
+ | `message` | `string` | Error details |
413
+
414
+ #### `Tooltip`
415
+
416
+ Hover tooltip with arbitrary content.
417
+
418
+ | Prop | Type | Description |
419
+ |------|------|-------------|
420
+ | `content` | `ReactNode` | Tooltip content |
421
+ | `children` | `ReactNode` | Trigger element |
422
+ | `position` | `"top" \| "bottom"` | Position (default: top) |
423
+ | `maxWidth` | `number?` | Max width in pixels (default: 240) |
424
+
425
+ #### `ResourceCacheBadge`
426
+
427
+ Cache metadata popover showing row count, source, load time, TTL, and "Clear cache & reload" button.
428
+
429
+ | Prop | Type | Description |
430
+ |------|------|-------------|
431
+ | `rows` | `number?` | Row count to display |
432
+ | `cache` | `ResourceCacheDetails` | Cache metadata from `useDataset()` |
433
+ | `onClearAndReload` | `() => Promise<void>` | Clear + reload handler (use dataset's `refresh()`) |
434
+
435
+ ## Refined SaaS shell primitives
436
+
437
+ A second track of components built for multi-view analytics apps. They're driven by a palette object (not CSS vars), so apps can pass a brand `accent` at the root and every surface (KPI top-line, active nav, filter chip, loading dot) picks it up. Pair with the `refined` template.
438
+
439
+ ```tsx
440
+ import {
441
+ buildPalette, PaletteProvider, usePalette,
442
+ ShellLayout, Sidebar, SaasKpiCard,
443
+ DrillProvider, useDrill, CachePopover,
444
+ callFiFast, buildDrillPrompt,
445
+ } from "@definite/runtime";
446
+
447
+ const palette = buildPalette(theme, { accent: "#FF006E" });
448
+
449
+ <PaletteProvider value={palette}>
450
+ <DrillProvider
451
+ aiChat={{
452
+ onAsk: (q, entity) => callFiFast({ prompt: buildDrillPrompt(q, entity) }),
453
+ }}
454
+ >
455
+ <ShellLayout
456
+ palette={palette}
457
+ sidebar={<Sidebar logo={...} navItems={...} activeView={...} ... />}
458
+ title="Overview"
459
+ headerRight={<CachePopover cache={data.cache} onRefresh={data.refresh} ... />}
460
+ >
461
+ <SaasKpiCard title="Total rows" value={count} onClick={() => drill.open({...})} />
462
+ </ShellLayout>
463
+ </DrillProvider>
464
+ </PaletteProvider>
465
+ ```
466
+
467
+ ### Palette
468
+
469
+ | Export | Purpose |
470
+ |--------|---------|
471
+ | `buildPalette(theme, { accent? })` | Returns a `SaasPalette` of semantic colors + fonts. `accent` override derives `accentSoft` automatically. |
472
+ | `PaletteProvider` / `usePalette()` | Context for descendant primitives. |
473
+
474
+ ### Shell
475
+
476
+ | Export | Purpose |
477
+ |--------|---------|
478
+ | `ShellLayout` | Outer flex container; renders the sidebar slot, breadcrumb, title, and a `headerRight` slot (typically `CachePopover` + an Export button). Wraps children in `PaletteProvider`. |
479
+ | `Sidebar` | Logo, nav, `dateRangeSlot`, `FilterAccordion` (if `filterGroups` provided), theme toggle, footer slot. |
480
+ | `FilterAccordion` | Collapsible filter groups with per-group counts, global + per-group search, selected chips. |
481
+ | `Breadcrumb` | Simple `/`-separated trail. |
482
+
483
+ ### Data display
484
+
485
+ | Export | Purpose |
486
+ |--------|---------|
487
+ | `SaasKpiCard` | Accent top-line + sparkline + delta pill + loading shimmer. Props: `title`, `value`, `delta?`, `up?`, `sub?`, `spark?`, `accent?`, `loading?`, `onClick?`. |
488
+ | `Sparkline` | 32-px SVG polyline + last-point dot. Props: `values`, `color?`, `width?`, `height?`. |
489
+ | `SkeletonShimmer` | Palette-driven loading shimmer. Props: `width?`, `height?`, `radius?`. |
490
+ | `CachePopover` | Click-to-inspect cache pill with "Clear cache & reload". Same cache object as `useDataset().cache`. |
491
+
492
+ ### Drill drawer
493
+
494
+ `DrillProvider` mounts a slide-over drawer. Any descendant calls `useDrill().open(entity)` to show it. The drawer renders computed stats, breakdown bars, SQL, and an optional AI follow-up chat.
495
+
496
+ ```tsx
497
+ const drill = useDrill();
498
+ drill.open({
499
+ kind: "kpi", // "kpi" | "row" | "chart"
500
+ id: "total_outstanding",
501
+ title: "Total outstanding",
502
+ value: "$50.8M",
503
+ breadcrumb: "Overview",
504
+ stats: [["Active", "2,511"], ["Avg", "$33K"]],
505
+ breakdown: [{ label: "2026-04", value: 1_780_000 }, ...],
506
+ sql: "SELECT SUM(balance) FROM loans WHERE ...",
507
+ narrative: "Total principal balance across active contracts.",
508
+ });
509
+ ```
510
+
511
+ Wire AI chat by passing `aiChat` to `DrillProvider`:
512
+
513
+ ```tsx
514
+ <DrillProvider
515
+ aiChat={{
516
+ onAsk: (userMessage, entity) => callFiFast({
517
+ prompt: buildDrillPrompt(userMessage, entity),
518
+ }),
519
+ placeholder: "Ask a follow-up…",
520
+ }}
521
+ >
522
+ ```
523
+
524
+ ### AI integration
525
+
526
+ | Export | Purpose |
527
+ |--------|---------|
528
+ | `callFiFast({ prompt, system?, authToken?, ... })` | One-shot POST to `/v4/fi-fast`. Extracts `response.content.parts[0].text`. Same-origin cookies by default; pass `authToken` for Bearer auth. |
529
+ | `buildDrillPrompt(userMessage, entity)` | Default prompt builder that grounds the model in the drill entity's stats + breakdown. Use as-is or wrap. |
530
+
531
+ ### When to use `ShellLayout` vs `AppShell`
532
+
533
+ | Use case | Component |
534
+ |----------|-----------|
535
+ | Multi-view standalone analytics app with nav rail | `ShellLayout` + `Sidebar` (refined template) |
536
+ | Embedded Doc tile, single-view dashboard, email-receipt-style view | `AppShell` (blank template) |
537
+
538
+ Both remain supported and will coexist.
539
+
540
+ ## Best practices
541
+
542
+ ### Always use SQL resources
543
+
544
+ Use `type: "sql"` for dataset resources. SQL gives you full control over column names via `AS` aliases. Avoid `type: "cube"` because Cube responses use long title-based column names (e.g., `"Credit Projects FICO Band"`) that require double-quoting everywhere.
545
+
546
+ ### Use camelCase aliases
547
+
548
+ Alias all columns to camelCase in your `app.json` SQL:
549
+
550
+ ```sql
551
+ SELECT
552
+ installer_account_name AS installerAccountName,
553
+ fico::INTEGER AS fico,
554
+ STRFTIME(application_date, '%Y-%m-%d') AS applicationDate,
555
+ approval_cnt::INTEGER AS approvalCnt
556
+ FROM LAKE.credit.projects
557
+ LIMIT 500000
558
+ ```
559
+
560
+ ### Keep client-side SQL simple
561
+
562
+ | Server-side (app.json SQL) | Client-side (useSqlQuery) |
563
+ |---|---|
564
+ | Complex joins, subqueries, CTEs | Simple GROUP BY + aggregation |
565
+ | CASE WHEN with compound booleans | SUM/COUNT of pre-computed columns |
566
+ | NOT IN, LIKE, regex filters | Simple WHERE on date/string equality |
567
+ | Type casts (BIGINT, DOUBLE) | `::INTEGER` on SUM results |
568
+ | Flag computation | Date range filters |
569
+
570
+ ### Always cast SUM to INTEGER
571
+
572
+ DuckDB WASM may return HUGEINT from SUM, which JavaScript cannot handle cleanly:
573
+
574
+ ```tsx
575
+ // Good
576
+ `SELECT SUM(amount)::INTEGER AS total FROM ${data.tableRef}`
577
+
578
+ // Bad: may return BigInt that breaks rendering
579
+ `SELECT SUM(amount) AS total FROM ${data.tableRef}`
580
+ ```
581
+
582
+ ### Pre-compute flags server-side
583
+
584
+ When you need conditional counts, compute the flags in `app.json` SQL, then aggregate client-side:
585
+
586
+ ```json
587
+ "sql": "SELECT ..., CASE WHEN agent_time > 0 AND skill NOT IN ('123','456') THEN 1 ELSE 0 END AS isHandled FROM ..."
588
+ ```
589
+
590
+ ```tsx
591
+ const kpis = useSqlQuery(data, data.tableRef ? `
592
+ SELECT SUM(isHandled)::INTEGER AS handled FROM ${data.tableRef}
593
+ ` : "", []);
594
+ ```
595
+
596
+ ### Convert dates with STRFTIME
597
+
598
+ Always convert DuckDB dates/timestamps to VARCHAR in app.json SQL:
599
+
600
+ ```sql
601
+ STRFTIME(created_at, '%Y-%m-%d') AS createdDate
602
+ ```
603
+
604
+ ## DuckDB WASM limitations
605
+
606
+ DuckDB WASM 1.29.0 has known issues with complex expressions. Compound `CASE WHEN` with `AND`/`OR` chains, `NOT IN` with many values, or `LIKE` patterns may silently return 0 for all rows in the browser, even though the same query works correctly server-side.
607
+
608
+ **Rule**: if you're writing a `CASE WHEN` with more than one condition in `useSqlQuery`, move it to `app.json` instead.
609
+
610
+ ## Caching
611
+
612
+ The runtime caches successful data loads in IndexedDB with a 24-hour TTL. Cache keys include the drive file path, resource key, mode, and manifest definition, so rebuilt apps invalidate naturally.
613
+
614
+ Use `ResourceCacheBadge` in your `AppShell` meta slot to show cache status:
615
+
616
+ ```tsx
617
+ <AppShell
618
+ title="My App"
619
+ meta={
620
+ <ResourceCacheBadge
621
+ rows={result.data?.length}
622
+ cache={data.cache}
623
+ onClearAndReload={async () => { await data.refresh(); }}
624
+ />
625
+ }
626
+ >
627
+ ```
628
+
629
+ Call `refresh()` on `useDataset()` or `useJsonResource()` for a hard refresh that bypasses IndexedDB.
630
+
631
+ ## Version pins
632
+
633
+ These versions are pinned because the Arrow ingestion path is version-sensitive:
634
+
635
+ | Library | Version |
636
+ |---------|---------|
637
+ | DuckDB WASM | 1.29.0 |
638
+ | Apache Arrow | 17.0.0 |
639
+ | Perspective | 4.3.0 |
640
+
641
+ ## License
642
+
643
+ MIT