* 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>
27 KiB
Database Documentation and DBML Export Design
Date: 2026-08-02
Summary
DBX can inspect a schema but cannot document it. There is no way to produce a browsable, shareable description of a database, and no occurrence of DBML anywhere in the repository.
This change adds a documentation subsystem built around one idea: SchemaSnapshot is the product, and every output is a serializer over it. crates/dbx-core/src/docs/ collects a normalized snapshot of a relational schema, merges it with user-authored notes and table groups, and serializes it to two artifacts — a .dbml interchange file and a self-contained HTML documentation site in the style of dbdocs.io.
The documentation UI is a standalone Vue application that makes no backend calls. The same component tree renders inside DBX as a dialog and inside an exported single-file HTML page. Generation is available headless through dbx docs and dbx dbml, so documentation can be regenerated in CI and schema drift can fail a build.
Schema versioning (a Changelog tab and per-table "Last Updated") is explicitly deferred, but the snapshot is version-stamped and storable from day one so that adding it later is additive rather than a refactor.
Goals
- Collect a normalized
SchemaSnapshotfor any relational engine DBX already supports, reusing the existingschema::providersmetadata layer. - Serialize the snapshot to valid DBML, including
TableGroupblocks,Enumblocks, andRefcardinality. - Render dbdocs-style documentation: project overview, per-group table index with inline markdown notes, per-table pages with columns/indexes/bidirectional relationships, an ER diagram with group containers, and client-side search.
- Let users author table notes, column notes, project notes, and table groups in DBX, persisted in a human-readable, git-committable file.
- Export a self-contained HTML file that works over
file://with no network access. - Provide
dbx docsanddbx dbmlCLI verbs producing byte-identical output to the desktop app. - Degrade visibly, never silently, when an engine cannot supply metadata.
Non-goals
- Schema versioning / Changelog / "Last Updated" — deferred. Live databases mostly cannot report a DDL timestamp (PostgreSQL has none; MySQL's
information_schema.tables.update_timeis NULL for most InnoDB tables), so this is a derivative of stored snapshot history, not of metadata. Section 12 defines the seam. - DBML import / parsing. The serializer is one-way. A
.dbmlfile is an output, never a source of truth. - Non-relational engines (MongoDB, Redis, Elasticsearch, InfluxDB, HBase). DBML has no vocabulary for them and schema inference from documents is a separate feature.
- Non-table objects — triggers, procedures, functions, sequences, packages, grants, partitions.
ObjectInfoexists for these but none map to DBML and dbdocs does not document them.format_versionmakes adding them additive. - Write-back to the database. DBX never issues
COMMENT ONas a side effect of documentation. - Publishing/hosting. DBX writes a file; distributing it is the user's business.
Existing Design to Reuse
crates/dbx-core/src/types.rs:57-141—TableInfo,ColumnInfo,IndexInfo,ForeignKeyInfo. These are serde mirrors of the TS interfaces inapps/desktop/src/types/database.tsand are composed directly into the snapshot rather than re-declared.crates/dbx-core/src/schema/providers/native.rs—list_databases,list_schemas,list_tables,get_columns,list_foreign_keys,list_indexes, already dispatching across every supported engine.crates/dbx-core/src/schema/normalization.rs— identifier normalization; the home for per-engine case folding of annotation keys.crates/dbx-core/src/table_structure_sql/dialect.rs—capabilities_for(database_type).comment, the authority on whether an engine supports comments at all.crates/dbx-core/src/schema_diff.rs:772—RenameCandidate { source_name, target_name, score, column_jaccard, type_similarity }plusjaccard_similarityandcolumn_type_similarity. Reused to suggest re-mapping orphaned notes after a rename.crates/dbx-core/src/database_export.rs— the established progress-callback and cancellation shape.apps/desktop/src/lib/diagram/erDiagram.ts—layoutDiagramTablesanddiagramZoom.ts. This module imports only types, so the standalone docs bundle can import it without pulling in DBX runtime code.buildDiagramRelationships/foreignKeySourceCardinalityare the reference implementation for the Rust port in Section 3.apps/desktop/src/lib/export/diagramSvgExport.ts— existing SVG serialization for diagram export.apps/desktop/src/styles/globals.css:1310+— the DBX token palette (strictly achromatic,oklch(… 0 0)), from which the docs theme subset is derived.marked@18andshiki@4are already dependencies. No new dependency is required by this design.
Design
1. Snapshot model (crates/dbx-core/src/docs/snapshot.rs)
use crate::types::{ColumnInfo, ForeignKeyInfo, IndexInfo};
#[derive(Serialize, Deserialize)]
pub struct SchemaSnapshot {
pub format_version: u32, // 1
pub project: ProjectMeta,
pub tables: Vec<DocTable>,
pub relationships: Vec<Relationship>,
pub groups: Vec<TableGroup>,
pub enums: Vec<DocEnum>,
pub warnings: Vec<SnapshotWarning>,
}
pub struct ProjectMeta {
pub name: String,
pub database_type: String,
pub database: Option<String>,
pub schemas: Vec<String>,
pub generated_at: String, // RFC3339
pub note: Option<String>, // markdown, the landing page
}
pub struct DocTable {
pub schema: Option<String>,
pub name: String,
pub kind: TableKind, // Table | View | MaterializedView
pub columns: Vec<ColumnInfo>,
pub indexes: Vec<IndexInfo>,
pub foreign_keys: Vec<ForeignKeyInfo>,
pub group_id: Option<String>,
pub note: Option<String>,
pub note_source: NoteSource, // Database | Local | None
pub shadowed_note: Option<String>, // db comment a local note overrides
pub column_notes: BTreeMap<String, ColumnNote>,
pub estimated_rows: Option<i64>,
pub view_definition: Option<String>,
}
pub struct TableGroup {
pub id: String, // slug, stable across renames
pub name: String,
pub hue: u16, // 0-359 — see Section 4
pub note: Option<String>,
}
pub struct Relationship {
pub id: String,
pub name: Option<String>,
pub from: FieldRef,
pub to: FieldRef,
pub cardinality: Cardinality,
pub on_update: Option<String>,
pub on_delete: Option<String>,
}
pub struct FieldRef { pub schema: Option<String>, pub table: String, pub column: String }
pub struct ColumnNote { pub note: String, pub source: NoteSource, pub shadowed: Option<String> }
pub struct DocEnum { pub schema: Option<String>, pub name: String,
pub values: Vec<String>, pub note: Option<String>,
pub synthesized: bool } // true for MySQL inline ENUM(...)
pub enum TableKind { Table, View, MaterializedView }
pub enum NoteSource { Database, Local, None }
pub enum Cardinality { OneToOne, ManyToOne }
pub enum SnapshotWarning {
TableSkipped { table: String, reason: String },
NoForeignKeyMetadata { engine: String },
CommentsUnsupported { engine: String },
OrphanedNotes { count: usize },
DbmlOmitted { table: String, item: String, reason: String },
}
Four decisions are embedded here:
format_versionguards viewer/data skew. The exported HTML pins a snapshot beside a viewer built from the same commit, but an in-app viewer may load a snapshot produced by an older DBX. One integer prevents a class of blank-render bugs and makes future additions additive.note_sourceandshadowed_noteexist so the UI can show where prose came from. When a local note shadows a database comment, both are available; the original is shown on hover. Without this, a DBA later writing a betterCOMMENT ONwould have their improvement silently hidden forever.warningsis the honest-degradation channel (Section 10).- Enums are first-class because DBML has native
enumblocks.ColumnInfo.enum_valuesis already populated for MySQL inlineENUM(...); PostgreSQL named enums are collected frompg_type/pg_enum. MySQL inline enums are synthesized into a named enum pertable_column. This is the one place the snapshot normalizes rather than passes through.
2. Collection (crates/dbx-core/src/docs/collector.rs)
v1 fetches through the existing provider functions, fanned out with a bounded semaphore (8 concurrent). No new per-engine SQL.
This is deliberately the naive-but-correct version: it works for every supported relational engine on day one because it reuses code already proven against all of them. A 400-table schema costs roughly 1200 queries — bounded, cancellable, and measurable.
The optimization path is targeted rather than speculative: PostgreSQL and MySQL can collect all columns, FKs and indexes for a schema in three information_schema sweeps instead of 3×N. That belongs in a collector::bulk specialization behind the same interface, added only where a benchmark under scripts/bench/ shows it matters.
Progress is reported through an impl Fn(DocsProgress) callback and cancellation via a checked AtomicBool, matching database_export.rs.
3. Relationship inference (crates/dbx-core/src/docs/relations.rs)
A port of the data half of erDiagram.ts. The split is deliberate:
- Data logic moves to Rust — given foreign keys and unique indexes, is an edge 1:1 or N:1? This is the
foreignKeySourceCardinalitycheck: a relationship isOneToOneonly when the source columns contain a unique key, otherwiseManyToOne. - Layout stays in the renderer —
layoutDiagramTablesis a view concern and remains in TypeScript, imported by the docs bundle.
This keeps the port to roughly 100 lines rather than a rewrite of the diagram feature. SchemaDiagramDialog.vue is not modified by this change; once relations.rs is proven it becomes the obvious candidate to consume the same inference, removing the duplicate.
4. Annotations and table groups (crates/dbx-core/src/docs/annotations.rs)
The notes file is the store — not a cache, not an export. dbx.db persists exactly one thing per connection: the path to this file.
// default: <app-data>/docs-notes/<connection-id>.json
// overridable per connection to e.g. ./docs/dbx-docs.json inside a repository
{
"format_version": 1,
"project": { "name": "Ecommerce", "note": "# Overview\n\nMarkdown." },
"groups": [
{ "id": "order-management", "name": "Order Management", "hue": 28,
"note": "Everything from checkout to carrier handoff." }
],
"tables": {
"core.orders": {
"group": "order-management",
"note": "One row per completed checkout.",
"columns": { "status": { "note": "State machine; only moves forward." } }
}
}
}
This shape is required by the CI goal. If notes lived only in dbx.db, dbx docs in CI would render documentation with every note missing — polished pages, empty of the prose that justifies them. As a file, notes are also reviewable in pull requests, which is arguably better than COMMENT ON (invisible to code review). DesktopSettings.saved_sql_sync_dir is the existing precedent for a user-chosen path.
Table groups are cross-schema domain groupings, authored by the user, stored here alongside notes, and serialized to DBML TableGroup blocks. A table belongs to at most one group; unassigned tables render under (no group). Grouping is documentation, not configuration — it never affects what is collected.
Group colour is stored as a hue integer (0–359), not a hex string. Lightness and chroma are fixed per theme in CSS and only the hue varies per group:
.group { --h: 28; }
/* light */ --group-c: oklch(0.55 0.15 var(--h)); --group-tint: oklch(0.965 0.022 var(--h));
/* dark */ --group-c: oklch(0.76 0.13 var(--h)); --group-tint: oklch(0.27 0.038 var(--h));
Storing a hex colour the dbdocs way would force either an unreadable dark mode (pastels tuned for a white page turn to mud on rgb(19 20 22)) or two colours per group that the user must choose twice. One hue guarantees legible contrast on both grounds by construction, and converts to hex on DBML export.
Group editor UI. Groups are created and edited in a dialog reached from the group header on the wiki index, the group badge on a table page, and the diagram group popover. Fields: name, note (markdown), colour, and member tables.
The colour control is 12 curated swatches plus a full 0–359 hue slider — every hue is reachable, but lightness and chroma remain theme-controlled, so no selection can produce an illegible group. The dialog renders a two-ground preview, showing the group chrome on a forced-light and a forced-dark tile simultaneously, because the user may be editing in one theme while a colleague reads the exported HTML in the other.
A native hex/RGB picker is deliberately not offered. It would let a user choose a colour that disappears against one of the two grounds, and the only remedies are to render something different from what the picker showed, or to ship an unreadable group. Hue-plus-fixed-lightness makes the illegible state unrepresentable rather than merely discouraged.
On save, the group is written to the notes file. Since colour is one integer, a group's full definition is a four-field object and diffs cleanly in review.
Keys and renames. Keys are schema.table and schema.table.column, case-folded per engine (PostgreSQL lower, Oracle upper, MySQL per lower_case_table_names) in schema/normalization.rs.
When a table or column is renamed its note orphans. User prose is never silently deleted. Orphans remain in the file, the collector emits SnapshotWarning::OrphanedNotes { count }, and the dialog shows an "Unmatched notes" panel. For each orphan, schema_diff::RenameCandidate is used to suggest the most likely new owner along with its confidence score; the user confirms, re-maps manually, or deletes. Suggestion plus confirmation, never automatic re-attachment — a wrong auto-guess would attach misleading documentation to the wrong column, which is worse than an honest orphan.
Merge precedence is local_annotation ?? database_comment, recorded in note_source. On engines where capabilities_for(db_type).comment == false (SQLite, ClickHouse) the annotation layer is the only prose channel, and the UI states this once rather than offering an inert "read from database" affordance.
5. DBML serialization (crates/dbx-core/src/docs/dbml.rs)
A hand-written serializer, roughly 250 lines. The @dbml/core npm package is unusable from Rust and the grammar is small enough not to warrant a dependency.
Project ecommerce {
database_type: 'PostgreSQL'
Note: '''Generated by DBX on 2026-08-02'''
}
Enum order_status { pending shipped cancelled }
TableGroup order_management [color: #c2410c] {
core.orders
core.order_items
Note: '''Everything from checkout to carrier handoff.'''
}
Table core.orders {
id integer [pk, increment]
user_id integer [not null, note: 'Owning customer']
status order_status [not null, default: 'pending']
placed_at timestamptz [default: `now()`]
Indexes {
(user_id, placed_at) [name: 'idx_orders_user_placed']
}
Note: '''One row per completed checkout.'''
}
Ref fk_orders_user: core.orders.user_id > core.users.id [delete: restrict]
Rules:
- Types pass through verbatim. DBML does not validate type names, so
timestamptz,jsonbandnumeric(10,2)survive. Wheredata_typearrives bare, precision is reconstructed fromcharacter_maximum_length/numeric_precision/numeric_scale. - Cardinality comes from
relations.rs:>for many-to-one,-for one-to-one. - Schema qualification is conditional — bare names for a single-schema snapshot (it reads better), fully qualified for multi-schema, since DBML has no
search_path. - Escaping — identifiers quoted when not
[A-Za-z_][A-Za-z0-9_]*; notes always in'''triple quotes'''so apostrophes cannot break the file; expression defaults in backticks, literals in single quotes. - Group colour is converted from hue to hex at the theme-neutral lightness.
DBML is lossy and this is documented in the output. It cannot express check constraints, partial or filtered indexes (IndexInfo.filter), included columns, collations, or generated columns. Composite foreign keys are emitted in the grouped (a, b) > (c, d) form, whose support across dbdocs/dbdiagram is uneven. Every omission produces a SnapshotWarning, and the HTML documentation renders the omitted item with a "not in DBML" marker.
The asymmetry is intentional: DBML is the interchange format; the HTML documentation is the complete record. Treating DBML as a source of truth would silently drop constraints.
6. Docs viewer (apps/desktop/src/docs/)
main.ts — standalone entry; reads window.__DBX_DOCS__, mounts
DocsApp.vue — root; hash routing
components/
DocsShell.vue — sidebar + main
DocsSidebar.vue — Group by: Schemas | Table Groups switch, trees, enums
DocsSearch.vue — ⌘K palette, client-side fuzzy over tables/columns/groups
WikiIndex.vue — stats, project note, per-group table index
TablePage.vue — orchestrates the sections below
ColumnTable.vue — columns grid; inline note editing when !readonly
IndexList.vue RelationshipList.vue GroupHeader.vue EnumPage.vue
GroupEditorDialog.vue — name/note/hue/members; rendered only when !readonly
ErdView.vue — pan/zoom SVG with group containers; reuses layoutDiagramTables()
NoteMarkdown.vue — marked → sanitized HTML
WarningBanner.vue — renders snapshot.warnings
__tests__/ — fixture snapshots in, rendered output asserted
The contract: this directory makes no backend calls. No api.*, no Tauri, no Pinia stores. Snapshot in, DOM out. This is what allows the same code to run inside an HTML file on a machine that has never installed DBX, and what makes the entire documentation UI testable without a database.
Editing is expressed as a readonly prop — true in exported HTML, false in-app. Components emit @update:note, @update:group and @update:table-group; DatabaseDocsDialog.vue performs the writes. GroupEditorDialog.vue is not rendered at all when readonly, so the exported HTML never ships an editor that cannot save.
Six further decisions:
- Hash routing (
#/table/core.orders), not the History API, which breaks overfile://. Also makes every table deep-linkable. - Raw HTML is stripped from markdown.
markedremovedsanitizein v5 and DOMPurify is not in the tree. Notes come partly fromCOMMENT ONvalues living in the database, which is not always under the document author's control, and the output is a file people forward to colleagues.markedis configured to escape raw HTML: markdown formatting works,<script>and<img onerror>do not. - The information architecture follows dbdocs: the Wiki tab is a scrolling per-group index whose Table Notes column renders full markdown inline (headings, prose, code blocks), with table detail pages behind it — not a bare list of links.
- Theme tokens are a curated subset, not
globals.css(2362 lines). The viewer has its own Tailwind v4 entry scanning onlysrc/docs/**plus roughly 25 hand-picked tokens matching DBX's variable names, so it inherits live tokens in-app and ships its own values (withprefers-color-schemeand a manual toggle) when exported. - No Shiki in the exported bundle. View definitions render in a plain
<pre>. Shiki's grammars would dominate the artifact weight for a feature nobody generates documentation to obtain. - i18n uses a
docsnamespace only — roughly 40 keys, imported directly, neveri18n/locales/en.tswholesale, or the standalone bundle inherits the whole application translation table. All 8 locales ship, selected fromnavigator.language, with a switcher.
7. Static HTML export (crates/dbx-core/src/docs/html.rs)
Output is HTML shell + inlined CSS + inlined viewer bundle + window.__DBX_DOCS__ = {snapshot}.
The viewer bundle is produced by pnpm build:docs-viewer into crates/dbx-core/src/docs/assets/docs-viewer.js, and that artifact is committed to git. cargo build never invokes Vite; it only include_str!s what is present.
This is the one piece of build coupling in the design, accepted deliberately:
- Rust-only contributors can still
cargo buildwithout a Node toolchain. - The artifact is reviewable in diffs.
- A CI job rebuilds the bundle and fails if the committed copy is stale.
The cost is a generated file in the tree that must be regenerated when the viewer changes. The CI check is what makes that safe.
Budget: under 400 KB uncompressed for a self-contained page documenting a 50-table schema, asserted by test (Section 11). If the real figure lands materially above this, the correct response is to question the Vue-in-a-file approach, not to ship something sluggish.
8. Desktop integration (apps/desktop/src/components/docs/DatabaseDocsDialog.vue)
The dialog owns all I/O: connection/database/schema selection, invoking the collector, progress and cancellation, note and group writes, the orphan re-map panel, and the two export actions. It mounts <DocsApp :snapshot :readonly="false">.
Entry point follows SchemaDiagramDialog.vue — a dialog reached from the connection context menu and the toolbar, not a new tab kind.
9. CLI (crates/dbx-cli/src/main.rs)
dbx docs <connection> --out <dir-or-file> [--schema s] [--database d]
[--notes path] [--tables a,b] [--open]
dbx dbml <connection> [--out path] [--schema s] [--database d] [--notes path]
dbx docs writes a self-contained index.html. dbx dbml writes to --out or stdout so it pipes.
The existing --format table|json|csv flag is not extended with html|dbml. That flag means "how do I tabulate result rows"; overloading it would invite dbx query --format dbml. Two verbs, one output each, matching the existing dbx schema list / dbx schema describe style.
Target CI shape:
- run: dbx dbml prod --notes docs/dbx-docs.json --out docs/schema.dbml
- run: git diff --exit-code docs/schema.dbml # migration drift fails the build
10. Error handling
| Situation | Behavior | Rationale |
|---|---|---|
| Notes file absent | Proceed with empty notes | First CI run, or a user who has not annotated yet. Not an error. |
| Notes file malformed | Hard fail, non-zero exit | User prose is at stake. Proceeding would render apparently-complete docs while discarding writing. |
| One table's metadata fails | SnapshotWarning::TableSkipped, continue |
A permission gap on one table must not kill a 400-table build. |
| Engine lacks FK metadata | Warning banner; ERD with no edges | ClickHouse, Doris, StarRocks. Honest degradation over a blank page. |
| Engine lacks comment support | Stated once in the UI | SQLite, ClickHouse — via capabilities_for(). |
| Orphaned notes after rename | Warning + re-map panel with suggestions | Section 4. |
| DBML-inexpressible construct | Warning + "not in DBML" marker in HTML | Section 5. |
| Connection failure | Existing error path | Already solved. |
| Stale committed viewer bundle | CI job fails | Guards the Section 7 build coupling. |
The through-line: fail loudly when user-authored content is at risk; degrade visibly when the database simply cannot answer.
11. Testing
Rust
- DBML snapshot tests following
crates/dbx-core/src/sql_dialect/snapshots/. relations.rs: FK with unique source →OneToOne; without →ManyToOne; composite FK grouping; self-referencing FK.annotations.rs: merge precedence; per-engine key folding; orphan detection;RenameCandidatesuggestion ranking; group assignment and hue round-trip through DBML hex.- Live-engine tests as
crates/dbx-core/tests/live_postgres_docs_snapshot.rs, matching the existinglive_postgres_query_result_export.rsconvention.
Frontend (packages/app-tests/, following erDiagram.test.ts and diagramSvgExport.test.ts)
- Every docs component rendered against fixture snapshot JSON — no database, no Tauri. This is the payoff of the Section 6 contract.
NoteMarkdowntested explicitly against<script>,<img onerror=…>andjavascript:hrefs.- Self-containment test: generate HTML from a fixture and assert zero
http:///https://in anysrc,hrefor@import. This catches a stray CDN font before it breaks someone's offline documentation. - Artifact size assertion against the 400 KB budget, so bloat fails a test rather than accumulating.
- Group colour contrast: assert computed
--group-cmeets the contrast floor against both grounds across all 360 hues, not only the 12 curated swatches — the slider makes every hue reachable, so the guarantee must hold for every one of them. GroupEditorDialogis absent from the rendered tree whenreadonlyis true.
12. Deferred: the versioning seam
The Changelog tab and per-table "Last Updated" are out of scope, but the design must not preclude them. Three constraints carry forward:
SchemaSnapshotisSerialize + Deserializeand carriesformat_versionandgenerated_at, so it is already storable as a version record.crates/dbx-core/src/schema_diff.rs(8728 lines) already computesTableDiff/ColumnDiff/ForeignKeyDiffbetween two schemas. A future Changelog is a storage and UI problem, not an algorithm problem.- The docs viewer's tab strip reserves the Changelog slot.
No storage, retention policy, or snapshot history is built in this change.
Build Order
Each step is independently verifiable; steps 1–2 have standalone value if work stops there.
SchemaSnapshot+collector.rs+relations.rs— pure data, fully testable, no UI.dbml.rs+dbx dbml— first shippable user value, small surface.annotations.rs— notes, groups, merge, orphan handling.- Docs viewer against fixture snapshots — UI with no backend at all.
DatabaseDocsDialog.vue— wire into DBX.html.rs+ committed bundle +dbx docs— the build coupling last, once the UI has settled.
Versioning and Documentation
- Version bump
0.5.73 → 0.6.0(feature, pre-1.0) inpackage.json. - Changelog entry through the existing CDN JSON flow (
crates/dbx-core/src/changelog.rs), not a rootCHANGELOG.md— this repository does not use one. - A feature page in the
docs/Next.js site. - A
docsi18n namespace across all 8 locales (en,es,it,ja,ko,pt-BR,zh-CN,zh-TW).
Validated Mock
The UI was prototyped and approved before this spec was written. The throwaway mock covered: the wiki index with inline markdown notes, a table page with the shadowed-note marker, table groups across sidebar/index/diagram/search, the Group by switch, group containers in the ERD, the DBML-lossiness banner, and both themes. It is not part of the implementation.