@tegmentum/ducklink
v0.5.0
Published
Ergonomic JS/TS API for DuckDB-wasm in the browser: create -> connect -> query typed Result rows, conformance-gated extension loading, and result streaming. Runs in any modern browser via a JSPI fast-path (Chrome 137+) with an automatic non-JSPI Web Worke
Readme
@tegmentum/ducklink
Ergonomic JS/TS API for DuckDB-wasm in the browser: create -> connect ->
query returning typed Result rows (JS objects, BigInt-aware), conformance-gated
extension loading, and result streaming. Built on
@tegmentum/datalink-browser (the DB-agnostic runtime
plumbing) + the DuckDB core wasm component + the ducklink registry catalog.
import { create } from '@tegmentum/ducklink'
const db = await create({ coreUrl: '/ducklink_core.wasm' })
const conn = await db.connect()
const r = await conn.query('SELECT 42 AS answer, $1 AS who', ['world'])
r.toArray() // [{ answer: 42n, who: 'world' }] typed JS objects
r.columns // [{ name:'answer', type:'int64' }, { name:'who', type:'text' }]
// conformance-gated extension load (resolver shim over registry/index.json)
await conn.load('sample_extension')
conn.providerOf('sample_extension') // 'wasm-component'
await conn.query('SELECT sample_plus_one(41) AS v') // [{ v: 42n }]
// stream a large result (AsyncIterable over the WIT result-stream resource)
for await (const row of conn.queryStream('SELECT i FROM range(1e6) t(i)')) { /* ... */ }
// register an in-memory file so FROM 'name.csv' resolves
conn.registerFileText('people.csv', 'id,name\n1,ada\n')
await conn.query("SELECT count(*) FROM read_csv('people.csv', header=true)")Surface
| | |
| --- | --- |
| create(opts) | instantiate the DuckDB core (coreUrl/coreBytes, registryUrl/registry, artifactUrl, network) |
| db.connect(path?) | open a connection (in-memory by default) |
| conn.query(sql, params?) | buffered Result; params are positional bind values |
| conn.prepare(sql) | PreparedStatement (parameterCount, execute) |
| conn.queryStream(sql, {batchSize}) | AsyncIterable<Row> over the result-stream resource |
| conn.queryArrow(sql) | Apache Arrow Table (lazy apache-arrow import) |
| conn.load(name) / install(name) | resolver-gated extension load; providerOf(name) / loadedExtensions() |
| conn.registerFileBuffer/Text/Handle | seed the in-memory FS for FROM 'name' / read_parquet(...) |
| conn.insertArrowTable(table, name) / appender(table) | bulk ingest |
| Result | .toArray() (typed objects), .toRows(), .get(i), iterator, .columns, .numRows, .toArrow() |
Typed errors: DuckLinkError, QueryError, ExtensionError, ConformanceError,
ContractError, InstantiationError, NotFoundError.
Value marshalling (duckvalue <-> JS, BigInt-aware) lives in ONE module
(src/values.mjs) so a WIT type-bump is a one-file change.
The resolver gate
conn.load(name) resolves over registry/index.json: it picks the wasm
provider whose conformance.passed === true and
conformance.at === entry.wit_contract (THE GATE -> ConformanceError /
ContractError otherwise), verifies the content_digest of the fetched bytes,
then preloads + LOADs it. Each loaded extension gets its own handle namespace
(the multi-extension router in @tegmentum/datalink-browser), so
load('a') + load('b') coexist.
Verify (headless Chromium)
npm install
# stage the gitignored test/demo artifacts: the @2.2.0 wasip2 core + a
# representative subset of @2.2.0 catalog extensions + the registry index.
# (the full catalog is R2-served; only the bundled subset loads live):
DUCKLINK_DIR=<ducklink> DUCKDB_WASM_DIR=<duckdb-wasm> scripts/stage-public-artifacts.sh
npm run verify # vite + playwright Chromium (JSPI: Chrome 137+)
npm run test-values # pure marshalling unit test (Node)Browser console (try-it-now playground)
An interactive, install-free SQL playground built on this facade (a
shell.duckdb.org-style REPL): web/console/.
npm run console:dev # vite dev server (drives the facade source live)
npm run console:build # static build -> web/dist (gh-pages-able, base: './')
npm run verify-console # vite + playwright Chromium end-to-end (captures a PNG)- Terminal UI (
web/console/repl.mjs): a dependency-free custom REPL — multi-line SQL (Enter submits a;-terminated statement, Shift+Enter newlines), history, dot-commands. No xterm dependency. - duckbox renderer (
web/console/duckbox.mjs): renders aResultas the DuckDB-shell Unicode box table — header + dim type row, right-aligned numerics, BigInt/date/decimal formatting, aN rows, M columnsfooter. - Wiring (
web/console/console.mjs):conn.queryfor normal results; results over ~50k rows re-run throughconn.queryStreamas a chunked, capped preview. Typed errors (QueryError/ConformanceError/NotFoundError) render as clean colored lines. - Extension showcase:
.load sample_extensionresolves through the conformance gate, thenSELECT sample_plus_one(41)dispatches into it. The.catalogcommand lists all registry extensions; the full catalog lights up automatically once the stale@2.0.0artifacts are reconciled to the@2.2.0core (ducklink #138) — no console change needed.
The static build copies the wasm core + sample_extension.wasm + registry into
web/dist/; apache-arrow stays a lazy chunk (never loaded by the console).
Extension marketplace (browse / inspect / install)
A visual extension marketplace — the browser counterpart to the CLI ext-UX —
two-paned with the console so an extension installed on the left is immediately
usable in the REPL on the right (one shared connection): web/app/.
npm run app:dev # vite dev server
npm run app:build # static build -> web/dist-app (gh-pages-able, base: './')
npm run verify-marketplace # vite + playwright Chromium end-to-end (captures PNGs)- Browse (
web/app/marketplace.mjs): a live grid of the 182 catalog extensions — name, one-line description, category chips, provider-kind badges (wasm/native), and a certified/uncertified conformance badge. A search box (name/description/exports/keywords) + a category filter narrow it live. - Detail view: the visual equivalent of
ducklink ext info— exports (function names), categories, the contract digest + version, and every provider with kind / reference / platform / digest / conformance status. The Resolver verdict panel reusesselectProviderfrom@tegmentum/datalink-browserto show which provider the resolver chooses and why (or why it is rejected) — never drifting fromconn.load(). - Install / Load: a button calls
conn.load(name)(the shared connection); the conformance gate surfaces as a loading -> loaded state or a friendly typed rejection (ConformanceError/ContractError/NotFoundError/InstantiationError). A Loaded bar tracks the current extensions. - Console integration: install
sample_extensionin the marketplace, thenSELECT sample_plus_one(41)returns42in the console pane — sameconn.
Static build copies the wasm core + sample_extension.wasm + registry into
web/dist-app/; apache-arrow stays a lazy chunk. The marketplace reuses the
JS API + resolver as-is and the console's repl.mjs / duckbox.mjs modules.
Deploy the demo (GCP / Firebase Hosting)
The demo fetches the extension catalog + artifacts directly from the live R2
distribution — the host (GCP/Firebase) is never in the artifact byte path. The
distribution base is a single knob, CATALOG_BASE in web/config.mjs:
- catalog:
<base>/ducklink/catalog.json - artifacts:
<base>/wasm/sha256/<digest>/<name>.wasm(content-addressed, immutable) - default
<base>=https://datalink-ext.tegmentum.ai; override with the build envVITE_CATALOG_BASE=…or the runtime globalwindow.__CATALOG_BASE.
If the R2 fetch fails (offline / CI / before CORS is applied for the deployed
origin), loadCatalog() falls back to the bundled local catalog so the demo
still runs — the wasm core is always served locally.
Public layout (scripts/build-demo.sh -> web/dist-deploy/, the Firebase
public dir):
/— the marketplace + console app (the rich two-pane demo)/console/— the standalone SQL console
npm run demo:build # builds both -> web/dist-deploy/ (app at root, console at /console/)
# choose/create a Firebase project, then point .firebaserc at it (replace {{PROJECT_ID}})
firebase deploy --only hosting --project <PROJECT_ID>firebase.json serves *.wasm as application/wasm, gives hashed assets/**
a 1-year immutable cache, and index.html no-cache; apache-arrow stays a
lazy chunk. To go live the deployed origin must be allowed by R2 CORS
(ducklink deploy/r2/apply-cors.sh, run once the origin is known) — until then
the demo runs on the bundled fallback.
Status
Core facade MVP (design steps 1-4) — proven in headless Chromium. Deferred:
the worker harness + JSPI fallback (step 5) and the full datalink/browser polish
(step 6). JSPI is required (Chrome 137+); create() throws InstantiationError
with a clear message where it is unavailable (the worker fallback is the
follow-on).
Note: the registry's currently-gated wasm artifacts (
aba,ascii85, ...) were built againstduckdb:[email protected]while the shipped core is@2.2.0; their stale logicaltype shape is rejected by the core onLOAD. The verify usessample_extension, which is built against the current contract. This is a ducklink artifact-staleness issue, not a facade bug — the resolver loads exactly what the registry points at.
