Skip to content

feat(providers): DuckDB as an embedded file engine, with the boundary the engine enforces (#424) - #516

Merged
cevheri merged 1 commit into
mainfrom
feat/duckdb-provider
Aug 27, 2026
Merged

feat(providers): DuckDB as an embedded file engine, with the boundary the engine enforces (#424)#516
cevheri merged 1 commit into
mainfrom
feat/duckdb-provider

Conversation

@cevheri

@cevheri cevheri commented Aug 27, 2026

Copy link
Copy Markdown
Member

Phase 6 of #424. DuckDB becomes the seventeenth type-id and the sixteenth external engine: a server-local .duckdb file or :memory:, through the official @duckdb/node-api 1.5.5-r.4 (DuckDB v1.5.5), imported lazily inside connect() so no other engine pays for the 68 MB of platform bindings.

Every claim below was measured against a live embedded engine. Several refute what the documentation implies, and the prime reference is docs/providers/duckdb.md.

The read-only boundary is the engine's, not a word list

access_mode: 'READ_ONLY' refuses writes to the attached database and is not a filesystem sandbox. Measured escaping a genuinely read-only handle: COPY ... TO, EXPORT DATABASE, INSTALL, LOAD, read_text, read_blob, glob, sniff_csv, read_csv_auto('/etc/hostname').

A name denylist was written first, then broken three ways — each reproduced end to end through the real provider, past the upstream statement guard:

bypass why the guard could not see it
SELECT * FROM "read_text"('/etc/hostname') findCodeWord skips quoted-identifier spans — correct for a keyword, wrong for a function name, since DuckDB resolves "read_text" exactly like read_text
SELECT * FROM '/tmp/x.csv' the replacement scan turns a bare path into read_csv_auto by extension; there is no word to find
json_execute_serialized_sql(...) a whole statement travels inside a string literal

The read-only handle now opens with enable_external_access: 'false' beside access_mode. Every form above is then refused engine-side (Permission Error: Cannot access file "..." - file system operations are disabled by configuration) and it cannot be re-enabled at runtime. The SQL guard remains as defence in depth and says so in its header; a test derives the file-reaching function list from duckdb_functions(), so a future DuckDB shipping a new reader fails CI instead of silently reopening the hole.

Measured facts that shaped the code

  • getRowObjects() is unusable — it throws on JSON.stringify ("Do not know how to serialize a BigInt"). Everything goes through getRowObjectsJson(); BIGINT/HUGEINT/DECIMAL arrive as strings, so DECIMAL(38,2) reaches the grid exactly.
  • A write is recognised by the answer's shape, never a leading keyword. DuckDB's FROM-first syntax, SUMMARIZE, PIVOT and CALL all produce rows. SELECT 1 AS Count keeps its grid; INSERT reports rowsChanged. Both pinned.
  • duckdb_schemas().internal is TRUE for main even in a user database — the obvious NOT internal filter silently drops the default schema. Reads are bounded by database_name = current_database().
  • estimated_size is an estimate, not a count. After DELETE FROM big WHERE id < 19000000 on 20 M rows it answered 1,076,480 where count(*) answered 1,000,000, and a CHECKPOINT left it there. The tree counts exactly instead — one UNION ALL over 41 tables including that one took 8.8 ms, because DuckDB answers count(*) from row-group metadata.
  • Per-table bytes are real: distinct persistent block_id from PRAGMA storage_info × block_size, verified non-double-counting (12 one-row tables → 12 distinct blocks, none shared). In-memory or un-CHECKPOINTed tables publish nothing, never a zero.
  • A second process is refused even read-only (IO Error: Could not set lock on file) — stricter than duckdb.org's summary, and why singleWriterFile: true is declared. Same-process handles are permitted, and a same-process READ_ONLY reopen is genuinely read-only, which is what lets the agent's handle exist beside the writer's.
  • interrupt() exists, so cancelQuery is implemented rather than deferred.
  • EXPLAIN (ANALYZE, FORMAT JSON) answers {"result":"error"} and executes the statement (a probe table went 0 → 1 rows). Only the estimating form is ever composed — permanently, whatever a later version does to that JSON.
  • Honest empties: duckdb_queries() and duckdb_connections() do not exist, so neither panel fabricates a zero. REINDEX, PRAGMA integrity_check and PRAGMA optimize do not exist either, so only vacuum, analyze and optimize (→ CHECKPOINT, global only) are offered, each with a spec so #U9 cannot repeat.

Agent support is real, not merely declared

DuckDB reached AGENT_EXECUTION_ENGINES and needed matching catalog composers, or inspect_schema, profile_table and inspect_plan would have refused on every run while POST /api/agent/runs happily accepted the workflows.

  • duckdb_constraints() publishes both sides of a foreign key as lists, and DuckDB has no WITH ORDINALITY — so a composite key is paired by one shared ordinal. That is B8's cross-product on this engine's catalog.
  • duckdb_indexes() lists only written indexes, so constraint-backed ones are unioned in — B25's hole, and the only primary-key information the agent path has.
  • A test asserts AGENT_EXECUTION_ENGINES is a subset of the composers' keys, so the next engine cannot repeat this.

Verification

Six local gates green, 100.00 % merged line coverage (45115/45115), build:lib + attw clean, and the addon loads on both Bun 1.3.14 and Node 24.14.0.

Driven in Chrome against a live two-schema database: connect · browse (exact counts, the view carrying none) · nested VARCHAR[] and STRUCT round-tripping to the grid · EXPLAIN · monitoring · both agent modes. Plan mode grounded on the real schema and drafted SQL that then ran unchanged; agent mode executed two statements on its own read-only path (3 ms, 8 ms) and composed a report whose claims cite the artifacts.

The three highest-value new tests were each proven non-vacuous by reverting their fix and observing red.

Found in the browser, filed rather than fixed

D44 — the Tables panel's per-row maintenance button sends table.tableName without the table.schemaName it renders on the next line, so clicking Analyze on analytics.events produced:

Catalog Error: Table with name events does not exist! Did you mean "analytics.events"?
LINE 1: ANALYZE "main"."events"

No gate could have caught it — both halves are correct in isolation. It is a shared component reaching all twelve providers that implement runMaintenance, so fixing it is a twelve-engine verification, not a provider change. The provider's own contract (a qualified target works; a bare one for another schema is refused rather than guessed) is pinned by a test.

D43 — there is no sessionsEmptyState, which is why DuckDB's Queries panel explains itself and its Sessions panel cannot.

Packaging

Follows the better-sqlite3 triad: external in both build configs, copied explicitly in the Dockerfile and the standalone payload because tracing misses the .node, and the musl bindings pruned before linuxdeploy, which treats a musl ELF as fatal.

Closes nothing on its own; advances #424.

Comment thread tests/integration/db/duckdb-provider.test.ts Fixed
@cevheri
cevheri force-pushed the feat/duckdb-provider branch from d5a1de0 to 7cb6513 Compare August 27, 2026 15:26
… the engine enforces (#424)

Phase 6 of #424. DuckDB is the seventeenth type-id and the sixteenth external engine:
a server-local `.duckdb` file or `:memory:`, through the official `@duckdb/node-api`
1.5.5-r.4 (DuckDB v1.5.5), loaded lazily inside `connect()` so no other engine pays for
the 68 MB of platform bindings.

Every finding below was MEASURED against a live embedded engine, not read from
duckdb.org, and several of them refute what the documentation implies.

**The read-only boundary is the engine's, not a word list.** `access_mode: 'READ_ONLY'`
refuses writes to the attached database and is NOT a filesystem sandbox: `COPY ... TO`,
`EXPORT DATABASE`, `INSTALL`, `LOAD`, `read_text`, `read_blob`, `glob`, `sniff_csv` and
`read_csv_auto('/etc/hostname')` all measurably escape a genuinely read-only handle. A
name denylist was written first and then broken three ways, each reproduced end to end
through the real provider past the upstream statement guard:

- `SELECT * FROM "read_text"('/etc/hostname')` returned the file, because `findCodeWord`
  skips quoted-identifier spans - correct for a keyword, wrong for a function name, since
  DuckDB resolves `"read_text"` exactly like `read_text`.
- `SELECT * FROM '/tmp/x.csv'` has no word to see at all: the replacement scan turns a
  bare path into `read_csv_auto` by extension.
- `json_execute_serialized_sql` carries a whole statement inside a string literal.

So the read-only handle now opens with `enable_external_access: 'false'` beside
`access_mode`, which refuses every form engine-side with `Permission Error: Cannot access
file "..." - file system operations are disabled by configuration`, and cannot be turned
back on at runtime. The SQL guard stays as defence in depth and says so; a test derives
the file-reaching function list from `duckdb_functions()` so a future DuckDB shipping a
new reader fails CI rather than silently reopening the hole.

Measured facts that shaped the code:

- **`getRowObjects()` is unusable** - it throws on `JSON.stringify` ("Do not know how to
  serialize a BigInt"). Everything goes through `getRowObjectsJson()`. BIGINT, HUGEINT and
  DECIMAL arrive as strings, so `DECIMAL(38,2)` survives to the grid exactly.
- **A write is recognised by the ANSWER'S SHAPE, never by a leading keyword.** DuckDB's
  FROM-first syntax, `SUMMARIZE`, `PIVOT` and `CALL` all produce rows, and a statement's
  first word cannot tell you which. `SELECT 1 AS Count` therefore had to keep its grid
  while `INSERT` reports `rowsChanged` - both are pinned.
- **`duckdb_schemas().internal` is TRUE for `main`** even in a user database, so the
  obvious `NOT internal` filter silently drops the default schema. Every catalog read is
  bounded by `database_name = current_database()` instead.
- **`duckdb_tables().estimated_size` is a row-group ESTIMATE, not a count**: after
  `DELETE FROM big WHERE id < 19000000` on 20,000,000 rows it answered 1,076,480 where
  `count(*)` answered 1,000,000, and a CHECKPOINT left it there. The tree counts exactly
  instead - one UNION ALL over 41 tables including that one took 8.8 ms, because DuckDB
  answers `count(*)` out of row-group metadata.
- **Per-table bytes are real, not estimated**: distinct persistent `block_id` from
  `PRAGMA storage_info` times `block_size`. Verified non-double-counting - 12 one-row
  tables occupied 12 distinct blocks with none shared. An in-memory or un-CHECKPOINTed
  table publishes NOTHING rather than a zero.
- **`pragma_database_size()` returns human strings** ("2.0 MiB", "0 bytes") for every
  size but `block_size`, so they are parsed; `memory_limit` is host-dependent and is
  never asserted.
- **A second PROCESS is refused even read-only** (`IO Error: Could not set lock on file`),
  which is stricter than duckdb.org's own summary and is why `singleWriterFile: true` is
  declared. Same-process second handles are permitted, and a same-process `READ_ONLY`
  reopen is genuinely read-only - which is what lets the agent's handle exist beside the
  writer's.
- **`interrupt()` exists** on the connection prototype, so `cancelQuery` is implemented
  rather than deferred.
- **`EXPLAIN (ANALYZE, FORMAT JSON)` answers `{"result": "error"}` AND executes the
  statement** (a probe table went 0 -> 1 rows). Only the estimating `EXPLAIN (FORMAT
  JSON)` is ever composed, permanently, whatever a later version does to that JSON.
- **No slow-query log and no session list**: `duckdb_queries()` and `duckdb_connections()
  ` do not exist. Both panels answer empty with DuckDB's own words rather than a
  fabricated zero. `REINDEX`, `PRAGMA integrity_check` and `PRAGMA optimize` do not exist
  either, so of the six maintenance operations only `vacuum`, `analyze` and `optimize`
  (mapped to `CHECKPOINT`, global only) are offered - each with a spec, so #U9 cannot
  repeat.

Agent support is real rather than declared: DuckDB reached `AGENT_EXECUTION_ENGINES` and
needed the four catalog composers to match, or `inspect_schema`, `profile_table` and
`inspect_plan` would have refused on every run while `POST /api/agent/runs` accepted the
workflows. `duckdb_constraints()` publishes both sides of a foreign key as LISTS and
DuckDB has no `WITH ORDINALITY`, so a composite key is paired by one shared ordinal - B8's
cross-product on this engine's own catalog. `duckdb_indexes()` lists only written indexes,
so constraint-backed ones are unioned in, which is B25's hole and the only primary-key
information the agent path has. A test now asserts `AGENT_EXECUTION_ENGINES` is a subset
of the composers' keys.

Verified in Chrome against a live two-schema database: connect, browse (exact counts, the
view carrying none), nested `VARCHAR[]` and `STRUCT` round-tripping to the grid, EXPLAIN,
monitoring, and both agent modes. Plan mode grounded on the real schema and drafted SQL
that then ran unchanged; agent mode executed two statements on its own read-only path in
3 ms and 8 ms and composed a report whose claims cite the artifacts.

The browser also found what no gate could: D44, the Tables panel's per-row maintenance
button sends `table.tableName` without the `table.schemaName` it renders beside it, so
`ANALYZE "main"."events"` refused for a table in `analytics`. That is a shared component
reaching all twelve providers that implement `runMaintenance`, so it is filed rather than
fixed here; the provider's own contract - a qualified target works, a bare one for another
schema is refused rather than guessed - is pinned by a test. D43 files the missing
`sessionsEmptyState`, which is why DuckDB's Queries panel explains itself and its Sessions
panel cannot.

Packaging follows the better-sqlite3 triad: external in both build configs, copied
explicitly in the Dockerfile and the standalone payload because tracing misses the
`.node`, and the musl bindings pruned before linuxdeploy, which treats a musl ELF as
fatal. Both Bun 1.3.14 and Node 24.14.0 load the addon.
@cevheri
cevheri force-pushed the feat/duckdb-provider branch from 7cb6513 to 106fdbf Compare August 27, 2026 16:14
@sonarqubecloud

Copy link
Copy Markdown

@cevheri
cevheri merged commit 244e001 into main Aug 27, 2026
26 checks passed
@cevheri
cevheri deleted the feat/duckdb-provider branch August 27, 2026 16:55
cevheri added a commit that referenced this pull request Aug 27, 2026
…ds a total (#517)

* fix(db,ui,docs): an unknown size is absent, not zero, and a share needs a total

Two providers stated both facts in one object. Trino paired
`databaseSize: TRINO_UNAVAILABLE_TEXT` with `databaseSizeBytes: 0`, and the search
seam paired `SEARCH_UNKNOWN_TEXT` with `sizeBytes ?? 0`, so one overview said
"unavailable" in the string and "zero bytes" in the number from a single input.
Both now omit the key, which is what the optional field is for, and a real zero
still reads as zero. Apache Cassandra had already written the argument down.

The search seam also held the last unconditional `activeConnections: 0`. It is
absent now, and `getHealth()` stops composing a key it has no source for. Not the
last zero of any kind: Trino, Druid, ClickHouse and Couchbase each degrade a
refused monitoring read to no rows and then map the absent row to zero, which is
the same encoding by a longer route and is filed rather than fixed here.

The Storage tab is where the fabrications were visible, and three of its figures
divided by a total it did not have. A share now requires `totalSize > 0`, which
excludes the absent size and the measured zero alike, so the remainder bar is empty
instead of full and the two Quick Stats percentages no longer sit under real
megabyte figures. The remainder's BYTES are gated on the contradiction rather than
on the share: a 0 total beside per-table reads that answered rendered a negative
byte count, while `0 - 0 - 0` is an honest zero and stays one. A refused overview
now carries the engine's own sentence, the way two sibling panels do.

Verified end to end in Chrome against the SQLite sample: unpatched, the breakdown
reads 504 + 288 + 4 = 796 KB with bars at -36.68% / -63.82% / -99.4975%; with the
overview's size forced to 0 while the table read answers, the remainder reads N/A,
all three bars are empty, and no percentage is drawn anywhere.

The shared `formatBytes` refused nothing. Measured live: -1, NaN and both
infinities all rendered "NaN undefined", and a petabyte rendered "1 undefined" - a
number wearing a missing unit, which scans as a figure rather than as garbage.
Non-magnitudes return "N/A", the string three other places in this codebase
already use for a size they do not have; the ladder gains PB and EB; and the index
is clamped, which makes indexing past the array unreachable rather than moving the
boundary up two rungs.

Also corrects three claims this repo made about itself. The Storage tab defines its
own local byte formatter and never imports the shared one, so the negative
remainder rendered "-1536 B" and not "NaN undefined" as mongodb.md, a mongodb.ts
comment and the backlog entry all said - a review lane reported exactly that last
round and was overruled by a measurement of the wrong function. The search docs'
claim that `TableStats` cannot say "unknown" is false for its four optional size
fields. And a new comment claiming the last zero anywhere was narrowed to the last
unconditional one.

* docs(backlog): three entries close, four open in their place

D46 and U24 close outright. D44 closes three of its fourteen type-ids - the ones
that contradicted themselves inside a single object - and its counts moved for a
second reason: DuckDB arrived as a seventeenth type-id in #516 and already writes
the key conditionally, so it is a fourth correct provider rather than a fifteenth
fabricating one. The title now reads 11 of 17, the remaining-work list names all
eleven files, and MongoDB is explicitly off that list because both its paths were
already right.

Four entries open, each measured rather than inferred:

D50 - the search seam zeroes two OPTIONAL `TableStats` size fields for a closed
index. Not fixed alongside the rest because omitting them takes the Data figure
away from every open index in the cluster, through `tables.every(...)`, which is a
visible panel change that wants its own decision rather than a drive-by.

D51 - Trino, Druid, ClickHouse and Couchbase each swallow an unavailable
monitoring surface into an empty result and then map the absent row to 0.
ClickHouse's does it to six fields at once, so one refused read publishes a
fully-zeroed overview that reads as measured. Filed as one entry because the fix
is one decision applied four times: the degrade step already knows the difference
and discards it before the mapper can act. Redis and PostgreSQL are named as NOT
measured rather than folded in on resemblance.

U25 - `formatBytes` exists three times, and the two component-local copies have no
guard: measured, they render "-1 B", "NaN B", "Infinity GB" and an exabyte in
gigabytes.

U26 - the admin fleet total re-parses formatted strings and has no `tb` branch, so
a 1 TB database contributes one byte, and this round's PB and EB rungs add two more
spellings that land in the same arm. The real defect is that `FleetHealthItem`
carries no numeric channel at all, which is also what makes an honest absence
inexpressible there.

The section index lines were recomputed from the surviving headings rather than
merged. Both new ids had to move: this branch took D47 and D48 before #516 merged
and published its own, and the bodies auto-merged without a conflict while only the
index line reported one - the same mechanism as #513, caught this time by the guard
#514 added.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants