Skip to content

Architecture Decision Records

Karthik P edited this page Oct 25, 2026 · 8 revisions

Architecture Decision Records

Significant technical decisions are recorded here so contributors can see not only what was chosen, but why, and what was given up. A new ADR is added whenever a decision is hard to reverse or shapes the rest of the codebase.

ADR Decision Status
ADR-001 Client-only architecture, no backend Accepted
ADR-002 React + Vite instead of Next.js Accepted
ADR-003 DuckDB-wasm's worker-based API; heavy work off the main thread Accepted
ADR-004 Keep query results as Arrow, outside React state Accepted
ADR-005 CodeMirror 6 instead of Monaco Accepted
ADR-006 Canvas charts with SQL-side aggregation Accepted
ADR-007 Start single-threaded; adopt multithreading only if benchmarks justify it Accepted
ADR-008 Self-host the DuckDB-wasm assets Accepted
ADR-009 Compose shadcn/ui components on Base UI; keep every visual value in one token file Accepted
ADR-010 Biome for linting and formatting instead of ESLint + Prettier Accepted
ADR-011 Keep imported files in OPFS by default Accepted

ADR-001: Client-only architecture, no backend

Context. Blinkql's core promises are privacy and zero setup. DuckDB-wasm can run the full analytical workload in the browser.

Decision. Blinkql has no application backend. It is deployed as static files.

Consequences.

  • ➕ User data cannot reach a server, because there is none.
  • ➕ Hosting is close to free and scales to any number of users; there is nothing to operate.
  • ➖ Capacity is bounded by the user's device memory and CPU.
  • ➖ No server-side collaboration. Sharing is limited to links that carry queries, not data.
  • ➖ No direct database connections.

ADR-002: React + Vite instead of Next.js

Context. Blinkql is a single-page app with no server rendering or SEO needs beyond a landing view. It relies on browser-only APIs: Web Workers, WebAssembly, OPFS.

Decision. A React single-page app built with Vite.

Alternatives considered.

  • Next.js: server rendering and server components add complexity with no benefit here, and browser-only APIs would need client-only boundaries throughout.
  • Svelte 5 / SolidJS: fine-grained reactivity suits data-heavy UIs and has a smaller runtime. Not chosen because React has the broadest ecosystem and contributor familiarity, and because Blinkql keeps large data outside framework state (ADR-004), which removes most of the reactivity advantage on the hot paths.

Consequences. Unnecessary re-renders must be actively prevented with profiling, selector-based subscriptions, and memoization where measurements show it helps.


ADR-003: DuckDB-wasm's worker-based API; heavy work off the main thread

Context. Queries over large files can take seconds. Any of that work on the main thread freezes scrolling, typing and clicks.

Decision. Use DuckDB-wasm's asynchronous, Web Worker–based API for all query execution. Any other CPU-heavy work (large exports, file conversion, expensive formatting) moves to a dedicated worker, exposed through a typed Comlink API, once profiling shows it's needed.

Consequences.

  • ➕ The UI stays responsive during long queries.
  • ➖ All engine access is asynchronous, and results cross a thread boundary, which ADR-004 addresses.

ADR-004: Keep query results as Arrow, outside React state

Context. Converting a million-row result into JavaScript row objects and putting it into React state costs time, memory and large re-renders.

Decision. Results are stored as Apache Arrow tables in a result store outside React. Components subscribe to small derived values (row count, column metadata, the visible window), and cell values are read lazily from Arrow vectors at render time.

Consequences.

  • ➕ Memory and render cost scale with what's on screen, not with result size.
  • ➖ The result store needs its own subscription mechanism and careful lifecycle management, such as releasing old results.

ADR-005: CodeMirror 6 instead of Monaco

Context. The editor needs SQL highlighting, autocomplete and good keyboard support, while cold-start time is a key performance budget.

Decision. CodeMirror 6 with @codemirror/lang-sql.

Consequences.

  • ➕ Much smaller footprint than Monaco; modular, so only what's used ships.
  • ➖ Fewer IDE-grade features out of the box. Schema-aware completion is built in-house.

ADR-006: Canvas charts with SQL-side aggregation

Context. SVG charting libraries create a DOM node per mark and slow down sharply with large series.

Decision. Use canvas renderers (Apache ECharts' canvas mode, uPlot for dense time series), and aggregate or bucket data in SQL before charting.

Consequences.

  • ➕ Charts stay smooth regardless of source data size.
  • ➖ Canvas charts need extra work for accessibility: data-table fallbacks and descriptive labels.

ADR-007: Start single-threaded; adopt multithreading only if benchmarks justify it

Context. DuckDB-wasm offers bundles with different capabilities; multithreading requires cross-origin isolation and SharedArrayBuffer.

Decision. Ship with the bundle DuckDB-wasm selects by feature detection. Evaluate the multithreaded bundle as experiment P-08 and adopt it only if it shows a meaningful gain without stability or compatibility cost.

Consequences. Decisions about threading are driven by data. Cross-origin isolation headers are configured from the start so the experiment is possible.


ADR-008: Self-host the DuckDB-wasm assets

Context. DuckDB-wasm can load its WebAssembly and worker files from a public CDN. It also loads some features on first use as extensions, by default from extensions.duckdb.org. The browser build doesn't include Parquet or JSON support, so reading either format triggers one of these downloads.

Decision. Serve these files from Blinkql's own origin, version-pinned and cached with long-term immutable headers. This covers the engine bundles and every extension Blinkql uses (currently parquet and json):

  • The engine bundles come from the exact @duckdb/duckdb-wasm version pinned in package.json. Vite emits them under /assets/ with content-hashed names.
  • The extensions are pinned to one DuckDB version, with a SHA-256 checksum per file, in src/engine/extensions.ts. The build downloads them, rejects any file whose checksum doesn't match, and serves them under /duckdb-extensions/<version>/. The engine points DuckDB's custom_extension_repository setting at that path.

Consequences.

  • ➕ Works cleanly under cross-origin isolation (COEP) and a strict Content Security Policy.
  • ➕ No runtime dependency on a third-party CDN or extension repository; full control over caching and versions.
  • ➖ The engine files count toward Blinkql's own hosting bandwidth.
  • ➖ Builds need network access to extensions.duckdb.org (files are cached in node_modules/.cache afterwards).
  • ➖ Extensions only load into the DuckDB version they were built for. Upgrading @duckdb/duckdb-wasm therefore also means updating the version and checksums in extensions.ts. Until then the engine refuses to start, so the mismatch fails CI instead of breaking Parquet quietly.

ADR-009: Compose shadcn/ui components on Base UI; keep every visual value in one token file

Context. Blinkql needs an interface that is consistent, accessible and distinctive (see Design Language), built by a small core team and outside contributors, within a tight bundle budget.

Decision. Build the interface only from shadcn/ui components (Mira style, on Base UI primitives), composed rather than custom-built. Keep every colour, radius, font and duration as a CSS variable in src/index.css (Design Tokens). The SQL editor, results grid and charts are the only surfaces not built from shadcn/ui, because the architecture requires them, and they read the same tokens.

Alternatives considered.

  • A custom component library: full control, but far more work and accessibility risk before the product exists.
  • A packaged component library styled from outside: faster to start, but harder to make look like Blinkql, and the components can't be trimmed to what's used.
  • shadcn/ui on Radix primitives: still fully supported, but Base UI has been shadcn/ui's default since July 2026 and is its recommendation for new projects. Some components, such as the new Toast, ship only for Base UI.
  • shadcn/ui on React Aria: the most thorough accessibility and internationalisation, but its size against the 250 KB initial-JavaScript budget is unmeasured, and the default path is better trodden.

Consequences.

  • ➕ Accessible primitives (keyboard, focus, ARIA) from day one, and a workflow most React contributors already know.
  • ➕ The whole look changes in one file, with contrast checked in CI.
  • ➖ Component source is copied into the repository, so upstream fixes have to be pulled in deliberately.
  • ➖ Many older shadcn/ui examples and blog posts assume Radix. Use the Base UI tab in shadcn/ui's docs, and don't mix in Radix packages.

ADR-010: Biome for linting and formatting instead of ESLint + Prettier

Context. The Tech Stack originally listed ESLint (with typescript-eslint and eslint-plugin-react-hooks) for linting and Prettier for formatting. That is two tools, two config files and several plugins that have to agree with each other, and both run on every save and in CI.

Decision. Use Biome for linting, formatting and import sorting, configured in biome.json. Rules start from Biome's recommended preset and are made moderately strict: unused imports, variables and parameters, any, missing import type and React hooks violations are errors; non-null assertions and stray console.log calls are warnings. Code is formatted with 2 spaces, double quotes, semicolons and a 100-column line width. Generated shadcn/ui files in src/components/ui/ are formatted but not linted, so upstream code can be pulled in unchanged.

Alternatives considered.

  • ESLint + Prettier: the largest rule ecosystem, but two tools to configure and keep in step, and noticeably slower on every save and in CI.
  • Oxlint (Vite's template default) + Prettier: very fast linting, but still needs a separate formatter.

Consequences.

  • ➕ One fast tool and one config file for linting, formatting and import order; one VS Code extension.
  • ➕ Includes the React hooks rules (useExhaustiveDependencies, useHookAtTopLevel) and Tailwind CSS directive parsing.
  • ➖ Fewer plugins than ESLint. Rules that only exist as ESLint plugins (for example eslint-plugin-jsx-a11y's full set) aren't available, though Biome covers most accessibility rules.
  • ➖ Contributors used to Prettier may see small formatting differences.

ADR-011: Keep imported files in OPFS by default

Context. Imported files lived only as long as the page, so every reload meant importing them again. Browsers offer the Origin Private File System (OPFS): per-origin storage on the device that suits large files, can be written as a stream, and is never uploaded.

Decision. Keep a copy of every imported local file in OPFS by default (datasets/<table name>, plus a manifest.json with each table's name, original file name, format, size and save time). The copy is written in the background after the table appears. On start-up the stored files are imported again under their old table names. Each table has a Forget action that drops it and deletes its copy. The first save asks for persistent storage (navigator.storage.persist()). Remote files are not copied. See #37.

Alternatives considered.

  • Opt-in per table ("Keep"): more cautious with on-device copies, but one more click for every file, and forgotten clicks look like lost data after a reload.
  • IndexedDB blobs: works everywhere, but large files are slower to write and can't be streamed to disk as simply.

Consequences.

  • ➕ Tables are back immediately after a reload, with no re-import.
  • ➖ Copies of user data stay on the device until forgotten or until site data is cleared. They are never synced or sent anywhere, but anyone using the same browser profile can see them.
  • ➖ Copies count against the storage quota, and without persistence the browser may evict them under pressure. A failed save never blocks the import; the table works for the visit and a notice says so, and evicted files are dropped from the manifest on start-up.
  • ➖ Private windows and some browsers (including Playwright's WebKit) can't keep files, so there tables last only for the visit.

Proposing a new ADR

Open a Discussion describing the problem. Once there's agreement, add an ADR to this page in a pull request using this template:

## ADR-XXX: <decision title>

**Context.** What problem or force led to this decision?
**Decision.** What we're doing.
**Alternatives considered.** What else we looked at, and why not.
**Consequences.** ➕ What gets better. ➖ What gets worse or harder.

Clone this wiki locally