Analytics (DuckDB / HTAP)
Analytics is added as a second engine over the same storage floor — not by making the row engine columnar. A thin materialization job periodically snapshots a table to an open columnar format (Parquet) on the shared store, and DuckDB queries those snapshots directly. The row engine stays OLTP; DuckDB does the aggregations. This is possible precisely because storage is decoupled.
The idea: two engines, one floor
The deciding rule (spec 12) is that you add analytics by composing a second engine, not by bloating the OLTP engine with a columnar path. Twill DB keeps its row engine focused on transactions. DuckDB — an off-the-shelf analytical engine — reads an open columnar snapshot of the same data. The only code you own is the materialization job: the glue that publishes the snapshot.
Why this works
Because durable state lives behind the storage seam, the columnar snapshots can sit beside the database's own objects on the same bucket. DuckDB reads object storage natively, so the analytical engine and the transactional engine share one floor without a pipeline between two systems.
writes ──▶ Twill row engine (OLTP) ──┐
│ materialization job (thin glue we own)
▼
Parquet snapshot on shared store ◀── DuckDB (OLAP) ──▶ aggregations
Materialize a snapshot
The @twilldb/bun/olap helper snapshots a table to an open columnar file, publishing atomically (write to a temp file, then rename) so a reader only ever sees a whole snapshot.
import { open } from "@twilldb/bun";
import { materialize } from "@twilldb/bun/olap";
using db = open("file://./app.db"); // or s3://bucket/app — snapshot lands on the same store
db.exec("CREATE TABLE events (id INTEGER PRIMARY KEY, kind TEXT, amount REAL)");
db.exec("INSERT INTO events VALUES (1,'click',1.5),(2,'view',0.0),(3,'click',2.5)");
const snap = materialize(db, { table: "events", dir: "./olap", format: "parquet" });
// → { path: "./olap/events.parquet", rows: 3, format: "parquet" }
Parquet is the production format. DuckDB is also the off-the-shelf writer the job shells out to (COPY … TO … (FORMAT PARQUET)); when DuckDB is not installed the job falls back to CSV, which DuckDB still reads via read_csv_auto — so a snapshot is always publishable, only the encoding changes.
Query it from DuckDB
DuckDB reads the snapshot unmodified — no import step, no second copy of the schema:
duckdb -c "SELECT kind, count(*) AS n, sum(amount) AS total
FROM './olap/events.parquet' GROUP BY kind ORDER BY kind"
The aggregation runs entirely in DuckDB over the columnar snapshot; the row engine is never on this path. A runnable end-to-end version is clients/bun/examples/duckdb-olap.ts (bun run examples/duckdb-olap.ts).
Configurable cadence
Snapshots refresh on a cadence you choose. Materializer wraps materialize with a configurable interval; the row engine keeps serving OLTP while snapshots refresh in the background, and DuckDB always reads the last whole snapshot.
import { Materializer } from "@twilldb/bun/olap";
const m = new Materializer(db, { table: "events", dir: "./olap", cadenceMs: 60_000 });
m.start(); // publish now, then every 60s
// … later …
m.stop();
Pick the cadence by how fresh the analytics need to be versus how often you want to pay the snapshot cost — seconds for near-real-time dashboards, minutes or hours for periodic reporting.
Scope
The row engine stays OLTP-only
This composition deliberately adds no columnar code to the engine — there is no second on-disk format, no analytical query path in crates/engine. Analytics is DuckDB's job over a published snapshot. The trade-off is that DuckDB queries the snapshot, not live rows: results are as fresh as the last materialization. Iceberg can replace Parquet as the snapshot format when you want table-level snapshot/branch semantics on the lake side; the materialization job is the only piece that changes.
Related
s3:// backend so snapshots share the database's floor.
AUTHAuth (better-auth)The other composed capability — a library in-process rather than an engine in front.
CAPCapabilities (spec)The built-in-vs-composed rule that keeps OLAP out of the core.
OBJObject-storage backend (spec)The decoupled storage that lets a second engine share the floor.