Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,21 @@ all bundled — see hard rule 4). Quality is held by tests.

1. **Coverage gate is non-negotiable.** `npm test` must pass, and `tsc --noEmit`
must pass (ADR-0002 — incremental strict TypeScript, dev-time only; wired
into the `pretest` step). The pure/network/state/DOM and render layers are
gated at **100/100/100/100 per file**. `src/ui/app.ts` + `src/main.ts` are
the browser glue — gated lower and integration-tested. Add tests in the
into the `pretest` step). The suite enforces per-file coverage floors of
**100/95/90/100** (statements/functions/branches/lines). Most
pure/network/state/DOM and render modules maintain 100/100/100/100;
`src/ui/app.ts` + `src/main.ts` are browser glue and integration-tested.
Add tests in the
same change as the code. The whole hand-written tree is strict TypeScript
(ADR-0002 complete, #267) — new modules start as `.ts`.
2. **Keep the layers honest.** Pure logic goes in `src/core/` (no DOM, no
globals). Network goes in `src/net/` with the fetch seam *injected*, never
imported. DOM rendering goes in `src/ui/` as functions that take the `app`
controller — except the editor, which lives in `src/editor/` behind the
globals). Workspace aggregates go in `src/workspace/`; Dashboard model,
layout, and application code goes in `src/dashboard/`, with dependency
direction `model/layouts <- application <- UI`. App-level coordination and
sessions go in `src/application/` and must not import `src/ui/` or
`src/editor/`. Network goes in `src/net/` with the fetch seam *injected*,
never imported. DOM rendering goes in `src/ui/` as functions that take the
`app` controller — except the editor, which lives in `src/editor/` behind the
injected editor seams (#143/#212): only `main.js` imports concrete adapters,
and everything else addresses `app.sqlEditor` or `app.specEditor` explicitly.
SQL execution, schema insertion, export, and SQL formatting must never target
Expand Down Expand Up @@ -52,7 +58,9 @@ all bundled — see hard rule 4). Quality is held by tests.
`docs/ADR-0001-reactivity.md`), and **marked** (the Markdown LEXER for
#60/#315 reference-doc bodies — used strictly as a pure tokenizer in
`core/doc-markdown.ts`, like the signals precedent it needs no seam;
`marked.parse()`/HTML-string output and `innerHTML` are FORBIDDEN — the
`marked.parse()`/HTML-string output and `innerHTML` are FORBIDDEN, except
for `ui/dom.ts`'s `html` prop: that escape hatch accepts only trusted,
code-owned static markup (never user, server, or Markdown content) — the
token tree is projected into DOM by `ui/doc-markdown-view.ts` under the
fail-closed policy: images/raw HTML/rejected links render as literal
text; measured +44 KB raw / ~3% artifact delta) — all inlined into the
Expand Down Expand Up @@ -112,6 +120,9 @@ Touch these in one change:
|---|---|
| `src/core/*` | pure logic, 100% covered |
| `src/net/*` | OAuth + ClickHouse client, injected fetch |
| `src/application/*` | app-level coordination, sessions, and pure projections; no UI/editor imports |
| `src/workspace/*` | pure stored-workspace aggregate, persistence contracts, and mutations |
| `src/dashboard/*` | Dashboard model, layouts, and application runtime; dependency direction is mechanically checked |
| `src/ui/*` | hyperscript, icons, render modules, controller |
| `src/editor/*` | injected SQL/Spec editor ports + CodeMirror adapters (#143/#21/#212) |
| `src/state.ts` | state model + pure ops (strict TS — ADR-0002 phase 2) |
Expand Down
35 changes: 22 additions & 13 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ route-scoped sessions behind a small composition root.
core/ pure logic (no DOM, no globals, no imports from other layers)
net/ integration: OAuth + the ClickHouse HTTP client (fetch injected via ctx)
application/ route-agnostic services & sessions (no App, no DOM, no ui/editor imports)
workspace/ pure stored-workspace aggregate, persistence contracts, and mutations
dashboard/ Dashboard model/layouts/application runtime (model/layouts <- application <- UI)
ui/workbench/ the workbench route: session (run lifecycle) + shell (DOM + effects)
ui/dashboard/ the dashboard route: session (tile/variable runtime); ui/dashboard.ts is its shell
ui/dashboard.ts the dashboard route shell (DOM + effects)
ui/* render modules (hyperscript), editor ports live in editor/
ui/app.ts composition/bootstrap: constructs everything, wires routes
state.ts the shared signal-backed model + pure ops
Expand All @@ -23,8 +25,9 @@ main.ts page bootstrap: OAuth callback, share links, route dispatch
Dependency direction is strictly downward. Enforced mechanically by
`build/check-boundaries.mjs` (runs in `pretest` as `check:arch`):

- `src/application/**` never imports `src/ui/**` or `src/editor/**` (type-only
imports count).
- `src/application/**` never imports `src/ui/**` or `src/editor/**`; the
Dashboard and workspace layers cannot import higher layers, and Dashboard
application depends only on Dashboard model/layouts (type-only imports count).
- `src/ui/workbench/**` and `src/ui/dashboard/**` never import each other,
never import the editor (dashboard), and never import `src/ui/app.ts` —
shells receive everything injected.
Expand All @@ -35,11 +38,12 @@ Two known, deliberate exceptions predate #276 and are out of its scope:

## The services (`src/application/`)

Each is a `create*(deps)` factory taking a narrow dependency bag — never the
`App` object or the full `AppState` (narrow `Pick`-shaped state slices are
structurally satisfied by `AppState`). Side effects are always injected
Services and sessions take narrow dependency bags — never the `App` object or
the full `AppState` (narrow `Pick`-shaped state slices are structurally
satisfied by `AppState`). The pure projections and state transitions in this
layer take their explicit inputs directly. Side effects are always injected
(fetch via the ClickHouse `ctx`, clocks, `uid`, storage, timers), so every
service is tested with plain stubs at the per-file coverage gate.
module is tested with plain stubs at the per-file coverage gate.

| Module | Owns |
|---|---|
Expand All @@ -53,6 +57,12 @@ service is tested with plain stubs at the per-file coverage gate.
| `schema-graph-session` (`app.graph`) | lineage load/expand/node-detail lifecycle with stale-request guards; abort state is session-private |
| `app-preferences` (`app.prefs`) | typed preference persistence (`save(name, value)` + `toggleTheme()`) |
| `ch-session-params` | pure helpers minting/attaching the per-tab ClickHouse HTTP `session_id` (TEMPORARY/SET stickiness), shared by the workbench hooks and export wiring |
| `dashboard-create` / `dashboard-delete` / `dashboard-title` | serialized workspace mutations for Dashboard creation, deletion, and title edits |
| `dashboard-panel-metadata` | serialized workspace mutations for Dashboard tile title/description overrides |
| `dashboard-tree-model` | pure Dashboard-tree projection, including inferred Variables and navigation rows |
| `dashboard-variable-config` | serialized commits for Dashboard Variable option-SQL configuration |
| `library-assignment-service` | serialized Library-to-Dashboard panel and Variable assignments, plus user-facing assignment outcomes |
| `main-surface` | pure Query/Dashboard surface state, routing, history restoration, and focus transitions |

## Route sessions and shells

Expand All @@ -67,12 +77,11 @@ service is tested with plain stubs at the per-file coverage gate.
workbench DOM (header, sidebar, splitters, tabs, toolbar, var strip,
results) and registers every other effect. `ui/app.ts`'s `renderApp` is a
thin call into it.
- `ui/dashboard/dashboard-session.ts` owns the dashboard runtime: the 6-way
tile pool, wave generations (reserved at wave creation), per-slot
cancellation, the variable-commit wave, `destroy()`. Its input is an
explicit `DashboardRuntimeInput` built by the shell from the favorites
list — a stored dashboard document can replace that source without touching
the session. `ui/dashboard.ts` is its shell (own header; no sidebar), typed
- `dashboard/application/dashboard-viewer-session.ts` owns the Dashboard
runtime: the 6-way tile pool, wave generations (reserved at wave creation),
per-tile cancellation, Variable commits, and `destroy()`. Its input is a
stored `DashboardDocumentV2` plus workspace queries and narrow injected
interfaces. `ui/dashboard.ts` is its shell (own header; no sidebar), typed
against a narrow `DashboardApp`, not `App`.

Lifecycle ownership: **cancellation state always lives with the session that
Expand Down
3 changes: 1 addition & 2 deletions src/application/connection-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,8 +333,7 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection
// The dashboard calls this before fanning tiles out, so the tiles never each
// race an expired-token refresh (a rotating refresh token used N-ways at once
// would invalidate itself), and a single sign-out is handled by the caller
// instead of N tiles each firing onSignedOut. Also used by bootstrap to
// refresh a handed-off-but-expired token before falling back to login.
// instead of N tiles each firing onSignedOut.
async function ensureFreshToken(): Promise<boolean> {
await ensureConfig();
return !!(await getToken());
Expand Down
7 changes: 5 additions & 2 deletions src/ui/dom.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Minimal hyperscript helper. `h(tag, props, ...children)` builds a DOM node;
// `s(tag, ...)` is the same in the SVG namespace. Both support function
// components (h only), style objects, class/className, raw html, on* event
// listeners, boolean/null skipping, and nested/array children.
// components (h only), style objects, class/className, trusted static html, on*
// event listeners, boolean/null skipping, and nested/array children. `html` is
// a trust-boundary escape hatch: never pass user, server, or Markdown content.

const SVG_NS = 'http://www.w3.org/2000/svg' as const;

Expand Down Expand Up @@ -34,6 +35,8 @@ function apply<T extends Element & ElementCSSInlineStyle>(el: T, props: ElProps
if (v == null || v === false) continue;
if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
else if (k === 'class' || k === 'className') el.setAttribute('class', String(v));
// `html` is reserved for trusted, code-owned static markup (currently
// inline SVG art). Dynamic content must be built as DOM/text nodes.
else if (k === 'html') el.innerHTML = String(v);
else if (k.startsWith('on') && typeof v === 'function') {
el.addEventListener(k.slice(2).toLowerCase(), v as EventListener);
Expand Down
21 changes: 8 additions & 13 deletions tests/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ const jsToTsInMixedTree: Plugin = {
// aggregate, so one weak file can't hide a regression in another. Every
// module under src/ is pure-or-DOM and individually testable under happy-dom;
// the fetch/crypto/storage seams are injected, never imported, so they mock
// with plain stubs. We hold the whole tree at 100/100/100/100.
// with plain stubs. Per-file floors are 100 statements/lines, 95 functions,
// and 90 branches; most modules maintain 100/100/100/100 in practice.
export default defineConfig({
root: repoRoot,
plugins: [jsToTsInMixedTree],
Expand All @@ -59,19 +60,13 @@ export default defineConfig({
// Type-only seam interface files (ADR-0002 phase 0 / #262) have no
// executable statements — nothing to cover, like src/generated/.
exclude: ['src/generated/*.js', 'src/**/*.types.ts'],
// Every src file must hit 100% on its own (perFile) — no global
// aggregate hiding a weak module. Code is written to avoid
// unreachable defensive branches so 100/100/100/100 is genuine.
// Per-file (no global aggregate hiding a weak module). The pure/network/
// state/DOM and render layers are written to hit 100/100/100/100. The
// ui/app.js controller is the browser glue — a few branches/functions are
// only exercised by the real autostart path (excluded from tests), so it
// is held at a 90%+ floor for now rather than padding tests artificially.
// Per-file floors. The pure/network/state/DOM and render layers sit at
// 100; the ui/app.js controller glue brings the floor down (a few of its
// branches/functions are only hit by the real browser autostart path,
// which tests exclude). statements/lines stay at 100; functions ≥95;
// branches ≥90 (v8's branch counter is strict and platform-sensitive).
// state/DOM and render layers generally maintain 100/100/100/100; the
// enforced floors below are deliberately explicit for the whole tree.
// Per-file floors: statements/lines stay at 100; functions ≥95; branches
// ≥90 (v8's branch counter is strict and platform-sensitive). Most
// modules exceed these floors, while browser-glue branches/functions are
// not padded for the autostart path tests intentionally exclude.
thresholds: {
perFile: true,
statements: 100,
Expand Down