Data Apps
Data apps are React dashboards that Definite builds into a single
self-contained HTML file and serves from the on-prem deployment at
/apps/<slug>. They are useful when you want a richer, purpose-built
analytical app than a saved SQL query or table preview.
The normal lifecycle is:
scaffold source -> edit app.json and React -> npm run build -> upload -> open /apps/<slug>
The compiled HTML, manifest, and editable source snapshot are stored in Postgres. The source snapshot is required for new uploads so a human, Fi, or an MCP client can fetch the app later, edit it, rebuild it against the current runtime, and restore prior versions when needed.
Quick start
Create a starter project, replace the sample SQL and React, build it, then upload it:
definite run app scaffold revenue-explorer
cd revenue-explorer
# Edit app.json and src/App.tsx.
npm run build
definite run app upload revenue-explorer . --validate
Open the app at:
https://<your-deployment>/apps/revenue-explorer
Useful lifecycle commands:
definite run app list
definite run app get revenue-explorer
definite run app get revenue-explorer --source
definite run app rebuild revenue-explorer --validate
definite run app delete revenue-explorer
Uploads are create-only by default. If the slug already exists, inspect it
first with definite run app get <slug> and use --force only when you mean
to replace that app. Replacements keep the prior HTML and source in the app's
version history.
Source fetched with app get --source includes a hidden local revision marker.
An update sends that revision back, so a checkout cannot silently overwrite a
newer edit. If the upload returns data_app_source_conflict, fetch the app into
a new directory and reapply your intended change to the current source. Do not
retry the stale checkout.
--validate checks each manifest resource before publishing the candidate. A
failed resource check leaves the current app unchanged. This validation does
not execute browser-side useSqlQuery, mount React, or initialize charts, so a
successful response still reports verification.render_verified=false. Open
the live app and confirm the modified views separately.
Manifest
Every app has an app.json manifest. On-prem manifests use version: 2 and
SQL-backed resources:
{
"version": 2,
"name": "Revenue Explorer",
"entry": "src/main.tsx",
"resources": {
"orders": {
"kind": "dataset",
"source": {
"type": "sql",
"sql": "SELECT * FROM marts.orders"
}
}
}
}
Supported resource shapes:
| Field | Meaning |
|---|---|
kind | dataset or json |
source.type | Must be sql |
source.sql | SQL run by the API against the lakehouse |
SQL should reference on-prem lakehouse objects directly, such as
marts.orders. LAKE.SCHEMA. prefixes are not valid in on-prem manifests.
Runtime data flow
The app runs in the browser, but the browser never receives lakehouse credentials. The bundled runtime calls:
POST /api/v1/data-apps/<slug>/query
with a named resource_key. The API checks the caller's app permission, runs
the resource SQL, and returns Arrow IPC bytes. The browser-side runtime loads
those bytes into DuckDB-WASM, so components can use useDataset and
useSqlQuery for local filtering, grouping, and chart preparation.
Do not load an export-sized or hidden-tab resource at mount. Keep the hook unconditional, but gate the request until the data is actually needed:
const detail = useDataset("detail_rows", {
enabled: activeTab === "detail",
});
enabled: false is idle rather than loading and performs no API request.
For large source tables, push the current structured filters into the server query instead of fetching the unfiltered table and filtering only in DuckDB-WASM:
const orders = useDataset("orders", {
enabled: Boolean(tenantId),
filters: tenantId
? [{ column: "tenant_id", operator: "equals", value: tenantId }]
: [],
});
Filter operators are equals, not_equals, in, gte, and lte. Range
operators are available for number columns and strict ISO date values
(YYYY-MM-DD). Date columns use calendar-day semantics: the projected value
is cast to DATE, so an lte bound includes the full end date even when the
resource returns timestamps. The column and value type are checked against the
resource's filterable declaration, and every predicate is compiled
server-side before DuckDB materializes the result.
Home and Apps cards use a preview query route backed by the app's last successful resource snapshots. Opening the full app runs the live resource queries.
Resource result limits
Live and embedded data-app resources use the workspace client-side query limits. The defaults are 200,000 rows and 100 MiB of Arrow IPC. An operator can lower either value in Settings → General → Client-side query limits; the data-app byte limit always has a hard 100 MiB ceiling even if another query surface is configured higher.
The API consumes DuckDB record batches incrementally and stops before accepting
an Arrow file larger than the byte limit. A result over either limit returns
HTTP 413 with a structured data_app_query_result_too_large detail containing
the limit kind, configured limit, observed size, row count, and accepted
serialized bytes. Narrow the resource SQL or move an export-sized result to an
on-demand export flow instead of loading it as a dashboard dataset.
Cold resource queries also have bounded server-side admission. Identical
concurrent cache misses share one materialization, the 60-second server cache
is bounded by both entries and bytes, and completed Arrow responses retain
their admission reservation until the API has handed the body to the client.
Admins can inspect current reservations, cache bytes, cumulative delivered
response bytes, and row/byte cap rejections at
GET /api/v1/admin/data-apps/materialization-metrics.
On-demand CSV exports
Use the queued export path when a user needs more detail than should be loaded into the browser:
import { exportResource } from "@definite/runtime";
const result = await exportResource("orders", {
filters: [
{ column: "tenant_id", operator: "equals", value: tenantId },
{ column: "created_on", operator: "gte", value: dateRange.from },
{ column: "created_on", operator: "lte", value: dateRange.to },
],
});
window.location.assign(result.downloadUrl);
exportResource enqueues the resource query on the job runner's backfill lane,
polls it, and returns a short-lived signed object-storage URL. DuckDB writes
the CSV directly to object storage; the API never holds the result rows. The
same filterable and required_filters rules as interactive resource queries
are enforced before enqueue.
Exports are capped at 500,000 rows and a 1 GiB CSV file. If the row cap is
reached, result.truncated is true. A file over the byte cap is deleted and
the export fails. This API is available to signed-in in-product apps; preview
cards and external embeds cannot start exports.
Cache TTL
Resource results are cached in the browser's IndexedDB for 24 hours by
default. The cache is partitioned by resource definition, serving surface,
credential, and effective filters. It retains at most 64 entries / 64 MiB and
does not persist any one entry over 32 MiB; oldest entries are evicted first.
Tune the TTL in app.json only when the app needs fresher or longer-lived
client-side data:
{
"version": 2,
"name": "Hourly Revenue",
"cache_ttl_hours": 1,
"resources": {
"orders": {
"kind": "dataset",
"cache_ttl_hours": 0.25,
"source": {
"type": "sql",
"sql": "SELECT * FROM marts.orders"
}
}
}
}
Top-level cache_ttl_hours sets the default for every resource. A
per-resource value overrides it for that resource. Values must be positive
numbers and can be fractional.
This is the in-browser cache. The external embed path also has server-side backpressure and cache behavior, covered in Data app embedding.
Compute profiles
For heavier dashboard queries, add a top-level compute_profile:
{
"version": 2,
"name": "Heavy Dashboard",
"compute_profile": "large",
"resources": {
"orders": {
"kind": "dataset",
"source": {
"type": "sql",
"sql": "SELECT * FROM marts.orders"
}
}
}
}
The profile name must exist in the deployment's compute_profiles registry.
Non-default profiles route SQL resources through the Fi sandbox burst path, so
interactive dashboards may need a warm pool to avoid first-load latency. See
Compute Profiles.
Embedding
Inside Definite, users open apps from Home, Apps, or /apps/<slug>. To put the
same app in another website, create an external embed. Embeds use revocable
emb_ credentials, optional short-lived emv_ view tokens, allowed-host
framing rules, and optional row/column scoping.
See Data app embedding for iframe setup, token minting, scoped filters, and capacity notes.
Each resource that accepts runtime or embed row filters must declare the filterable columns:
{
"resources": {
"orders": {
"kind": "dataset",
"source": {
"type": "sql",
"sql": "SELECT * FROM marts.orders"
},
"filterable": [
{ "column": "tenant_id", "type": "number" },
{ "column": "region", "type": "string" },
{ "column": "created_on", "type": "date" }
]
}
}
}
Scoped embed queries fail closed when a resource does not declare a required filterable column.
For resources that must never execute unfiltered, add required_filters.
This is enforced for authenticated app queries, external embeds, and exports:
{
"resources": {
"orders": {
"kind": "dataset",
"source": {
"type": "sql",
"sql": "SELECT * FROM marts.orders"
},
"filterable": [
{ "column": "tenant_id", "type": "number" },
{ "column": "region", "type": "string" }
],
"required_filters": ["tenant_id"]
}
}
}
Every required column must also be declared in filterable. A missing
required column returns a structured HTTP 400 with code
data_app_required_filter_missing. The camel-case cloud field
requiredFilters is a different contract and is rejected during on-prem build
and upload; migrate it explicitly to filterable plus required_filters.
Source, rebuilds, and versions
Every current upload includes editable source files. You can download them:
definite run app get revenue-explorer --source
After editing, rebuild locally with npm run build and upload with --force,
or ask Definite to rebuild the stored source against the deployment's current
data-app runtime:
definite run app rebuild revenue-explorer --validate
The app detail page and API expose runtime metadata so stale apps can be found after a runtime/template change. Admins can also bulk rebuild stored-source apps through the admin API.
Permissions
Creating a data app requires an editor or admin session. Viewing the app, loading its HTML, and running its resources require view access to that app. Replacing, renaming, rebuilding, changing its compute profile, or deleting it requires edit access to the app.
New apps are initialized with the deployment's default content visibility, then use the same content sharing model as other Definite objects.
Authoring with Fi or MCP
Fi and external MCP clients can build normal data apps. The MCP server exposes
scaffold_data_app, save_data_app, validate_data_app,
query_data_app_resource, list_data_apps, and get_data_app. Apps created
this way use the same source snapshot, version history, permissions, and
runtime as CLI-created apps.
See MCP Server for the tool flow.
Troubleshooting
| Symptom | What to check |
|---|---|
| Upload returns a slug conflict | Use definite run app get <slug> to inspect the existing app, then pick a new slug or upload with --force only for an intentional replacement. |
Upload says source_files is required | Upload a project directory, not just a loose index.html; the directory must include editable source such as app.json and src/. |
Upload returns data_app_source_conflict | Another editor published after this source was fetched. Fetch into a new directory and reapply the intended edit; do not retry the stale checkout. |
| App opens but a chart errors | Resource validation does not execute browser-side SQL or charts. Open the app, inspect the surfaced runtime error, and fix the named component or useSqlQuery; use --validate separately for manifest-resource SQL. |
| App still shows old data | Check cache_ttl_hours, then reload after the browser cache expires or upload a manifest with a shorter TTL. |
| App is marked stale | Rebuild it from stored source so it picks up the current runtime and mirrored library URLs. |
| Embedded app fails with scoped filters | Make sure every queried resource declares every filtered column in filterable. |
| Resource query returns HTTP 413 | The resource exceeded the workspace row limit or the 100 MiB data-app Arrow ceiling. Narrow the SQL; do not eagerly load export-sized data into a dashboard resource. |