@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
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+ // Headless smoke check for a built data-app.
3
+ //
4
+ // Usage: node scripts/headless-smoke.mjs <path-to-built-index.html> [--require-text "<text>"]
5
+ //
6
+ // What it does:
7
+ // 1. Serves the directory containing index.html on a localhost port.
8
+ // 2. Launches Playwright Chromium and navigates to the page.
9
+ // 3. Listens for uncaught exceptions, page crashes, and console errors.
10
+ // 4. Waits for the React root (#root) to render at least one child element,
11
+ // i.e. the runtime mounted without throwing on import or first render.
12
+ // 5. Optionally waits for a specific text snippet (--require-text), useful
13
+ // when an example has real preview data and the shell paints fully.
14
+ // 6. Exits non-zero if any pageerror/crash fired, or if the wait timed out.
15
+ //
16
+ // Designed to run identically locally and in CI. The static server is
17
+ // stdlib-only (node:http + node:fs); only chromium is an external dep.
18
+
19
+ import { createServer } from "node:http";
20
+ import { readFile, stat } from "node:fs/promises";
21
+ import path from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ const argv = process.argv.slice(2);
25
+ if (argv.length < 1) {
26
+ console.error("Usage: node scripts/headless-smoke.mjs <path-to-index.html> [--require-text <text>]");
27
+ process.exit(2);
28
+ }
29
+
30
+ const htmlPath = path.resolve(argv[0]);
31
+ let requireText = null;
32
+ for (let i = 1; i < argv.length; i++) {
33
+ if (argv[i] === "--require-text" && argv[i + 1]) {
34
+ requireText = argv[i + 1];
35
+ i++;
36
+ }
37
+ }
38
+
39
+ const serveDir = path.dirname(htmlPath);
40
+ const htmlFile = path.basename(htmlPath);
41
+
42
+ const MIME = {
43
+ ".html": "text/html; charset=utf-8",
44
+ ".js": "text/javascript; charset=utf-8",
45
+ ".mjs": "text/javascript; charset=utf-8",
46
+ ".css": "text/css; charset=utf-8",
47
+ ".json": "application/json; charset=utf-8",
48
+ ".wasm": "application/wasm",
49
+ ".parquet": "application/octet-stream",
50
+ ".map": "application/json; charset=utf-8",
51
+ };
52
+
53
+ function startServer() {
54
+ return new Promise((resolve, reject) => {
55
+ const server = createServer(async (req, res) => {
56
+ try {
57
+ const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
58
+ const rel = urlPath === "/" ? htmlFile : urlPath.replace(/^\/+/, "");
59
+ const full = path.join(serveDir, rel);
60
+ // Prevent path traversal outside serveDir.
61
+ if (!full.startsWith(serveDir)) {
62
+ res.statusCode = 403;
63
+ res.end("forbidden");
64
+ return;
65
+ }
66
+ const s = await stat(full).catch(() => null);
67
+ if (!s || !s.isFile()) {
68
+ res.statusCode = 404;
69
+ res.end("not found");
70
+ return;
71
+ }
72
+ const buf = await readFile(full);
73
+ res.setHeader("Content-Type", MIME[path.extname(full)] ?? "application/octet-stream");
74
+ res.setHeader("Cache-Control", "no-store");
75
+ // Permit cross-origin loaders the runtime might use.
76
+ res.setHeader("Access-Control-Allow-Origin", "*");
77
+ res.end(buf);
78
+ } catch (err) {
79
+ res.statusCode = 500;
80
+ res.end(String(err));
81
+ }
82
+ });
83
+ server.on("error", reject);
84
+ server.listen(0, "127.0.0.1", () => {
85
+ const addr = server.address();
86
+ if (typeof addr !== "object" || !addr) {
87
+ reject(new Error("failed to determine server address"));
88
+ return;
89
+ }
90
+ resolve({ server, port: addr.port });
91
+ });
92
+ });
93
+ }
94
+
95
+ async function main() {
96
+ const { server, port } = await startServer();
97
+ const url = `http://127.0.0.1:${port}/${htmlFile}`;
98
+ console.log(`[smoke] serving ${serveDir}`);
99
+ console.log(`[smoke] navigating to ${url}`);
100
+
101
+ // Lazy-import Playwright so the script can at least print usage without
102
+ // requiring the dep to be installed.
103
+ const { chromium } = await import("playwright");
104
+ const browser = await chromium.launch();
105
+ const context = await browser.newContext();
106
+ const page = await context.newPage();
107
+
108
+ const pageErrors = [];
109
+ const consoleErrors = [];
110
+ page.on("pageerror", (err) => {
111
+ pageErrors.push(err.message ?? String(err));
112
+ });
113
+ page.on("crash", () => {
114
+ pageErrors.push("page crashed");
115
+ });
116
+ page.on("console", (msg) => {
117
+ if (msg.type() === "error") {
118
+ consoleErrors.push(msg.text());
119
+ }
120
+ });
121
+
122
+ let timedOut = false;
123
+ let textMissing = false;
124
+ try {
125
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
126
+ // Wait for React to mount _something_ inside #root. If the runtime throws
127
+ // on import or the first render crashes, #root stays empty and this times
128
+ // out.
129
+ await page.waitForFunction(
130
+ () => {
131
+ const root = document.querySelector("#root");
132
+ return !!root && root.childElementCount > 0;
133
+ },
134
+ { timeout: 30_000 },
135
+ );
136
+ if (requireText) {
137
+ // Poll for the text to appear in a visible element. DuckDB-WASM init
138
+ // + first useDataset can take several seconds.
139
+ try {
140
+ await page.waitForFunction(
141
+ (needle) => {
142
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
143
+ let node;
144
+ while ((node = walker.nextNode())) {
145
+ const text = node.nodeValue ?? "";
146
+ if (!text.includes(needle)) continue;
147
+ let el = node.parentElement;
148
+ while (el) {
149
+ if (el.offsetParent !== null) return true;
150
+ el = el.parentElement;
151
+ }
152
+ }
153
+ return false;
154
+ },
155
+ requireText,
156
+ { timeout: 30_000 },
157
+ );
158
+ } catch {
159
+ textMissing = true;
160
+ }
161
+ } else {
162
+ // Settle for non-text-required runs so deferred runtime errors get
163
+ // captured by pageerror.
164
+ await page.waitForTimeout(2000);
165
+ }
166
+ } catch (err) {
167
+ timedOut = true;
168
+ pageErrors.push(`navigation/wait failed: ${err?.message ?? err}`);
169
+ } finally {
170
+ await browser.close();
171
+ server.close();
172
+ }
173
+
174
+ const failed = pageErrors.length > 0 || timedOut || textMissing;
175
+ if (consoleErrors.length > 0) {
176
+ console.log("[smoke] console.error messages (informational, non-fatal):");
177
+ for (const e of consoleErrors) console.log(` - ${e}`);
178
+ }
179
+ if (pageErrors.length > 0) {
180
+ console.log("[smoke] page errors:");
181
+ for (const e of pageErrors) console.log(` - ${e}`);
182
+ }
183
+ if (textMissing) {
184
+ console.log(`[smoke] required text not found on page: ${JSON.stringify(requireText)}`);
185
+ }
186
+ if (failed) {
187
+ console.log("[smoke] FAIL");
188
+ process.exit(1);
189
+ }
190
+ console.log("[smoke] PASS");
191
+ }
192
+
193
+ main().catch((err) => {
194
+ console.error("[smoke] unexpected error:", err);
195
+ process.exit(1);
196
+ });
@@ -0,0 +1,15 @@
1
+ {
2
+ "version": 2,
3
+ "name": "My App",
4
+ "entry": "src/main.tsx",
5
+ "resources": {
6
+ "main": {
7
+ "kind": "dataset",
8
+ "source": {
9
+ "type": "sql",
10
+ "sql": "SELECT id, STRFTIME(created_at, '%Y-%m-%d') AS createdDate, name FROM LAKE.SCHEMA.my_table LIMIT 10000"
11
+ },
12
+ "public": false
13
+ }
14
+ }
15
+ }
@@ -0,0 +1,41 @@
1
+ import React from "react";
2
+ import {
3
+ useDataset,
4
+ useSqlQuery,
5
+ useTheme,
6
+ AppShell,
7
+ Card,
8
+ KpiCard,
9
+ DataTable,
10
+ LoadingState,
11
+ ErrorState,
12
+ } from "@definite/runtime";
13
+
14
+ export default function App() {
15
+ const theme = useTheme();
16
+ const data = useDataset("main");
17
+
18
+ const summary = useSqlQuery(
19
+ data,
20
+ data.tableRef
21
+ ? `SELECT COUNT(*)::INTEGER AS totalRows FROM ${data.tableRef}`
22
+ : "",
23
+ [],
24
+ );
25
+
26
+ if (data.loading) return <LoadingState message="Loading data..." />;
27
+ if (data.error) return <ErrorState title="Load Error" message={data.error} />;
28
+
29
+ return (
30
+ <AppShell title="My App" subtitle="Edit src/App.tsx to build your dashboard">
31
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
32
+ <KpiCard
33
+ title="Total Rows"
34
+ value={summary.data?.[0]?.totalRows}
35
+ format="number"
36
+ loading={summary.loading}
37
+ />
38
+ </div>
39
+ </AppShell>
40
+ );
41
+ }
@@ -0,0 +1,12 @@
1
+ import React from "react";
2
+ import { createRoot } from "react-dom/client";
3
+
4
+ import App from "./App";
5
+
6
+ const rootElement = document.getElementById("root");
7
+
8
+ if (!rootElement) {
9
+ throw new Error("Missing #root mount element");
10
+ }
11
+
12
+ createRoot(rootElement).render(<App />);
@@ -0,0 +1,15 @@
1
+ {
2
+ "version": 2,
3
+ "name": "My App",
4
+ "entry": "src/main.tsx",
5
+ "resources": {
6
+ "main": {
7
+ "kind": "dataset",
8
+ "source": {
9
+ "type": "sql",
10
+ "sql": "SELECT id, STRFTIME(created_at, '%Y-%m-%d') AS createdDate, name FROM LAKE.SCHEMA.my_table LIMIT 10000"
11
+ },
12
+ "public": false
13
+ }
14
+ }
15
+ }
@@ -0,0 +1,198 @@
1
+ import React, { useMemo, useState } from "react";
2
+
3
+ import {
4
+ buildPalette,
5
+ CachePopover,
6
+ DateRangeFilter,
7
+ DEFAULT_DATE_RANGE_PRESETS,
8
+ type DateRangeValue,
9
+ DrillProvider,
10
+ ErrorState,
11
+ FiInspectable,
12
+ LoadingState,
13
+ PaletteProvider,
14
+ SaasKpiCard,
15
+ ShellLayout,
16
+ Sidebar,
17
+ type SidebarNavItem,
18
+ useDataset,
19
+ useDrill,
20
+ usePalette,
21
+ useSqlQuery,
22
+ useTheme,
23
+ } from "@definite/runtime";
24
+
25
+ // Default to the "Last 12 months" preset. The filter is applied against the
26
+ // `createdDate` column declared in app.json — change DATE_COLUMN if you
27
+ // rename it.
28
+ const DATE_COLUMN = "createdDate";
29
+
30
+ function initialDateRange(): DateRangeValue {
31
+ const preset =
32
+ DEFAULT_DATE_RANGE_PRESETS.find((p) => p.key === "last12m") ??
33
+ DEFAULT_DATE_RANGE_PRESETS[0];
34
+ return preset.compute();
35
+ }
36
+
37
+ const escSql = (v: string) => v.replace(/'/g, "''");
38
+
39
+ function buildWhere(from: string, to: string): string {
40
+ const clauses: string[] = [];
41
+ if (from) clauses.push(`${DATE_COLUMN} >= '${escSql(from)}'`);
42
+ if (to) clauses.push(`${DATE_COLUMN} <= '${escSql(to)}'`);
43
+ return clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
44
+ }
45
+
46
+ // ── Sidebar navigation ────────────────────────────────────────────────────
47
+ // Each entry maps to a view rendered in the main pane. Icons are single
48
+ // glyphs (Unicode) so apps don't need an icon library; swap for SVGs when
49
+ // you want brand-specific marks.
50
+ const NAV_ITEMS: SidebarNavItem[] = [
51
+ { id: "overview", label: "Overview", icon: "◧" },
52
+ { id: "detail", label: "Detail", icon: "≣" },
53
+ ];
54
+
55
+ // ── App root ─────────────────────────────────────────────────────────────
56
+ // Pattern: outer <App> holds dataset-loading fallbacks; <InnerApp> runs
57
+ // inside the palette + drill providers so every descendant can usePalette()
58
+ // and useDrill() without prop-drilling.
59
+
60
+ export default function App() {
61
+ const { theme, toggleTheme } = useTheme();
62
+ // Optionally pass a brand accent: buildPalette(theme, { accent: "#FF006E" })
63
+ const palette = useMemo(() => buildPalette(theme), [theme]);
64
+
65
+ const data = useDataset("main");
66
+ if (data.loading) return <LoadingState message="Loading data…" />;
67
+ if (data.error) return <ErrorState title="Dataset failed to load" message={data.error} />;
68
+
69
+ return (
70
+ <PaletteProvider value={palette}>
71
+ <DrillProvider>
72
+ <InnerApp
73
+ theme={theme}
74
+ onThemeChange={(t) => { if (t !== theme) toggleTheme(); }}
75
+ dataset={data}
76
+ />
77
+ </DrillProvider>
78
+ </PaletteProvider>
79
+ );
80
+ }
81
+
82
+ type DatasetHandle = ReturnType<typeof useDataset>;
83
+
84
+ function InnerApp({ theme, onThemeChange, dataset }: {
85
+ theme: "dark" | "light";
86
+ onThemeChange: (t: "dark" | "light") => void;
87
+ dataset: DatasetHandle;
88
+ }) {
89
+ const P = usePalette();
90
+ const [view, setView] = useState("overview");
91
+ const [dateRange, setDateRange] = useState<DateRangeValue>(initialDateRange);
92
+
93
+ const where = useMemo(
94
+ () => buildWhere(dateRange.from, dateRange.to),
95
+ [dateRange.from, dateRange.to],
96
+ );
97
+
98
+ // Example query — replace with your own.
99
+ const summary = useSqlQuery<Array<{ rowCount: number }>>(
100
+ dataset,
101
+ dataset.tableRef
102
+ ? `SELECT COUNT(*)::INTEGER AS rowCount FROM ${dataset.tableRef}${where}`
103
+ : "",
104
+ [where],
105
+ );
106
+
107
+ const rowCount = summary.data?.[0]?.rowCount ?? 0;
108
+ const navItem = NAV_ITEMS.find((n) => n.id === view) ?? NAV_ITEMS[0];
109
+
110
+ const sidebar = (
111
+ <Sidebar
112
+ logo={{ title: "My App", subtitle: "Replace this" }}
113
+ navItems={NAV_ITEMS}
114
+ activeView={view}
115
+ onViewChange={setView}
116
+ dateRangeSlot={
117
+ <DateRangeFilter
118
+ value={dateRange}
119
+ onChange={setDateRange}
120
+ label={null}
121
+ popoverPlacement="right-start"
122
+ triggerStyle={{ width: "100%", minWidth: 0, justifyContent: "space-between", padding: "7px 10px", fontSize: 12 }}
123
+ />
124
+ }
125
+ theme={theme}
126
+ onThemeChange={onThemeChange}
127
+ footer={<>Live DuckDB · {rowCount.toLocaleString()} rows</>}
128
+ />
129
+ );
130
+
131
+ const headerRight = (
132
+ <CachePopover
133
+ isLoading={summary.loading}
134
+ rowCount={dataset.cache?.rowCount ?? rowCount}
135
+ cache={dataset.cache}
136
+ onRefresh={dataset.refresh}
137
+ />
138
+ );
139
+
140
+ return (
141
+ <ShellLayout
142
+ palette={P}
143
+ sidebar={sidebar}
144
+ title={navItem.label}
145
+ breadcrumb={["App", navItem.label]}
146
+ headerRight={headerRight}
147
+ >
148
+ {view === "overview" && <OverviewView rowCount={rowCount} loading={summary.loading} />}
149
+ {view === "detail" && <DetailView rowCount={rowCount} />}
150
+ </ShellLayout>
151
+ );
152
+ }
153
+
154
+ function OverviewView({ rowCount, loading }: { rowCount: number; loading: boolean }) {
155
+ const drill = useDrill();
156
+ return (
157
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 12 }}>
158
+ {/* Wrap each tile in <FiInspectable> so the host can pick it as Fi context.
159
+ `fiId` should be stable across renders; pass `datum` for richer prompts. */}
160
+ <FiInspectable
161
+ fiId="kpi-total-rows"
162
+ datasetKey="main"
163
+ description="Total row count KPI"
164
+ datum={{ rowCount }}
165
+ >
166
+ <SaasKpiCard
167
+ title="Total rows"
168
+ value={rowCount.toLocaleString()}
169
+ sub="in dataset"
170
+ loading={loading}
171
+ onClick={() => drill.open({
172
+ kind: "kpi",
173
+ id: "rows",
174
+ title: "Total rows",
175
+ value: rowCount.toLocaleString(),
176
+ breadcrumb: "Overview",
177
+ stats: [["Row count", rowCount.toLocaleString()]],
178
+ narrative: "Dataset row count. Replace with your own computed stats.",
179
+ sql: `SELECT COUNT(*) FROM main;`,
180
+ })}
181
+ />
182
+ </FiInspectable>
183
+ </div>
184
+ );
185
+ }
186
+
187
+ function DetailView({ rowCount }: { rowCount: number }) {
188
+ const P = usePalette();
189
+ return (
190
+ <div style={{
191
+ background: P.card, border: `1px solid ${P.border}`, borderRadius: 10,
192
+ padding: 24, color: P.sub, fontSize: 14, lineHeight: 1.6,
193
+ }}>
194
+ <div style={{ fontSize: 16, color: P.text, fontWeight: 600, marginBottom: 8 }}>Detail view</div>
195
+ Replace this with your own detail content — tables, forms, drill-downs. The dataset has <b style={{ color: P.text }}>{rowCount.toLocaleString()}</b> rows available via <code style={{ fontFamily: P.mono, background: P.elev, padding: "1px 5px", borderRadius: 3 }}>useSqlQuery(data, "...")</code>.
196
+ </div>
197
+ );
198
+ }
@@ -0,0 +1,12 @@
1
+ import React from "react";
2
+ import { createRoot } from "react-dom/client";
3
+
4
+ import App from "./App";
5
+
6
+ const rootElement = document.getElementById("root");
7
+
8
+ if (!rootElement) {
9
+ throw new Error("Missing #root mount element");
10
+ }
11
+
12
+ createRoot(rootElement).render(<App />);