diff --git a/CLAUDE.md b/CLAUDE.md index f29ae91e..4e3b67cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 @@ -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) | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dd9a9f9d..8a4fe28a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 @@ -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. @@ -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 | |---|---| @@ -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 @@ -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 diff --git a/src/application/connection-session.ts b/src/application/connection-session.ts index 9180b78e..185608fa 100644 --- a/src/application/connection-session.ts +++ b/src/application/connection-session.ts @@ -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 { await ensureConfig(); return !!(await getToken()); diff --git a/src/ui/dom.ts b/src/ui/dom.ts index 33de9730..6a1d452a 100644 --- a/src/ui/dom.ts +++ b/src/ui/dom.ts @@ -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; @@ -34,6 +35,8 @@ function apply(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); diff --git a/tests/vitest.config.ts b/tests/vitest.config.ts index a14085d1..30d0488b 100644 --- a/tests/vitest.config.ts +++ b/tests/vitest.config.ts @@ -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], @@ -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,