@makoai/app-sdk
v2.7.2
Published
Mako app SDK: data bindings (useQuery/useDuckDB), URL state, theme, and the house dashboard kit (/ui).
Maintainers
Readme
@makoai/app-sdk
The runtime SDK for Mako data apps — React hooks over an
app's data bindings, a Vite plugin that serves those bindings during a local
vite dev, and Mako's house dashboard kit (@makoai/app-sdk/ui).
Apps depend on the published package with a caret range
("@makoai/app-sdk": "^2.7.0"), and Mako's deploys move every app to the
newest release within that range — fixes and design updates reach apps
without anyone copying files. Do not vendor it into a workspace repo.
Install
Apps use pnpm (new apps pin it in packageManager; Mako installs each
app with the package manager its lockfile names):
pnpm add @makoai/app-sdkDo not run npm install in a pnpm app — it ignores pnpm-lock.yaml and
writes a competing package-lock.json.
In the app
import { useQuery, useDuckDB, useSearchParams, useTheme } from "@makoai/app-sdk";
// Rows of bindings/<name>.sql, materialized to parquet by Mako.
const { data, loading, error } = useQuery("latest_sales");
// Analytical SQL over every binding (DuckDB-WASM in the browser; table
// names are binding names).
const totals = useDuckDB("select country, sum(revenue) r from latest_sales group by 1");
// Rematerialize on demand: re-runs the binding's query on the warehouse,
// then every hook reading it re-renders with the new rows. The old rows
// stay on screen while `refreshing` is true.
const { refresh, refreshing } = useQuery("latest_sales");
<button onClick={() => refresh().catch(e => alert(e.message))} disabled={refreshing}>
Refresh
</button>useDuckDB(...).refresh() refreshes every binding; refreshBinding(name) /
refreshBindings() do the same outside a component. A refresh POSTs to
__data/<name>/refresh, the data URL's sibling, so whoever serves the app's
data (Mako, the sandbox dev server, the Vite plugin below) rebuilds it with
its own authorization: a signed-in member can always refresh; a public share
only when its owner enabled live queries, and at most once every few minutes
per binding. A refused refresh rejects with status (403, 429 + retryAfterMs)
or 502 with the query's error.
useLocation / useSearchParams / navigate keep filter state in the URL;
useTheme follows the OS preference. Theme tokens (--background,
--chart-1, …) match the ones the scaffold's styles.css declares.
Data arrives from __data/<name>.parquet, relative to the page — the same
path in Mako's sandbox, in a published app, and on a laptop.
Who is looking: useViewer()
import { useViewer } from "@makoai/app-sdk";
const { viewer, loading } = useViewer();
// viewer === null → anonymous share link (or still loading)
// viewer.email → "[email protected]"
// viewer.workspace.role → "owner" | "admin" | "member" | "viewer" | null
// viewer.app.role → "owner" | "editor" | "viewer" | nullMako resolves the viewer server-side from the session or the signed view token — the page cannot forge it — and reports only what the platform knows: identity, the workspace and the person's access role in it, and their role on this app. There is no job title, team or country in the platform, on purpose: those are your data. Put a roster in a binding and join on the email:
-- bindings/viewers.sql
SELECT lower(email) AS email, team, country, is_lead FROM hr.peopleconst { viewer } = useViewer();
const me = useDuckDB(
viewer ? `select * from viewers where email = '${viewer.email.replace(/'/g, "''")}'` : "select 1 where false",
);
const board = useDuckDB(
me.data?.[0]?.is_lead
? "select * from pipeline"
: `select * from pipeline where team = '${me.data?.[0]?.team ?? ""}'`,
);Keep the roster binding to the columns the app needs for its logic: every binding the app can read is downloaded whole into the viewer's browser, so this shapes the UI rather than enforcing access. Who may open the app is the app's access setting in Mako; server-side row filtering is a follow-up on the same identity (apps.md §28).
The house style: @makoai/app-sdk/ui
Importing the SDK injects Mako's theme tokens (stone, warm canvas, #527df2
brand; light and dark): --background, --foreground, --card, --border,
--muted-foreground, --brand, --canvas, --positive / --warning /
--negative, --chart-1…--chart-5, … Style with these names and the app
follows the house palette and dark mode for free. Override a token in your own
stylesheet to re-theme; do not paste the whole block.
The kit is the dashboard furniture every app was re-forking:
import "@makoai/app-sdk/ui.css"; // before ./styles.css, so the app wins
import {
PageHeader, Card, KpiRow, KpiTile, Button, StatusDot,
MultiSelect, FreshnessBadge, RefreshAllButton,
} from "@makoai/app-sdk/ui";
<PageHeader
title="Spain renewals"
subtitle="Contracts up for renewal in the next 90 days"
actions={<>
<FreshnessBadge tone="ok" headline="Synced 12m ago"
title="Data as of 09:05" schedule="Refreshes 06:30 / 18:30 Europe/Zurich"
sources={[{ label: "Stripe", value: "09:05", tone: "ok" }]} />
<RefreshAllButton bindings={__APP_BINDING_NAMES__}
labels={{ renewals: "Renewals" }} last={["data_freshness*"]} />
</>}
/>
<KpiRow>
<KpiTile label="Up for renewal" value="412" delta="+8%" tone="ok" hint="vs last quarter" />
</KpiRow>
<MultiSelect options={["CH", "ES", "IT"]} value={countries} onChange={setCountries} allLabel="All countries" />MultiSelect— controlled (value/onChange). An empty selection means "all" unlessemptyMeansAll={false}(then "all" = every value selected). Options are strings or{ value, label, color };showOnlyadds an "Only" button per row. On phones the menu is a bottom sheet.FreshnessBadge— presentational: the app decides what fresh means and passestone(ok/warn/bad, orgreen/amber/red), aheadline, and per-source rows;actionis a callout slot (e.g. a "Load latest" button).RefreshAllButton— rebuilds every binding (refreshBinding),concurrencyat a time (default 3 — keep it low),first/last/skippatterns (*suffix = prefix), with a progress modal. Give it the binding names with a build-time define invite.config.ts:import { readdirSync } from "node:fs"; // inside defineConfig({ … }): define: { __APP_BINDING_NAMES__: JSON.stringify( readdirSync(new URL("./bindings", import.meta.url)) .filter(f => f.endsWith(".sql")).map(f => f.slice(0, -4)), ), },and declare it once (
src/vite-env.d.ts):declare const __APP_BINDING_NAMES__: string[];
ui.css is plain CSS (no Tailwind needed; it coexists with Tailwind) and
every kit class is prefixed mk-. It also sets the house base: Inter 14px on
the warm --canvas.
In vite.config.ts
import { makoData } from "@makoai/app-sdk/vite";
export default defineConfig({ plugins: [react(), makoData()] });makoData() answers __data/index.json (the app's bindings/*.sql) and
__data/<name>.parquet during vite dev from the Mako API, and what it asks
for is your local binding file: it sends the text of
bindings/<name>.sql and gets back the parquet of exactly that query, run
read-only through the workspace connection its front matter names. When the
text is the committed binding, that is the app's stored artifact (built on
first request if it never was); when you have edited it, Mako builds a draft
from your text and hands it back without storing it — nobody else, and no
published viewer, ever sees uncommitted SQL. Building needs edit access to the
app; read-only members get committed artifacts. POST __data/<name>/refresh
(the SDK's refresh()) rebuilds from the local text on demand.
Builds run as jobs: the plugin asks for one (async), gets a job id back at
once, and polls it until the parquet is ready — so a query that runs for
minutes is waited for instead of failing when a proxy cuts the request at
100 s. pollIntervalMs / buildTimeoutMs tune the wait (1 s, 30 min).
Results are cached under node_modules/.mako-data/ for five minutes
(revalidateMs; ?refresh bypasses), next to a fingerprint of the text they
were built from: after an edit the cache is never served, however long
revalidateMs is, and while the API is unreachable a stale copy is served
only if it was built from the same text. It is apply: "serve" only —
production builds never load it.
dbt models you are still building
A binding linked to dbt (-- dbt_project: <id>) writes {{ dbt_schema }},
which renders to the production schema. To preview models you built into
your own dbt environment, point the dev server at it:
makoData({ dbtEnvironment: "joan" }) // or MAKO_DBT_ENV=joan in the repo's .envThe environment must exist in the linked dbt project (dbt/environments.yml),
and a personal environment (owner_user_id) renders only for its owner.
These builds are drafts too: never stored, never what a published app reads.
It also answers __data/viewer.json (you, as Mako sees you), and
MAKO_VIEWER_AS=<email> — in the environment or the repo's .env —
previews the app as that member instead (editors of the app only).
Credentials, in order: MAKO_API_URL / MAKO_API_KEY in the environment,
then in the repo-root .env. The workspace id comes from
.mako/workspace.json (or MAKO_WORKSPACE_ID). Without a key the app runs
and every binding answers 503 with a hint.
Dependency-free: the DuckDB engine loads from jsDelivr at runtime; the plugin uses only Node built-ins.
