Architecture¶
GeoMarmot is a static web app. Everything that touches data runs in the user's browser tab, in DuckDB-Wasm; an optional Python server serves the app on the user's own machine and relays what a browser cannot do by itself (reading private buckets with the user's credentials, calling a model with a key from the environment).
files, URLs, gs:// ┌───────────────────────── browser tab ─────────────────────────┐
───────────────────────────▶│ io/sources ──▶ graph model ──▶ compiler ──▶ views per port │
│ ▲ │ (generations, leases) │
│ canvas, inspector │ ▼ │
│ table, map ◀──────┴──── readers hold a lease ───┐ │
│ │ │
│ assistant: tools ──▶ privacy gate ──▶ provider ─┼──▶ Claude, │
│ drafts ─▶ isolated preview engine │ OpenAI │
└──────────────────────────────────────────────────┼─────────────┘
optional local server (127.0.0.1): /proxy/gs, /list, /ai
Layout¶
| Path | What |
|---|---|
app/src/core/ |
no DOM. duck.js (engines, queries, watchdog, restart), graph/ (model, compiler, expansion of generated transformers), sqlguard/, template.js, schema.js, jsonschema.js |
app/src/io/ |
sources (sources.js), remote URLs (remote.js), readers (readers/, zarr/), writers (writers/) |
app/src/engines/ |
JavaScript geometry DuckDB cannot do: JSTS overlays and smoothing, H3 cells |
app/src/ui/ |
the shell: canvas, inspector, table, map, modals, the compile loop, the assistant's drawer and chat |
app/src/ai/ |
the assistant: catalogue, providers, privacy gate, tools, agent loop, drafts and previews, generated transformers (spec/) |
transformers/ |
one folder per transformer, and _kit/, the only API they import |
server/ |
the geomarmot Python package |
schemas/ |
the graph file format, and schemas generated from the kit (npm run build:schemas) |
From a file to a view¶
- Sources. A dropped file is read into memory once and registered with DuckDB as a buffer; a
URL is registered for HTTP range reads, so opening a remote Parquet costs its footer. Excel
sheets and Zarr arrays are read in JavaScript and land in a table. Each source records its
columns, row count, geometry column and CRS (
io/sources.js, formats.md). - The graph.
core/graph/model.jsholds nodes, edges, undo history and the saved-file format (ADR 0007). A node is a transformer type plus params. - Compiling.
createCompiler({ namespace, engine })turns the graph into one DuckDB view per output port. For each node in topological order it runs the SQL guard, resolves inputs and the incoming CRS, then the transformer's hooks —prepare(tables, JavaScript engines),sql(one SELECT per port),crs,check— and creates the node's views in one transaction. The contract is transformer-api.md. - Reading. Nothing is computed until something reads a view: the table's page, the map, the port counts, an export, an assistant tool.
Generations and leases¶
A compile never drops what a reader might be using. Every compile is a new generation whose
relations are named after it (m_g12_n3_output), and only read each other. Readers take a lease
on the latest completed generation and release it when done; a replaced generation is retired and
dropped once its last lease ends. A new compile waits until every retired generation is gone, so at
most two exist at once. Edits arriving during a compile abort it (latest wins), and an aborted
compile's partial generation is dropped at once. A compile where a node fails still publishes: the
working part of the graph stays inspectable, the failed node reports its error, and everything
downstream reports "blocked". The UI takes every node's state from its lease, so it never shows an
older generation's rows for a node that has since changed.
Engines¶
The main engine holds the user's sources. Interactive reads are watched: cancelled after 30 s,
and if the query does not stop, the engine is restarted and every source registered again
(ADR 0004). Isolated engines are separate
DuckDB instances for the assistant's previews; they hold only sampled copies and can be thrown away
at any time. Hooks run SQL through ctx.engine, so the same transformer runs in either. Details in
engines.md.
The SQL boundary¶
SQL the user or the assistant writes is an untrusted fragment. The guard (core/sqlguard/)
parses each fragment in a placeholder form with DuckDB's own parser and refuses anything but one
SELECT reading the node's own inputs; the transformer then splices it with the same function that
built the placeholder form. SQL that reviewed transformer code builds around fragments is trusted
and never guarded. See security.md.
The assistant¶
A chat drawer beside the canvas. The agent loop (ai/agent.js) sends the conversation to a
provider (ai/provider.js; directly with the user's key, or through the local server's /ai
relay, ADR 0009), runs the tools the model asks for (ai/tools/),
and sends their results back. Every result passes through the privacy gate (ai/gate/), which
shapes and checks it for the data level the user chose. The model proposes nodes as a draft;
drafts are previewed in an isolated engine and enter the graph only when the user applies them.
When no built-in transformer fits, it can write a generated transformer: a declarative spec of
SQL templates and calls to built-in transformers (ai/spec/), which the compiler expands into
ordinary nodes (core/graph/expand.js).
The local server¶
server/geomarmot is a FastAPI app bound to 127.0.0.1. It serves the built app, proxies gs://
objects with the user's Application Default Credentials (range requests preserved), lists buckets,
and relays assistant requests. Every route but the static files needs a session cookie that only a
page opened with the launch token can get. Without a server — on GitHub Pages — the app finds that
./healthz does not answer and hides what needs one.
Boot¶
main.js: trade the launch token for a session; ask whether a server is there; boot DuckDB with
the bundled extensions; install the generated transformers kept in this browser; restore the
autosaved graph; compile.