* feat(docs): add SchemaSnapshot model * feat(docs): infer relationship cardinality from foreign keys * fix(docs): resolve foreign keys within the source schema * feat(docs): convert group hue to sRGB hex for DBML * feat(docs): add DBML lexical primitives * feat(docs): render DBML table blocks * feat(docs): render DBML refs, enums and table groups * feat(docs): assemble complete DBML documents * feat(docs): collect schema snapshots with bounded fan-out * fix(docs): gate FK warning on engine capability and reference synthesized enums * fix(docs): qualify synthesized enum references in multi-schema output * test(docs): anchor the multi-schema enum reference assertion * feat(docs): add snapshot collection route * feat(docs): add collect_docs_snapshot to DbxBackend * feat(docs): add dbx dbml command * test(docs): add live snapshot and DBML verification Runs collect_snapshot + to_dbml against a real PostgreSQL database (organon, 47 tables) and asserts structural DBML validity: Project header, every table present, balanced braces, trailing newline. * refactor(docs): use sort_by_key for snapshot table ordering * docs(docs): add database documentation design and implementation plan Records the Part 1 design (SchemaSnapshot + DBML export) and the plan that produced it. The plan carries an appendix listing the thirteen assumptions that proved wrong during execution, so the corrected facts are not re-derived from the surrounding prose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKcC15gEuQBHMidPCFFYwn * fix(docs): use native enum names, canonical engine labels, and surface metadata failures Final review fix wave covering four findings: - PostgreSQL named enums (e.g. `ConversationStatus`) now keep their own type name and synthesized: false, instead of being renamed to `{table}_{column}` and losing identity. A type shared by several columns now dedupes to a single Enum block instead of colliding or duplicating. `synthesize_enum` and `render_type` route through one shared `enum_type_name` helper so the two can't drift apart again. - `database_type` (and the CommentsUnsupported/NoForeignKeyMetadata warnings) now use the same canonical engine label already used throughout table_structure_sql's own warning prose, instead of a raw Rust Debug string (`Postgres`, `SqlServer`, `MongoDb`). - An index-fetch failure during collection now surfaces as a TableSkipped warning instead of silently degrading to an empty index list, which relations.rs uses to infer relationship cardinality. - A schema-enumeration failure now surfaces as a warning instead of silently proceeding against schema "". - Removed the redundant Arc<Semaphore>; buffer_unordered already caps concurrency at MAX_CONCURRENT_TABLES. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKcC15gEuQBHMidPCFFYwn * feat(docs): add annotation file model * test(docs): assert full round-trip fidelity for the annotation model * feat(docs): add per-engine annotation key folding * fix(docs): do not fold identifiers on case-sensitive engines * feat(docs): load and validate the notes file * fix(docs): report a version mismatch before unknown-field errors * feat(docs): merge annotations into the schema snapshot * feat(docs): detect orphaned annotations without deleting them * feat(docs): add --notes to dbx dbml * feat(docs): remember a notes file path per connection Adds docs_notes_path to ConnectionConfig so the desktop app (Part 3) can persist where a connection's documentation notes file lives. The CLI is unaffected — it takes an explicit --notes path. ConnectionConfig has a hand-written Deserialize impl that delegates to a mirror struct, ConnectionConfigData, and converts via From. Adding the field only to ConnectionConfig would compile but never populate from stored JSON, since ConnectionConfigData's fields are what serde actually reads. The field is threaded through all three places: ConnectionConfig, ConnectionConfigData, and the From impl, mirroring the existing `color` field. * test(docs): verify annotations against a live database * test(docs): assert exactly one orphaned annotation * feat(docs): add snapshot types for the docs viewer * fix(docs): correct snapshot type nullability and add missing column fields * feat(docs): add a real-output fixture and drift conformance test The fixture is generated by dump_docs_fixture.rs from a live collect_snapshot run against the keycloak database in the shared local-infra stack, with annotations applied so a LOCAL note, column note, group and orphanedNotes warning are all present. fixtureConformance.spec.ts asserts against that real JSON rather than a hand-written literal, so a change to the Rust snapshot shape breaks the test instead of silently drifting from the hand-maintained types.ts. Keycloak is used because its schema is public open-source knowledge, so the committed fixture carries no private schema. The kept tables are an explicit allowlist rather than an alphabetical slice, because the conformance test needs a connected foreign-key subgraph: protocol_mapper has two foreign keys to different tables and composite_role has two to the same one. * feat(docs): add index grouping for the docs viewer * feat(docs): describe snapshot warnings for the viewer * feat(docs): add client-side search for the docs viewer * test(docs): make the search fixture able to fail * feat(docs): expose group hue as a CSS custom property * docs(plan): add annotations and viewer plans, correct Task 7 Parts 2 and 3a were planned after the first plan was committed and were never tracked. docs/superpowers/ is gitignored, so both needed -f, matching how the existing specs and plans in that directory were added. The viewer plan's Task 7 is corrected against the installed marked@18.0.4. Its original text carried four defects, found by probing the library rather than by review: an assertion that fails against a correct implementation, a pre-escaping approach that double-escapes entities, a javascript: blocklist with live bypasses (entity-encoded, vbscript:, data:text/html, and <img src>, which was never covered at all), and a link renderer whose text property is raw markdown source rather than parsed HTML — an XSS hole found while verifying the fix for the previous defect. The viewer plan also gains a corrections appendix grouping defects by failure mode. The annotations plan is committed as written; its defects are recorded in a follow-up. * feat(docs): render note markdown with raw HTML escaped * fix(docs): drop protocol-relative URLs in note markdown The URL allowlist permitted anything starting with / so relative paths work, and //evil.com qualifies. Over https that grants nothing a note author could not do with an ordinary https link, but the Part 3b standalone export is opened via file://, where //host/path is a UNC path. On Windows that opens an SMB connection and leaks an NTLM hash, with no click required since images auto-load, and it is plantable from a COMMENT ON value. Found by an adversarial probe of the committed module rather than by review; the backslash form was already dropped, only the slash form slipped through. * docs(plan): add corrections appendix to the annotations plan Groups the defects found executing Part 2 by failure mode, matching the form of the viewer plan's appendix. The notable one is Mode A: docs_notes_path had to be added in three places because ConnectionConfig has a serde mirror struct, and adding it in one place compiles, passes a round-trip test written against the same struct, and then reads None forever after every load. Also records a controller hypothesis that turned out to be wrong, since checking it cost one read. * fix(docs): reject backslash protocol-relative URLs in notes The // guard from the previous commit was itself a blocklist: /\evil.com starts with a single slash, so it passed, and the WHATWG URL spec treats /\ identically to // for special schemes. Browsers normalise the backslash, so it reaches the same file:// UNC path and the same no-click NTLM leak. Rejecting both separator characters in both positions closes it. Found by probing what String(raw).trim() leaves unnormalised before the prefix checks; the third defect on this file found by probing rather than by reading. * feat(docs): add docs viewer components Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKcC15gEuQBHMidPCFFYwn * test(docs): pin each theme's legacy colour base independently The ordering check indexOf(hsl) < indexOf(@supports) quantified over any occurrence, so deleting the light .docs-group block left the dark block's hsl satisfying it. The test passed while every table group rendered colourless on light-theme WebViews without oklch. Asserting each selector's own base block catches deleting either one. Found by the task implementer, which deleted one block and then both to show the guard pinned 'some base exists' rather than 'each selector has a base'. * fix(docs): escape single quotes in note attribute values escapeHtml covered & < > and double quotes but not single quotes. Not exploitable today because every attribute in this file is double-quoted, but that is a formatting convention enforced nowhere and living in a different part of the file from the escaper. A future edit writing title='...' would turn a formatting choice into an attribute breakout. Raised by review as latent fragility rather than a defect; fixed because the escaper should be correct on its own rather than correct-given-an-invariant. * test(docs): match single-quoted v-html bindings in the contract guard The guard matched /v-html\s*=\s*"([^"]*)"/ — double quotes only. A binding written `v-html='table.note'` produced zero matches and passed, handing a database COMMENT ON value straight to the DOM with the renderNote sanitiser bypassed. Both quote styles are valid Vue and nothing in the repo enforces one, so the guard had a hole exactly where it mattered most. Verified by temporarily rewriting a real WikiIndex binding as `v-html='table.note'`: the test now fails with "WikiIndex.vue: v-html must render renderNote output: expected 'table.note' to contain 'renderNote'". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKcC15gEuQBHMidPCFFYwn * fix(docs): cap search results per kind instead of overall DocsSearch sliced searchDocs output to 40 AFTER concatenation, and the concatenation order is tables -> columns -> groups -> enums. Columns always flooded the list, so the cap deleted the tail — every group and enum hit. Against the real fixture, "e" produced 155 hits (9 table, 133 column, 1 group, 12 enum) and rendered 9 tables, 31 columns and nothing else; groups and enums were structurally unreachable through search. Cap each kind against its own limit inside docsSearch.ts, where the logic is tested, and drop the slice from the template so exactly one place limits results. Ranking is unchanged: tables still precede columns. Verified by reverting to a single .slice(0, 40) over the concatenation: the new tests fail with "enums must survive a column flood: expected false to be true" and "expected 30 to be 20". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKcC15gEuQBHMidPCFFYwn * fix(docs): move the index card note out of its button The card rendered renderNote output with v-html inside the <button>. A note containing a markdown link — [spec](https://example.com) — put an <a> inside a <button>: invalid nesting, and the anchor was not keyboard reachable because the button swallows it in the tab order. The <li> now carries the card's border, background and hover, the button holds only the table name and kind, and the note is its sibling. Visually identical — same padding, same 0.5 gap, previously mt-0.5 — and the name row still spans the full width as the click target. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKcC15gEuQBHMidPCFFYwn * test(docs): pin every fixture struct's key set in both directions The guard pinned ColumnInfo and IndexInfo plus three DocTable keys, and only in the "no missing key" direction. Relationship, FieldRef, DocEnum, ProjectMeta, ColumnNote, ForeignKeyInfo, TableGroup and 10 of 13 DocTable keys were unchecked. Renaming Relationship::to to `target` in Rust kept the suite at 67/67 and vue-tsc at exit 0 while RelationshipList read `field.table` on undefined and every table page rendered blank. Every struct in the fixture is now pinned both ways — no missing key, no unexpected key — over every instance rather than element [0]. Each direction catches a different half of a rename. Object.hasOwn throughout, so a key that is present and null stays distinguishable from one skip_serializing_if omitted. ForeignKeyInfo, TableGroup and the SchemaSnapshot root are included beyond the list the review gave: they are equally present in the fixture and equally unpinned. Verified against a modified copy of the fixture outside the repo with Relationship::to renamed: "Relationship[0] must always carry to: expected false to be true". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKcC15gEuQBHMidPCFFYwn * fix(cli): error when an explicit --notes path does not exist load_annotations returns Ok(None) for a missing file, which is right for the implicit per-connection notes path — it may legitimately not exist yet. It is wrong for --notes, where the user named a specific file: a mistyped path produced DBML with every note silently absent and no diagnostic at all, indistinguishable from a database that has no documentation. Both final reviewers independently ruled this must-fix-before-merge. * fix(docs): treat an explicit FK ref_schema as authoritative find_target tried the explicit ref_schema, then fell through to the source table's own schema on failure. When the referenced schema was not collected — routine, since users select schemas — an FK from sales.orders to archive.customers resolved to sales.customers instead: a different table, and a diagram that is confidently wrong rather than visibly incomplete. The function's own doc comment already promised that keys pointing outside the collected set are dropped. Now it does that. This is a regression of the defect found in Part 1: the three-tier lookup was added then, but tier one was written to fall through rather than to decide. * fix(docs): corroborate engine capability warnings against what was collected supports_comments and supports_foreign_keys delegate to the structure editor's DDL-generation capabilities, not to introspection support. IRIS is the proven divergence: it reports %DESCRIPTION on introspection but DBX cannot ALTER an existing one, so the flag is false while the collector reads and includes those comments — producing a snapshot that warned comments were unsupported alongside the comments themselves. Each warning now fires only when the flag says the engine cannot AND collection found nothing of the kind to contradict it. ClickHouse and Doris, which genuinely report no foreign keys, still warn. The doc comments now say what the functions actually measure. Found by final review, which traced every other caller to establish the flag's real semantics. * docs(fixture): finish repointing the fixture source to keycloak Follows the rebase that replaced the fixture at its origin commit. Repoints the live annotation test's project identity and both plans, and records the one capability keycloak costs us. Keycloak declares no PostgreSQL enum types, so the fixture cannot exercise DocEnum — a Rust-side rename of a DocEnum field would pass every test here and break the viewer's enum rendering silently. Rather than delete the test, it now asserts the gap, so it fails the day the fixture source gains an enum and prompts restoring the pin. * docs(spec): design for in-app database documentation (Part 3b) Mounts the Part 3a viewer in DBX and makes it editable: table/column notes, table groups, and per-group colour, autosaved to a notes file that can live in the user's repository. Two findings shaped the scope. DBX already ships SchemaDiagramDialog, so the viewer links to it rather than building a second ER diagram — only the Part 3c export needs its own minimal renderer, because that dialog reaches into stores and cannot be inlined. And nothing currently reads docs_notes_path or writes annotations at all, so 'in-app editing' needs new Rust rather than frontend wiring alone. The standalone export, dbx docs verb and hash routing are deferred to 3c. * docs(plan): implementation plan for in-app database documentation Ten tasks: atomic annotation save and path resolution, Tauri commands, web route parity, the frontend facade, pure edit transforms, the i18n namespace with a parity guard, editing components, the enum page, edit plumbing through the viewer, and the dialog with debounced autosave. Self-review caught three defects before dispatch, all the same class that cost Part 3a ten fix rounds: a return type named DescribedWarning that does not exist (it is WarningNotice), a test calling emptySnapshot() which does not exist, and a table() helper invoked with columns when its real signature takes a groupId. Every identifier the plan names is a claim about the codebase. * docs(plan): resolve the data directory without a dbx-mcp dependency Tasks 2 and 3 called dbx_mcp::paths::app_data_dir(), but neither src-tauri nor dbx-web depends on dbx-mcp, so neither would have compiled. Both already have a better source: AppState.storage.data_dir() honours a custom data dir, and WebState already carries data_dir. Found by the pre-flight scan before any implementer saw it. * docs(plan): fix Task 1 against the real crate (no Default, no tempfile) ConnectionConfig has no Default derive and ~60 fields, so resolve_notes_path now takes the connection id and the optional override directly — the two fields it actually reads. The test pain was pointing at the signature. dbx-core has no dev-dependencies, so the tests use the temp_dir + uuid idiom already present in annotations.rs rather than tempfile. * feat(docs): add atomic annotation save and notes path resolution * docs(plan): guard the autosave against concurrent writes flush() cleared the debounce timer but not an in-flight write, so closing the dialog while a debounced save was awaiting the backend started a second one. Two concurrent saves of the same file waste a round trip, race to land stale, and are the exact concurrency that corrupts the notes file when the temp path is not unique per writer. Found while adjudicating the Task 1 review, which demonstrated the Rust half of the same problem. * fix(docs): make temp paths unique to prevent concurrent save corruption * fix(docs): replace vacuous atomicity test with inode-based verification * docs(plan): correct the apply_annotations import path dbx_core::docs re-exports collector, color, dbml, keys, relations and snapshot but NOT annotations, so apply_annotations and friends are only reachable at dbx_core::docs::annotations. Tasks 2 and 3 both used the shorter path and would not have compiled. Verified against crates/dbx-core/src/docs/mod.rs before either was dispatched. * fix(docs): truncate temp filename to stay within 255-byte filesystem limit * feat(docs): add Tauri commands for docs snapshot and annotations * docs(plan): propagate the resolve_notes_path signature to its callers Pre-flight changed resolve_notes_path to take (connection_id, docs_notes_path, data_dir) instead of a ConnectionConfig, but only Task 1 was updated. Tasks 2 and 3 still called it with the old signature and would not have compiled. The Task 2 implementer caught it and used the correct form from its dispatch note. Task 3 had the identical stale call and had not been dispatched yet. The plan's self-review checks signature consistency across tasks; this changed AFTER that review, during pre-flight, and nothing re-ran the check. * docs(plan): pin Task 4's http.ts idiom and the Tauri argument names http.ts uses a post<T>(url, body) helper at line 222; the plan said only 'match the existing idiom', which is delegating verification to someone with less context. Written out concretely now. Also records what Task 2's review flagged as unverifiable from its own diff: Tauri serialises command arguments by name, so the invoke object keys must match the Rust parameter names. That mismatch compiles cleanly on both sides and fails only when a user clicks — and it falls in the gap between two task-scoped reviews, since neither diff contains both halves. * feat(docs): add web routes for annotation load, apply and save Mirrors the Tauri commands (docs_load_annotations, docs_apply_annotations, docs_save_annotations) added in the previous task: collect returns the raw snapshot, apply is separate so the shadowedNote rule stays in one place. * docs(plan): make the i18n parity guard actually observable Every non-English locale is export default withEnglishFallback({...}) — the fallback is applied at module level, inside the locale file. Only en.ts is a bare object. So importing a locale's default export yields the ALREADY-MERGED object, and the parity test would have found every key present in every locale and passed while translations were missing. The test written to catch silent English fallback would have been silently defeated by that fallback. Task 6 now puts the new namespace in per-locale modules under locales/docs/, which the test imports directly and unwrapped. Scoped entirely to the new namespace; the existing 315 KB of keys are untouched. Step 6 also asks the implementer to demonstrate the trap: point the imports back at the merged modules and watch a missing key pass. * feat(docs): expose docs snapshot and annotations to the frontend * docs(plan): locate the duplicated table-key rule correctly in Task 8 The plan said docsIndex.ts builds the qualified table key inline. It does not — it groups by table.schema, a section key. The table key rule lives in a private qualified() in docsSearch.ts and in a hand-rolled tableKey() in DocsApp.vue, DocsSidebar.vue and WikiIndex.vue. Part 3a's final review flagged that duplication as a Minor and it was deferred. Task 8 was about to add a fifth copy, so it now extracts docsKeys.ts first and replaces the existing ones. This is the key annotations are stored under, so two call sites disagreeing attaches a note to the wrong table. * feat(docs): add pure annotation edit transforms * docs(plan): ground the group hue picker in DBX's existing swatch idiom ConnectionDialog.vue already has a swatch row — h-6 w-6 rounded-full buttons, ring-2 selected state, i18n titles. Task 7 now points at it so the new picker looks native. With an explicit warning not to copy the fill mechanism: connection colours are hex painted via Tailwind classes, group colours are hues rendered through docs.css. A naive copy introduces hex literals and fails the contract test — correctly, since a hardcoded hex cannot stay legible on both grounds, which is why groups store a hue at all. * docs(plan): make Task 10's dialog wiring concrete Five exact locations, all verified: the store ref and its export, the useDialogSources watcher, the AppDialogs import and render, and the ObjectBrowser trigger plus its context-menu entry. Records the non-obvious part: the watcher clears the source back to null after firing, and that clearing is what makes the dialog re-openable — without it, setting the same value twice does not re-trigger the watch. ObjectBrowser is the entry point rather than the connection tree, because the tree has no diagram entry either and ObjectBrowser already supplies exactly the prefills the docs dialog needs. * feat(docs): add the docs i18n namespace with a parity guard * fix(docs): ban vue-i18n from standalone-exportable docs components WarningBanner.vue used useI18n() directly, which throws with no Vue app instance -- exactly the standalone HTML export case describeWarning's translator parameter exists to avoid. Thread translate as a prop from DocsApp instead, and add vue-i18n/useI18n( to the component contract's forbidden list so the constraint is enforced, not just documented. * docs(plan): note that DocsApp already has snapshot and translate Task 6's fix added translate to DocsApp when describeWarning started taking a translator. Task 9 said to add it, which would have been a duplicate prop. It now says to add only annotations and readonly to the existing defineProps. * feat(docs): add note editor, group editor and group picker * docs(plan): give Task 10 the dialog shell from SchemaDiagramDialog Exact Dialog primitives, the get/set computed every dialog here uses to bridge the open prop, and the sizing class copied verbatim from SchemaDiagramDialog.vue:827 — the docs viewer is the same kind of full-window workspace, not a form, so it should not invent dimensions. Also states explicitly that this component lives outside src/docs/ and so may and must use useI18n(): it is what supplies the translate prop the viewer components need, since they are banned from importing vue-i18n themselves. * test(docs): guard the light-ground group tokens too * feat(docs): add the enum page and a shared table-key helper EnumPage renders an enum's values and every column declared with that type. It is read-only on purpose: AnnotationFile has no `enums` key, so an edited note would have nowhere to be saved. qualifiedTableKey moves the `schema.name` rule — bare name on schema-less engines like SQLite and MySQL — into docsKeys, where the call sites that had each copied it can share one definition. It is the key annotations are stored under, so two call sites disagreeing would attach a note to the wrong table. columnsUsingEnum matches data_type exactly rather than by substring: an enum named `state` would otherwise claim every `estado` and `statement` column. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VTBWnT4goXLA9iqDrfTPqk * refactor(docs): share one qualified table key across the viewer Six call sites had each copied the `schema.name` rule; they now import qualifiedTableKey from docsKeys instead. Two were not in the plan's list: TablePage.vue and RelationshipList.vue. RelationshipList passes a remapped FieldRef rather than a DocTable, which is why the helper takes Pick<DocTable, "schema" | "name"> — that widening is what let every call site be adapted directly instead of keeping a thin delegating wrapper. Also strengthens the columnsUsingEnum substring guard, which was not guarding anything. Its only column had type `integer`, and "integer".includes("state") is false, so replacing the exact match with includes() left all 8 tests green. Adds a `statement` column — the only type here that really contains `state` — and drops `estado` from the doc comment, since it does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VTBWnT4goXLA9iqDrfTPqk * feat(docs): thread editing through the viewer as emitted events DocsApp gains `annotations` and `readonly` and re-emits a DocsEdit for every change its children request. Nothing under src/docs/ persists anything, which is what keeps the directory bundleable into the standalone HTML export; a new contract guard now pins that by rejecting any component that names save/load/applyDocsAnnotations. NoteEditor is fed the MERGED snapshot note, not the local annotation layer. It renders and edits one value, so seeding it locally would show nothing for a note that came from a database comment. Writing over one shadows it, which is what noteSource and shadowedNote already exist to disclose. `annotations` is threaded for what the merge erases: `groups` carries the editable GroupAnnotation records, while snapshot.groups carries resolved TableGroups that GroupPicker and GroupEditor cannot write back to. Also makes enums reachable. EnumPage rendered nowhere and search returned enum hits that DocsSearch deliberately disabled, since enums carry no table key; they now navigate by bare name, which is how columnsUsingEnum resolves them too. Groups remain unclickable — they still have no page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VTBWnT4goXLA9iqDrfTPqk * feat(docs): mount the documentation viewer in DBX with autosaved editing DatabaseDocsDialog hosts DocsApp outside src/docs/, which is what lets it use useI18n() and supply the `translate` prop the viewer components are banned from importing for themselves. It collects the snapshot, loads the notes file (falling back to emptyAnnotations), and holds the raw snapshot so every edit can re-derive the merged view through applyDocsAnnotations. createAutosave debounces writes and, above all, makes a failure visible: a silently swallowed write is the worst outcome here, because the user keeps typing and believes their notes are saved. It also refuses to run two writes at once — flush() clearing the timer does not stop a write already awaiting save, and two concurrent writes of the same file is the exact race that corrupted the notes file before the Rust side used a unique temp path. Both properties are pinned by tests I confirmed fail when the guard is removed. Loads and re-derivations carry a generation number so a slow response cannot overwrite a newer one, and closing flushes the debounce rather than dropping a note typed a moment earlier. Trigger mirrors the schema diagram's wiring: docsSource on connectionStore, a watch in useDialogSources that clears the source so the dialog is re-openable, an async component in AppDialogs, and openDocs in ObjectBrowser. The context entry is added at BOTH object menus — the plan named only the table one, but views offer diagram.open too and documentation is no less relevant there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VTBWnT4goXLA9iqDrfTPqk * docs(plan): record Tasks 1-10 as done The plan carried 58 unticked boxes after ten completed tasks, so progress had to be reconstructed from the commit trail instead of read off the document. Tasks 1-7 are ticked from that commit evidence rather than from step-by-step observation — they landed in earlier sessions. Tasks 8-10 were executed and verified directly. Task 8 Step 2 stays open on purpose. `columnsUsingEnum` was already implemented and committed before that step was reached, so its failure was never observed; the exact-match guard was verified by Step 5 instead, which is what exposed that the test could not detect a substring match at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VTBWnT4goXLA9iqDrfTPqk * feat(docs): print snapshot warnings as prose from the CLI `dbx dbml` printed `{warning:?}`, so a skipped table surfaced as `TableSkipped { table: "public.orders", reason: "permission denied" }` — the struct shape, reading like a panic rather than like advice. The prose lives in Rust rather than in the `docs.warnings` i18n namespace because the CLI has no i18n runtime. That is the same constraint that made `describeWarning` take a translator instead of importing vue-i18n: the viewer translates, the CLI cannot, so each needs its own source for the same text. A second test asserts the rendering is not the Debug form, because reverting the CLI to `{warning:?}` is a one-character edit that still compiles and still prints something. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ro4mfGEmsbbH32WYsvxsfH * feat(docs): let a connection point its notes file at a repository `docs_notes_path` has existed on `ConnectionConfig` since Part 2 and has been read by `resolve_notes_path` since Part 3b, but nothing ever set it — so every connection silently used the app data directory default and the override was unreachable. The field is what makes schema documentation reviewable: pointing it at a file inside a repository puts notes in the same diff as the migration that changed the schema. Gated on `isSchemaAware`, matching the row above it, since documentation is a relational-only feature. A cleared field is normalised to absent rather than "" — `resolve_notes_path` treats blank as unset, but an empty string would still be persisted as though a path had been chosen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ro4mfGEmsbbH32WYsvxsfH * docs: document database documentation and DBML export Covers opening the viewer, notes and groups, the LOCAL/database-comment rule, the notes file format and where it lives, every warning the viewer can raise, and the `dbx dbml` verb including the CI drift check. States the boundaries explicitly — relational engines only, no triggers or procedures, and DBML export is one-way — because each of those is a question the feature invites and would otherwise be answered by trying it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ro4mfGEmsbbH32WYsvxsfH --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: t8y2 <t8y2@users.noreply.github.com>
59 KiB
Database Documentation Part 3a: Docs Viewer — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the dbdocs-style documentation viewer as a standalone Vue app that renders a SchemaSnapshot and makes zero backend calls — the contract that later lets the same components run inside DBX and inside an exported HTML file.
Architecture: All decision-making logic lives in pure .ts modules under apps/desktop/src/docs/ with thorough vitest coverage. The .vue files are thin templates verified by static SFC parsing, matching this repo's established erDiagram.ts (tested) / SchemaDiagramDialog.vue (untested) split. TypeScript interfaces mirror the Rust SchemaSnapshot; a committed fixture generated from real Rust output plus a conformance test turns model drift into a failing test rather than a silent bug.
Tech Stack: Vue 3 + TypeScript + Tailwind v4, vitest. No new dependencies.
Branch: continue on feature/docs-snapshot-dbml (Parts 1+2, 31 commits, unmerged). Worktree at /Users/possebon/workspaces/dbx.feature-docs-snapshot-dbml.
Spec: docs/superpowers/specs/2026-08-02-database-documentation-dbml-design.md, Section 6.
Approved visual reference: the mock at the group/table/diagram level — sidebar with Group by: Schemas | Table Groups, wiki index with inline markdown notes, table page with columns/indexes/bidirectional relationships, ⬤ LOCAL shadow marker, warning banner.
Global Constraints
- NO new dependencies. In particular do NOT add
@vue/test-utils— this repo has never mounted a component in a test and deliberately has no such harness. cargois NOT on PATH. Prefix Rust commands withexport PATH="$HOME/.rustup/toolchains/stable-aarch64-apple-darwin/bin:$PATH".apps/desktop/src/docs/makes NO backend calls. No@/lib/backend/*, no Tauri, no Pinia stores, nofetch. Snapshot in, DOM out. A test enforces this.- Logic in
.ts, templates thin. If a.vuefile contains a non-trivial decision (filtering, sorting, formatting, colour maths), that decision belongs in a.tsmodule with a test. - Group colour is a hue integer. Lightness/chroma are theme-controlled in CSS; never store or compute hex in the viewer.
- Run
pnpm lintscoped to the touched directory, never repo-wide (rootpnpm lintOOMs on this repo). - Conventional Commits.
- Report mismatches, do not work around them. Twenty plan defects across Parts 1-2 were caught that way.
Pre-resolved facts (verified — these override any assumption in the spec)
-
The spec's testing plan does not work here. Section 6 says "every docs component rendered against fixture snapshot JSON". This repo has no component-mount harness:
@vue/test-utilsis not a dependency and zero specs callmount().packages/app-tests/aiAssistantSendGuard.test.ts:13documents this in a comment. The established pattern for verifying a.vuefile is static SFC parsing viareadFileSync+vue/compiler-sfc'sparse. -
No Rust→TypeScript codegen exists.
apps/desktop/src/types/database.tsis a hand-maintained 1167-line mirror. Part 3a therefore hand-writes TS interfaces forSchemaSnapshot— a genuine drift seam. Task 2's conformance test is the mitigation. -
vitest config (
vitest.config.ts):@vitejs/plugin-vueloaded; alias@→apps/desktop/src;includecoversapps/desktop/src/**/*.spec.ts;maxWorkers: 4;globalSetup: packages/test-globals.ts. Noenvironment:is set, so there is no DOM — another reason component mounting is out of scope. -
node_modulesdoes not exist in this worktree or the main checkout.pnpm installis Task 0. -
Rust field names arrive as camelCase.
SchemaSnapshotand friends use#[serde(rename_all = "camelCase")], so the JSON hasformatVersion,noteSource,shadowedNote,columnNotes,groupId,estimatedRows,viewDefinition. -
SnapshotWarningis the exception. It uses#[serde(rename_all = "camelCase", tag = "kind")], so its discriminant is{ "kind": "tableSkipped", ... }— camelCase, unlikeTableKind/NoteSource/Cardinalitywhich are SCREAMING_SNAKE ("MATERIALIZED_VIEW","LOCAL","MANY_TO_ONE"). Getting this wrong silently breaks warning rendering.
File Structure
| File | Responsibility |
|---|---|
apps/desktop/src/docs/types.ts |
TS interfaces mirroring the Rust SchemaSnapshot |
apps/desktop/src/docs/fixtures/keycloak.snapshot.json |
Real snapshot from a live database, committed |
apps/desktop/src/docs/docsIndex.ts |
Sidebar/index model: schema grouping, table-group grouping, (no group) bucket |
apps/desktop/src/docs/docsSearch.ts |
Client-side search over tables, columns, groups, enums |
apps/desktop/src/docs/docsWarnings.ts |
SnapshotWarning → human-readable banner text |
apps/desktop/src/docs/groupColor.ts |
Hue → CSS custom properties (never hex) |
apps/desktop/src/docs/DocsApp.vue + components/*.vue |
Thin templates |
apps/desktop/src/docs/__tests__/*.spec.ts |
vitest coverage for every .ts module + SFC guards |
Task 0: Environment setup
- Step 1: Install dependencies
There is no node_modules in this worktree. From the worktree root:
cd /Users/possebon/workspaces/dbx.feature-docs-snapshot-dbml
pnpm install
This takes several minutes and downloads 1-3GB. Disk is at ~45Gi free — if you hit ENOSPC, STOP and report it; do not delete anything.
- Step 2: Confirm the baseline
pnpm vitest run apps/desktop/src/lib/__tests__ --reporter=dot
git branch --show-current # must be feature/docs-snapshot-dbml
git status --short # must be empty apart from node_modules being ignored
Expected: tests pass, branch correct. If pnpm install fails or tests fail before you have changed anything, STOP and report — Part 3a builds on Parts 1-2 and a broken baseline makes every later failure ambiguous.
Task 1: Snapshot types
Files:
- Create:
apps/desktop/src/docs/types.ts - Test:
apps/desktop/src/docs/__tests__/types.spec.ts
Interfaces:
- Produces:
SchemaSnapshot,ProjectMeta,DocTable,TableGroup,Relationship,FieldRef,ColumnNote,DocEnum,TableKind,NoteSource,Cardinality,SnapshotWarning
Casing is load-bearing. Struct fields are camelCase; TableKind/NoteSource/Cardinality are SCREAMING_SNAKE strings; SnapshotWarning is internally tagged with a camelCase kind.
- Step 1: Write the failing test
import { describe, expect, it } from "vitest";
import type { SchemaSnapshot, SnapshotWarning } from "../types";
describe("snapshot types", () => {
it("accepts a minimal snapshot shaped like the Rust output", () => {
const snapshot: SchemaSnapshot = {
formatVersion: 1,
project: {
name: "Ecommerce",
databaseType: "postgres",
database: "shop",
schemas: ["public"],
generatedAt: "2026-08-03T00:00:00Z",
note: null,
},
tables: [
{
schema: "public",
name: "orders",
kind: "TABLE",
columns: [],
indexes: [],
foreignKeys: [],
groupId: null,
note: "Checkout rows.",
noteSource: "DATABASE",
shadowedNote: null,
columnNotes: {},
estimatedRows: null,
viewDefinition: null,
},
],
relationships: [],
groups: [],
enums: [],
warnings: [],
};
expect(snapshot.tables[0].noteSource).toBe("DATABASE");
expect(snapshot.tables[0].kind).toBe("TABLE");
});
it("discriminates warnings on a camelCase kind", () => {
// Rust: #[serde(rename_all = "camelCase", tag = "kind")] — so the
// discriminant is camelCase even though sibling enums are SCREAMING_SNAKE.
const warning: SnapshotWarning = {
kind: "tableSkipped",
table: "public.secret",
reason: "permission denied",
};
expect(warning.kind).toBe("tableSkipped");
const orphans: SnapshotWarning = { kind: "orphanedNotes", count: 3 };
if (orphans.kind === "orphanedNotes") {
expect(orphans.count).toBe(3);
} else {
throw new Error("discriminated union must narrow on kind");
}
});
});
- Step 2: Run it and watch it fail
pnpm vitest run apps/desktop/src/docs/__tests__/types.spec.ts
Expected: FAIL — cannot resolve ../types.
- Step 3: Write the types
Create apps/desktop/src/docs/types.ts:
// Mirrors crates/dbx-core/src/docs/snapshot.rs. There is no Rust->TS codegen
// in this repo, so this file is maintained by hand — see
// __tests__/fixtureConformance.spec.ts, which validates it against a snapshot
// generated by the real Rust code and fails if the two drift apart.
export type TableKind = "TABLE" | "VIEW" | "MATERIALIZED_VIEW";
export type NoteSource = "DATABASE" | "LOCAL" | "NONE";
export type Cardinality = "ONE_TO_ONE" | "MANY_TO_ONE";
export interface ColumnInfo {
name: string;
data_type: string;
is_nullable: boolean;
column_default: string | null;
is_primary_key: boolean;
extra: string | null;
comment?: string | null;
numeric_precision?: number | null;
numeric_scale?: number | null;
character_maximum_length?: number | null;
enum_values?: string[] | null;
}
export interface IndexInfo {
name: string;
columns: string[];
is_unique: boolean;
is_primary: boolean;
filter?: string | null;
index_type?: string | null;
included_columns?: string[] | null;
comment?: string | null;
}
export interface ForeignKeyInfo {
name: string;
column: string;
ref_schema?: string | null;
ref_table: string;
ref_column: string;
on_update?: string | null;
on_delete?: string | null;
}
export interface ProjectMeta {
name: string;
databaseType: string;
database: string | null;
schemas: string[];
generatedAt: string;
note: string | null;
}
export interface ColumnNote {
note: string;
source: NoteSource;
shadowed: string | null;
}
export interface DocTable {
schema: string | null;
name: string;
kind: TableKind;
columns: ColumnInfo[];
indexes: IndexInfo[];
foreignKeys: ForeignKeyInfo[];
groupId: string | null;
note: string | null;
noteSource: NoteSource;
shadowedNote: string | null;
columnNotes: Record<string, ColumnNote>;
estimatedRows: number | null;
viewDefinition: string | null;
}
export interface TableGroup {
id: string;
name: string;
/** 0-359. Lightness and chroma are theme-controlled in CSS. */
hue: number;
note: string | null;
}
export interface FieldRef {
schema: string | null;
table: string;
column: string;
}
export interface Relationship {
id: string;
name: string | null;
from: FieldRef;
to: FieldRef;
cardinality: Cardinality;
onUpdate: string | null;
onDelete: string | null;
}
export interface DocEnum {
schema: string | null;
name: string;
values: string[];
note: string | null;
synthesized: boolean;
}
/**
* Internally tagged on a camelCase `kind`. Note this differs from
* TableKind/NoteSource/Cardinality, which are SCREAMING_SNAKE — the Rust enum
* carries both `tag = "kind"` and `rename_all = "camelCase"`.
*/
export type SnapshotWarning =
| { kind: "tableSkipped"; table: string; reason: string }
| { kind: "noForeignKeyMetadata"; engine: string }
| { kind: "commentsUnsupported"; engine: string }
| { kind: "orphanedNotes"; count: number }
| { kind: "dbmlOmitted"; table: string; item: string; reason: string };
export interface SchemaSnapshot {
formatVersion: number;
project: ProjectMeta;
tables: DocTable[];
relationships: Relationship[];
groups: TableGroup[];
enums: DocEnum[];
warnings: SnapshotWarning[];
}
Note ColumnInfo/IndexInfo/ForeignKeyInfo keep snake_case field names: those Rust structs come from crate::types and do NOT carry rename_all. Task 2's conformance test will confirm this against real output — if it disagrees, fix this file, not the test.
- Step 4: Run it and watch it pass
pnpm vitest run apps/desktop/src/docs/__tests__/types.spec.ts
Expected: PASS, 2 tests.
- Step 5: Commit
pnpm exec oxlint --vue-plugin apps/desktop/src/docs
git add apps/desktop/src/docs/
git commit -m "feat(docs): add snapshot types for the docs viewer"
Task 2: Real fixture + drift guard
Files:
- Create:
apps/desktop/src/docs/fixtures/keycloak.snapshot.json(generated, committed) - Create:
apps/desktop/src/docs/__tests__/fixtureConformance.spec.ts - Create:
crates/dbx-core/tests/dump_docs_fixture.rs
This is the task that closes the drift seam. The fixture must come from the real Rust serializer, not be hand-written — a hand-written fixture would encode the same assumptions as types.ts and prove nothing.
A live PostgreSQL is available: 127.0.0.1:5432, postgres/postgres, database keycloak (90 tables).
- Step 1: Write the Rust dumper
Create crates/dbx-core/tests/dump_docs_fixture.rs, copying the gating and setup from crates/dbx-core/tests/live_postgres_docs_annotations.rs (#[tokio::test], #[ignore = "..."], env vars with defaults, the live_postgres_config(...) builder). It must collect a snapshot of schema public from keycloak, apply a small in-memory AnnotationFile so that notes, a group and an orphan all appear, then write serde_json::to_string_pretty(&snapshot) to the path given by DBX_FIXTURE_OUT, defaulting to apps/desktop/src/docs/fixtures/keycloak.snapshot.json relative to the repo root.
Include annotations covering every field the viewer must render: a table note, a column note, a group with a hue, and an annotation for a nonexistent table so an orphanedNotes warning is present.
- Step 2: Generate the fixture
export PATH="$HOME/.rustup/toolchains/stable-aarch64-apple-darwin/bin:$PATH"
DBX_LIVE_POSTGRES_HOST=127.0.0.1 DBX_LIVE_POSTGRES_PORT=5432 \
DBX_LIVE_POSTGRES_USER=postgres DBX_LIVE_POSTGRES_PASSWORD=postgres \
DBX_LIVE_POSTGRES_DATABASE=keycloak \
cargo test -p dbx-core --test dump_docs_fixture -- --ignored --nocapture
Then confirm the file exists and contains real data:
head -30 apps/desktop/src/docs/fixtures/keycloak.snapshot.json
grep -c '"name"' apps/desktop/src/docs/fixtures/keycloak.snapshot.json
Report the file size. If it exceeds ~500KB, trim to the first 12 tables inside the dumper (keeping the annotated table, the group and the orphan warning) rather than committing a huge fixture.
- Step 3: Write the conformance test
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { SchemaSnapshot } from "../types";
const fixturePath = path.resolve(__dirname, "../fixtures/keycloak.snapshot.json");
function loadFixture(): SchemaSnapshot {
return JSON.parse(readFileSync(fixturePath, "utf8")) as SchemaSnapshot;
}
describe("fixture conformance", () => {
// This fixture is generated by the REAL Rust serializer. Its job is to fail
// when the Rust model changes shape, so the hand-maintained types.ts cannot
// drift silently.
it("has the shape types.ts declares", () => {
const snapshot = loadFixture();
expect(snapshot.formatVersion).toBe(1);
expect(Array.isArray(snapshot.tables)).toBe(true);
expect(snapshot.tables.length).toBeGreaterThan(0);
expect(typeof snapshot.project.databaseType).toBe("string");
expect(Array.isArray(snapshot.project.schemas)).toBe(true);
});
it("uses camelCase for snapshot-owned fields", () => {
const raw = JSON.parse(readFileSync(fixturePath, "utf8")) as Record<string, unknown>;
expect(raw).toHaveProperty("formatVersion");
expect(raw).not.toHaveProperty("format_version");
const table = (raw.tables as Record<string, unknown>[])[0];
expect(table).toHaveProperty("noteSource");
expect(table).toHaveProperty("columnNotes");
expect(table).toHaveProperty("foreignKeys");
expect(table).not.toHaveProperty("note_source");
});
it("keeps snake_case on columns, which come from crate::types", () => {
const snapshot = loadFixture();
const withColumns = snapshot.tables.find((table) => table.columns.length > 0);
expect(withColumns, "fixture must contain at least one table with columns").toBeDefined();
const column = withColumns!.columns[0] as unknown as Record<string, unknown>;
expect(column).toHaveProperty("data_type");
expect(column).toHaveProperty("is_nullable");
expect(column).not.toHaveProperty("dataType");
});
it("carries an annotated note, a group and an orphan warning", () => {
const snapshot = loadFixture();
const annotated = snapshot.tables.find((table) => table.noteSource === "LOCAL");
expect(annotated, "fixture must include a LOCAL-sourced note").toBeDefined();
expect(annotated!.note).toBeTruthy();
expect(snapshot.groups.length).toBeGreaterThan(0);
expect(typeof snapshot.groups[0].hue).toBe("number");
const orphan = snapshot.warnings.find((warning) => warning.kind === "orphanedNotes");
expect(orphan, "fixture must include an orphanedNotes warning").toBeDefined();
});
it("every warning discriminant is one types.ts knows", () => {
const known = new Set([
"tableSkipped",
"noForeignKeyMetadata",
"commentsUnsupported",
"orphanedNotes",
"dbmlOmitted",
]);
for (const warning of loadFixture().warnings) {
expect(known.has(warning.kind), `unknown warning kind: ${warning.kind}`).toBe(true);
}
});
});
- Step 4: Run it
pnpm vitest run apps/desktop/src/docs/__tests__/fixtureConformance.spec.ts
Expected: PASS, 5 tests.
If any assertion fails, the fixture is telling you types.ts is wrong — fix types.ts, not the test. Report exactly which field disagreed; that is a real Rust/TS mismatch and I want to know about it.
- Step 5: Commit
git add apps/desktop/src/docs/ crates/dbx-core/tests/dump_docs_fixture.rs
git commit -m "feat(docs): add a real-output fixture and drift conformance test"
Task 3: Index model (grouping)
Files:
- Create:
apps/desktop/src/docs/docsIndex.ts - Test:
apps/desktop/src/docs/__tests__/docsIndex.spec.ts
Interfaces:
- Produces:
groupBySchema(snapshot): IndexSection[],groupByTableGroup(snapshot): IndexSection[],interface IndexSection { key: string; label: string; hue: number | null; note: string | null; tables: DocTable[] }
This is the Group by: Schemas | Table Groups switch from the approved mock, including the (no group) bucket.
- Step 1: Write the failing test
import { describe, expect, it } from "vitest";
import { groupBySchema, groupByTableGroup } from "../docsIndex";
import type { DocTable, SchemaSnapshot, TableGroup } from "../types";
function table(schema: string | null, name: string, groupId: string | null = null): DocTable {
return {
schema, name, kind: "TABLE", columns: [], indexes: [], foreignKeys: [],
groupId, note: null, noteSource: "NONE", shadowedNote: null,
columnNotes: {}, estimatedRows: null, viewDefinition: null,
};
}
function snapshot(tables: DocTable[], groups: TableGroup[] = []): SchemaSnapshot {
return {
formatVersion: 1,
project: { name: "p", databaseType: "postgres", database: null, schemas: [], generatedAt: "", note: null },
tables, relationships: [], groups, enums: [], warnings: [],
};
}
describe("groupBySchema", () => {
it("groups tables under their schema, sorted by schema then name", () => {
const sections = groupBySchema(snapshot([
table("public", "orders"),
table("analytics", "daily_sales"),
table("public", "customers"),
]));
expect(sections.map((section) => section.key)).toEqual(["analytics", "public"]);
expect(sections[1].tables.map((t) => t.name)).toEqual(["customers", "orders"]);
});
it("puts schema-less tables in a single bare section", () => {
const sections = groupBySchema(snapshot([table(null, "orders")]));
expect(sections).toHaveLength(1);
expect(sections[0].tables[0].name).toBe("orders");
});
});
describe("groupByTableGroup", () => {
const groups: TableGroup[] = [
{ id: "order-mgmt", name: "Order Management", hue: 28, note: "Checkout." },
{ id: "product-mgmt", name: "Product Management", hue: 148, note: null },
];
it("groups tables by their group, preserving the snapshot's group order", () => {
const sections = groupByTableGroup(snapshot([
table("product", "products", "product-mgmt"),
table("core", "orders", "order-mgmt"),
], groups));
expect(sections.map((section) => section.key)).toEqual(["order-mgmt", "product-mgmt"]);
expect(sections[0].label).toBe("Order Management");
expect(sections[0].hue).toBe(28);
expect(sections[0].note).toBe("Checkout.");
});
it("collects ungrouped tables into a trailing (no group) section", () => {
const sections = groupByTableGroup(snapshot([
table("core", "orders", "order-mgmt"),
table("core", "users", null),
], groups));
const last = sections[sections.length - 1];
expect(last.key).toBe("");
expect(last.hue).toBeNull();
expect(last.tables.map((t) => t.name)).toEqual(["users"]);
});
it("omits a group that has no members", () => {
// render_group in the serializer skips empty groups; the viewer must not
// show an empty header where the DBML shows nothing.
const sections = groupByTableGroup(snapshot([table("core", "orders", "order-mgmt")], groups));
expect(sections.map((section) => section.key)).not.toContain("product-mgmt");
});
it("treats a table whose groupId names no group as ungrouped", () => {
const sections = groupByTableGroup(snapshot([table("core", "orders", "ghost")], groups));
expect(sections).toHaveLength(1);
expect(sections[0].key).toBe("");
});
});
- Step 2: Run it and watch it fail
pnpm vitest run apps/desktop/src/docs/__tests__/docsIndex.spec.ts
Expected: FAIL — cannot resolve ../docsIndex.
- Step 3: Implement
import type { DocTable, SchemaSnapshot } from "./types";
export interface IndexSection {
/** Schema name, group id, or "" for the ungrouped bucket. */
key: string;
label: string;
/** Group hue, or null for schema sections and the ungrouped bucket. */
hue: number | null;
note: string | null;
tables: DocTable[];
}
function byName(a: DocTable, b: DocTable): number {
return a.name.localeCompare(b.name);
}
export function groupBySchema(snapshot: SchemaSnapshot): IndexSection[] {
const sections = new Map<string, DocTable[]>();
for (const table of snapshot.tables) {
const key = table.schema ?? "";
const bucket = sections.get(key);
if (bucket) {
bucket.push(table);
} else {
sections.set(key, [table]);
}
}
return [...sections.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, tables]) => ({
key,
label: key,
hue: null,
note: null,
tables: [...tables].sort(byName),
}));
}
export function groupByTableGroup(snapshot: SchemaSnapshot): IndexSection[] {
const known = new Map(snapshot.groups.map((group) => [group.id, group]));
const sections: IndexSection[] = [];
// Snapshot order is the notes file's order — the user's own arrangement.
for (const group of snapshot.groups) {
const tables = snapshot.tables.filter((table) => table.groupId === group.id).sort(byName);
// An empty group renders nothing, matching render_group in the serializer.
if (tables.length === 0) {
continue;
}
sections.push({
key: group.id,
label: group.name,
hue: group.hue,
note: group.note,
tables,
});
}
// A groupId naming no known group is treated as ungrouped rather than
// creating a phantom section.
const ungrouped = snapshot.tables
.filter((table) => table.groupId === null || !known.has(table.groupId))
.sort(byName);
if (ungrouped.length > 0) {
sections.push({ key: "", label: "(no group)", hue: null, note: null, tables: ungrouped });
}
return sections;
}
- Step 4: Run it and watch it pass
pnpm vitest run apps/desktop/src/docs/__tests__/docsIndex.spec.ts
Expected: PASS, 6 tests.
- Step 5: Commit
pnpm exec oxlint --vue-plugin apps/desktop/src/docs
git add apps/desktop/src/docs/
git commit -m "feat(docs): add index grouping for the docs viewer"
Task 4: Warning presentation
Files:
- Create:
apps/desktop/src/docs/docsWarnings.ts - Test:
apps/desktop/src/docs/__tests__/docsWarnings.spec.ts
Interfaces:
- Produces:
describeWarning(warning: SnapshotWarning): WarningNotice,interface WarningNotice { severity: "info" | "warning"; title: string; detail: string }
Why this matters: the feature's stated principle is "degrade visibly, never silently". These strings are how degradation becomes visible. A warning rendered as [object Object] — which is what happens if the camelCase discriminant is mismatched — defeats the whole mechanism.
- Step 1: Write the failing test
import { describe, expect, it } from "vitest";
import { describeWarning } from "../docsWarnings";
import type { SnapshotWarning } from "../types";
describe("describeWarning", () => {
it("explains a skipped table as a warning naming the table and reason", () => {
const notice = describeWarning({ kind: "tableSkipped", table: "public.secret", reason: "permission denied" });
expect(notice.severity).toBe("warning");
expect(notice.detail).toContain("public.secret");
expect(notice.detail).toContain("permission denied");
});
it("explains missing foreign-key metadata as an engine limitation, not a fault", () => {
const notice = describeWarning({ kind: "noForeignKeyMetadata", engine: "ClickHouse" });
expect(notice.detail).toContain("ClickHouse");
// The diagram will have no edges — the user must learn why.
expect(notice.detail.toLowerCase()).toContain("relationship");
});
it("explains unsupported comments", () => {
const notice = describeWarning({ kind: "commentsUnsupported", engine: "SQLite" });
expect(notice.detail).toContain("SQLite");
});
it("reports orphaned notes with the count", () => {
const notice = describeWarning({ kind: "orphanedNotes", count: 3 });
expect(notice.detail).toContain("3");
});
it("explains a DBML omission naming the item", () => {
const notice = describeWarning({
kind: "dbmlOmitted",
table: "public.orders",
item: "idx_orders_open",
reason: "partial index filter has no DBML equivalent",
});
expect(notice.severity).toBe("info");
expect(notice.detail).toContain("idx_orders_open");
});
it("never returns an empty or placeholder string for any known kind", () => {
const samples: SnapshotWarning[] = [
{ kind: "tableSkipped", table: "t", reason: "r" },
{ kind: "noForeignKeyMetadata", engine: "e" },
{ kind: "commentsUnsupported", engine: "e" },
{ kind: "orphanedNotes", count: 1 },
{ kind: "dbmlOmitted", table: "t", item: "i", reason: "r" },
];
for (const sample of samples) {
const notice = describeWarning(sample);
expect(notice.title.length, `empty title for ${sample.kind}`).toBeGreaterThan(0);
expect(notice.detail.length, `empty detail for ${sample.kind}`).toBeGreaterThan(0);
expect(notice.detail).not.toContain("[object Object]");
expect(notice.detail).not.toContain("undefined");
}
});
});
- Step 2: Run it and watch it fail
pnpm vitest run apps/desktop/src/docs/__tests__/docsWarnings.spec.ts
Expected: FAIL — cannot resolve ../docsWarnings.
- Step 3: Implement
import type { SnapshotWarning } from "./types";
export interface WarningNotice {
severity: "info" | "warning";
title: string;
detail: string;
}
/**
* Turn a snapshot warning into something a reader can act on.
*
* This is where "degrade visibly, never silently" becomes literal: if a table
* could not be read, or an engine cannot report relationships, the reader has
* to learn that from the page rather than infer it from an absence.
*/
export function describeWarning(warning: SnapshotWarning): WarningNotice {
switch (warning.kind) {
case "tableSkipped":
return {
severity: "warning",
title: "A table could not be documented",
detail: `${warning.table} was skipped: ${warning.reason}. It is missing from this documentation.`,
};
case "noForeignKeyMetadata":
return {
severity: "info",
title: "No relationships available",
detail: `${warning.engine} does not report foreign key metadata, so no relationship edges could be derived. The diagram is complete for this engine.`,
};
case "commentsUnsupported":
return {
severity: "info",
title: "Database comments unavailable",
detail: `${warning.engine} does not support table or column comments, so every description here comes from this project's own notes.`,
};
case "orphanedNotes":
return {
severity: "warning",
title: "Some notes no longer match anything",
detail: `${warning.count} note(s) refer to a table or column that no longer exists. Nothing was deleted — re-map or remove them in the notes file.`,
};
case "dbmlOmitted":
return {
severity: "info",
title: "Not representable in DBML",
detail: `${warning.item} on ${warning.table} is documented here but omitted from the exported DBML: ${warning.reason}.`,
};
}
}
- Step 4: Run it and watch it pass
pnpm vitest run apps/desktop/src/docs/__tests__/docsWarnings.spec.ts
Expected: PASS, 6 tests.
- Step 5: Commit
pnpm exec oxlint --vue-plugin apps/desktop/src/docs
git add apps/desktop/src/docs/
git commit -m "feat(docs): describe snapshot warnings for the viewer"
Task 5: Search
Files:
- Create:
apps/desktop/src/docs/docsSearch.ts - Test:
apps/desktop/src/docs/__tests__/docsSearch.spec.ts
Interfaces:
- Produces:
searchDocs(snapshot, query): SearchHit[],interface SearchHit { kind: "table" | "column" | "group" | "enum"; label: string; context: string; tableKey: string | null }
This is the ⌘K palette from the approved mock.
- Step 1: Write the failing test
import { describe, expect, it } from "vitest";
import { searchDocs } from "../docsSearch";
import type { DocTable, SchemaSnapshot } from "../types";
function column(name: string) {
return {
name, data_type: "text", is_nullable: true, column_default: null,
is_primary_key: false, extra: null,
};
}
function table(schema: string, name: string, columns: string[] = []): DocTable {
return {
schema, name, kind: "TABLE", columns: columns.map(column), indexes: [], foreignKeys: [],
groupId: null, note: null, noteSource: "NONE", shadowedNote: null,
columnNotes: {}, estimatedRows: null, viewDefinition: null,
};
}
const snapshot: SchemaSnapshot = {
formatVersion: 1,
project: { name: "p", databaseType: "postgres", database: null, schemas: [], generatedAt: "", note: null },
tables: [table("public", "orders", ["status", "total"]), table("public", "customers", ["status"])],
relationships: [],
groups: [{ id: "g1", name: "Order Management", hue: 28, note: null }],
enums: [{ schema: "public", name: "order_status", values: ["pending"], note: null, synthesized: false }],
warnings: [],
};
describe("searchDocs", () => {
it("returns nothing for an empty query", () => {
expect(searchDocs(snapshot, "")).toEqual([]);
expect(searchDocs(snapshot, " ")).toEqual([]);
});
it("matches table names case-insensitively", () => {
const hits = searchDocs(snapshot, "ORD");
expect(hits.some((hit) => hit.kind === "table" && hit.label === "orders")).toBe(true);
});
it("matches columns and reports which table they belong to", () => {
const hits = searchDocs(snapshot, "total");
const hit = hits.find((candidate) => candidate.kind === "column");
expect(hit).toBeDefined();
expect(hit!.label).toBe("total");
expect(hit!.context).toContain("orders");
});
it("returns one hit per table for a column name shared by several tables", () => {
const hits = searchDocs(snapshot, "status").filter((hit) => hit.kind === "column");
expect(hits).toHaveLength(2);
expect(hits.map((hit) => hit.context).sort()).toEqual(["public.customers", "public.orders"]);
});
it("matches groups and enums", () => {
expect(searchDocs(snapshot, "Order Man").some((hit) => hit.kind === "group")).toBe(true);
expect(searchDocs(snapshot, "order_status").some((hit) => hit.kind === "enum")).toBe(true);
});
it("ranks table matches above column matches for the same term", () => {
// Someone typing "orders" almost always wants the table.
const hits = searchDocs(snapshot, "orders");
expect(hits[0].kind).toBe("table");
});
it("carries a tableKey so a hit can navigate", () => {
const hit = searchDocs(snapshot, "total").find((candidate) => candidate.kind === "column");
expect(hit!.tableKey).toBe("public.orders");
});
});
- Step 2: Run it and watch it fail
pnpm vitest run apps/desktop/src/docs/__tests__/docsSearch.spec.ts
Expected: FAIL — cannot resolve ../docsSearch.
- Step 3: Implement
import type { SchemaSnapshot } from "./types";
export interface SearchHit {
kind: "table" | "column" | "group" | "enum";
label: string;
/** Where the hit lives — a qualified table name, or a count for groups. */
context: string;
/** Qualified table name for navigation, or null for groups and enums. */
tableKey: string | null;
}
function qualified(schema: string | null, name: string): string {
return schema ? `${schema}.${name}` : name;
}
/**
* Case-insensitive substring search over the whole snapshot.
*
* Tables rank first: someone typing a table's name almost always wants the
* table, not a column that happens to share the word.
*/
export function searchDocs(snapshot: SchemaSnapshot, query: string): SearchHit[] {
const needle = query.trim().toLowerCase();
if (needle.length === 0) {
return [];
}
const tables: SearchHit[] = [];
const columns: SearchHit[] = [];
for (const table of snapshot.tables) {
const key = qualified(table.schema, table.name);
if (table.name.toLowerCase().includes(needle)) {
tables.push({ kind: "table", label: table.name, context: key, tableKey: key });
}
for (const column of table.columns) {
if (column.name.toLowerCase().includes(needle)) {
columns.push({ kind: "column", label: column.name, context: key, tableKey: key });
}
}
}
const groups: SearchHit[] = snapshot.groups
.filter((group) => group.name.toLowerCase().includes(needle))
.map((group) => ({
kind: "group",
label: group.name,
context: `${snapshot.tables.filter((table) => table.groupId === group.id).length} tables`,
tableKey: null,
}));
const enums: SearchHit[] = snapshot.enums
.filter((value) => value.name.toLowerCase().includes(needle))
.map((value) => ({
kind: "enum",
label: value.name,
context: `${value.values.length} values`,
tableKey: null,
}));
return [...tables, ...columns, ...groups, ...enums];
}
- Step 4: Run it and watch it pass
pnpm vitest run apps/desktop/src/docs/__tests__/docsSearch.spec.ts
Expected: PASS, 7 tests.
- Step 5: Commit
pnpm exec oxlint --vue-plugin apps/desktop/src/docs
git add apps/desktop/src/docs/
git commit -m "feat(docs): add client-side search for the docs viewer"
Task 6: Group colour
Files:
- Create:
apps/desktop/src/docs/groupColor.ts - Test:
apps/desktop/src/docs/__tests__/groupColor.spec.ts
Interfaces:
- Produces:
groupStyle(hue: number | null): Record<string, string>
The design rule: a group stores ONE number, a hue. Lightness and chroma are fixed per theme in CSS, so every hue is legible on both light and dark grounds by construction. The viewer must never compute hex — that would defeat the whole scheme and reintroduce the dark-mode mud problem the mock solved.
- Step 1: Write the failing test
import { describe, expect, it } from "vitest";
import { groupStyle } from "../groupColor";
describe("groupStyle", () => {
it("exposes the hue as a CSS custom property", () => {
expect(groupStyle(28)).toEqual({ "--h": "28" });
});
it("returns no custom property for an ungrouped section", () => {
expect(groupStyle(null)).toEqual({});
});
it("wraps hues into 0-359 rather than emitting an out-of-range value", () => {
expect(groupStyle(360)).toEqual({ "--h": "0" });
expect(groupStyle(388)).toEqual({ "--h": "28" });
expect(groupStyle(-1)).toEqual({ "--h": "359" });
});
it("never emits a colour value", () => {
// Lightness and chroma belong to the theme. If this function ever returns
// a hex or oklch string, the dark-mode contrast guarantee is gone.
const style = groupStyle(200);
const serialized = JSON.stringify(style);
expect(serialized).not.toContain("#");
expect(serialized).not.toContain("oklch");
expect(serialized).not.toContain("rgb");
});
it("coerces a non-integer hue to an integer", () => {
expect(groupStyle(28.7)).toEqual({ "--h": "28" });
});
});
- Step 2: Run it and watch it fail
pnpm vitest run apps/desktop/src/docs/__tests__/groupColor.spec.ts
Expected: FAIL — cannot resolve ../groupColor.
- Step 3: Implement
/**
* Inline style exposing a table group's hue to CSS.
*
* A group stores ONE number. Lightness and chroma are fixed per theme in the
* stylesheet — `--group-c: oklch(0.55 0.15 var(--h))` in light,
* `oklch(0.76 0.13 var(--h))` in dark — so every hue is legible on both
* grounds by construction. Computing a colour here would throw that away.
*/
export function groupStyle(hue: number | null): Record<string, string> {
if (hue === null) {
return {};
}
const wrapped = ((Math.trunc(hue) % 360) + 360) % 360;
return { "--h": String(wrapped) };
}
- Step 4: Run it and watch it pass
pnpm vitest run apps/desktop/src/docs/__tests__/groupColor.spec.ts
Expected: PASS, 5 tests.
- Step 5: Commit
pnpm exec oxlint --vue-plugin apps/desktop/src/docs
git add apps/desktop/src/docs/
git commit -m "feat(docs): expose group hue as a CSS custom property"
Task 7: Note markdown rendering
This brief was corrected by the controller after empirically probing
marked@18.0.4. The original plan text contained three defects: an assertion that fails against a correct implementation, a suggested approach that double-escapes HTML entities, and a URL filter with several live bypasses. Everything below has been run against the installedmarkedand its exact output verified. Use it verbatim.
Files:
- Create:
apps/desktop/src/docs/renderNote.ts - Test:
apps/desktop/src/docs/__tests__/renderNote.spec.ts
Interfaces:
- Produces:
renderNote(markdown: string | null): string
Notes are markdown. marked@18.0.4 is already a dependency — do NOT add one, and do NOT
add a sanitiser (no DOMPurify).
This is the security-relevant task in Part 3a. Note text comes from two places, and one of
them — COMMENT ON values — lives in the database, which is not always under the document
author's control. In Part 3b the same renderer runs inside a standalone HTML file people forward
to colleagues. So raw HTML must be escaped, not rendered, and unsafe URL schemes must be dropped.
Verified facts about marked@18 you must not re-derive:
-
It escapes nothing by default.
<script>alert(1)</script>passes straight through. -
It does not block
javascript:,vbscript:, ordata:URLs in links or images. -
Pre-escaping the source string before parsing is wrong — it double-escapes entities, so
a & brenders as the literal texta & binstead ofa & b. -
In the
linkrenderer, thetextproperty is the raw markdown source, not parsed HTML. Returning it directly is an XSS hole and also breaks**bold**inside link text. You must usethis.parser.parseInline(tokens)instead. -
Step 1: Write the failing test
import { describe, expect, it } from "vitest";
import { renderNote } from "../renderNote";
describe("renderNote", () => {
it("renders ordinary markdown", () => {
expect(renderNote("One row per **checkout**.")).toContain("<strong>checkout</strong>");
});
it("renders inline code and fenced blocks", () => {
expect(renderNote("see `order_status`")).toContain("<code>order_status</code>");
expect(renderNote("```sql\nSELECT 1;\n```")).toContain("<pre>");
});
it("returns an empty string for null or blank input", () => {
expect(renderNote(null)).toBe("");
expect(renderNote(" ")).toBe("");
});
it("escapes a script tag rather than rendering it", () => {
const html = renderNote("<script>alert(1)</script>");
expect(html).not.toContain("<script>");
expect(html).toContain("<script>");
});
it("escapes an img onerror payload", () => {
// NB: asserting !contains("onerror=") would FAIL against a correct
// implementation — the escaped text legitimately still contains that
// substring. What matters is that no <img> ELEMENT is produced.
const html = renderNote('<img src=x onerror="alert(1)">');
expect(html.toLowerCase()).not.toContain("<img");
expect(html).toContain("<img");
});
it("escapes raw HTML even when it looks harmless", () => {
// Blanket rule: no author-supplied HTML is rendered, ever. A rule with
// exceptions is a rule someone will find a way around.
const html = renderNote("<b>bold</b>");
expect(html).not.toContain("<b>bold</b>");
expect(html).toContain("<b>");
});
it("does not preserve HTML entities twice", () => {
// Guards the pre-escaping approach, which turns "a & b" into
// "a &amp; b" and renders the entity visibly to the reader.
expect(renderNote("a & b")).toContain("a & b");
expect(renderNote("a & b")).not.toContain("&amp;");
});
it.each([
["javascript:alert(1)", "javascript"],
["JaVaScRiPt:alert(1)", "javascript"],
["javascript:alert(1)", "avascript"],
["vbscript:alert(1)", "vbscript"],
["data:text/html;base64,PHNjcmlwdD4=", "data:"],
])("drops the unsafe link scheme %s", (href, forbidden) => {
const html = renderNote(`[click](${href})`).toLowerCase();
expect(html).not.toContain(forbidden);
expect(html).toContain("click"); // the text survives; only the href is dropped
});
it("drops an unsafe image scheme", () => {
const html = renderNote(")").toLowerCase();
expect(html).not.toContain("javascript");
expect(html).not.toContain("<img");
});
it.each(["https://example.com", "http://example.com", "mailto:a@b.com", "#anchor", "./rel.html"])(
"keeps the safe link target %s",
(href) => {
expect(renderNote(`[ok](${href})`)).toContain(`href="${href}"`);
},
);
it("parses markdown inside link text instead of emitting it raw", () => {
// marked hands the renderer the RAW source text. Emitting it directly
// both breaks formatting and injects unescaped HTML.
expect(renderNote("[**keep**](https://example.com)")).toContain("<strong>keep</strong>");
const dropped = renderNote('[<img src=x onerror="alert(1)">](javascript:alert(1))');
expect(dropped.toLowerCase()).not.toContain("<img");
expect(dropped).toContain("<img");
});
});
- Step 2: Run it and watch it fail
pnpm vitest run apps/desktop/src/docs/__tests__/renderNote.spec.ts
Expected: FAIL — cannot resolve ../renderNote.
- Step 3: Implement
This exact implementation was run against marked@18.0.4 and every assertion above verified.
import { Marked } from "marked";
/** Escape the five characters that can break out of text or an attribute value. */
function escapeHtml(value: unknown): string {
return String(value ?? "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
/**
* URL allowlist. Blocklists lose: `javascript:` alone misses `JaVaScRiPt:`,
* the entity-encoded `javascript:`, `vbscript:` and `data:text/html`.
* Permitting only what we understand is both shorter and complete.
*/
const SAFE_SCHEME = /^(https?:|mailto:)/i;
function safeUrl(raw: unknown): string | null {
const url = String(raw ?? "").trim();
if (url === "") {
return null;
}
if (url.startsWith("#") || url.startsWith("/") || url.startsWith("./") || url.startsWith("../")) {
return url;
}
return SAFE_SCHEME.test(url) ? url : null;
}
const renderer = new Marked({
renderer: {
// Raw HTML in the source is shown as text, never rendered.
html({ text }) {
return escapeHtml(text);
},
link({ href, title, tokens }) {
// `tokens`, not `text` — `text` is the raw markdown source, so returning
// it would emit author HTML unescaped and swallow inline formatting.
const inner = this.parser.parseInline(tokens);
const safe = safeUrl(href);
if (safe === null) {
return inner;
}
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
return `<a href="${escapeHtml(safe)}"${titleAttr}>${inner}</a>`;
},
image({ href, title, text }) {
const safe = safeUrl(href);
if (safe === null) {
return escapeHtml(text);
}
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
return `<img src="${escapeHtml(safe)}" alt="${escapeHtml(text)}"${titleAttr}>`;
},
},
});
/** Render a note's markdown to HTML with all author-supplied HTML escaped. */
export function renderNote(markdown: string | null): string {
if (markdown === null || markdown.trim() === "") {
return "";
}
return renderer.parse(markdown) as string;
}
If vue-tsc objects to the renderer callback signatures, fix the types rather than the
behaviour, and report what you changed. Do NOT loosen the escaping to satisfy the compiler.
- Step 4: Run it and watch it pass
pnpm vitest run apps/desktop/src/docs/__tests__/renderNote.spec.ts
- Step 5: Verify the guards are real — three deliberate breaks
One at a time, restoring between each. Report each failure message.
- Delete the
htmlrenderer override entirely → the script-tag, img and<b>tests must fail. - Change
safeUrltoreturn url;unconditionally → the unsafe-scheme cases must fail. - In
link, replacethis.parser.parseInline(tokens)with the token'stextproperty → the "parses markdown inside link text" test must fail.
If any deliberate break does NOT produce a failure, stop and tell me — that means the test is not actually pinning the guard, which is the exact defect this step exists to catch.
- Step 6: Commit
pnpm exec vue-tsc --noEmit --project apps/desktop/tsconfig.json
pnpm exec oxlint --vue-plugin apps/desktop/src/docs
git add apps/desktop/src/docs/
git commit -m "feat(docs): render note markdown with raw HTML escaped"
Confirm git status is clean after your restores, so no deliberate break leaks into the commit.
Task 8: Components
Files:
- Create:
apps/desktop/src/docs/DocsApp.vue - Create:
apps/desktop/src/docs/components/DocsSidebar.vue,WikiIndex.vue,TablePage.vue,ColumnTable.vue,RelationshipList.vue,WarningBanner.vue,DocsSearch.vue - Test:
apps/desktop/src/docs/__tests__/componentContract.spec.ts
Templates only. Every decision they display comes from Tasks 3-7. The visual reference is the approved mock: sidebar with the Group-by switch, wiki index with inline notes, table page with columns / indexes / bidirectional relationships, the ⬤ LOCAL shadow marker, and the warning banner.
Because this repo has no component-mount harness, verification is static SFC parsing — copy the technique from packages/app-tests/aiAssistantSendGuard.test.ts (readFileSync + parse from vue/compiler-sfc).
- Step 1: Write the contract test
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { parse } from "vue/compiler-sfc";
const docsRoot = path.resolve(__dirname, "..");
function vueFiles(): string[] {
const found: string[] = [];
const walk = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory() && entry.name !== "__tests__" && entry.name !== "fixtures") {
walk(full);
} else if (entry.isFile() && entry.name.endsWith(".vue")) {
found.push(full);
}
}
};
walk(docsRoot);
return found;
}
function scriptOf(file: string): string {
const { descriptor } = parse(readFileSync(file, "utf8"), { filename: file });
return `${descriptor.script?.content ?? ""}\n${descriptor.scriptSetup?.content ?? ""}`;
}
describe("docs viewer component contract", () => {
it("finds the expected components", () => {
const names = vueFiles().map((file) => path.basename(file)).sort();
expect(names).toContain("DocsApp.vue");
expect(names).toContain("DocsSidebar.vue");
expect(names).toContain("WikiIndex.vue");
expect(names).toContain("TablePage.vue");
expect(names).toContain("WarningBanner.vue");
});
it("makes no backend calls", () => {
// The whole point of this directory: snapshot in, DOM out. If a component
// ever reaches for the backend, the same code cannot run inside an
// exported HTML file on a machine that has never installed DBX.
const forbidden = [
"@/lib/backend",
"@tauri-apps",
"invoke(",
"useConnectionStore",
"useQueryStore",
"useSettingsStore",
"fetch(",
"axios",
];
for (const file of vueFiles()) {
const script = scriptOf(file);
for (const needle of forbidden) {
expect(script.includes(needle), `${path.basename(file)} must not reference ${needle}`).toBe(false);
}
}
});
it("keeps colour decisions out of templates", () => {
// Hue -> CSS belongs in groupColor.ts so the theme keeps control of
// lightness and chroma.
for (const file of vueFiles()) {
const source = readFileSync(file, "utf8");
expect(source.includes("oklch("), `${path.basename(file)} must not compute colour`).toBe(false);
}
});
it("uses <script setup lang=\"ts\">", () => {
for (const file of vueFiles()) {
const { descriptor } = parse(readFileSync(file, "utf8"), { filename: file });
expect(descriptor.scriptSetup, `${path.basename(file)} must use <script setup>`).toBeTruthy();
expect(descriptor.scriptSetup?.lang, `${path.basename(file)} must be TypeScript`).toBe("ts");
}
});
});
- Step 2: Run it and watch it fail
pnpm vitest run apps/desktop/src/docs/__tests__/componentContract.spec.ts
Expected: FAIL — no .vue files exist yet.
- Step 3: Build the components
Write the seven components as thin templates. Rules:
DocsApp.vuetakes a single propsnapshot: SchemaSnapshotand areadonly = trueprop, holds the current view (index|table) and the group-by mode, and renders the others. No editing affordances in Part 3a —readonlyexists so Part 3b can flip it.DocsSidebar.vuerenders theGroup by: Schemas | Table Groupssegmented control and the section list, usinggroupBySchema/groupByTableGroupfrom Task 3 andgroupStylefrom Task 6.WikiIndex.vuerenders each section header (name, count, note) and its table list.TablePage.vuerenders the table's note with a⬤ LOCALmarker whennoteSource === "LOCAL", the shadowed database comment in atitleattribute whenshadowedNoteis present, thenColumnTable, indexes, andRelationshipList.ColumnTable.vuerenders name / type / settings / note, reading notes fromcolumnNotesby the column's real name.RelationshipList.vuerenders outgoing and incoming relationships in two lists, derived fromsnapshot.relationshipsfiltered on the current table.WarningBanner.vuerendersdescribeWarningoutput from Task 4.DocsSearch.vuerenders the ⌘K palette usingsearchDocsfrom Task 5.
Style with Tailwind classes and DBX's existing token names (bg-background, text-foreground, border-border, text-muted-foreground). Group colours come only from var(--group-c) / var(--group-tint) driven by the --h custom property.
- Step 4: Run the contract test
pnpm vitest run apps/desktop/src/docs/__tests__/componentContract.spec.ts
Expected: PASS, 4 tests.
- Step 5: Run everything and lint
pnpm vitest run apps/desktop/src/docs
pnpm exec oxlint --vue-plugin apps/desktop/src/docs
pnpm exec vue-tsc --noEmit --project apps/desktop/tsconfig.json
All must pass. vue-tsc is the only thing that type-checks the templates, so it is doing the work a mount test would otherwise do — report any error rather than suppressing it.
- Step 6: Commit
git add apps/desktop/src/docs/
git commit -m "feat(docs): add docs viewer components"
Done criteria
apps/desktop/src/docs/contains a viewer that renders aSchemaSnapshotwith zero backend calls, enforced by a test.- Every decision (grouping, search, warning text, group colour) lives in a tested
.tsmodule. - A fixture generated from real Rust output guards the hand-maintained TS types against drift.
pnpm vitest run apps/desktop/src/docspasses;vue-tscandoxlintare clean.
Deferred to Part 3b
- Mounting the viewer inside DBX (
DatabaseDocsDialog.vue) and the editing affordances behindreadonly=false. - The standalone Vite bundle, the committed
docs-viewer.jsartifact, and the self-contained HTML export — including inlining the real Geist font, which IS in the repo atapps/desktop/public/fonts/geist-latin-wght-normal.woff2. - The
dbx docsCLI verb. - Hash routing (
#/table/public.orders). Part 3a keeps the current view inDocsAppstate; hash routing belongs with the standalone entry point, since its whole purpose is surviving afile://load. - The
EnumPageview listing enum values. - The ER diagram view (reuses
layoutDiagramTablesfromlib/diagram/erDiagram.ts, which imports only types and is therefore safe for the standalone bundle). - i18n: Part 3a hardcodes English strings in
docsWarnings.ts; Part 3b extracts them into adocsnamespace across all 8 locales.
Appendix: Corrections found during execution
Defects in this plan's own text, found while executing it. Recorded by failure mode rather than by task, because the modes repeat and the tasks do not. Every one originated in the plan; none was an implementer error.
Mode 1 — a test whose fixture cannot distinguish the property from its negation
The most common failure by a wide margin, and the hardest to see on a green run. The assertion names a real property; the data supplied cannot tell that property apart from its opposite.
- Task 5, ranking.
expect(hits[0].kind).toBe("table")for query"orders". No column in the fixture contained that substring, so exactly one hit came back and the assertion held trivially. Reversing the concatenation order produced byte-identical output. Fixed by adding anorders_countcolumn to a second table so the query yields both kinds, and by assertingindexOf(first table) < indexOf(first column)instead of inspecting element zero. - Task 5, case-insensitivity. Every identifier in the fixture was already lowercase, so a
mutant lowercasing only the needle and not each candidate passed all seven tests. Fixed by
adding a mixed-case
Invoicestable andAmountcolumn.
The lesson: write the assertion, then ask what fixture would make it fail. If no such fixture exists, the test is decorative. Watching the test fail once is the only cheap proof.
Mode 2 — an assertion that fails against a correct implementation
- Task 7.
expect(html).not.toContain("onerror=")for the input<img src=x onerror="…">. Correctly escaped output is<img src=x onerror="alert(1)">, which legitimately contains that substring as inert text. The test would have driven an implementer to strip attributes from text content — wrong, and a plausible route to breaking the escaping entirely. Fixed by asserting no<imgelement is produced and that<imgis present.
The lesson: an assertion about a security property must target the dangerous structure, not a scary-looking string. Escaped text is supposed to still read like the attack.
Mode 3 — offering two approaches as equivalent when one is broken
- Task 7. Step 3 presented "escape the source before parsing" and "override the renderer's
html hook" as interchangeable. Pre-escaping double-escapes entities:
a & brenders as the literal texta & b. Fixed by mandating the renderer hook and stating why.
The lesson: when a plan says "either approach works", it usually means neither was tested.
Mode 4 — a blocklist where an allowlist was needed
- Task 7. The instruction was to drop any
hrefstarting withjavascript:after trimming and lowercasing. Probingmarked@18.0.4showed it blocks nothing and emits verbatim hrefs forJaVaScRiPt:, the entity-encodedjavascript:(which the browser decodes and fires),vbscript:, anddata:text/html;base64,…. The instruction also covered onlyhref, never<img src>. Replaced with an allowlist ofhttps?:,mailto:, and relative/anchor forms — shorter than the blocklist and complete without enumerating a single bypass.
The lesson: enumerating what is forbidden requires knowing every attack; enumerating what is permitted requires knowing only the feature.
Mode 5 — assuming a library parameter contains what its name suggests
- Task 7. Found while verifying the replacement for Mode 4, not in the original text. In
marked@18thelinkrenderer'stextproperty is the raw markdown source, not the parsed inner HTML. Returning it emits author HTML unescaped —[<img src=x onerror=…>](javascript:…)renders a live element even though the URL was correctly dropped — and it also swallows inline formatting, so[**keep**](…)loses its emphasis. Fixed withthis.parser.parseInline(tokens).
The lesson: the first ten payloads all put the attack in the URL, because that is where the danger was assumed to be. The hole was in the field nobody was thinking about. Passing security tests confirm the threat model they were written from; they do not probe it.
Mode 6 — a verification step that verifies nothing
- Task 1. The drift check instructed renaming
types.tsto confirm the conformance test would catch schema drift. Renaming the type file breaks the import, which fails for reasons having nothing to do with drift. Caught by the implementer, who proposed mutating the fixture instead — the only change that actually exercises the check. Fixed in the executed task.