feat(docs): database documentation viewer and DBML export

* 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>
This commit is contained in:
Fernando Possebon 2026-08-06 16:35:19 -03:00 committed by GitHub
parent 352403d79e
commit f30d59b989
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
117 changed files with 18546 additions and 3 deletions

View File

@ -298,6 +298,7 @@ const defaultForm = (): ConnectionForm => ({
informix_server: "",
external_config: undefined,
init_script: undefined,
docs_notes_path: undefined,
read_only: false,
show_system_schemas: false,
is_production: false,
@ -2273,6 +2274,7 @@ watch(
external_config: config.external_config,
attached_databases: config.attached_databases || [],
init_script: config.init_script,
docs_notes_path: config.docs_notes_path,
read_only: config.read_only || false,
show_system_schemas: config.show_system_schemas || false,
is_production: config.is_production || false,
@ -7337,6 +7339,17 @@ function openExternalUrl(url: string) {
<span class="text-xs text-muted-foreground">{{ t("connection.showSystemSchemasHint") }}</span>
</label>
</div>
<!-- Documentation notes are a relational-only feature, so this
follows the same isSchemaAware gate as the row above. -->
<div v-if="isSchemaAware(form.db_type)" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelTopClass">{{ t("connection.docsNotesPath") }}</Label>
<div class="col-span-3 space-y-1">
<Input v-model="form.docs_notes_path" :placeholder="t('connection.docsNotesPathPlaceholder')" spellcheck="false" />
<p class="text-xs text-muted-foreground">
{{ t("connection.docsNotesPathHint") }}
</p>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4 rounded-[6px] border border-red-500/25 bg-red-500/[0.035] px-3 py-2.5">
<Label :class="[connectionLabelSmallClass, 'pt-0.5 text-red-700 dark:text-red-300']">
<span class="inline-flex items-center justify-end gap-1"><ShieldAlert class="h-3.5 w-3.5" />PROD</span>

View File

@ -0,0 +1,196 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Network } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import DocsApp from "@/docs/DocsApp.vue";
import { emptyAnnotations, removeGroup, setColumnNote, setProjectNote, setTableGroup, setTableNote, upsertGroup } from "@/docs/annotationEdits";
import type { AnnotationFile, DocsEdit, SchemaSnapshot } from "@/docs/types";
import type { Translate } from "@/docs/docsWarnings";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { createAutosave } from "./docsAutosave";
const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
prefillSchema?: string;
}>();
const open = defineModel<boolean>("open", { default: false });
// This component lives OUTSIDE src/docs/, so it may and must use useI18n():
// it is what supplies the `translate` prop that the viewer components need,
// since they are banned from importing vue-i18n themselves.
const { t } = useI18n();
// `Translate` is one narrow signature; vue-i18n's `t` is heavily overloaded and
// does not assign to it directly, so bridge it explicitly.
const translate: Translate = (key, params) => (params === undefined ? t(key) : t(key, params));
const connectionStore = useConnectionStore();
/** The snapshot as collected, kept so every edit can re-derive the merged view. */
const rawSnapshot = ref<SchemaSnapshot | null>(null);
const snapshot = ref<SchemaSnapshot | null>(null);
const annotations = ref<AnnotationFile>(emptyAnnotations());
const loading = ref(false);
const loadError = ref<string | null>(null);
/**
* Guards against a slow re-derivation landing after a newer one. Each load or
* edit takes the next number and only writes if it is still the latest, so a
* fast second edit is never overwritten by the first edit's stale response.
*/
let generation = 0;
const autosave = createAutosave(async (file) => {
const connectionId = props.prefillConnectionId;
if (connectionId === undefined || connectionId === "") {
return;
}
await api.saveDocsAnnotations(connectionId, file);
}, 500);
const status = autosave.status;
const statusLabel = computed(() => {
switch (status.value.state) {
case "saving":
return t("docs.saving");
case "saved":
return t("docs.saved");
case "failed":
return t("docs.saveFailed", { error: status.value.message });
default:
return "";
}
});
const canOpenDiagram = computed(() => (props.prefillConnectionId ?? "") !== "" && (props.prefillDatabase ?? "") !== "");
async function load(): Promise<void> {
const connectionId = props.prefillConnectionId;
const database = props.prefillDatabase;
if (connectionId === undefined || connectionId === "" || database === undefined || database === "") {
return;
}
const mine = ++generation;
loading.value = true;
loadError.value = null;
try {
// An absent schema means "everything the collector finds" rather than a
// filter naming nothing.
const schemas = props.prefillSchema ? [props.prefillSchema] : [];
const collected = await api.collectDocsSnapshot(connectionId, database, schemas, [], database);
const file = (await api.loadDocsAnnotations(connectionId)) ?? emptyAnnotations();
const merged = await api.applyDocsAnnotations(connectionId, collected, file);
if (mine !== generation) {
return;
}
rawSnapshot.value = collected;
annotations.value = file;
snapshot.value = merged;
} catch (error) {
if (mine === generation) {
loadError.value = error instanceof Error ? error.message : String(error);
}
} finally {
if (mine === generation) {
loading.value = false;
}
}
}
function nextAnnotations(file: AnnotationFile, edit: DocsEdit): AnnotationFile {
switch (edit.kind) {
case "projectNote":
return setProjectNote(file, edit.note);
case "tableNote":
return setTableNote(file, edit.tableKey, edit.note);
case "columnNote":
return setColumnNote(file, edit.tableKey, edit.column, edit.note);
case "tableGroup":
return setTableGroup(file, edit.tableKey, edit.groupId);
case "upsertGroup":
return upsertGroup(file, edit.group);
case "removeGroup":
return removeGroup(file, edit.groupId);
}
}
async function onEdit(edit: DocsEdit): Promise<void> {
const connectionId = props.prefillConnectionId;
const collected = rawSnapshot.value;
if (connectionId === undefined || connectionId === "" || collected === null) {
return;
}
const file = nextAnnotations(annotations.value, edit);
annotations.value = file;
// Schedule before re-deriving: the write must not wait on a display refresh.
autosave.schedule(file);
const mine = ++generation;
try {
const merged = await api.applyDocsAnnotations(connectionId, collected, file);
if (mine === generation) {
snapshot.value = merged;
}
} catch (error) {
if (mine === generation) {
loadError.value = error instanceof Error ? error.message : String(error);
}
}
}
function openDiagram(): void {
const connectionId = props.prefillConnectionId;
const database = props.prefillDatabase;
if (connectionId === undefined || database === undefined) {
return;
}
connectionStore.diagramSource = { connectionId, database, schema: props.prefillSchema };
}
watch(
open,
(isOpen, wasOpen) => {
if (isOpen) {
void load();
return;
}
if (wasOpen) {
// A note typed a moment before closing is still sitting behind the
// debounce; flushing here is what keeps it.
void autosave.flush();
}
},
{ immediate: true },
);
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="w-[94vw] max-w-[94vw] sm:max-w-[94vw] md:max-w-[94vw] lg:max-w-[94vw] xl:max-w-[94vw] h-[86vh] max-h-[86vh] gap-0 p-0 overflow-hidden flex flex-col">
<DialogHeader class="px-4 py-3 border-b">
<DialogTitle class="flex items-center gap-2">
<span>{{ t("docs.title") }}</span>
<span v-if="statusLabel" class="text-xs font-normal" :class="status.state === 'failed' ? 'text-destructive' : 'text-muted-foreground'">
{{ statusLabel }}
</span>
<Button v-if="canOpenDiagram" variant="outline" size="sm" class="ml-auto" @click="openDiagram()">
<Network class="w-4 h-4" />
{{ t("docs.openDiagram") }}
</Button>
</DialogTitle>
</DialogHeader>
<div class="min-h-0 flex-1 overflow-hidden">
<p v-if="loadError" class="p-4 text-sm text-destructive">{{ loadError }}</p>
<p v-else-if="loading || snapshot === null" class="p-4 text-sm text-muted-foreground">{{ t("common.loading") }}</p>
<DocsApp v-else :snapshot="snapshot" :annotations="annotations" :readonly="false" :translate="translate" @edit="onEdit" />
</div>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,76 @@
import { describe, expect, it, vi } from "vitest";
import { createAutosave } from "../docsAutosave";
const file = { formatVersion: 1 } as const;
describe("createAutosave", () => {
it("coalesces rapid edits into one save", async () => {
vi.useFakeTimers();
const save = vi.fn().mockResolvedValue(undefined);
const autosave = createAutosave(save, 500);
autosave.schedule(file);
autosave.schedule(file);
autosave.schedule(file);
expect(save).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(500);
expect(save).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it("surfaces a failure instead of swallowing it", async () => {
// A silently swallowed write failure is the worst outcome this feature
// can produce: the user keeps typing and believes their notes are saved.
vi.useFakeTimers();
const save = vi.fn().mockRejectedValue(new Error("disk full"));
const autosave = createAutosave(save, 500);
autosave.schedule(file);
await vi.advanceTimersByTimeAsync(500);
expect(autosave.status.value.state).toBe("failed");
expect(autosave.status.value.message).toContain("disk full");
vi.useRealTimers();
});
it("reports saved after a successful write", async () => {
vi.useFakeTimers();
const autosave = createAutosave(vi.fn().mockResolvedValue(undefined), 500);
autosave.schedule(file);
await vi.advanceTimersByTimeAsync(500);
expect(autosave.status.value.state).toBe("saved");
vi.useRealTimers();
});
it("never runs two saves concurrently", async () => {
// flush() clears the timer, but a debounced write may already be awaiting
// save. Starting a second one issues two concurrent writes of the same
// file — a wasted round trip, a stale-write race, and the exact
// concurrency that corrupts the notes file.
let concurrent = 0;
let maxConcurrent = 0;
const save = vi.fn().mockImplementation(async () => {
concurrent += 1;
maxConcurrent = Math.max(maxConcurrent, concurrent);
await new Promise((resolve) => setTimeout(resolve, 10));
concurrent -= 1;
});
const autosave = createAutosave(save, 0);
autosave.schedule(file);
await Promise.all([autosave.flush(), autosave.flush(), autosave.flush()]);
expect(maxConcurrent).toBe(1);
});
it("flush writes immediately without waiting for the timer", async () => {
// The dialog calls this on close, so a note typed a moment earlier is
// not lost to a pending debounce.
const save = vi.fn().mockResolvedValue(undefined);
const autosave = createAutosave(save, 500);
autosave.schedule(file);
await autosave.flush();
expect(save).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,83 @@
import { ref, type Ref } from "vue";
import type { AnnotationFile } from "@/docs/types";
export type SaveStatus = { state: "idle" } | { state: "saving" } | { state: "saved" } | { state: "failed"; message: string };
export interface Autosave {
schedule: (file: AnnotationFile) => void;
flush: () => Promise<void>;
status: Ref<SaveStatus>;
}
/**
* Debounced autosave.
*
* A failed write MUST become visible: the user keeps typing and believes
* their notes are saved otherwise. The pending file is retained on failure so
* the next edit retries rather than discarding what they wrote.
*/
export function createAutosave(save: (file: AnnotationFile) => Promise<void>, delayMs: number): Autosave {
const status = ref<SaveStatus>({ state: "idle" });
let timer: ReturnType<typeof setTimeout> | undefined;
let pending: AnnotationFile | undefined;
// The write currently in flight, if any. `flush()` clearing the timer is not
// enough: a debounced write may already be awaiting `save`, and starting a
// second one issues two concurrent saves of the same file. Beyond wasting a
// round trip, the later one can land stale, and it is the exact concurrency
// that corrupted the notes file before the Rust side used a unique temp path.
let inFlight: Promise<void> | undefined;
async function write(): Promise<void> {
if (inFlight !== undefined) {
// Wait for the current write, then run again if an edit arrived while it
// was going — never two at once.
await inFlight;
if (pending === undefined) {
return;
}
}
if (pending === undefined) {
return;
}
const file = pending;
status.value = { state: "saving" };
const attempt = (async () => {
try {
await save(file);
// Only clear if no newer edit arrived while this was in flight.
if (pending === file) {
pending = undefined;
}
status.value = { state: "saved" };
} catch (error) {
status.value = { state: "failed", message: error instanceof Error ? error.message : String(error) };
}
})();
inFlight = attempt;
try {
await attempt;
} finally {
if (inFlight === attempt) {
inFlight = undefined;
}
}
}
return {
status,
schedule(file) {
pending = file;
if (timer !== undefined) {
clearTimeout(timer);
}
timer = setTimeout(() => void write(), delayMs);
},
async flush() {
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
await write();
},
};
}

View File

@ -11,6 +11,7 @@ const SchemaDiffDialog = defineAsyncComponent(() => import("@/components/diff/Sc
const DataCompareDialog = defineAsyncComponent(() => import("@/components/diff/DataCompareDialog.vue"));
const SqlFileExecutionDialog = defineAsyncComponent(() => import("@/components/sql-file/SqlFileExecutionDialog.vue"));
const SchemaDiagramDialog = defineAsyncComponent(() => import("@/components/diagram/SchemaDiagramDialog.vue"));
const DatabaseDocsDialog = defineAsyncComponent(() => import("@/components/docs/DatabaseDocsDialog.vue"));
const TableImportDialog = defineAsyncComponent(() => import("@/components/import/TableImportDialog.vue"));
const FieldLineageDialog = defineAsyncComponent(() => import("@/components/lineage/FieldLineageDialog.vue"));
const ConfigPassphraseDialog = defineAsyncComponent(() => import("@/components/config/ConfigPassphraseDialog.vue"));
@ -204,6 +205,7 @@ watch(
:focus-table-name="dialogs.diagramFocusTableName.value"
@open-target="emit('openDiagramTarget', $event)"
/>
<DatabaseDocsDialog v-if="dialogs.showDocsDialog.value" v-model:open="dialogs.showDocsDialog.value" :prefill-connection-id="dialogs.docsPrefillConnectionId.value" :prefill-database="dialogs.docsPrefillDatabase.value" :prefill-schema="dialogs.docsPrefillSchema.value" />
<TableImportDialog
v-if="dialogs.showTableImportDialog.value"
v-model:open="dialogs.showTableImportDialog.value"

View File

@ -6,6 +6,7 @@ import {
ArrowDown,
ArrowRightLeft,
ArrowUp,
BookOpen,
Braces,
CheckSquare,
Clipboard,
@ -1461,6 +1462,16 @@ function openDiagram(row: ObjectBrowserRow) {
};
}
function openDocs(row: ObjectBrowserRow) {
// The docs viewer documents the whole schema rather than one object, so the
// row only supplies which schema to collect.
connectionStore.docsSource = {
connectionId: props.connection.id,
database: props.database,
schema: row.schema || selectedSchema.value,
};
}
function openTableImport(row: ObjectBrowserRow) {
if (row.type !== "TABLE") return;
connectionStore.tableImportSource = {
@ -2651,7 +2662,12 @@ function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
...(canOpenStructureEditor.value ? [{ label: t("contextMenu.editStructure"), action: () => openStructureEditor(item), icon: PencilRuler }] : []),
...(canRename(item) ? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }] : []),
{ label: t("contextMenu.newQuery"), action: () => openNewQuery(item), icon: TerminalSquare },
...(canOpenDiagram.value ? [{ label: t("diagram.open"), action: () => openDiagram(item), icon: Network }] : []),
...(canOpenDiagram.value
? [
{ label: t("diagram.open"), action: () => openDiagram(item), icon: Network },
{ label: t("docs.title"), action: () => openDocs(item), icon: BookOpen },
]
: []),
...(canOpenTableImport.value ? [{ label: t("contextMenu.importData"), action: () => openTableImport(item), icon: Download }] : []),
{ label: t("dataCompare.title"), action: () => openDataCompare(item), icon: ArrowRightLeft },
{ label: "", separator: true },
@ -2701,7 +2717,12 @@ function getViewMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
},
...(canRename(item) ? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }] : []),
{ label: t("contextMenu.newQuery"), action: () => openNewQuery(item), icon: TerminalSquare },
...(canOpenDiagram.value ? [{ label: t("diagram.open"), action: () => openDiagram(item), icon: Network }] : []),
...(canOpenDiagram.value
? [
{ label: t("diagram.open"), action: () => openDiagram(item), icon: Network },
{ label: t("docs.title"), action: () => openDocs(item), icon: BookOpen },
]
: []),
{ label: "", separator: true },
exportDataSubmenu(item),
{ label: t("contextMenu.exportDatabase"), action: () => openDatabaseExport(item), icon: Upload },

View File

@ -9,6 +9,7 @@ const showSchemaDiffDialog = ref(false);
const showDataCompareDialog = ref(false);
const showSqlFileDialog = ref(false);
const showDiagramDialog = ref(false);
const showDocsDialog = ref(false);
const showTableImportDialog = ref(false);
const showTableDataGenerateDialog = ref(false);
const showFieldLineageDialog = ref(false);
@ -43,6 +44,9 @@ const diagramPrefillConnectionId = ref("");
const diagramPrefillDatabase = ref("");
const diagramPrefillSchema = ref("");
const diagramFocusTableName = ref("");
const docsPrefillConnectionId = ref("");
const docsPrefillDatabase = ref("");
const docsPrefillSchema = ref("");
const tableImportPrefillConnectionId = ref("");
const tableImportPrefillDatabase = ref("");
const tableImportPrefillSchema = ref("");
@ -173,6 +177,21 @@ export function useDialogSources() {
},
);
watch(
() => connectionStore.docsSource,
(v) => {
if (v) {
docsPrefillConnectionId.value = v.connectionId;
docsPrefillDatabase.value = v.database;
docsPrefillSchema.value = v.schema ?? "";
showDocsDialog.value = true;
// Clearing the source is what makes the dialog re-openable: setting
// the same value twice would not re-trigger this watcher.
connectionStore.docsSource = null;
}
},
);
watch(
() => connectionStore.tableImportSource,
(v) => {
@ -321,6 +340,7 @@ export function useDialogSources() {
showDataCompareDialog,
showSqlFileDialog,
showDiagramDialog,
showDocsDialog,
showTableImportDialog,
showTableDataGenerateDialog,
showFieldLineageDialog,
@ -354,6 +374,9 @@ export function useDialogSources() {
diagramPrefillDatabase,
diagramPrefillSchema,
diagramFocusTableName,
docsPrefillConnectionId,
docsPrefillDatabase,
docsPrefillSchema,
tableImportPrefillConnectionId,
tableImportPrefillDatabase,
tableImportPrefillSchema,

View File

@ -0,0 +1,153 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import DocsSearch from "./components/DocsSearch.vue";
import DocsSidebar from "./components/DocsSidebar.vue";
import EnumPage from "./components/EnumPage.vue";
import GroupEditor from "./components/GroupEditor.vue";
import NoteEditor from "./components/NoteEditor.vue";
import TablePage from "./components/TablePage.vue";
import WarningBanner from "./components/WarningBanner.vue";
import WikiIndex from "./components/WikiIndex.vue";
import "./docs.css";
import type { Translate } from "./docsWarnings";
import { qualifiedTableKey } from "./docsKeys";
import { groupBySchema, groupByTableGroup } from "./docsIndex";
import type { AnnotationFile, DocsEdit, GroupAnnotation, SchemaSnapshot } from "./types";
const props = defineProps<{
snapshot: SchemaSnapshot;
/**
* The local notes layer. `snapshot` already carries notes merged for display,
* so this is here for what the merge erases: `groups` holds the editable
* `GroupAnnotation` records, while `snapshot.groups` holds resolved
* `TableGroup`s that GroupEditor and GroupPicker cannot write back to.
*/
annotations: AnnotationFile;
readonly?: boolean;
translate: Translate;
}>();
const emit = defineEmits<{
edit: [edit: DocsEdit];
}>();
/** `readonly` is the one optional prop; absent means editing is allowed. */
const isReadonly = computed(() => props.readonly ?? false);
const annotationGroups = computed<GroupAnnotation[]>(() => props.annotations.groups ?? []);
// Grouping is computed once here and handed to both the sidebar and the index,
// so the two can never disagree about what the sections are.
const mode = ref<"schema" | "group">(props.snapshot.groups.length > 0 ? "group" : "schema");
const activeKey = ref<string | null>(null);
const activeEnumName = ref<string | null>(null);
const sections = computed(() => (mode.value === "schema" ? groupBySchema(props.snapshot) : groupByTableGroup(props.snapshot)));
const activeTable = computed(() => props.snapshot.tables.find((table) => qualifiedTableKey(table) === activeKey.value) ?? null);
/**
* Matched on the bare name because that is the only thing a column's
* `data_type` can be compared against `columnsUsingEnum` resolves enums the
* same way, so both agree when one name appears in two schemas.
*/
const activeEnum = computed(() => (activeEnumName.value === null ? null : (props.snapshot.enums.find((value) => value.name === activeEnumName.value) ?? null)));
const view = computed<"index" | "table" | "enum">(() => {
if (activeEnum.value !== null) {
return "enum";
}
return activeTable.value === null ? "index" : "table";
});
const activeGroup = computed(() => {
const groupId = activeTable.value?.groupId;
if (!groupId) {
return null;
}
return props.snapshot.groups.find((group) => group.id === groupId) ?? null;
});
function open(key: string): void {
// A key naming no table leaves the reader where they are rather than
// dropping them on a blank page.
if (props.snapshot.tables.some((table) => qualifiedTableKey(table) === key)) {
activeEnumName.value = null;
activeKey.value = key;
}
}
function openEnum(name: string): void {
if (props.snapshot.enums.some((value) => value.name === name)) {
activeKey.value = null;
activeEnumName.value = name;
}
}
function home(): void {
activeKey.value = null;
activeEnumName.value = null;
}
/**
* GroupPicker asks for a new group without naming it, so the id and hue are
* minted here. The hue rotates rather than repeating so two fresh groups are
* visually distinct before anyone opens GroupEditor to choose a colour.
*/
function createGroupFor(tableKey: string): void {
const group: GroupAnnotation = {
id: crypto.randomUUID(),
// Reuses the picker's own "New group" string rather than adding a locale
// key for a placeholder the user renames immediately in GroupEditor.
name: props.translate("docs.newGroup"),
hue: (annotationGroups.value.length * 47) % 360,
};
emit("edit", { kind: "upsertGroup", group });
emit("edit", { kind: "tableGroup", tableKey, groupId: group.id });
}
</script>
<template>
<div class="flex h-full min-h-0 bg-background text-foreground">
<DocsSidebar :sections="sections" :mode="mode" :active-key="activeKey" @update:mode="mode = $event" @select="open" @home="home()" />
<main class="flex min-w-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
<header class="flex flex-wrap items-start justify-between gap-3">
<div class="flex flex-col gap-1">
<h1 class="text-base font-semibold">{{ snapshot.project.name }}</h1>
<p class="text-xs text-muted-foreground">
{{ snapshot.project.databaseType }}<template v-if="snapshot.project.database"> · {{ snapshot.project.database }}</template> · {{ snapshot.tables.length }} tables · generated {{ snapshot.project.generatedAt }}
</p>
</div>
<DocsSearch :snapshot="snapshot" @select="open" @select-enum="openEnum" />
</header>
<WarningBanner :warnings="snapshot.warnings" :translate="translate" />
<div v-if="view === 'index'" class="flex flex-col gap-4">
<NoteEditor :model-value="snapshot.project.note ?? ''" :readonly="isReadonly" :translate="translate" @update:model-value="emit('edit', { kind: 'projectNote', note: $event })" />
<WikiIndex :sections="sections" @select="open" />
<section v-if="!isReadonly && annotationGroups.length > 0" class="flex flex-col gap-2">
<h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Groups</h2>
<GroupEditor v-for="group in annotationGroups" :key="group.id" :group="group" :translate="translate" @update:group="emit('edit', { kind: 'upsertGroup', group: $event })" @delete="emit('edit', { kind: 'removeGroup', groupId: $event })" />
</section>
</div>
<TablePage
v-else-if="view === 'table' && activeTable"
:table="activeTable"
:relationships="snapshot.relationships"
:group="activeGroup"
:annotation-groups="annotationGroups"
:readonly="isReadonly"
:translate="translate"
@select="open"
@edit="emit('edit', $event)"
@create-group="createGroupFor"
/>
<EnumPage v-else-if="activeEnum" :enum-type="activeEnum" :snapshot="snapshot" :translate="translate" @select="open" />
</main>
</div>
</template>

View File

@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { emptyAnnotations, removeGroup, setColumnNote, setProjectNote, setTableGroup, setTableNote, upsertGroup } from "../annotationEdits";
const base = emptyAnnotations();
describe("annotationEdits", () => {
it("starts from a valid empty file", () => {
expect(emptyAnnotations()).toEqual({ formatVersion: 1 });
});
it("never mutates its input", () => {
// Every function returns a new file. Mutating in place would make Vue's
// reactivity miss the change and make undo impossible to add later.
const before = JSON.stringify(base);
setTableNote(base, "public.orders", "hello");
expect(JSON.stringify(base)).toBe(before);
});
it("sets and clears a table note", () => {
const withNote = setTableNote(base, "public.orders", "One row per checkout.");
expect(withNote.tables?.["public.orders"].note).toBe("One row per checkout.");
const cleared = setTableNote(withNote, "public.orders", " ");
expect(cleared.tables?.["public.orders"]).toBeUndefined();
});
it("keeps a table entry when it still carries a group after the note clears", () => {
// Dropping the whole entry here would silently unassign the group.
const grouped = setTableGroup(setTableNote(base, "public.orders", "n"), "public.orders", "g1");
const cleared = setTableNote(grouped, "public.orders", "");
expect(cleared.tables?.["public.orders"].group).toBe("g1");
expect(cleared.tables?.["public.orders"].note).toBeUndefined();
});
it("sets and clears a column note", () => {
const withNote = setColumnNote(base, "public.orders", "status", "Lifecycle state.");
expect(withNote.tables?.["public.orders"].columns?.status.note).toBe("Lifecycle state.");
const cleared = setColumnNote(withNote, "public.orders", "status", "");
expect(cleared.tables?.["public.orders"]).toBeUndefined();
});
it("upserts a group by id", () => {
const created = upsertGroup(base, { id: "g1", name: "Core", hue: 28 });
expect(created.groups).toEqual([{ id: "g1", name: "Core", hue: 28 }]);
const renamed = upsertGroup(created, { id: "g1", name: "Core Accounts", hue: 200 });
expect(renamed.groups).toHaveLength(1);
expect(renamed.groups?.[0]).toEqual({ id: "g1", name: "Core Accounts", hue: 200 });
});
it("removing a group also clears every table that referenced it", () => {
// A dangling groupId renders as no group at all, so the file would look
// correct while carrying a reference to something that does not exist.
const withGroup = setTableGroup(upsertGroup(base, { id: "g1", name: "Core", hue: 28 }), "public.orders", "g1");
const removed = removeGroup(withGroup, "g1");
expect(removed.groups ?? []).toEqual([]);
expect(removed.tables?.["public.orders"]).toBeUndefined();
});
it("removing a group keeps a table that still has a note", () => {
const seeded = setTableNote(setTableGroup(upsertGroup(base, { id: "g1", name: "Core", hue: 28 }), "public.orders", "g1"), "public.orders", "keep me");
const removed = removeGroup(seeded, "g1");
expect(removed.tables?.["public.orders"].note).toBe("keep me");
expect(removed.tables?.["public.orders"].group).toBeUndefined();
});
it("sets and clears the project note", () => {
const withNote = setProjectNote(base, "# Sales\n\nThe billing schema.");
expect(withNote.project?.note).toBe("# Sales\n\nThe billing schema.");
expect(setProjectNote(withNote, "").project).toBeUndefined();
});
});

View File

@ -0,0 +1,36 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { ANNOTATION_FILE_KEYS, COLUMN_ANNOTATION_KEYS, GROUP_ANNOTATION_KEYS, PROJECT_ANNOTATION_KEYS, TABLE_ANNOTATION_KEYS } from "../types";
// The Rust structs carry deny_unknown_fields, so any property TypeScript adds
// that Rust does not declare turns every save into a deserialization error at
// runtime. vue-tsc cannot see across the language boundary, so this reads the
// Rust source and compares the field sets directly.
const rustSource = readFileSync(path.resolve(__dirname, "../../../../../crates/dbx-core/src/docs/annotations.rs"), "utf8");
function rustFields(structName: string): string[] {
const start = rustSource.indexOf(`pub struct ${structName} {`);
expect(start, `struct ${structName} not found in annotations.rs`).toBeGreaterThan(-1);
const body = rustSource.slice(start, rustSource.indexOf("\n}", start));
return [...body.matchAll(/^\s{4}pub ([a-z_]+):/gm)].map((match) => toCamel(match[1])).sort();
}
function toCamel(value: string): string {
return value.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase());
}
describe("annotation types match the Rust structs", () => {
it.each([
["AnnotationFile", ANNOTATION_FILE_KEYS],
["ProjectAnnotation", PROJECT_ANNOTATION_KEYS],
["GroupAnnotation", GROUP_ANNOTATION_KEYS],
["TableAnnotation", TABLE_ANNOTATION_KEYS],
["ColumnAnnotation", COLUMN_ANNOTATION_KEYS],
])("%s", (structName, witness) => {
// Rust source on one side, the TS interface's own keys on the other —
// so this fails whichever side drifts. Comparing Rust against a hardcoded
// list would only ever catch the Rust side.
expect(rustFields(structName as string)).toEqual(Object.keys(witness).sort());
});
});

View File

@ -0,0 +1,157 @@
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 ?? ""}`;
}
const EXPECTED = ["ColumnTable.vue", "DocsApp.vue", "DocsSearch.vue", "DocsSidebar.vue", "EnumPage.vue", "GroupEditor.vue", "GroupPicker.vue", "NoteEditor.vue", "RelationshipList.vue", "TablePage.vue", "WarningBanner.vue", "WikiIndex.vue"];
describe("docs viewer component contract", () => {
it("finds every expected component", () => {
expect(
vueFiles()
.map((file) => path.basename(file))
.sort(),
).toEqual(EXPECTED);
});
// Every test below loops over vueFiles(). On an empty set those loops run zero
// assertions and pass while proving nothing, so each one asserts the set is
// populated first. Without this, deleting every component turns the whole
// contract green.
it("makes no backend calls", () => {
const files = vueFiles();
expect(files.length).toBe(EXPECTED.length);
// vue-i18n belongs here for the same reason as the backend imports: these
// components are bundled into a standalone HTML file with no Vue app
// around them, and useI18n() throws without a provided instance. Strings
// arrive as a `translate` prop from the host instead.
const forbidden = ["@/lib/backend", "@tauri-apps", "invoke(", "useConnectionStore", "useQueryStore", "useSettingsStore", "fetch(", "axios", "vue-i18n", "useI18n("];
for (const file of files) {
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", () => {
const files = vueFiles();
expect(files.length).toBe(EXPECTED.length);
for (const file of files) {
const source = readFileSync(file, "utf8");
expect(source.includes("oklch("), `${path.basename(file)} must not compute colour`).toBe(false);
expect(/#[0-9a-fA-F]{6}\b/.test(source), `${path.basename(file)} must not hardcode a hex colour`).toBe(false);
}
});
it("only ever feeds renderNote output to v-html", () => {
// The single most dangerous thing a template here can do. Task 7 escapes
// author HTML, but `v-html="table.note"` bypasses it entirely and hands a
// COMMENT ON value straight to the DOM. Every v-html binding must name
// renderNote, and no component may build HTML any other way.
const files = vueFiles();
expect(files.length).toBe(EXPECTED.length);
for (const file of files) {
const source = readFileSync(file, "utf8");
// Either quote style. A double-quote-only pattern finds zero matches in
// `v-html='table.note'` and passes it, which is the exact binding this
// test exists to catch.
for (const match of source.matchAll(/v-html\s*=\s*(["'])(.*?)\1/g)) {
expect(match[2], `${path.basename(file)}: v-html must render renderNote output`).toContain("renderNote");
}
expect(source.includes("innerHTML"), `${path.basename(file)} must not touch innerHTML`).toBe(false);
}
});
it("the viewer emits edits rather than persisting them", () => {
// src/docs/ must stay free of I/O so Part 3c can bundle it. Editing works
// by emitting upward; the dialog outside this directory does the saving.
const files = vueFiles();
expect(files.length).toBe(EXPECTED.length);
for (const file of files) {
const source = readFileSync(file, "utf8");
for (const needle of ["saveDocsAnnotations", "loadDocsAnnotations", "applyDocsAnnotations"]) {
expect(source.includes(needle), `${path.basename(file)} must not call ${needle}`).toBe(false);
}
}
});
it("editing components accept a readonly mode", () => {
// Part 3c renders these same components with editing off inside an
// exported HTML file. A component that cannot be made read-only would
// have to be forked for the export.
const files = vueFiles();
expect(files.length).toBe(EXPECTED.length);
const editors = files.filter((file) => path.basename(file) === "NoteEditor.vue");
expect(editors.length).toBe(1);
expect(readFileSync(editors[0], "utf8")).toContain("readonly");
});
it('uses <script setup lang="ts">', () => {
const files = vueFiles();
expect(files.length).toBe(EXPECTED.length);
for (const file of files) {
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");
}
});
it("defines the group colour tokens for WebViews without oklch", () => {
// DBX supports legacy WebViews with no oklch (globals.css carries an
// `@supports not (color: oklch(...))` block). The repo's convention is
// progressive enhancement: a legacy-safe base value first, then the same
// token redefined inside `@supports (color: oklch(1 0 0))`. Without the
// base, every table group renders colourless on those WebViews.
//
// Assert each selector's base block separately. An ordering check like
// `indexOf(hsl) < indexOf(@supports)` quantifies over ANY occurrence, so
// deleting the light block leaves the dark block's hsl satisfying it — the
// test passes while light-theme legacy WebViews render every group
// colourless.
//
// `.docs-ground-light` is here for the same reason as the other two, not as
// a third theme: GroupEditor previews a hue on a light ground whatever the
// app's theme, so that ground needs its own legacy-safe base. Without this
// entry the whole block could be deleted with the suite still green, and
// the preview would quietly show dark-theme colours on white in dark mode.
const css = readFileSync(path.join(docsRoot, "docs.css"), "utf8");
const enhanced = css.indexOf("@supports (color: oklch(1 0 0))");
expect(enhanced).toBeGreaterThan(-1);
const legacyBase = css.slice(0, enhanced);
for (const selector of [".docs-group", ".dark .docs-group", ".docs-ground-light .docs-group"]) {
// `^` with the m flag anchors to a line start, so `.docs-group` cannot
// match inside `.dark .docs-group`, and neither matches the indented
// copies inside the @supports block.
const pattern = new RegExp(`^${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\{([^}]*)\\}`, "m");
const block = pattern.exec(legacyBase)?.[1];
expect(block, `${selector} needs a legacy-safe base block before @supports`).toBeTruthy();
expect(block, `${selector} must define --group-c without oklch`).toContain("--group-c: hsl(");
expect(block, `${selector} must define --group-tint without oklch`).toContain("--group-tint: hsl(");
}
});
});

View File

@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import { columnsUsingEnum, 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("");
});
});
describe("columnsUsingEnum", () => {
// The file's existing helper is `table(schema, name, groupId)` and builds a
// table with NO columns, so these tests need columns attached. Add this
// helper beside it rather than changing the existing signature — the other
// tests in this file call it positionally.
function withColumns(base: DocTable, columns: Array<{ name: string; type: string }>): DocTable {
return {
...base,
columns: columns.map((column) => ({
name: column.name,
data_type: column.type,
is_nullable: false,
column_default: null,
is_primary_key: false,
extra: "",
comment: null,
numeric_precision: null,
numeric_scale: null,
character_maximum_length: null,
})),
};
}
function snapshotOf(tables: DocTable[], enums: SchemaSnapshot["enums"]): SchemaSnapshot {
return {
formatVersion: 1,
project: { name: "p", databaseType: "postgres", database: null, schemas: [], generatedAt: "", note: null },
tables,
relationships: [],
groups: [],
enums,
warnings: [],
};
}
it("finds every column using an enum, across tables", () => {
const snapshot = snapshotOf(
[withColumns(table("public", "orders"), [{ name: "status", type: "order_status" }]), withColumns(table("public", "returns"), [{ name: "state", type: "order_status" }]), withColumns(table("public", "users"), [{ name: "id", type: "integer" }])],
[{ schema: "public", name: "order_status", values: ["new"], note: null, synthesized: false }],
);
expect(columnsUsingEnum(snapshot, "order_status")).toEqual([
{ tableKey: "public.orders", table: "orders", column: "status" },
{ tableKey: "public.returns", table: "returns", column: "state" },
]);
});
it("returns nothing for an enum no column references", () => {
// Must not fall back to "every column". The `statement` column is the one
// that matters: it is the only type here that *contains* `state`, so a
// substring match would claim it while an unrelated type like `integer`
// would not. Drop it and this test passes against `includes()` too, which
// makes it stop guarding anything.
const snapshot = snapshotOf(
[
withColumns(table("public", "users"), [
{ name: "id", type: "integer" },
{ name: "body", type: "statement" },
]),
],
[{ schema: "public", name: "state", values: ["a"], note: null, synthesized: false }],
);
expect(columnsUsingEnum(snapshot, "state")).toEqual([]);
});
});

View File

@ -0,0 +1,149 @@
import { describe, expect, it } from "vitest";
import { searchDocs, type SearchHit } 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"]),
// `orders_count` contains "orders", so a query for "orders" matches BOTH
// a table and a column — which is what makes the ranking test able to fail.
table("public", "customers", ["status", "orders_count"]),
// Mixed case, so a mutant that lowercases only the needle and not the
// candidate would fail the case-insensitivity test.
table("public", "Invoices", ["Amount"]),
],
relationships: [],
groups: [{ id: "g1", name: "Order Management", hue: 28, note: null }],
enums: [{ schema: "public", name: "order_status", values: ["pending"], note: null, synthesized: false }],
warnings: [],
};
// Columns outnumber every other kind by two orders of magnitude in a real
// database — the fixture answers "e" with 133 columns against 1 group and 12
// enums. This snapshot reproduces that shape: one term matching more of each
// kind than its cap allows.
const flooded: SchemaSnapshot = {
formatVersion: 1,
project: { name: "p", databaseType: "postgres", database: null, schemas: [], generatedAt: "", note: null },
tables: [
table(
"public",
"notes",
Array.from({ length: 30 }, (_, index) => `note_${index}`),
),
],
relationships: [],
groups: Array.from({ length: 12 }, (_, index) => ({ id: `g${index}`, name: `note group ${index}`, hue: 28, note: null })),
enums: Array.from({ length: 12 }, (_, index) => ({ schema: "public", name: `note_enum_${index}`, 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", () => {
// "orders" now matches the `orders` table AND the `orders_count` column
// on `customers`. Reversing the concatenation order must fail this.
const hits = searchDocs(snapshot, "orders");
const firstTable = hits.findIndex((hit) => hit.kind === "table");
const firstColumn = hits.findIndex((hit) => hit.kind === "column");
expect(firstTable).toBeGreaterThanOrEqual(0);
expect(firstColumn).toBeGreaterThanOrEqual(0);
expect(firstTable).toBeLessThan(firstColumn);
});
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");
});
it("still returns group and enum hits when columns flood the results", () => {
// A single cap applied after concatenation deletes the tail of the list,
// and groups and enums are the tail — making them unreachable through
// search no matter what the user types.
const hits = searchDocs(flooded, "note");
expect(
hits.some((hit) => hit.kind === "group"),
"groups must survive a column flood",
).toBe(true);
expect(
hits.some((hit) => hit.kind === "enum"),
"enums must survive a column flood",
).toBe(true);
});
it("caps each kind independently", () => {
const hits = searchDocs(flooded, "note");
const count = (kind: SearchHit["kind"]) => hits.filter((hit) => hit.kind === kind).length;
expect(count("table")).toBe(1);
expect(count("column")).toBe(20);
expect(count("group")).toBe(10);
expect(count("enum")).toBe(10);
});
it("matches a mixed-case identifier from a lowercase query", () => {
// The candidate, not just the needle, must be lowercased — otherwise
// search silently becomes case-sensitive for databases with quoted or
// uppercase identifiers.
expect(searchDocs(snapshot, "invoice").some((hit) => hit.label === "Invoices")).toBe(true);
expect(searchDocs(snapshot, "amount").some((hit) => hit.label === "Amount")).toBe(true);
});
});

View File

@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { describeWarning, type Translate } from "../docsWarnings";
import type { SnapshotWarning } from "../types";
// A fake translator that echoes the key and params back rather than English
// prose. This is what actually matters now: that describeWarning calls
// translate with the right key and the right params, not what the English
// copy says — the copy itself lives in the i18n namespace and is covered by
// the parity test.
const translate: Translate = (key, params) => `${key}|${JSON.stringify(params ?? {})}`;
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" }, translate);
expect(notice.severity).toBe("warning");
expect(notice.title).toBe("docs.warnings.tableSkipped.title|{}");
expect(notice.detail).toBe('docs.warnings.tableSkipped.detail|{"table":"public.secret","reason":"permission denied"}');
});
it("explains missing foreign-key metadata as an engine limitation, not a fault", () => {
const notice = describeWarning({ kind: "noForeignKeyMetadata", engine: "ClickHouse" }, translate);
expect(notice.severity).toBe("info");
expect(notice.title).toBe("docs.warnings.noForeignKeyMetadata.title|{}");
expect(notice.detail).toBe('docs.warnings.noForeignKeyMetadata.detail|{"engine":"ClickHouse"}');
});
it("explains unsupported comments", () => {
const notice = describeWarning({ kind: "commentsUnsupported", engine: "SQLite" }, translate);
expect(notice.severity).toBe("info");
expect(notice.title).toBe("docs.warnings.commentsUnsupported.title|{}");
expect(notice.detail).toBe('docs.warnings.commentsUnsupported.detail|{"engine":"SQLite"}');
});
it("reports orphaned notes with the count", () => {
const notice = describeWarning({ kind: "orphanedNotes", count: 3 }, translate);
expect(notice.severity).toBe("warning");
expect(notice.title).toBe("docs.warnings.orphanedNotes.title|{}");
expect(notice.detail).toBe('docs.warnings.orphanedNotes.detail|{"count":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",
},
translate,
);
expect(notice.severity).toBe("info");
expect(notice.title).toBe("docs.warnings.dbmlOmitted.title|{}");
expect(notice.detail).toBe('docs.warnings.dbmlOmitted.detail|{"item":"idx_orders_open","table":"public.orders","reason":"partial index filter has no DBML equivalent"}');
});
it("never returns an empty title or detail 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, translate);
expect(notice.title.length, `empty title for ${sample.kind}`).toBeGreaterThan(0);
expect(notice.detail.length, `empty detail for ${sample.kind}`).toBeGreaterThan(0);
}
});
});

View File

@ -0,0 +1,158 @@
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);
}
});
// Every struct below is pinned in BOTH directions — no missing key, no
// unexpected key — because each direction catches a different drift. A
// missing key is a removal or a rename's old name; an unexpected key is the
// rename's new name. Pinning only "required keys present" lets a rename of
// Relationship::to slip through the whole suite AND vue-tsc while
// RelationshipList reads `field.table` on undefined and every table page
// renders blank.
//
// `Object.hasOwn`, never truthiness: most of these keys are legitimately
// null, and "present and null" must stay distinguishable from "absent",
// which is what skip_serializing_if produces.
function pinShape(label: string, instances: unknown[], required: string[], optional: string[] = []): void {
// An empty instance list runs zero assertions and passes while proving
// nothing, so every struct has to actually appear in the fixture.
expect(instances.length, `fixture must contain at least one ${label}`).toBeGreaterThan(0);
const allowed = new Set([...required, ...optional]);
for (const [position, instance] of instances.entries()) {
const record = instance as Record<string, unknown>;
for (const key of required) {
expect(Object.hasOwn(record, key), `${label}[${position}] must always carry ${key}`).toBe(true);
}
for (const key of Object.keys(record)) {
expect(allowed.has(key), `${label}[${position}] carries unexpected key ${key}`).toBe(true);
}
}
}
it("the snapshot root has exactly the keys types.ts declares", () => {
pinShape("SchemaSnapshot", [loadFixture()], ["formatVersion", "project", "tables", "relationships", "groups", "enums", "warnings"]);
});
it("ProjectMeta has exactly the keys types.ts declares", () => {
pinShape("ProjectMeta", [loadFixture().project], ["name", "databaseType", "database", "schemas", "generatedAt", "note"]);
});
it("DocTable has exactly the keys types.ts declares", () => {
pinShape("DocTable", loadFixture().tables, ["schema", "name", "kind", "columns", "indexes", "foreignKeys", "groupId", "note", "noteSource", "shadowedNote", "columnNotes", "estimatedRows", "viewDefinition"]);
});
it("ColumnInfo has exactly the keys types.ts declares", () => {
// The required ten are required because Rust has no skip_serializing_if on
// them. The optional three DO carry one, so the key is absent rather than
// null when there is no value.
const columns = loadFixture().tables.flatMap((table) => table.columns);
pinShape("ColumnInfo", columns, ["name", "data_type", "is_nullable", "column_default", "is_primary_key", "extra", "comment", "numeric_precision", "numeric_scale", "character_maximum_length"], ["enum_values", "character_set", "collation"]);
});
it("IndexInfo has exactly the keys types.ts declares", () => {
const indexes = loadFixture().tables.flatMap((table) => table.indexes);
pinShape("IndexInfo", indexes, ["name", "columns", "is_unique", "is_primary", "filter", "index_type", "included_columns", "comment"]);
});
it("ForeignKeyInfo has exactly the keys types.ts declares", () => {
const foreignKeys = loadFixture().tables.flatMap((table) => table.foreignKeys);
pinShape("ForeignKeyInfo", foreignKeys, ["name", "column", "ref_table", "ref_column"], ["ref_schema", "on_update", "on_delete"]);
});
it("ColumnNote has exactly the keys types.ts declares", () => {
const notes = loadFixture().tables.flatMap((table) => Object.values(table.columnNotes));
pinShape("ColumnNote", notes, ["note", "source", "shadowed"]);
});
it("Relationship and FieldRef have exactly the keys types.ts declares", () => {
const relationships = loadFixture().relationships;
pinShape("Relationship", relationships, ["id", "name", "from", "to", "cardinality", "onUpdate", "onDelete"]);
pinShape(
"FieldRef",
relationships.flatMap((relationship) => [relationship.from, relationship.to]),
["schema", "table", "column"],
);
});
it("TableGroup has exactly the keys types.ts declares", () => {
pinShape("TableGroup", loadFixture().groups, ["id", "name", "hue", "note"]);
});
it("records that the fixture source declares no enum types, leaving DocEnum unpinned", () => {
// Keycloak's schema has no PostgreSQL enum types, so this fixture cannot
// exercise DocEnum. That is a real gap: a Rust-side rename of a DocEnum
// field would pass every test in this file and break the viewer's enum
// rendering silently.
//
// Asserting the gap rather than deleting the test keeps it visible and
// makes it self-correcting — the day the fixture source gains an enum this
// fails, and the pin below should replace it:
//
// pinShape("DocEnum", loadFixture().enums, ["schema", "name", "values", "note", "synthesized"]);
expect(loadFixture().enums, "fixture gained enums — restore the DocEnum pinShape here").toEqual([]);
});
});

View File

@ -0,0 +1,32 @@
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" });
});
});

View File

@ -0,0 +1,100 @@
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("&lt;script&gt;");
});
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("&lt;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("&lt;b&gt;");
});
it("does not preserve HTML entities twice", () => {
// Guards the pre-escaping approach, which turns "a &amp; b" into
// "a &amp;amp; b" and renders the entity visibly to the reader.
expect(renderNote("a &amp; b")).toContain("a &amp; b");
expect(renderNote("a &amp; b")).not.toContain("&amp;amp;");
});
it.each([
["javascript:alert(1)", "javascript"],
["JaVaScRiPt:alert(1)", "javascript"],
["&#106;avascript: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("![img](javascript:alert(1))").toLowerCase();
expect(html).not.toContain("javascript");
expect(html).not.toContain("<img");
});
it.each(["//evil.example.com", "//evil.example.com/a.png", "/\\evil.example.com", "/\\evil.example.com/a.png", "\\\\evil.example.com"])("drops the protocol-relative URL %s", (href) => {
// The URL spec treats /\ like //, so both separators must be rejected.
expect(renderNote(`[x](${href})`)).not.toContain("evil.example.com");
expect(renderNote(`![x](${href})`)).not.toContain("evil.example.com");
});
it("escapes single quotes so the escaper does not rely on double-quoted attributes", () => {
// Not exploitable while every attribute here is double-quoted, but that is
// a formatting convention enforced nowhere. Escaping ' keeps escapeHtml
// correct on its own rather than correct-given-a-distant-invariant.
const html = renderNote(`[x](https://example.com "it's here")`);
expect(html).toContain("&#39;");
expect(html).not.toContain("it's here");
});
it("still allows an ordinary root-relative path", () => {
// The // guard must not break the single-slash relative case it sits in
// front of.
expect(renderNote("[x](/docs/page.html)")).toContain('href="/docs/page.html"');
});
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("&lt;img");
});
});

View File

@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import type { ColumnInfo, IndexInfo, 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");
}
});
it("requires the always-serialized column and index fields", () => {
// These keys have no skip_serializing_if in Rust, so they are always
// present in the JSON. Typing them optional would let `undefined` reach
// code that checks `=== null`.
const column: ColumnInfo = {
name: "status",
data_type: "text",
is_nullable: false,
column_default: null,
is_primary_key: false,
extra: null,
comment: null,
numeric_precision: null,
numeric_scale: null,
character_maximum_length: null,
};
expect(column.comment).toBeNull();
const index: IndexInfo = {
name: "idx",
columns: ["status"],
is_unique: false,
is_primary: false,
filter: null,
index_type: null,
included_columns: null,
comment: null,
};
expect(index.filter).toBeNull();
// The skip_serializing_if fields may simply be absent.
const minimal: ColumnInfo = { ...column, enum_values: undefined };
expect(minimal.character_set).toBeUndefined();
});
});

View File

@ -0,0 +1,100 @@
import type { AnnotationFile, GroupAnnotation, TableAnnotation } from "./types";
/**
* Every function here returns a NEW file rather than mutating.
*
* Empty or whitespace-only prose removes the entry instead of storing ""
* the notes file is meant to be committed and reviewed, so it must not
* accumulate keys holding nothing.
*/
export function emptyAnnotations(): AnnotationFile {
return { formatVersion: 1 };
}
function blank(value: string): boolean {
return value.trim() === "";
}
/** Drop a table entry once it carries neither a note nor a group. */
function pruneTable(tables: Record<string, TableAnnotation>, key: string): Record<string, TableAnnotation> {
const entry = tables[key];
const empty = entry !== undefined && entry.note === undefined && entry.group === undefined && Object.keys(entry.columns ?? {}).length === 0;
if (!empty) {
return tables;
}
const { [key]: _dropped, ...rest } = tables;
return rest;
}
function withTable(file: AnnotationFile, key: string, change: (entry: TableAnnotation) => TableAnnotation): AnnotationFile {
const tables = { ...file.tables };
tables[key] = change(tables[key] ?? {});
const pruned = pruneTable(tables, key);
return { ...file, tables: Object.keys(pruned).length > 0 ? pruned : undefined };
}
export function setTableNote(file: AnnotationFile, tableKey: string, note: string): AnnotationFile {
return withTable(file, tableKey, (entry) => {
const { note: _old, ...rest } = entry;
return blank(note) ? rest : { ...rest, note };
});
}
export function setColumnNote(file: AnnotationFile, tableKey: string, column: string, note: string): AnnotationFile {
return withTable(file, tableKey, (entry) => {
const columns = { ...entry.columns };
if (blank(note)) {
delete columns[column];
} else {
columns[column] = { note };
}
const { columns: _old, ...rest } = entry;
return Object.keys(columns).length > 0 ? { ...rest, columns } : rest;
});
}
export function setTableGroup(file: AnnotationFile, tableKey: string, groupId: string | null): AnnotationFile {
return withTable(file, tableKey, (entry) => {
const { group: _old, ...rest } = entry;
return groupId === null ? rest : { ...rest, group: groupId };
});
}
export function upsertGroup(file: AnnotationFile, group: GroupAnnotation): AnnotationFile {
const groups = [...(file.groups ?? [])];
const index = groups.findIndex((candidate) => candidate.id === group.id);
if (index >= 0) {
groups[index] = group;
} else {
groups.push(group);
}
return { ...file, groups };
}
/**
* Remove a group and every reference to it.
*
* `docsIndex` already drops a dangling groupId when rendering, so the viewer
* degrades correctly either way but the committed file should not carry a
* reference to a group that no longer exists.
*/
export function removeGroup(file: AnnotationFile, groupId: string): AnnotationFile {
const groups = (file.groups ?? []).filter((group) => group.id !== groupId);
let next: AnnotationFile = { ...file, groups };
for (const [key, entry] of Object.entries(file.tables ?? {})) {
if (entry.group === groupId) {
next = setTableGroup(next, key, null);
}
}
return next;
}
export function setProjectNote(file: AnnotationFile, note: string): AnnotationFile {
const project = { ...file.project };
if (blank(note)) {
delete project.note;
} else {
project.note = note;
}
return { ...file, project: Object.keys(project).length > 0 ? project : undefined };
}

View File

@ -0,0 +1,101 @@
<script setup lang="ts">
import type { Translate } from "../docsWarnings";
import type { ColumnInfo, ColumnNote, DocsEdit } from "../types";
import NoteEditor from "./NoteEditor.vue";
const props = defineProps<{
columns: ColumnInfo[];
/** Keyed by the column's real name, exactly as the snapshot emits it. */
columnNotes: Record<string, ColumnNote>;
/** Qualified key of the table these columns belong to, for emitted edits. */
tableKey: string;
readonly: boolean;
translate: Translate;
}>();
const emit = defineEmits<{
edit: [edit: DocsEdit];
}>();
/** Rebuild the declared type from the parts the snapshot reports separately. */
function typeLabel(column: ColumnInfo): string {
if (column.character_maximum_length !== null) {
return `${column.data_type}(${column.character_maximum_length})`;
}
if (column.numeric_precision !== null) {
const scale = column.numeric_scale === null ? "" : `,${column.numeric_scale}`;
return `${column.data_type}(${column.numeric_precision}${scale})`;
}
return column.data_type;
}
function settings(column: ColumnInfo): string[] {
const parts: string[] = [];
if (column.is_primary_key) {
parts.push("pk");
}
if (!column.is_nullable) {
parts.push("not null");
}
if (column.column_default !== null) {
parts.push(`default: ${column.column_default}`);
}
if (column.extra !== null && column.extra !== "") {
parts.push(column.extra);
}
if (column.enum_values !== undefined && column.enum_values.length > 0) {
parts.push(`enum: ${column.enum_values.join(", ")}`);
}
return parts;
}
function noteOf(column: ColumnInfo): ColumnNote | null {
return props.columnNotes[column.name] ?? null;
}
/**
* The database's own comment, when a local note replaced it. Bound with
* `:title` so Vue escapes it this is author text like any other note.
*/
function shadowedTitle(column: ColumnInfo): string | undefined {
const shadowed = noteOf(column)?.shadowed;
return shadowed ? `Database comment: ${shadowed}` : undefined;
}
</script>
<template>
<div class="overflow-hidden rounded-md border border-border">
<table class="w-full text-xs">
<thead>
<tr class="bg-muted/30">
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Column</th>
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Type</th>
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Settings</th>
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Note</th>
</tr>
</thead>
<tbody>
<tr v-for="column in columns" :key="column.name" class="border-t border-border align-top">
<td class="px-2 py-1.5 font-mono font-medium text-foreground">{{ column.name }}</td>
<td class="px-2 py-1.5 font-mono text-muted-foreground">{{ typeLabel(column) }}</td>
<td class="px-2 py-1.5">
<div class="flex flex-wrap gap-1">
<span v-for="setting in settings(column)" :key="setting" class="rounded bg-muted/50 px-1.5 py-0.5 text-[10px] text-muted-foreground">
{{ setting }}
</span>
</div>
</td>
<td class="px-2 py-1.5 text-muted-foreground">
<div class="flex items-start gap-1">
<span v-if="noteOf(column)?.source === 'LOCAL'" class="mt-0.5 shrink-0 text-[10px] font-medium" :title="shadowedTitle(column)"> LOCAL</span>
<!-- Merged note, for the same reason as TablePage: NoteEditor
renders and edits one value, and the local layer alone would
hide notes that came from the database. -->
<NoteEditor class="min-w-0 flex-1" :model-value="noteOf(column)?.note ?? ''" :readonly="readonly" :translate="translate" @update:model-value="emit('edit', { kind: 'columnNote', tableKey, column: column.name, note: $event })" />
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>

View File

@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import { searchDocs, type SearchHit } from "../docsSearch";
import type { SchemaSnapshot } from "../types";
const props = defineProps<{
snapshot: SchemaSnapshot;
}>();
const emit = defineEmits<{
select: [tableKey: string];
selectEnum: [enumName: string];
}>();
const open = ref(false);
const query = ref("");
const input = ref<HTMLInputElement | null>(null);
// searchDocs caps its own results, per kind. Slicing again here would just
// re-create the bug where columns crowd out every other kind.
const hits = computed(() => searchDocs(props.snapshot, query.value));
async function show(): Promise<void> {
open.value = true;
query.value = "";
await nextTick();
input.value?.focus();
}
function onKeydown(event: KeyboardEvent): void {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
if (open.value) {
open.value = false;
} else {
void show();
}
return;
}
if (event.key === "Escape" && open.value) {
open.value = false;
}
}
/** Enums navigate by name, everything else by table key; groups have neither. */
function isNavigable(hit: SearchHit): boolean {
return hit.kind === "enum" || hit.tableKey !== null;
}
function choose(hit: SearchHit): void {
// Enums navigate by name they have no table key, and EnumPage resolves them
// by bare name for the same reason columnsUsingEnum does.
if (hit.kind === "enum") {
emit("selectEnum", hit.label);
open.value = false;
return;
}
// Groups still have no page of their own, so they stay unclickable rather
// than navigating somewhere arbitrary.
if (hit.tableKey === null) {
return;
}
emit("select", hit.tableKey);
open.value = false;
}
onMounted(() => window.addEventListener("keydown", onKeydown));
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
</script>
<template>
<div>
<button type="button" class="flex items-center gap-2 rounded border border-border bg-background px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/40" @click="show()">
<span>Search</span>
<span class="rounded bg-muted/50 px-1 py-0.5 text-[10px]">K</span>
</button>
<div v-if="open" class="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-24" @click.self="open = false">
<div class="w-full max-w-lg overflow-hidden rounded-md border border-border bg-background shadow-lg">
<input ref="input" v-model="query" type="text" placeholder="Search tables, columns, groups, enums…" class="w-full border-b border-border bg-transparent px-3 py-2 text-sm text-foreground outline-none" />
<ul class="max-h-80 overflow-y-auto">
<li v-if="query.trim() !== '' && hits.length === 0" class="px-3 py-4 text-center text-xs text-muted-foreground">Nothing matches {{ query }}.</li>
<li v-for="(hit, position) in hits" :key="`${hit.kind}-${hit.context}-${hit.label}-${position}`">
<button type="button" class="flex w-full items-baseline gap-2 px-3 py-1.5 text-left text-xs transition-colors" :class="isNavigable(hit) ? 'hover:bg-muted/40' : 'cursor-default text-muted-foreground'" :disabled="!isNavigable(hit)" @click="choose(hit)">
<span class="w-14 shrink-0 text-[10px] uppercase text-muted-foreground">{{ hit.kind }}</span>
<span class="font-mono text-foreground">{{ hit.label }}</span>
<span class="truncate text-[11px] text-muted-foreground">{{ hit.context }}</span>
</button>
</li>
</ul>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,51 @@
<script setup lang="ts">
import type { IndexSection } from "../docsIndex";
import { qualifiedTableKey } from "../docsKeys";
import { groupStyle } from "../groupColor";
defineProps<{
sections: IndexSection[];
mode: "schema" | "group";
/** Qualified name of the table currently open, or null on the index. */
activeKey: string | null;
}>();
const emit = defineEmits<{
"update:mode": ["schema" | "group"];
select: [tableKey: string];
home: [];
}>();
</script>
<template>
<nav class="flex w-64 shrink-0 flex-col gap-3 overflow-y-auto border-r border-border bg-background p-3">
<button type="button" class="rounded px-2 py-1 text-left text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/40" @click="emit('home')">Overview</button>
<div class="flex flex-col gap-1">
<span class="px-2 text-[10px] uppercase tracking-wide text-muted-foreground">Group by</span>
<div class="flex rounded border border-border p-0.5">
<button type="button" class="flex-1 rounded px-2 py-1 text-xs transition-colors" :class="mode === 'schema' ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/40'" @click="emit('update:mode', 'schema')">Schemas</button>
<button type="button" class="flex-1 rounded px-2 py-1 text-xs transition-colors" :class="mode === 'group' ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/40'" @click="emit('update:mode', 'group')">Table Groups</button>
</div>
</div>
<div v-for="section in sections" :key="section.key" class="flex flex-col gap-0.5">
<div class="flex items-center gap-1.5 px-2 py-1" :class="{ 'docs-group': section.hue !== null }" :style="groupStyle(section.hue)">
<span v-if="section.hue !== null" class="h-2 w-2 shrink-0 rounded-full" style="background-color: var(--group-c)"></span>
<span class="truncate text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{{ section.label || "(no schema)" }}
</span>
</div>
<button
v-for="table in section.tables"
:key="qualifiedTableKey(table)"
type="button"
class="truncate rounded px-2 py-1 text-left font-mono text-xs transition-colors"
:class="activeKey === qualifiedTableKey(table) ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/40'"
@click="emit('select', qualifiedTableKey(table))"
>
{{ table.name }}
</button>
</div>
</nav>
</template>

View File

@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed } from "vue";
import { columnsUsingEnum } from "../docsIndex";
import type { Translate } from "../docsWarnings";
import type { DocEnum, SchemaSnapshot } from "../types";
import NoteEditor from "./NoteEditor.vue";
const props = defineProps<{
enumType: DocEnum;
/** Every table in the snapshot; usedBy filters them down to enum columns. */
snapshot: SchemaSnapshot;
translate: Translate;
}>();
const emit = defineEmits<{
select: [tableKey: string];
}>();
const qualifiedName = computed(() => (props.enumType.schema ? `${props.enumType.schema}.${props.enumType.name}` : props.enumType.name));
// No annotation storage exists for enum notes yet (AnnotationFile has no
// `enums` key), so this is always read-only there is nowhere to save an
// edit to.
const usedBy = computed(() => columnsUsingEnum(props.snapshot, props.enumType.name));
</script>
<template>
<article class="flex flex-col gap-5">
<header class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<h2 class="font-mono text-lg font-semibold text-foreground">{{ qualifiedName }}</h2>
<span v-if="enumType.synthesized" class="rounded bg-muted/50 px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">synthesized</span>
</div>
<NoteEditor :model-value="enumType.note ?? ''" readonly :translate="translate" />
</header>
<section>
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ translate("docs.enumValues") }}</h3>
<ul class="flex flex-wrap gap-1">
<li v-for="value in enumType.values" :key="value" class="rounded bg-muted/50 px-1.5 py-0.5 font-mono text-[11px] text-foreground">{{ value }}</li>
</ul>
</section>
<section>
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ translate("docs.usedBy") }}</h3>
<p v-if="usedBy.length === 0" class="text-xs text-muted-foreground">No column uses this enum.</p>
<ul v-else class="flex flex-col gap-1">
<li v-for="hit in usedBy" :key="`${hit.tableKey}.${hit.column}`" class="text-xs">
<button type="button" class="w-full rounded border border-border bg-background px-2 py-1.5 text-left font-mono transition-colors hover:bg-muted/40" @click="emit('select', hit.tableKey)">{{ hit.tableKey }}.{{ hit.column }}</button>
</li>
</ul>
</section>
</article>
</template>

View File

@ -0,0 +1,96 @@
<script setup lang="ts">
import type { Translate } from "../docsWarnings";
import { groupStyle } from "../groupColor";
import type { GroupAnnotation } from "../types";
const props = defineProps<{
group: GroupAnnotation;
translate: Translate;
}>();
const emit = defineEmits<{
"update:group": [group: GroupAnnotation];
delete: [groupId: string];
}>();
/**
* Twelve evenly spaced hues.
*
* Presets are hues, not colours: docs.css fixes lightness and chroma per
* theme, so any point on the wheel is legible on both grounds and an even
* split cannot produce a bad preset.
*/
const PRESET_HUES = Array.from({ length: 12 }, (_, step) => step * 30);
function rename(event: Event): void {
emit("update:group", { ...props.group, name: (event.target as HTMLInputElement).value });
}
function recolour(hue: number): void {
emit("update:group", { ...props.group, hue });
}
function slide(event: Event): void {
recolour(Number((event.target as HTMLInputElement).value));
}
/**
* No per-hue i18n key exists and adding one means eight locale files, so the
* tooltip pairs the "Colour" label with the raw hue.
*/
function swatchTitle(hue: number): string {
return `${props.translate("docs.groupColour")} ${hue}°`;
}
</script>
<template>
<div class="flex flex-col gap-3">
<label class="flex flex-col gap-1">
<span class="text-[10px] uppercase tracking-wide text-muted-foreground">{{ translate("docs.groupName") }}</span>
<input type="text" :value="group.name" :placeholder="translate('docs.groupName')" class="rounded border border-border bg-background px-2 py-1 text-sm text-foreground outline-none focus:border-ring" @input="rename($event)" />
</label>
<div class="flex flex-col gap-2">
<span class="text-[10px] uppercase tracking-wide text-muted-foreground">{{ translate("docs.groupColour") }}</span>
<!-- `docs-group` and the hue go on the SAME element: the class brings in
the theme's lightness and chroma, groupStyle only supplies --h. A
hex fill here could not stay legible on both grounds. -->
<div class="flex items-center gap-1.5">
<button
v-for="hue in PRESET_HUES"
:key="hue"
type="button"
class="docs-group h-6 w-6 rounded-full border ring-offset-background transition hover:scale-105"
:class="group.hue === hue ? 'ring-2 ring-ring ring-offset-2' : 'border-border'"
style="background-color: var(--group-c)"
:style="groupStyle(hue)"
:title="swatchTitle(hue)"
@click="recolour(hue)"
></button>
</div>
<input type="range" min="0" max="359" :value="group.hue" class="w-full" :title="swatchTitle(group.hue)" @input="slide($event)" />
<!-- Both grounds at once: the reader picks one hue and it has to work in
either theme, so neither can be left to imagination. `docs-ground-light`
and `dark` pin each half to a theme regardless of the app's own. -->
<div class="grid grid-cols-2 gap-2">
<div class="docs-ground-light flex items-center gap-2 overflow-hidden rounded border border-border bg-white p-2">
<span class="docs-group h-4 w-4 shrink-0 rounded-full" style="background-color: var(--group-c)" :style="groupStyle(group.hue)"></span>
<span class="docs-group truncate rounded px-1.5 py-0.5 text-[11px] font-medium" style="background-color: var(--group-tint); color: var(--group-c)" :style="groupStyle(group.hue)">{{ group.name }}</span>
</div>
<div class="dark flex items-center gap-2 overflow-hidden rounded border border-border bg-neutral-900 p-2">
<span class="docs-group h-4 w-4 shrink-0 rounded-full" style="background-color: var(--group-c)" :style="groupStyle(group.hue)"></span>
<span class="docs-group truncate rounded px-1.5 py-0.5 text-[11px] font-medium" style="background-color: var(--group-tint); color: var(--group-c)" :style="groupStyle(group.hue)">{{ group.name }}</span>
</div>
</div>
</div>
<div class="flex justify-end">
<button type="button" class="rounded border border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/40" @click="emit('delete', group.id)">
{{ translate("docs.deleteGroup") }}
</button>
</div>
</div>
</template>

View File

@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed } from "vue";
import type { Translate } from "../docsWarnings";
import { groupStyle } from "../groupColor";
import type { GroupAnnotation } from "../types";
const props = defineProps<{
groups: GroupAnnotation[];
/** Id of the selected group, or null when the table belongs to none. */
modelValue: string | null;
translate: Translate;
}>();
const emit = defineEmits<{
"update:modelValue": [groupId: string | null];
create: [];
}>();
/**
* Value of the "New group…" option. Group ids come from a hand-editable notes
* file, so this is treated as the sentinel only when no real group claims it
* otherwise picking that group would silently open a create flow instead.
*/
const CREATE = "__dbx_new_group__";
const selected = computed(() => props.groups.find((group) => group.id === props.modelValue) ?? null);
function choose(event: Event): void {
const select = event.target as HTMLSelectElement;
if (select.value === CREATE && !props.groups.some((group) => group.id === CREATE)) {
// "New group" is an action, not a selection: park the select back on the
// current group so it does not display a group that does not exist.
select.value = props.modelValue ?? "";
emit("create");
return;
}
emit("update:modelValue", select.value === "" ? null : select.value);
}
</script>
<template>
<div class="flex items-center gap-1.5">
<span v-if="selected" class="docs-group h-3 w-3 shrink-0 rounded-full" style="background-color: var(--group-c)" :style="groupStyle(selected.hue)"></span>
<select :value="modelValue ?? ''" class="rounded border border-border bg-background px-2 py-1 text-xs text-foreground outline-none focus:border-ring" @change="choose($event)">
<option value="">{{ translate("docs.noGroup") }}</option>
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
<option :value="CREATE">{{ translate("docs.newGroup") }}</option>
</select>
</div>
</template>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import { nextTick, ref } from "vue";
import type { Translate } from "../docsWarnings";
import { renderNote } from "../renderNote";
const props = defineProps<{
/** Raw markdown. Displayed through renderNote, edited as its source. */
modelValue: string;
/**
* Part 3c renders this same component inside the standalone HTML export,
* where nothing can be saved. Read-only means the note never turns into a
* textarea not a disabled one, none at all.
*/
readonly: boolean;
translate: Translate;
}>();
const emit = defineEmits<{
"update:modelValue": [note: string];
}>();
const editing = ref(false);
const draft = ref("");
const input = ref<HTMLTextAreaElement | null>(null);
async function begin(): Promise<void> {
if (props.readonly) {
return;
}
draft.value = props.modelValue;
editing.value = true;
await nextTick();
input.value?.focus();
}
function commit(): void {
editing.value = false;
// The parent owns the value. Emitting an unchanged note would dirty the
// annotation file for a click that edited nothing.
if (draft.value !== props.modelValue) {
emit("update:modelValue", draft.value);
}
}
/** Escape abandons the draft; the stored note stays the source of truth. */
function cancel(): void {
draft.value = props.modelValue;
editing.value = false;
}
</script>
<template>
<div class="text-sm text-muted-foreground">
<textarea v-if="editing" ref="input" v-model="draft" rows="4" class="w-full resize-y rounded border border-border bg-background p-2 font-mono text-xs text-foreground outline-none focus:border-ring" @blur="commit()" @keydown.escape="cancel()"></textarea>
<!-- renderNote is the sanitiser: a note can be a database COMMENT ON value
the reader never wrote, so its markdown is rendered here and its HTML
is escaped there. Binding the raw note would hand that comment to the
DOM verbatim. -->
<div v-else-if="modelValue.trim() !== ''" class="leading-relaxed" :class="readonly ? '' : 'cursor-text rounded transition-colors hover:bg-muted/30'" :title="readonly ? undefined : translate('docs.editNote')" @click="begin()" v-html="renderNote(modelValue)"></div>
<button v-else-if="!readonly" type="button" class="rounded border border-dashed border-border px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/40" @click="begin()">
{{ translate("docs.addNote") }}
</button>
</div>
</template>

View File

@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed } from "vue";
import { qualifiedTableKey } from "../docsKeys";
import type { FieldRef, Relationship } from "../types";
const props = defineProps<{
/** Every relationship in the snapshot; filtered here on the current table. */
relationships: Relationship[];
schema: string | null;
table: string;
}>();
const emit = defineEmits<{
select: [tableKey: string];
}>();
function isCurrent(field: FieldRef): boolean {
return field.table === props.table && (field.schema ?? null) === props.schema;
}
// FieldRef names its table property `table`, not `name`, so it is remapped
// rather than passed to qualifiedTableKey directly.
function keyOf(field: FieldRef): string {
return qualifiedTableKey({ schema: field.schema, name: field.table });
}
function label(field: FieldRef): string {
return `${keyOf(field)}.${field.column}`;
}
function notation(relationship: Relationship): string {
return relationship.cardinality === "ONE_TO_ONE" ? "1 1" : "* 1";
}
function actions(relationship: Relationship): string[] {
const parts: string[] = [];
if (relationship.onUpdate) {
parts.push(`on update ${relationship.onUpdate}`);
}
if (relationship.onDelete) {
parts.push(`on delete ${relationship.onDelete}`);
}
return parts;
}
const outgoing = computed(() => props.relationships.filter((relationship) => isCurrent(relationship.from)));
const incoming = computed(() => props.relationships.filter((relationship) => isCurrent(relationship.to)));
</script>
<template>
<div class="grid gap-4 sm:grid-cols-2">
<section>
<h4 class="mb-1.5 text-xs font-medium text-muted-foreground">References ({{ outgoing.length }})</h4>
<p v-if="outgoing.length === 0" class="text-xs text-muted-foreground">This table references no other table.</p>
<ul v-else class="flex flex-col gap-1">
<li v-for="relationship in outgoing" :key="relationship.id" class="text-xs">
<button type="button" class="w-full rounded border border-border bg-background px-2 py-1.5 text-left transition-colors hover:bg-muted/40" @click="emit('select', keyOf(relationship.to))">
<span class="font-mono">{{ relationship.from.column }}</span>
<span class="mx-1.5 text-muted-foreground">{{ notation(relationship) }}</span>
<span class="font-mono">{{ label(relationship.to) }}</span>
<span v-if="actions(relationship).length > 0" class="ml-1.5 text-[10px] text-muted-foreground">
{{ actions(relationship).join(", ") }}
</span>
</button>
</li>
</ul>
</section>
<section>
<h4 class="mb-1.5 text-xs font-medium text-muted-foreground">Referenced by ({{ incoming.length }})</h4>
<p v-if="incoming.length === 0" class="text-xs text-muted-foreground">No table references this one.</p>
<ul v-else class="flex flex-col gap-1">
<li v-for="relationship in incoming" :key="relationship.id" class="text-xs">
<button type="button" class="w-full rounded border border-border bg-background px-2 py-1.5 text-left transition-colors hover:bg-muted/40" @click="emit('select', keyOf(relationship.from))">
<span class="font-mono">{{ label(relationship.from) }}</span>
<span class="mx-1.5 text-muted-foreground">{{ notation(relationship) }}</span>
<span class="font-mono">{{ relationship.to.column }}</span>
<span v-if="actions(relationship).length > 0" class="ml-1.5 text-[10px] text-muted-foreground">
{{ actions(relationship).join(", ") }}
</span>
</button>
</li>
</ul>
</section>
</div>
</template>

View File

@ -0,0 +1,112 @@
<script setup lang="ts">
import { computed } from "vue";
import type { Translate } from "../docsWarnings";
import { qualifiedTableKey } from "../docsKeys";
import { groupStyle } from "../groupColor";
import type { DocsEdit, DocTable, GroupAnnotation, Relationship, TableGroup } from "../types";
import ColumnTable from "./ColumnTable.vue";
import GroupPicker from "./GroupPicker.vue";
import NoteEditor from "./NoteEditor.vue";
import RelationshipList from "./RelationshipList.vue";
const props = defineProps<{
table: DocTable;
/** Every relationship in the snapshot; RelationshipList filters them. */
relationships: Relationship[];
/** The table's group, or null when it belongs to none. */
group: TableGroup | null;
/** The editable group records, which `group` above cannot be written back to. */
annotationGroups: GroupAnnotation[];
readonly: boolean;
translate: Translate;
}>();
const emit = defineEmits<{
select: [tableKey: string];
edit: [edit: DocsEdit];
createGroup: [tableKey: string];
}>();
const qualified = computed(() => qualifiedTableKey(props.table));
const kindLabel = computed(() => props.table.kind.toLowerCase().replace(/_/g, " "));
/**
* The database comment a local note replaced. Bound with `:title` so Vue
* escapes it it is author text, exactly like the note itself.
*/
const shadowedTitle = computed(() => (props.table.shadowedNote ? `Database comment: ${props.table.shadowedNote}` : undefined));
</script>
<template>
<article class="flex flex-col gap-5">
<header class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<h2 class="font-mono text-lg font-semibold text-foreground">{{ qualified }}</h2>
<span class="rounded bg-muted/50 px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">{{ kindLabel }}</span>
<span v-if="group" class="docs-group rounded px-1.5 py-0.5 text-[10px] font-medium" style="background-color: var(--group-tint); color: var(--group-c)" :style="groupStyle(group.hue)">
{{ group.name }}
</span>
<span v-if="table.estimatedRows !== null" class="text-[10px] text-muted-foreground"> ~{{ table.estimatedRows }} rows </span>
</div>
<!-- NoteEditor is fed the MERGED note, not the local one. It renders and
edits a single value, so seeding it from the annotation file would
show nothing for a note that came from a database comment. Writing
one shadows that comment, which is what noteSource and shadowedNote
below exist to disclose. -->
<div class="flex items-start gap-2">
<span v-if="table.noteSource === 'LOCAL'" class="mt-0.5 shrink-0 text-[10px] font-medium text-muted-foreground" :title="shadowedTitle"> LOCAL</span>
<NoteEditor class="min-w-0 flex-1" :model-value="table.note ?? ''" :readonly="readonly" :translate="translate" @update:model-value="emit('edit', { kind: 'tableNote', tableKey: qualified, note: $event })" />
</div>
<GroupPicker v-if="!readonly" :groups="annotationGroups" :model-value="table.groupId" :translate="translate" @update:model-value="emit('edit', { kind: 'tableGroup', tableKey: qualified, groupId: $event })" @create="emit('createGroup', qualified)" />
</header>
<section>
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Columns</h3>
<ColumnTable :columns="table.columns" :column-notes="table.columnNotes" :table-key="qualified" :readonly="readonly" :translate="translate" @edit="emit('edit', $event)" />
</section>
<section v-if="table.indexes.length > 0">
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Indexes</h3>
<div class="overflow-hidden rounded-md border border-border">
<table class="w-full text-xs">
<thead>
<tr class="bg-muted/30">
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Name</th>
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Columns</th>
<th class="px-2 py-1.5 text-left font-medium text-muted-foreground">Settings</th>
</tr>
</thead>
<tbody>
<tr v-for="index in table.indexes" :key="index.name" class="border-t border-border align-top">
<td class="px-2 py-1.5 font-mono">{{ index.name }}</td>
<td class="px-2 py-1.5 font-mono text-muted-foreground">
{{ index.columns.join(", ") }}<template v-if="index.included_columns && index.included_columns.length > 0"> (include {{ index.included_columns.join(", ") }}) </template>
</td>
<td class="px-2 py-1.5">
<div class="flex flex-wrap gap-1 text-[10px] text-muted-foreground">
<span v-if="index.is_primary" class="rounded bg-muted/50 px-1.5 py-0.5">pk</span>
<span v-if="index.is_unique" class="rounded bg-muted/50 px-1.5 py-0.5">unique</span>
<span v-if="index.index_type" class="rounded bg-muted/50 px-1.5 py-0.5">{{ index.index_type }}</span>
<span v-if="index.filter" class="rounded bg-muted/50 px-1.5 py-0.5">where {{ index.filter }}</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section>
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Relationships</h3>
<RelationshipList :relationships="relationships" :schema="table.schema" :table="table.name" @select="emit('select', $event)" />
</section>
<section v-if="table.viewDefinition">
<h3 class="mb-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Definition</h3>
<pre class="overflow-x-auto rounded-md border border-border bg-muted/20 p-2 font-mono text-xs">{{ table.viewDefinition }}</pre>
</section>
</article>
</template>

View File

@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed } from "vue";
import { describeWarning, type Translate } from "../docsWarnings";
import type { SnapshotWarning } from "../types";
const props = defineProps<{
warnings: SnapshotWarning[];
translate: Translate;
}>();
// Keyed by index: two warnings of the same kind can carry identical text.
const notices = computed(() => props.warnings.map((warning, index) => ({ key: `${warning.kind}-${index}`, ...describeWarning(warning, props.translate) })));
</script>
<template>
<div v-if="notices.length > 0" class="flex flex-col gap-2">
<div
v-for="notice in notices"
:key="notice.key"
class="rounded-md border px-3 py-2 text-xs"
:class="notice.severity === 'warning' ? 'border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200' : 'border-border bg-muted/30 text-muted-foreground'"
>
<div class="font-medium">{{ notice.title }}</div>
<div class="mt-0.5 leading-relaxed">{{ notice.detail }}</div>
</div>
</div>
</template>

View File

@ -0,0 +1,44 @@
<script setup lang="ts">
import type { IndexSection } from "../docsIndex";
import { qualifiedTableKey } from "../docsKeys";
import { groupStyle } from "../groupColor";
import { renderNote } from "../renderNote";
defineProps<{
sections: IndexSection[];
}>();
const emit = defineEmits<{
select: [tableKey: string];
}>();
</script>
<template>
<div class="flex flex-col gap-6">
<section v-for="section in sections" :key="section.key" class="flex flex-col gap-2">
<div class="flex flex-col gap-1 border-l-2 pl-3" :class="{ 'docs-group': section.hue !== null }" style="border-color: var(--group-c, var(--border))" :style="groupStyle(section.hue)">
<div class="flex items-baseline gap-2">
<h2 class="text-sm font-semibold text-foreground">{{ section.label || "(no schema)" }}</h2>
<span class="text-xs text-muted-foreground">{{ section.tables.length }} tables</span>
</div>
<div v-if="section.note" class="text-xs text-muted-foreground" v-html="renderNote(section.note)"></div>
</div>
<ul class="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
<!-- The note renders author markdown, so it can contain an <a>. Inside
the <button> that would be invalid nesting and the link would not
be keyboard reachable, so the note is a sibling of the button and
the <li> carries the card. -->
<li v-for="table in section.tables" :key="qualifiedTableKey(table)" class="flex flex-col gap-0.5 rounded border border-border bg-background px-2 py-1.5 transition-colors hover:bg-muted/40">
<button type="button" class="flex w-full items-baseline gap-1.5 text-left" @click="emit('select', qualifiedTableKey(table))">
<span class="font-mono text-xs font-medium text-foreground">{{ table.name }}</span>
<span v-if="table.kind !== 'TABLE'" class="text-[10px] uppercase text-muted-foreground">
{{ table.kind.toLowerCase().replace(/_/g, " ") }}
</span>
</button>
<div v-if="table.note" class="line-clamp-2 text-[11px] text-muted-foreground" v-html="renderNote(table.note)"></div>
</li>
</ul>
</section>
</div>
</template>

View File

@ -0,0 +1,42 @@
/* Group colour. A group stores one number (the hue); lightness and chroma are
* fixed here per theme, so no hue can produce an illegible swatch.
*
* Base values use hsl() because DBX supports WebViews without oklch the same
* bare `--h` number works in both functions. The oklch values below are the
* enhancement, matching how globals.css defines its own tokens. */
.docs-group {
--group-c: hsl(var(--h, 220), 55%, 38%);
--group-tint: hsl(var(--h, 220), 55%, 94%);
}
.dark .docs-group {
--group-c: hsl(var(--h, 220), 50%, 72%);
--group-tint: hsl(var(--h, 220), 30%, 18%);
}
/* GroupEditor previews a hue on a light and a dark ground side by side. The
* dark half just carries `.dark`, which works in either theme; the light half
* needs this, because in dark mode `.dark .docs-group` would otherwise paint
* dark-theme values onto a white ground and the preview would lie. Same
* specificity as the rule above and declared after it, so it wins. */
.docs-ground-light .docs-group {
--group-c: hsl(var(--h, 220), 55%, 38%);
--group-tint: hsl(var(--h, 220), 55%, 94%);
}
@supports (color: oklch(1 0 0)) {
.docs-group {
--group-c: oklch(0.55 0.15 var(--h, 220));
--group-tint: oklch(0.96 0.03 var(--h, 220));
}
.dark .docs-group {
--group-c: oklch(0.76 0.13 var(--h, 220));
--group-tint: oklch(0.28 0.05 var(--h, 220));
}
.docs-ground-light .docs-group {
--group-c: oklch(0.55 0.15 var(--h, 220));
--group-tint: oklch(0.96 0.03 var(--h, 220));
}
}

View File

@ -0,0 +1,88 @@
import { qualifiedTableKey } from "./docsKeys";
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;
}
/**
* Every column whose declared type is this enum.
*
* Exact match on `data_type`, never a substring: an enum named `state` would
* otherwise claim every column of type `statement`.
*/
export function columnsUsingEnum(snapshot: SchemaSnapshot, enumName: string): Array<{ tableKey: string; table: string; column: string }> {
const hits: Array<{ tableKey: string; table: string; column: string }> = [];
for (const table of snapshot.tables) {
for (const column of table.columns) {
if (column.data_type === enumName) {
hits.push({ tableKey: qualifiedTableKey(table), table: table.name, column: column.name });
}
}
}
return hits;
}

View File

@ -0,0 +1,17 @@
import type { DocTable } from "./types";
/**
* The key that identifies a table across the viewer `schema.name`, or the
* bare name on schema-less engines like SQLite and MySQL.
*
* This rule was copied into four places before it lived here. It is the key
* that annotations are stored under, so two call sites disagreeing would
* attach a note to the wrong table.
*
* Typed as `Pick<DocTable, "schema" | "name">` rather than the full `DocTable`
* so callers that only have a schema/name pair on hand a relationship's
* `FieldRef`, remapped can use it too without an unsafe cast.
*/
export function qualifiedTableKey(table: Pick<DocTable, "schema" | "name">): string {
return table.schema ? `${table.schema}.${table.name}` : table.name;
}

View File

@ -0,0 +1,66 @@
import { qualifiedTableKey } from "./docsKeys";
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;
}
/** Per-kind result caps. A single overall cap lets columns by far the most
* numerous kind crowd groups and enums out of the list entirely. */
const LIMITS = { table: 20, column: 20, group: 10, enum: 10 } as const;
/**
* 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.
*
* Results are capped per kind see LIMITS. This is the only place results are
* limited; callers render everything they are handed.
*/
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 = qualifiedTableKey(table);
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.slice(0, LIMITS.table), ...columns.slice(0, LIMITS.column), ...groups.slice(0, LIMITS.group), ...enums.slice(0, LIMITS.enum)];
}

View File

@ -0,0 +1,60 @@
import type { SnapshotWarning } from "./types";
export interface WarningNotice {
severity: "info" | "warning";
title: string;
detail: string;
}
/**
* A `useI18n().t`-shaped function, passed in rather than imported directly.
*
* `useI18n()` throws without a provided Vue instance exactly the standalone
* HTML export case (Part 3c), which bootstraps no Vue app around this module.
* Taking the translator as a parameter keeps this file a pure module the
* export can call with an English identity function, and is why `src/docs/`
* must never import vue-i18n directly.
*/
export type Translate = (key: string, params?: Record<string, string | number>) => 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, translate: Translate): WarningNotice {
switch (warning.kind) {
case "tableSkipped":
return {
severity: "warning",
title: translate("docs.warnings.tableSkipped.title"),
detail: translate("docs.warnings.tableSkipped.detail", { table: warning.table, reason: warning.reason }),
};
case "noForeignKeyMetadata":
return {
severity: "info",
title: translate("docs.warnings.noForeignKeyMetadata.title"),
detail: translate("docs.warnings.noForeignKeyMetadata.detail", { engine: warning.engine }),
};
case "commentsUnsupported":
return {
severity: "info",
title: translate("docs.warnings.commentsUnsupported.title"),
detail: translate("docs.warnings.commentsUnsupported.detail", { engine: warning.engine }),
};
case "orphanedNotes":
return {
severity: "warning",
title: translate("docs.warnings.orphanedNotes.title"),
detail: translate("docs.warnings.orphanedNotes.detail", { count: warning.count }),
};
case "dbmlOmitted":
return {
severity: "info",
title: translate("docs.warnings.dbmlOmitted.title"),
detail: translate("docs.warnings.dbmlOmitted.detail", { item: warning.item, table: warning.table, reason: warning.reason }),
};
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
/**
* 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) };
}

View File

@ -0,0 +1,78 @@
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
// Single quotes too, so the escaper is self-sufficient. Every attribute
// here is double-quoted today, which makes this redundant — but that is a
// formatting convention enforced nowhere, and the day someone writes
// title='...' it becomes the difference between escaping and a breakout.
.replace(/'/g, "&#39;")
);
}
/**
* URL allowlist. Blocklists lose: `javascript:` alone misses `JaVaScRiPt:`,
* the entity-encoded `&#106;avascript:`, `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;
}
// Protocol-relative. Harmless over https, 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, and images need no click.
// Both separators must be rejected: the URL spec treats /\ exactly like //.
if (/^[/\\]{2}/.test(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;
}

View File

@ -0,0 +1,210 @@
// 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;
// No skip_serializing_if in Rust — these keys are ALWAYS present, so they
// are required and may be null. Marking them optional would let `undefined`
// reach code that checks `=== null`.
comment: string | null;
numeric_precision: number | null;
numeric_scale: number | null;
character_maximum_length: number | null;
// These three DO carry skip_serializing_if, so the key is absent rather
// than null when there is no value.
enum_values?: string[];
character_set?: string;
collation?: string;
}
export interface IndexInfo {
name: string;
columns: string[];
is_unique: boolean;
is_primary: boolean;
// No skip_serializing_if on any of these in Rust — required, may be null.
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[];
}
/** Mirrors `dbx_core::docs::annotations::ColumnAnnotation`. */
export interface ColumnAnnotation {
note: string;
}
/** Mirrors `TableAnnotation`. Absent keys are omitted, never sent as null. */
export interface TableAnnotation {
group?: string;
note?: string;
columns?: Record<string, ColumnAnnotation>;
}
/** Mirrors `GroupAnnotation`. `hue` is 0359; lightness and chroma are the theme's. */
export interface GroupAnnotation {
id: string;
name: string;
hue: number;
note?: string;
}
/** Mirrors `ProjectAnnotation`. */
export interface ProjectAnnotation {
name?: string;
note?: string;
}
/**
* The on-disk notes file. Rust declares `deny_unknown_fields`, so adding a
* property here without adding it in Rust makes every save fail.
*/
export interface AnnotationFile {
formatVersion: number;
project?: ProjectAnnotation;
groups?: GroupAnnotation[];
tables?: Record<string, TableAnnotation>;
}
/**
* What the viewer asks its host to do. The viewer never persists anything
* that is what keeps `src/docs/` free of backend calls and bundleable into a
* standalone HTML file.
*/
export type DocsEdit =
| { kind: "projectNote"; note: string }
| { kind: "tableNote"; tableKey: string; note: string }
| { kind: "columnNote"; tableKey: string; column: string; note: string }
| { kind: "tableGroup"; tableKey: string; groupId: string | null }
| { kind: "upsertGroup"; group: GroupAnnotation }
| { kind: "removeGroup"; groupId: string };
/**
* Runtime witnesses for the interfaces above.
*
* TypeScript types are erased at runtime, so a test cannot enumerate an
* interface's keys. `Record<keyof T, true>` gives a value that IS enumerable
* and that `vue-tsc` checks against the interface: add a field and this object
* is missing a key; remove one and it carries an excess property. Either way
* the build fails here rather than at a user's save.
*
* These live in this file, not in the spec, because `tsconfig.json` excludes
* `src/**\/__tests__/**` from `vue-tsc` witnesses in a spec file would be
* checked by nothing.
*/
export const COLUMN_ANNOTATION_KEYS: Record<keyof ColumnAnnotation, true> = { note: true };
export const TABLE_ANNOTATION_KEYS: Record<keyof TableAnnotation, true> = { group: true, note: true, columns: true };
export const GROUP_ANNOTATION_KEYS: Record<keyof GroupAnnotation, true> = { id: true, name: true, hue: true, note: true };
export const PROJECT_ANNOTATION_KEYS: Record<keyof ProjectAnnotation, true> = { name: true, note: true };
export const ANNOTATION_FILE_KEYS: Record<keyof AnnotationFile, true> = {
formatVersion: true,
project: true,
groups: true,
tables: true,
};

View File

@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import en from "../locales/docs/en";
import es from "../locales/docs/es";
import it_ from "../locales/docs/it";
import ja from "../locales/docs/ja";
import ko from "../locales/docs/ko";
import ptBR from "../locales/docs/pt-BR";
import zhCN from "../locales/docs/zh-CN";
import zhTW from "../locales/docs/zh-TW";
// These import the per-locale DOCS modules, NOT ../locales/<name>.
//
// Every non-English locale file is `export default withEnglishFallback({...})`,
// which deep-merges `en` UNDER the locale at module level. Importing those
// default exports yields the ALREADY-MERGED object, so every locale appears to
// have every key and this test would pass while translations were missing —
// the fallback would silently defeat the test written to catch it.
const locales: Array<[string, Record<string, unknown>]> = [
["es", es],
["it", it_],
["ja", ja],
["ko", ko],
["pt-BR", ptBR],
["zh-CN", zhCN],
["zh-TW", zhTW],
];
function leafKeys(value: unknown, prefix = ""): string[] {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return [prefix];
}
return Object.entries(value as Record<string, unknown>).flatMap(([key, child]) => leafKeys(child, prefix ? `${prefix}.${key}` : key));
}
describe("docs i18n namespace parity", () => {
const expected = leafKeys(en as Record<string, unknown>).sort();
it("english declares the docs namespace", () => {
expect(expected.length, "locales/docs/en.ts must declare keys").toBeGreaterThan(0);
});
it.each(locales)("%s declares exactly the same docs keys as en", (_name, locale) => {
expect(leafKeys(locale).sort()).toEqual(expected);
});
});

View File

@ -0,0 +1,48 @@
export default {
title: "Documentation",
groupBySchema: "Schemas",
groupByTableGroup: "Table Groups",
noGroup: "(no group)",
noSchema: "(no schema)",
search: "Search tables, columns, groups…",
columns: "Columns",
indexes: "Indexes",
references: "References",
referencedBy: "Referenced by",
localNote: "LOCAL",
shadowedComment: "Database comment: {comment}",
addNote: "Add a note",
editNote: "Edit note",
newGroup: "New group…",
groupName: "Group name",
groupColour: "Colour",
deleteGroup: "Delete group",
enumValues: "Values",
usedBy: "Used by",
openDiagram: "Open schema diagram",
saving: "Saving…",
saved: "Saved",
saveFailed: "Could not save notes: {error}",
warnings: {
tableSkipped: {
title: "A table could not be documented",
detail: "{table} was skipped: {reason}. It is missing from this documentation.",
},
noForeignKeyMetadata: {
title: "No relationships available",
detail: "{engine} does not report foreign key metadata, so no relationship edges could be derived. The diagram is complete for this engine.",
},
commentsUnsupported: {
title: "Database comments unavailable",
detail: "{engine} does not support table or column comments, so every description here comes from this project's own notes.",
},
orphanedNotes: {
title: "Some notes no longer match anything",
detail: "{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.",
},
dbmlOmitted: {
title: "Not representable in DBML",
detail: "{item} on {table} is documented here but omitted from the exported DBML: {reason}.",
},
},
};

View File

@ -0,0 +1,48 @@
export default {
title: "Documentación",
groupBySchema: "Esquemas",
groupByTableGroup: "Grupos de tablas",
noGroup: "(sin grupo)",
noSchema: "(sin esquema)",
search: "Buscar tablas, columnas, grupos…",
columns: "Columnas",
indexes: "Índices",
references: "Referencias",
referencedBy: "Referenciado por",
localNote: "LOCAL",
shadowedComment: "Comentario de la base de datos: {comment}",
addNote: "Agregar una nota",
editNote: "Editar nota",
newGroup: "Nuevo grupo…",
groupName: "Nombre del grupo",
groupColour: "Color",
deleteGroup: "Eliminar grupo",
enumValues: "Valores",
usedBy: "Usado por",
openDiagram: "Abrir diagrama del esquema",
saving: "Guardando…",
saved: "Guardado",
saveFailed: "No se pudieron guardar las notas: {error}",
warnings: {
tableSkipped: {
title: "No se pudo documentar una tabla",
detail: "{table} fue omitida: {reason}. Falta en esta documentación.",
},
noForeignKeyMetadata: {
title: "No hay relaciones disponibles",
detail: "{engine} no reporta metadatos de claves foráneas, por lo que no se pudieron derivar relaciones. El diagrama está completo para este motor.",
},
commentsUnsupported: {
title: "Comentarios de base de datos no disponibles",
detail: "{engine} no admite comentarios en tablas o columnas, por lo que toda descripción aquí proviene de las notas de este proyecto.",
},
orphanedNotes: {
title: "Algunas notas ya no coinciden con nada",
detail: "{count} nota(s) hacen referencia a una tabla o columna que ya no existe. No se eliminó nada — vuelva a asignarlas o elimínelas en el archivo de notas.",
},
dbmlOmitted: {
title: "No representable en DBML",
detail: "{item} en {table} está documentado aquí pero se omitió del DBML exportado: {reason}.",
},
},
};

View File

@ -0,0 +1,48 @@
export default {
title: "Documentazione",
groupBySchema: "Schemi",
groupByTableGroup: "Gruppi di tabelle",
noGroup: "(nessun gruppo)",
noSchema: "(nessuno schema)",
search: "Cerca tabelle, colonne, gruppi…",
columns: "Colonne",
indexes: "Indici",
references: "Riferimenti",
referencedBy: "Referenziato da",
localNote: "LOCALE",
shadowedComment: "Commento del database: {comment}",
addNote: "Aggiungi una nota",
editNote: "Modifica nota",
newGroup: "Nuovo gruppo…",
groupName: "Nome del gruppo",
groupColour: "Colore",
deleteGroup: "Elimina gruppo",
enumValues: "Valori",
usedBy: "Usato da",
openDiagram: "Apri diagramma dello schema",
saving: "Salvataggio…",
saved: "Salvato",
saveFailed: "Impossibile salvare le note: {error}",
warnings: {
tableSkipped: {
title: "Impossibile documentare una tabella",
detail: "{table} è stata saltata: {reason}. Manca da questa documentazione.",
},
noForeignKeyMetadata: {
title: "Nessuna relazione disponibile",
detail: "{engine} non riporta i metadati delle chiavi esterne, quindi non è stato possibile derivare alcuna relazione. Il diagramma è completo per questo motore.",
},
commentsUnsupported: {
title: "Commenti del database non disponibili",
detail: "{engine} non supporta i commenti su tabelle o colonne, quindi ogni descrizione qui proviene dalle note di questo progetto.",
},
orphanedNotes: {
title: "Alcune note non corrispondono più a nulla",
detail: "{count} nota/e fanno riferimento a una tabella o colonna che non esiste più. Nulla è stato eliminato — rimappa o rimuovi queste note nel file delle note.",
},
dbmlOmitted: {
title: "Non rappresentabile in DBML",
detail: "{item} su {table} è documentato qui ma omesso dal DBML esportato: {reason}.",
},
},
};

View File

@ -0,0 +1,48 @@
export default {
title: "ドキュメント",
groupBySchema: "スキーマ",
groupByTableGroup: "テーブルグループ",
noGroup: "(グループなし)",
noSchema: "(スキーマなし)",
search: "テーブル、カラム、グループを検索…",
columns: "カラム",
indexes: "インデックス",
references: "参照",
referencedBy: "参照元",
localNote: "ローカル",
shadowedComment: "データベースのコメント: {comment}",
addNote: "メモを追加",
editNote: "メモを編集",
newGroup: "新しいグループ…",
groupName: "グループ名",
groupColour: "色",
deleteGroup: "グループを削除",
enumValues: "値",
usedBy: "使用元",
openDiagram: "スキーマ図を開く",
saving: "保存中…",
saved: "保存済み",
saveFailed: "メモを保存できませんでした: {error}",
warnings: {
tableSkipped: {
title: "ドキュメント化できないテーブルがあります",
detail: "{table} はスキップされました: {reason}。このドキュメントには含まれていません。",
},
noForeignKeyMetadata: {
title: "リレーションシップを表示できません",
detail: "{engine} は外部キーのメタデータを報告しないため、リレーションシップを導出できませんでした。この図はこのエンジンについては完全です。",
},
commentsUnsupported: {
title: "データベースのコメントは利用できません",
detail: "{engine} はテーブルやカラムのコメントをサポートしていないため、ここでの説明はすべてこのプロジェクトのメモから提供されています。",
},
orphanedNotes: {
title: "対象がなくなったメモがあります",
detail: "{count} 件のメモが、既に存在しないテーブルまたはカラムを参照しています。何も削除されていません。メモファイルで再割り当てまたは削除してください。",
},
dbmlOmitted: {
title: "DBMLでは表現できません",
detail: "{table} の {item} はここに記載されていますが、エクスポートされたDBMLからは除外されています: {reason}。",
},
},
};

View File

@ -0,0 +1,48 @@
export default {
title: "문서",
groupBySchema: "스키마",
groupByTableGroup: "테이블 그룹",
noGroup: "(그룹 없음)",
noSchema: "(스키마 없음)",
search: "테이블, 컬럼, 그룹 검색…",
columns: "컬럼",
indexes: "인덱스",
references: "참조",
referencedBy: "참조된 위치",
localNote: "로컬",
shadowedComment: "데이터베이스 코멘트: {comment}",
addNote: "메모 추가",
editNote: "메모 편집",
newGroup: "새 그룹…",
groupName: "그룹 이름",
groupColour: "색상",
deleteGroup: "그룹 삭제",
enumValues: "값",
usedBy: "사용처",
openDiagram: "스키마 다이어그램 열기",
saving: "저장 중…",
saved: "저장됨",
saveFailed: "메모를 저장할 수 없습니다: {error}",
warnings: {
tableSkipped: {
title: "문서화할 수 없는 테이블이 있습니다",
detail: "{table} 을(를) 건너뛰었습니다: {reason}. 이 문서에서 누락되었습니다.",
},
noForeignKeyMetadata: {
title: "표시할 관계가 없습니다",
detail: "{engine} 은(는) 외래 키 메타데이터를 보고하지 않으므로 관계를 도출할 수 없습니다. 이 엔진에 대해서는 다이어그램이 완전합니다.",
},
commentsUnsupported: {
title: "데이터베이스 코멘트를 사용할 수 없습니다",
detail: "{engine} 은(는) 테이블 또는 컬럼 코멘트를 지원하지 않으므로 여기의 모든 설명은 이 프로젝트의 메모에서만 제공됩니다.",
},
orphanedNotes: {
title: "더 이상 일치하는 대상이 없는 메모가 있습니다",
detail: "{count} 개의 메모가 더 이상 존재하지 않는 테이블 또는 컬럼을 참조하고 있습니다. 삭제된 항목은 없습니다 — 메모 파일에서 다시 매핑하거나 제거하세요.",
},
dbmlOmitted: {
title: "DBML로 표현할 수 없습니다",
detail: "{table} 의 {item} 은(는) 여기에 문서화되어 있지만 내보낸 DBML에서는 제외되었습니다: {reason}.",
},
},
};

View File

@ -0,0 +1,48 @@
export default {
title: "Documentação",
groupBySchema: "Schemas",
groupByTableGroup: "Grupos de tabelas",
noGroup: "(sem grupo)",
noSchema: "(sem schema)",
search: "Buscar tabelas, colunas, grupos…",
columns: "Colunas",
indexes: "Índices",
references: "Referências",
referencedBy: "Referenciado por",
localNote: "LOCAL",
shadowedComment: "Comentário do banco de dados: {comment}",
addNote: "Adicionar uma nota",
editNote: "Editar nota",
newGroup: "Novo grupo…",
groupName: "Nome do grupo",
groupColour: "Cor",
deleteGroup: "Excluir grupo",
enumValues: "Valores",
usedBy: "Usado por",
openDiagram: "Abrir diagrama do schema",
saving: "Salvando…",
saved: "Salvo",
saveFailed: "Não foi possível salvar as notas: {error}",
warnings: {
tableSkipped: {
title: "Uma tabela não pôde ser documentada",
detail: "{table} foi ignorada: {reason}. Ela está faltando nesta documentação.",
},
noForeignKeyMetadata: {
title: "Nenhum relacionamento disponível",
detail: "{engine} não reporta metadados de foreign key, então nenhum relacionamento pôde ser derivado. O diagrama está completo para este engine.",
},
commentsUnsupported: {
title: "Comentários do banco de dados indisponíveis",
detail: "{engine} não suporta comentários em tabelas ou colunas, então toda descrição aqui vem apenas das notas deste projeto.",
},
orphanedNotes: {
title: "Algumas notas não correspondem mais a nada",
detail: "{count} nota(s) referenciam uma tabela ou coluna que não existe mais. Nada foi excluído — remapeie ou remova essas notas no arquivo de notas.",
},
dbmlOmitted: {
title: "Não representável em DBML",
detail: "{item} em {table} está documentado aqui mas foi omitido do DBML exportado: {reason}.",
},
},
};

View File

@ -0,0 +1,48 @@
export default {
title: "文档",
groupBySchema: "Schema",
groupByTableGroup: "表分组",
noGroup: "(无分组)",
noSchema: "(无 Schema)",
search: "搜索表、列、分组…",
columns: "列",
indexes: "索引",
references: "引用",
referencedBy: "被引用",
localNote: "本地",
shadowedComment: "数据库注释: {comment}",
addNote: "添加备注",
editNote: "编辑备注",
newGroup: "新建分组…",
groupName: "分组名称",
groupColour: "颜色",
deleteGroup: "删除分组",
enumValues: "值",
usedBy: "被使用于",
openDiagram: "打开 Schema 图",
saving: "保存中…",
saved: "已保存",
saveFailed: "无法保存备注: {error}",
warnings: {
tableSkipped: {
title: "有一张表无法生成文档",
detail: "{table} 被跳过: {reason}。此文档中缺少该表。",
},
noForeignKeyMetadata: {
title: "没有可用的关系",
detail: "{engine} 不报告外键元数据,因此无法推导出任何关系。此图对该数据库引擎而言已经完整。",
},
commentsUnsupported: {
title: "数据库注释不可用",
detail: "{engine} 不支持表或列注释,因此这里的所有描述均仅来自本项目的备注。",
},
orphanedNotes: {
title: "部分备注已不再对应任何内容",
detail: "{count} 条备注引用的表或列已不存在。没有任何内容被删除 — 请在备注文件中重新映射或移除它们。",
},
dbmlOmitted: {
title: "无法在 DBML 中表示",
detail: "{table} 上的 {item} 已在此处记录,但已从导出的 DBML 中省略: {reason}。",
},
},
};

View File

@ -0,0 +1,48 @@
export default {
title: "文件",
groupBySchema: "Schema",
groupByTableGroup: "資料表分組",
noGroup: "(無分組)",
noSchema: "(無 Schema)",
search: "搜尋資料表、欄位、分組…",
columns: "欄位",
indexes: "索引",
references: "參照",
referencedBy: "被參照",
localNote: "本機",
shadowedComment: "資料庫註解: {comment}",
addNote: "新增備註",
editNote: "編輯備註",
newGroup: "新增分組…",
groupName: "分組名稱",
groupColour: "顏色",
deleteGroup: "刪除分組",
enumValues: "值",
usedBy: "被使用於",
openDiagram: "開啟 Schema 圖",
saving: "儲存中…",
saved: "已儲存",
saveFailed: "無法儲存備註: {error}",
warnings: {
tableSkipped: {
title: "有一張資料表無法產生文件",
detail: "{table} 被略過: {reason}。此文件中缺少該資料表。",
},
noForeignKeyMetadata: {
title: "沒有可用的關聯",
detail: "{engine} 未回報外鍵中繼資料,因此無法推導出任何關聯。此圖對該資料庫引擎而言已經完整。",
},
commentsUnsupported: {
title: "資料庫註解無法使用",
detail: "{engine} 不支援資料表或欄位註解,因此這裡的所有說明均僅來自本專案的備註。",
},
orphanedNotes: {
title: "部分備註已不再對應任何內容",
detail: "{count} 筆備註參照的資料表或欄位已不存在。沒有任何內容被刪除 — 請在備註檔案中重新對應或移除它們。",
},
dbmlOmitted: {
title: "無法在 DBML 中表示",
detail: "{table} 上的 {item} 已在此處記錄,但已從匯出的 DBML 中省略: {reason}。",
},
},
};

View File

@ -1,7 +1,10 @@
import docs from "./docs/en";
export default {
app: {
name: "DBX",
},
docs,
auth: {
rateLimited: "Please try again in {seconds}s",
setupTitle: "Set up access password",
@ -770,6 +773,9 @@ export default {
readOnlyHint: "Block all write operations (INSERT, UPDATE, DELETE, etc.)",
showSystemSchemas: "Show System Schemas",
showSystemSchemasHint: "Show built-in and metadata schemas in the sidebar and schema pickers for this connection.",
docsNotesPath: "Notes file",
docsNotesPathPlaceholder: "docs/dbx-docs.json",
docsNotesPathHint: "Where documentation notes are stored. Leave empty to keep them in the app data directory, or point this at a file in your repository to review schema documentation in pull requests.",
readOnlyBadge: "Read-only",
proxy: "Proxy",
proxyEnable: "Connect database through proxy",

View File

@ -1,9 +1,11 @@
import { withEnglishFallback } from "./fallback";
import docs from "./docs/es";
export default withEnglishFallback({
app: {
name: "DBX",
},
docs,
auth: {
rateLimited: "Vuelva a intentarlo en {seconds} s",
setupTitle: "Configurar contraseña de acceso",
@ -750,6 +752,9 @@ export default withEnglishFallback({
jdbcMissingRuntimeDependencyHint: "El controlador JDBC actual carece de dependencias de ejecución. Utilice las coordenadas Maven en 'Administración de controladores' para instalar, o importe el controlador y todos los JAR de dependencia de una vez.",
showSystemSchemas: "Mostrar Schema del sistema",
showSystemSchemasHint: "Mostrar el Schema integrado/de metadatos para la conexión actual en la barra lateral y el selector de Schema.",
docsNotesPath: "Archivo de notas",
docsNotesPathPlaceholder: "docs/dbx-docs.json",
docsNotesPathHint: "Dónde se guardan las notas de documentación. Déjalo vacío para mantenerlas en el directorio de datos de la aplicación, o apunta a un archivo de tu repositorio para revisar la documentación del schema en los pull requests.",
sshHostKeyVerifyTitle: "Confirmar clave de host SSH desconocida",
sshHostKeyVerifyMessage: "No se puede confirmar la autenticidad del host '{host}:{port}'. Para evitar ataques de intermediario, verifique la huella digital de la clave del host con el administrador del servidor antes de continuar.",
sshHostKeyVerifyKeyType: "Tipo de clave",

View File

@ -1,8 +1,10 @@
import { withEnglishFallback } from "./fallback";
import docs from "./docs/it";
export default withEnglishFallback({
app: {
name: "DBX",
},
docs,
auth: {
rateLimited: "Riprova tra {seconds} s",
setupTitle: "Imposta la password di accesso",
@ -748,6 +750,9 @@ export default withEnglishFallback({
jdbcMissingRuntimeDependencyHint: "Il driver JDBC corrente manca di dipendenze runtime. Installare utilizzando le coordinate Maven in 'Gestione driver', o importare il driver e tutti i JAR delle dipendenze in una volta.",
showSystemSchemas: "Mostra Schema di sistema",
showSystemSchemasHint: "Mostra lo Schema built-in/di metadati nella barra laterale e nel selettore Schema per la connessione corrente.",
docsNotesPath: "File delle note",
docsNotesPathPlaceholder: "docs/dbx-docs.json",
docsNotesPathHint: "Dove vengono salvate le note della documentazione. Lascia vuoto per tenerle nella directory dati dell'applicazione, oppure indica un file nel tuo repository per revisionare la documentazione dello schema nelle pull request.",
sshHostKeyVerifyTitle: "Conferma chiave host SSH sconosciuta",
sshHostKeyVerifyMessage: "Impossibile confermare l'autenticità dell'host '{host}:{port}'. Per prevenire attacchi man-in-the-middle, verifica l'impronta della chiave host con l'amministratore del server prima di procedere.",
sshHostKeyVerifyKeyType: "Tipo di chiave",

View File

@ -1,9 +1,11 @@
import { withEnglishFallback } from "./fallback";
import docs from "./docs/ja";
export default withEnglishFallback({
app: {
name: "DBX",
},
docs,
auth: {
rateLimited: "{seconds} 秒後に再試行してください",
setupTitle: "アクセスパスワードを設定",
@ -748,6 +750,9 @@ export default withEnglishFallback({
jdbcMissingRuntimeDependencyHint: "現在のJDBCドライバーには実行依存関係が不足しています。「ドライバ管理」でMaven座標を使用してインストールするか、ドライバーとすべての依存JARを一度にインポートしてください。",
showSystemSchemas: "システムスキーマを表示",
showSystemSchemasHint: "現在の接続で、サイドバーとスキーマセレクターに組み込み/メタデータスキーマを表示します。",
docsNotesPath: "ノートファイル",
docsNotesPathPlaceholder: "docs/dbx-docs.json",
docsNotesPathHint: "ドキュメントのノートの保存先です。空のままにするとアプリのデータディレクトリに保存されます。リポジトリ内のファイルを指定すると、スキーマのドキュメントをプルリクエストでレビューできます。",
sshHostKeyVerifyTitle: "不明なSSHホストキーの確認",
sshHostKeyVerifyMessage: "ホスト '{host}:{port}' の正当性を確認できません。中間者攻撃を防ぐため、続行する前にサーバー管理者にホストキーのフィンガープリントを確認してください。",
sshHostKeyVerifyKeyType: "キータイプ",

View File

@ -1,9 +1,11 @@
import { withEnglishFallback } from "./fallback";
import docs from "./docs/ko";
export default withEnglishFallback({
app: {
name: "DBX",
},
docs,
auth: {
rateLimited: "{seconds}초 후에 다시 시도해 주세요",
setupTitle: "접속 비밀번호 설정",
@ -687,6 +689,9 @@ export default withEnglishFallback({
readOnlyHint: "모든 쓰기 작업 차단 (INSERT, UPDATE, DELETE 등)",
showSystemSchemas: "시스템 스키마 표시",
showSystemSchemasHint: "이 연결의 사이드바와 스키마 선택기에 내장 및 메타데이터 스키마를 표시합니다.",
docsNotesPath: "노트 파일",
docsNotesPathPlaceholder: "docs/dbx-docs.json",
docsNotesPathHint: "문서 노트를 저장할 위치입니다. 비워 두면 앱 데이터 디렉터리에 저장되며, 저장소 내 파일을 지정하면 스키마 문서를 풀 리퀘스트에서 검토할 수 있습니다.",
readOnlyBadge: "읽기 전용",
proxy: "프록시",
proxyEnable: "프록시를 통해 데이터베이스 연결",

View File

@ -1,9 +1,11 @@
import { withEnglishFallback } from "./fallback";
import docs from "./docs/pt-BR";
export default withEnglishFallback({
app: {
name: "DBX",
},
docs,
auth: {
rateLimited: "Tente novamente em {seconds}s",
setupTitle: "Configurar senha de acesso",
@ -749,6 +751,9 @@ export default withEnglishFallback({
jdbcMissingRuntimeDependencyHint: "O driver JDBC atual está sem dependências de execução. Por favor, instale-o usando as coordenadas Maven no 'Gerenciamento de Drivers' ou importe o driver e todos os JARs de dependência de uma só vez.",
showSystemSchemas: "Mostrar Schema do sistema",
showSystemSchemasHint: "Exibir Schemas de sistema/metadados integrados na barra lateral e no seletor de Schema para a conexão atual.",
docsNotesPath: "Arquivo de notas",
docsNotesPathPlaceholder: "docs/dbx-docs.json",
docsNotesPathHint: "Onde as notas da documentação são gravadas. Deixe vazio para mantê-las no diretório de dados do aplicativo, ou aponte para um arquivo do seu repositório para revisar a documentação do schema em pull requests.",
sshHostKeyVerifyTitle: "Confirmar chave de host SSH desconhecida",
sshHostKeyVerifyMessage: "Não foi possível confirmar a autenticidade do host '{host}:{port}'. Para evitar ataques man-in-the-middle, verifique a impressão digital da chave do host com o administrador do servidor antes de continuar.",
sshHostKeyVerifyKeyType: "Tipo de chave",

View File

@ -1,9 +1,11 @@
import { withEnglishFallback } from "./fallback";
import docs from "./docs/zh-CN";
export default withEnglishFallback({
app: {
name: "DBX",
},
docs,
auth: {
rateLimited: "请 {seconds} 秒后再试",
setupTitle: "设置访问密码",
@ -774,6 +776,9 @@ export default withEnglishFallback({
readOnlyHint: "阻止所有写操作INSERT、UPDATE、DELETE 等)",
showSystemSchemas: "显示系统 Schema",
showSystemSchemasHint: "为当前连接在侧边栏和 Schema 选择器中显示内置/元数据 Schema。",
docsNotesPath: "笔记文件",
docsNotesPathPlaceholder: "docs/dbx-docs.json",
docsNotesPathHint: "文档笔记的存储位置。留空则保存在应用数据目录中;指向仓库中的文件后,即可在 Pull Request 中审阅 Schema 文档。",
readOnlyBadge: "只读",
proxy: "代理",
proxyEnable: "通过代理连接数据库",

View File

@ -1,9 +1,11 @@
import { withEnglishFallback } from "./fallback";
import docs from "./docs/zh-TW";
export default withEnglishFallback({
app: {
name: "DBX",
},
docs,
auth: {
rateLimited: "請 {seconds} 秒後再試",
setupTitle: "設定存取密碼",
@ -748,6 +750,9 @@ export default withEnglishFallback({
jdbcMissingRuntimeDependencyHint: "目前 JDBC 驅動缺少執行依賴。請在「驅動管理」中使用 Maven 座標安裝,或一次匯入驅動及全部依賴 JAR。",
showSystemSchemas: "顯示系統 Schema",
showSystemSchemasHint: "為目前連線在側邊欄和 Schema 選擇器中顯示內建/中繼資料 Schema。",
docsNotesPath: "筆記檔案",
docsNotesPathPlaceholder: "docs/dbx-docs.json",
docsNotesPathHint: "文件筆記的儲存位置。留空則儲存在應用程式資料目錄中;指向存放庫中的檔案後,即可在 Pull Request 中審閱 Schema 文件。",
sshHostKeyVerifyTitle: "確認未知的 SSH 主機金鑰",
sshHostKeyVerifyMessage: "無法確認主機 '{host}:{port}' 的真實性。為防止中間人攻擊,請在繼續前與伺服器管理員核對主機金鑰指紋。",
sshHostKeyVerifyKeyType: "金鑰類型",

View File

@ -179,6 +179,12 @@ export const prepareSchemaDiff = forward("prepareSchemaDiff");
export const generateSchemaSyncSql = forward("generateSchemaSyncSql");
export const listDialectDataTypes = forward("listDialectDataTypes");
// Docs
export const collectDocsSnapshot = forward("collectDocsSnapshot");
export const loadDocsAnnotations = forward("loadDocsAnnotations");
export const applyDocsAnnotations = forward("applyDocsAnnotations");
export const saveDocsAnnotations = forward("saveDocsAnnotations");
// Query
export const executeQuery = forward("executeQuery");
export const executeMulti = forward("executeMulti");

View File

@ -202,6 +202,7 @@ import type {
} from "@/types/nacos";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
import type { AnnotationFile, SchemaSnapshot } from "@/docs/types";
// ---------------------------------------------------------------------------
// Helpers
@ -839,6 +840,26 @@ export async function listDialectDataTypes(dialectName: string): Promise<string[
return get(`/api/dialect/data-types?${qs({ dialect_name: dialectName })}`);
}
// ---------------------------------------------------------------------------
// Docs
// ---------------------------------------------------------------------------
export async function collectDocsSnapshot(connectionId: string, database: string, schemas: string[], tables: string[], projectName?: string): Promise<SchemaSnapshot> {
return post("/api/docs/snapshot", { connectionId, database, schemas, tables, projectName });
}
export async function loadDocsAnnotations(connectionId: string): Promise<AnnotationFile | null> {
return post("/api/docs/annotations/load", { connectionId });
}
export async function applyDocsAnnotations(connectionId: string, snapshot: SchemaSnapshot, annotations: AnnotationFile): Promise<SchemaSnapshot> {
return post("/api/docs/annotations/apply", { connectionId, snapshot, annotations });
}
export async function saveDocsAnnotations(connectionId: string, annotations: AnnotationFile): Promise<void> {
return post("/api/docs/annotations/save", { connectionId, annotations });
}
// ---------------------------------------------------------------------------
// Query
// ---------------------------------------------------------------------------

View File

@ -59,6 +59,7 @@ import type {
ExternalSqlFileVersion,
} from "@/types/database";
import { isTauriCommandUnavailable, normalizeConnectionTestResult } from "@/lib/connection/connectionDatabaseInfo";
import type { AnnotationFile, SchemaSnapshot } from "@/docs/types";
import type { CollectionInfo } from "@/types/database";
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
import type { AiChatSelectionState, AiConfig, AiConfigItem, AiEffortCapability, AiEffortLevel, AiTestConnectionResult } from "@/types/ai";
@ -1662,6 +1663,24 @@ export async function listAvailableExtensions(connectionId: string, database: st
return invoke("list_available_extensions", { connectionId, database });
}
// --- Docs ---
export async function collectDocsSnapshot(connectionId: string, database: string, schemas: string[], tables: string[], projectName?: string): Promise<SchemaSnapshot> {
return invoke("docs_collect_snapshot", { connectionId, database, schemas, tables, projectName });
}
export async function loadDocsAnnotations(connectionId: string): Promise<AnnotationFile | null> {
return invoke("docs_load_annotations", { connectionId });
}
export async function applyDocsAnnotations(connectionId: string, snapshot: SchemaSnapshot, annotations: AnnotationFile): Promise<SchemaSnapshot> {
return invoke("docs_apply_annotations", { connectionId, snapshot, annotations });
}
export async function saveDocsAnnotations(connectionId: string, annotations: AnnotationFile): Promise<void> {
return invoke("docs_save_annotations", { connectionId, annotations });
}
export async function saveConnections(configs: ConnectionConfig[]): Promise<void> {
return invoke("save_connections", { configs });
}

View File

@ -389,6 +389,12 @@ export const useConnectionStore = defineStore("connection", () => {
schema?: string;
tableName?: string;
} | null>(null);
const docsSource = ref<{
connectionId: string;
database: string;
schema?: string;
tableName?: string;
} | null>(null);
const tableImportSource = ref<{
connectionId: string;
database: string;
@ -1052,6 +1058,10 @@ export const useConnectionStore = defineStore("connection", () => {
agent_java_options: Array.isArray(config.agent_java_options) ? config.agent_java_options : [],
attached_databases: Array.isArray(config.attached_databases) ? config.attached_databases.filter((database) => database.name?.trim() && database.path?.trim()) : [],
init_script: config.init_script?.trim() ? config.init_script : undefined,
// A cleared field must become absent, not "". `resolve_notes_path` treats
// blank as unset anyway, but an empty string would still be written to
// the config file as though a path had been chosen.
docs_notes_path: config.docs_notes_path?.trim() ? config.docs_notes_path.trim() : undefined,
transport_layers: Array.isArray(config.transport_layers) ? config.transport_layers : [],
show_system_schemas: config.show_system_schemas === true,
connect_timeout_secs: config.connect_timeout_secs || 10,
@ -7059,6 +7069,7 @@ export const useConnectionStore = defineStore("connection", () => {
dataCompareSource,
sqlFileSource,
diagramSource,
docsSource,
tableImportSource,
tableDataGenerateSource,
fieldLineageSource,

View File

@ -144,6 +144,12 @@ export interface ConnectionConfig {
attached_databases?: AttachedDatabaseConfig[];
init_script?: string;
color?: string;
/**
* Where this connection's documentation notes are stored. Absent means the
* per-connection default inside the app data directory; an explicit path
* lets the notes file live in a repository and be reviewed in pull requests.
*/
docs_notes_path?: string;
transport_layers?: TransportLayerConfig[];
connect_timeout_secs?: number;
query_timeout_secs?: number;

View File

@ -7,6 +7,7 @@ use dbx_core::{
types::{ColumnInfo, QueryResult, TableInfo},
};
use dbx_mcp::{
backend::DocsSnapshotOptions,
mongo::{self, MongoSafetyError},
DbxBackend, LocalBackend, WebBackend,
};
@ -91,6 +92,8 @@ struct Flags {
max_rows: Option<usize>,
timeout_ms: Option<u64>,
file: Option<PathBuf>,
out: Option<PathBuf>,
notes: Option<PathBuf>,
allow_writes: bool,
allow_dangerous: bool,
help: bool,
@ -109,6 +112,22 @@ impl CliError {
}
}
/// `--notes` names a file explicitly, so a missing one is a typo rather than
/// "no notes yet".
///
/// `load_annotations` deliberately returns `Ok(None)` for a missing file,
/// because the implicit per-connection notes path may legitimately not exist
/// yet. That is the right behaviour there and the wrong behaviour here: without
/// this check, `--notes ./typo.json` produces DBML with every note silently
/// absent and no diagnostic at all, which reads exactly like a database that
/// has no documentation.
fn require_notes_file(path: &std::path::Path) -> Result<(), CliError> {
if path.exists() {
return Ok(());
}
Err(CliError::new("NOTES_NOT_FOUND", format!("Notes file {} does not exist.", path.display())))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Diagnostics {
@ -223,6 +242,9 @@ async fn run_with_backend(backend: &dyn DbxBackend, flags: Flags) -> Result<Stri
if args.first().is_some_and(|arg| arg == "context") {
return run_context(backend, &flags).await;
}
if args.first().is_some_and(|arg| arg == "dbml") {
return run_dbml(backend, &flags).await;
}
if args.first().is_some_and(|arg| arg == "open") {
ensure_arg_count(args, 3, "dbx open")?;
let connection = required(args.get(1), "Connection name is required.")?;
@ -436,6 +458,46 @@ async fn run_context(backend: &dyn DbxBackend, flags: &Flags) -> Result<String,
Ok(output)
}
async fn run_dbml(backend: &dyn DbxBackend, flags: &Flags) -> Result<String, CliError> {
let args = &flags.args;
let connection_name = required(args.get(1), "Connection name is required.")?;
let connection = find_connection(backend, connection_name).await?;
let database = selected_database(&connection, flags.database.as_deref());
let options = DocsSnapshotOptions {
schemas: flags.schema.clone().into_iter().collect(),
tables: flags.tables.clone(),
project_name: Some(connection.name.clone()),
};
let mut snapshot = backend.collect_docs_snapshot(&connection, &database, options).await.map_err(command_error)?;
if let Some(path) = flags.notes.as_ref() {
require_notes_file(path)?;
if let Some(annotations) = dbx_core::docs::annotations::load_annotations(path)
.map_err(|error| CliError::new("NOTES_INVALID", error))?
{
dbx_core::docs::annotations::apply_annotations(&mut snapshot, &annotations, connection.db_type);
}
}
let output = dbx_core::docs::to_dbml(&snapshot);
for warning in &output.warnings {
eprintln!("warning: {warning}");
}
match flags.out.as_ref() {
Some(path) => {
std::fs::write(path, &output.text).map_err(|error| {
CliError::new("WRITE_FAILED", format!("Failed to write {}: {error}", path.display()))
})?;
Ok(format!("Wrote {} bytes to {}", output.text.len(), path.display()))
}
None => Ok(output.text),
}
}
async fn find_connection(backend: &dyn DbxBackend, name: &str) -> Result<ConnectionConfig, CliError> {
backend
.load_connections()
@ -461,6 +523,8 @@ fn parse_flags(argv: &[String]) -> Result<Flags, CliError> {
max_rows: None,
timeout_ms: None,
file: None,
out: None,
notes: None,
allow_writes: false,
allow_dangerous: false,
help: false,
@ -505,6 +569,8 @@ fn parse_flags(argv: &[String]) -> Result<Flags, CliError> {
flags.timeout_ms = Some(duration_ms(&option_value(argv, &mut index, "--timeout")?, "--timeout")?)
}
"--file" => flags.file = Some(PathBuf::from(option_value(argv, &mut index, "--file")?)),
"--out" => flags.out = Some(PathBuf::from(option_value(argv, &mut index, "--out")?)),
"--notes" => flags.notes = Some(PathBuf::from(option_value(argv, &mut index, "--notes")?)),
"--allow-writes" => flags.allow_writes = true,
"--allow-dangerous-sql" => flags.allow_dangerous = true,
value if value.starts_with('-') => {
@ -833,12 +899,31 @@ fn csv_cell(value: &str) -> String {
}
fn usage() -> &'static str {
"Usage:\n dbx doctor [--json]\n dbx capabilities [--json]\n dbx connections list [--json]\n dbx schema list <connection> [--schema name] [--json]\n dbx schema describe <connection> <table> [--schema name] [--json]\n dbx query <connection> <sql> [--file path] [--limit n] [--timeout 10s] [--allow-writes] [--allow-dangerous-sql] [--json]\n dbx context <connection> [--schema name] [--tables a,b] [--max-tables n] [--json]\n dbx open <connection> <table> [--schema name] [--database name] [--json]"
"Usage:\n dbx doctor [--json]\n dbx capabilities [--json]\n dbx connections list [--json]\n dbx schema list <connection> [--schema name] [--json]\n dbx schema describe <connection> <table> [--schema name] [--json]\n dbx query <connection> <sql> [--file path] [--limit n] [--timeout 10s] [--allow-writes] [--allow-dangerous-sql] [--json]\n dbx context <connection> [--schema name] [--tables a,b] [--max-tables n] [--json]\n dbx dbml <connection> [--out path] [--notes path] [--schema name] [--database name] [--tables a,b]\n dbx open <connection> <table> [--schema name] [--database name] [--json]"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn require_notes_file_accepts_an_existing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("notes.json");
std::fs::write(&path, "{}").expect("write");
assert!(require_notes_file(&path).is_ok());
}
#[test]
fn require_notes_file_rejects_a_mistyped_path() {
// The regression: without this, `--notes ./typo.json` emitted DBML with
// every note missing and printed nothing, indistinguishable from a
// database that genuinely has no documentation.
let dir = tempfile::tempdir().expect("tempdir");
let error = require_notes_file(&dir.path().join("typo.json")).expect_err("missing file must error");
assert_eq!(error.code, "NOTES_NOT_FOUND");
assert!(error.message.contains("typo.json"), "message names the path: {}", error.message);
}
use async_trait::async_trait;
use dbx_core::{
agent_events::ToolResult,
@ -1057,4 +1142,40 @@ mod tests {
.unwrap();
run_with_backend(&backend, cleanup).await.expect("final cleanup");
}
#[test]
fn parses_the_out_flag() {
let flags = parse_flags(&args(&["dbml", "local", "--out", "schema.dbml"])).expect("parse");
assert_eq!(flags.args, args(&["dbml", "local"]));
assert_eq!(flags.out.as_deref(), Some(std::path::Path::new("schema.dbml")));
}
#[test]
fn out_requires_a_value() {
let error = parse_flags(&args(&["dbml", "local", "--out"])).expect_err("should fail");
assert_eq!(error.code, "INVALID_OPTION");
}
#[test]
fn parses_the_notes_flag() {
let flags = parse_flags(&args(&["dbml", "local", "--notes", "docs/dbx-docs.json"])).expect("parse");
assert_eq!(flags.args, args(&["dbml", "local"]));
assert_eq!(flags.notes.as_deref(), Some(std::path::Path::new("docs/dbx-docs.json")));
}
#[test]
fn notes_requires_a_value() {
let error = parse_flags(&args(&["dbml", "local", "--notes"])).expect_err("should fail");
assert_eq!(error.code, "INVALID_OPTION");
}
#[test]
fn notes_appears_in_the_usage_text() {
assert!(usage().contains("--notes"), "got: {}", usage());
}
#[test]
fn dbml_appears_in_the_usage_text() {
assert!(usage().contains("dbx dbml <connection>"), "got: {}", usage());
}
}

View File

@ -151,6 +151,7 @@ fn env_required(name: &str) -> Result<String, String> {
fn connection_config(id: &str, database: BenchDatabase) -> Result<ConnectionConfig, String> {
let database_name = env_required("DBX_BENCH_DATABASE")?;
Ok(ConnectionConfig {
docs_notes_path: None,
id: id.to_string(),
name: id.to_string(),
note: String::new(),

View File

@ -634,6 +634,7 @@ mod tests {
fn config(db_type: DatabaseType, database: Option<&str>) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: "conn".to_string(),
name: "Connection".to_string(),
note: String::new(),

View File

@ -1151,6 +1151,7 @@ for line in sys.stdin:
#[cfg(unix)]
fn agent_test_connection(id: &str, name: &str, db_type: DatabaseType, database: &str) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: id.to_string(),
name: name.to_string(),
note: String::new(),

View File

@ -1471,6 +1471,7 @@ mod tests {
fn postgres_connection(id: &str, password: &str) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: id.to_string(),
name: "Postgres".to_string(),
note: String::new(),
@ -1528,6 +1529,7 @@ mod tests {
fn nacos_connection(id: &str, password: &str) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: id.to_string(),
name: "Nacos".to_string(),
note: String::new(),
@ -1622,6 +1624,7 @@ mod tests {
#[test]
fn scrubs_connection_secret_fields() {
let mut config = ConnectionConfig {
docs_notes_path: None,
id: "id".to_string(),
name: "name".to_string(),
note: String::new(),

View File

@ -4987,6 +4987,7 @@ mod tests {
fn mysql_config(database: Option<&str>) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: "conn".to_string(),
name: "MySQL".to_string(),
note: String::new(),

View File

@ -824,6 +824,7 @@ mod tests {
fn connection(id: &str, password: &str, _ssh_password: &str) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: id.to_string(),
name: format!("{id} connection"),
note: String::new(),

View File

@ -5333,6 +5333,7 @@ mod tests {
fn redis_test_connection_config() -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: "redis".to_string(),
name: "Redis".to_string(),
note: String::new(),

View File

@ -0,0 +1,873 @@
use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::docs::keys::{column_key, fold_identifier, table_key};
use crate::docs::{ColumnNote, NoteSource, SchemaSnapshot, SnapshotWarning, TableGroup};
use crate::models::connection::DatabaseType;
/// The on-disk notes file. This IS the store — not a cache of anything.
/// It is meant to be committed to a repository and reviewed in pull
/// requests, so it must stay small, readable, and stable in key order.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AnnotationFile {
pub format_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<ProjectAnnotation>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<GroupAnnotation>,
/// Keyed by `schema.table` (or bare `table` on schema-less engines).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub tables: BTreeMap<String, TableAnnotation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ProjectAnnotation {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// Markdown. Becomes the documentation landing page.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct GroupAnnotation {
/// Stable slug, referenced by `TableAnnotation::group`.
pub id: String,
pub name: String,
/// 0..=359. Lightness and chroma are theme-controlled, so any hue is
/// legible on both light and dark grounds by construction.
pub hue: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TableAnnotation {
/// References `GroupAnnotation::id`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
/// Keyed by bare column name.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub columns: BTreeMap<String, ColumnAnnotation>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ColumnAnnotation {
pub note: String,
}
/// The only format version this build understands.
pub const ANNOTATION_FORMAT_VERSION: u32 = 1;
/// Load the notes file.
///
/// An ABSENT file returns `Ok(None)` — that is the normal first-run and
/// first-CI-run state. A MALFORMED file is a hard error: someone's prose is
/// in there, and rendering apparently-complete documentation while silently
/// discarding it is worse than failing.
pub fn load_annotations(path: &Path) -> Result<Option<AnnotationFile>, String> {
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(format!("Failed to read notes file {}: {error}", path.display())),
};
// Read the version BEFORE full deserialization. `deny_unknown_fields`
// would otherwise reject a future-format file with a confusing
// "unknown field" error, never reaching the version check — defeating
// the entire purpose of having a version field.
let probe: serde_json::Value = serde_json::from_str(&contents)
.map_err(|error| format!("Failed to parse notes file {}: {error}", path.display()))?;
match probe.get("formatVersion").and_then(serde_json::Value::as_u64) {
Some(version) if version == u64::from(ANNOTATION_FORMAT_VERSION) => {}
Some(version) => {
return Err(format!(
"Notes file {} has formatVersion {version}, but this build understands {}.",
path.display(),
ANNOTATION_FORMAT_VERSION
))
}
None => return Err(format!("Notes file {} is missing formatVersion.", path.display())),
}
let parsed: AnnotationFile = serde_json::from_value(probe)
.map_err(|error| format!("Failed to parse notes file {}: {error}", path.display()))?;
Ok(Some(parsed))
}
/// A unique sibling temp path for an atomic save.
///
/// MUST be unique per call: a temp name derived from the target alone is
/// shared by every concurrent writer, so two saves interleave their bytes into
/// one file and the last rename publishes the mixture. MUST also be a sibling
/// — rename is only atomic within a filesystem.
///
/// The base name is truncated so the whole component stays within the 255-byte
/// limit most filesystems enforce on a single path component. The wrapper
/// costs 14 bytes, so a long-but-valid target name would otherwise make every
/// save fail with ENAMETOOLONG — a regression against the previous 4-byte
/// `.tmp` suffix. Truncating the base is safe because uniqueness comes from
/// the uuid, not from the name.
fn temp_save_path(path: &Path) -> PathBuf {
/// Most filesystems cap one path component at 255 bytes.
const MAX_COMPONENT: usize = 255;
/// `.` + `.` + 8 hex + `.tmp`
const WRAPPER: usize = 14;
let name = path.file_name().map(|value| value.to_string_lossy().into_owned()).unwrap_or_default();
let budget = MAX_COMPONENT - WRAPPER;
// Truncate on a char boundary — slicing a String by bytes can split a
// multi-byte character and panic.
let mut used = 0usize;
let trimmed: String = name
.chars()
.take_while(|character| {
used += character.len_utf8();
used <= budget
})
.collect();
let unique = uuid::Uuid::new_v4().simple().to_string();
path.with_file_name(format!(".{trimmed}.{}.tmp", &unique[..8]))
}
/// Write the notes file atomically.
///
/// A partial write destroys prose a human typed, and `load_annotations`
/// errors loudly on malformed JSON — so a torn write becomes "your notes file
/// is corrupt" the next time the viewer opens. Write a sibling temp file,
/// flush it to disk, then rename: rename within a directory is atomic on
/// every platform DBX targets.
pub fn save_annotations(path: &Path, annotations: &AnnotationFile) -> Result<(), String> {
let json =
serde_json::to_string_pretty(annotations).map_err(|error| format!("Failed to serialize notes: {error}"))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|error| format!("Failed to create {}: {error}", parent.display()))?;
}
let temp = temp_save_path(path);
{
let mut file =
std::fs::File::create(&temp).map_err(|error| format!("Failed to create {}: {error}", temp.display()))?;
file.write_all(json.as_bytes()).map_err(|error| {
let _ = std::fs::remove_file(&temp);
format!("Failed to write {}: {error}", temp.display())
})?;
file.sync_all().map_err(|error| {
let _ = std::fs::remove_file(&temp);
format!("Failed to flush {}: {error}", temp.display())
})?;
}
std::fs::rename(&temp, path).map_err(|error| {
let _ = std::fs::remove_file(&temp);
format!("Failed to replace {}: {error}", path.display())
})
}
/// Where a connection's notes file lives.
///
/// An explicit `docs_notes_path` wins — that is the entire point of the field.
/// Pointing it at a file inside a repository is what lets schema documentation
/// be reviewed in pull requests. Otherwise the file lives under the app data
/// directory keyed by connection id, so the feature works with no setup.
///
/// Takes the two fields it needs rather than a whole `ConnectionConfig`:
/// that struct has no `Default` and ~60 fields, so passing it would force
/// every test to build a literal full of values the function never reads.
/// `data_dir` is a parameter because `dbx-core` cannot reach the caller's
/// data directory on its own.
pub fn resolve_notes_path(connection_id: &str, docs_notes_path: Option<&str>, data_dir: &Path) -> PathBuf {
if let Some(path) = docs_notes_path.map(str::trim).filter(|value| !value.is_empty()) {
return PathBuf::from(path);
}
data_dir.join("docs-notes").join(format!("{connection_id}.json"))
}
/// Merge a notes file into a collected snapshot.
///
/// Precedence is `local ?? database_comment`. When a local note shadows a
/// database comment the comment is kept in `shadowed_note`, so a later
/// `COMMENT ON` improvement stays visible rather than being silently hidden.
pub fn apply_annotations(snapshot: &mut SchemaSnapshot, annotations: &AnnotationFile, db_type: DatabaseType) {
if let Some(project) = annotations.project.as_ref() {
if let Some(name) = project.name.as_deref().filter(|value| !value.trim().is_empty()) {
snapshot.project.name = name.to_string();
}
if project.note.is_some() {
snapshot.project.note = project.note.clone();
}
}
let known_groups: std::collections::HashSet<&str> =
annotations.groups.iter().map(|group| group.id.as_str()).collect();
let mut seen_group_ids = std::collections::HashSet::new();
snapshot.groups = annotations
.groups
.iter()
// A duplicate id would emit two TableGroup blocks with the same
// name, which is invalid DBML. First occurrence wins.
.filter(|group| seen_group_ids.insert(group.id.as_str()))
.map(|group| TableGroup {
id: group.id.clone(),
name: group.name.clone(),
hue: group.hue,
note: group.note.clone(),
})
.collect();
for table in &mut snapshot.tables {
let key = table_key(db_type, table.schema.as_deref(), &table.name);
let Some(annotation) = annotations.tables.get(&key) else { continue };
if let Some(note) = annotation.note.as_deref().filter(|value| !value.trim().is_empty()) {
// Preserve whatever the database said before overwriting it.
if matches!(table.note_source, NoteSource::Database) {
table.shadowed_note = table.note.clone();
}
table.note = Some(note.to_string());
table.note_source = NoteSource::Local;
}
// A group reference that names no defined group is dropped rather
// than assigned — a dangling id would render an empty group header.
table.group_id = annotation.group.as_deref().filter(|id| known_groups.contains(id)).map(ToOwned::to_owned);
for column in &table.columns {
let column_fold = fold_identifier(db_type, &column.name);
let annotated = annotation.columns.iter().find(|(name, _)| fold_identifier(db_type, name) == column_fold);
let Some((_, column_annotation)) = annotated else { continue };
if column_annotation.note.trim().is_empty() {
continue;
}
table.column_notes.insert(
column.name.clone(),
ColumnNote {
note: column_annotation.note.clone(),
source: NoteSource::Local,
shadowed: column.comment.clone().filter(|value| !value.trim().is_empty()),
},
);
}
}
let orphans = detect_orphans(snapshot, annotations, db_type);
if !orphans.is_empty() {
snapshot.warnings.push(SnapshotWarning::OrphanedNotes { count: orphans.len() });
}
}
/// Annotation keys whose target no longer exists in the collected schema.
///
/// Returns fully-qualified keys, sorted, so the caller can list them for a
/// human to re-map. This function NEVER mutates the notes file — user prose
/// is only ever removed by an explicit human action.
///
/// Suggestions for where a renamed target went are deliberately absent:
/// producing them requires the OLD schema to diff against, and the notes
/// file stores prose only. That becomes possible once snapshot history
/// exists (see the spec's deferred versioning seam).
pub fn detect_orphans(snapshot: &SchemaSnapshot, annotations: &AnnotationFile, db_type: DatabaseType) -> Vec<String> {
use std::collections::HashSet;
let live_tables: HashSet<String> =
snapshot.tables.iter().map(|table| table_key(db_type, table.schema.as_deref(), &table.name)).collect();
let live_columns: HashSet<String> = snapshot
.tables
.iter()
.flat_map(|table| {
table
.columns
.iter()
.map(move |column| column_key(db_type, table.schema.as_deref(), &table.name, &column.name))
})
.collect();
let mut orphans = Vec::new();
for (key, annotation) in &annotations.tables {
let folded_table = fold_key(db_type, key);
if !live_tables.contains(&folded_table) {
orphans.push(folded_table);
continue;
}
for column in annotation.columns.keys() {
let folded_column = format!("{folded_table}.{}", fold_identifier(db_type, column));
if !live_columns.contains(&folded_column) {
orphans.push(folded_column);
}
}
}
orphans.sort();
orphans
}
/// Fold an already-dotted key (e.g. `Core.Orders`) segment by segment.
fn fold_key(db_type: DatabaseType, key: &str) -> String {
key.split('.').map(|segment| fold_identifier(db_type, segment)).collect::<Vec<_>>().join(".")
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r##"{
"formatVersion": 1,
"project": { "name": "Ecommerce", "note": "# Overview" },
"groups": [
{ "id": "order-management", "name": "Order Management", "hue": 28, "note": "Checkout to handoff." }
],
"tables": {
"core.orders": {
"group": "order-management",
"note": "One row per checkout.",
"columns": { "status": { "note": "State machine." } }
}
}
}"##;
#[test]
fn parses_a_complete_notes_file() {
let parsed: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
assert_eq!(parsed.format_version, 1);
assert_eq!(parsed.project.as_ref().unwrap().name.as_deref(), Some("Ecommerce"));
assert_eq!(parsed.groups.len(), 1);
assert_eq!(parsed.groups[0].hue, 28);
assert_eq!(parsed.tables.len(), 1);
let orders = parsed.tables.get("core.orders").expect("orders");
assert_eq!(orders.group.as_deref(), Some("order-management"));
assert_eq!(orders.note.as_deref(), Some("One row per checkout."));
assert_eq!(orders.columns.get("status").unwrap().note, "State machine.");
}
#[test]
fn a_minimal_file_needs_only_the_format_version() {
let parsed: AnnotationFile = serde_json::from_str(r#"{"formatVersion": 1}"#).expect("parse");
assert!(parsed.tables.is_empty());
assert!(parsed.groups.is_empty());
assert!(parsed.project.is_none());
}
#[test]
fn round_trips_through_json() {
let parsed: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
let written = serde_json::to_string(&parsed).expect("serialize");
let reparsed: AnnotationFile = serde_json::from_str(&written).expect("reparse");
// Every field in SAMPLE must survive a write/read cycle. Asserting
// only a count here would pass against a model that silently drops
// fields on write via a wrong skip_serializing_if predicate.
assert_eq!(reparsed.format_version, 1);
let project = reparsed.project.as_ref().expect("project survived");
assert_eq!(project.name.as_deref(), Some("Ecommerce"));
assert_eq!(project.note.as_deref(), Some("# Overview"));
assert_eq!(reparsed.groups.len(), 1);
let group = &reparsed.groups[0];
assert_eq!(group.id, "order-management");
assert_eq!(group.name, "Order Management");
assert_eq!(group.hue, 28);
assert_eq!(group.note.as_deref(), Some("Checkout to handoff."));
assert_eq!(reparsed.tables.len(), 1);
let orders = reparsed.tables.get("core.orders").expect("orders survived");
assert_eq!(orders.group.as_deref(), Some("order-management"));
assert_eq!(orders.note.as_deref(), Some("One row per checkout."));
assert_eq!(orders.columns.len(), 1);
assert_eq!(orders.columns.get("status").expect("column survived").note, "State machine.");
}
#[test]
fn rejects_a_file_with_an_unknown_top_level_field() {
// Typos in a hand-edited file must not be silently ignored — a
// misspelled "tabels" key would otherwise discard every note in it.
let result: Result<AnnotationFile, _> = serde_json::from_str(r#"{"formatVersion": 1, "tabels": {}}"#);
assert!(result.is_err(), "unknown fields must be rejected");
}
fn temp_notes(contents: &str) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!("dbx-notes-test-{}.json", uuid::Uuid::new_v4()));
std::fs::write(&path, contents).expect("write temp notes file");
path
}
fn temp_case_dir(label: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("dbx-notes-{label}-{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&dir).expect("create temp dir");
dir
}
#[test]
fn save_then_load_round_trips() {
let dir = temp_case_dir("round-trip");
let path = dir.join("notes.json");
let file = AnnotationFile {
format_version: 1,
project: Some(ProjectAnnotation { name: Some("P".into()), note: Some("hello".into()) }),
groups: vec![GroupAnnotation { id: "g".into(), name: "G".into(), hue: 200, note: None }],
tables: BTreeMap::from([(
"public.t".to_string(),
TableAnnotation { group: Some("g".into()), note: Some("n".into()), columns: BTreeMap::new() },
)]),
};
save_annotations(&path, &file).expect("save");
let loaded = load_annotations(&path).expect("load").expect("present");
assert_eq!(loaded.format_version, 1);
assert_eq!(loaded.groups[0].hue, 200);
assert_eq!(loaded.tables["public.t"].note.as_deref(), Some("n"));
assert_eq!(loaded.project.and_then(|p| p.note).as_deref(), Some("hello"));
}
#[test]
fn save_creates_missing_parent_directories() {
let dir = temp_case_dir("nested");
let path = dir.join("nested").join("deeper").join("notes.json");
let file = AnnotationFile { format_version: 1, project: None, groups: Vec::new(), tables: BTreeMap::new() };
save_annotations(&path, &file).expect("save into a new directory");
assert!(path.exists());
}
#[test]
fn save_leaves_no_temp_file_behind() {
// A stray notes.json.tmp would be picked up by nothing, but it means
// the rename did not happen and the write was not atomic.
let dir = temp_case_dir("no-temp-left");
let path = dir.join("notes.json");
let file = AnnotationFile { format_version: 1, project: None, groups: Vec::new(), tables: BTreeMap::new() };
save_annotations(&path, &file).expect("save");
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.expect("read_dir")
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.ends_with(".tmp"))
.collect();
assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}");
}
#[cfg(unix)]
#[test]
fn a_save_replaces_the_file_by_rename_rather_than_writing_in_place() {
// The whole point of temp-and-rename. A reader either sees the old
// file or the new one, never a half-written mix — load_annotations
// errors loudly on malformed JSON, so a torn write reads as "your
// notes file is corrupt".
//
// Writing in place keeps the inode; renaming over the target changes
// it. That is the difference, and it is observable without having to
// make a write fail halfway.
use std::os::unix::fs::MetadataExt;
let dir = temp_case_dir("atomic-replace");
let path = dir.join("notes.json");
let first = AnnotationFile {
format_version: 1,
project: None,
groups: Vec::new(),
tables: BTreeMap::from([(
"public.a".to_string(),
TableAnnotation { group: None, note: Some("first".into()), columns: BTreeMap::new() },
)]),
};
save_annotations(&path, &first).expect("first save");
let before = std::fs::metadata(&path).expect("metadata").ino();
let second = AnnotationFile {
format_version: 1,
project: None,
groups: Vec::new(),
tables: BTreeMap::from([(
"public.b".to_string(),
TableAnnotation { group: None, note: Some("second".into()), columns: BTreeMap::new() },
)]),
};
save_annotations(&path, &second).expect("second save");
let after = std::fs::metadata(&path).expect("metadata").ino();
assert_ne!(before, after, "an atomic save replaces the file by rename, never writes in place");
let loaded = load_annotations(&path).expect("load").expect("present");
assert_eq!(loaded.tables["public.b"].note.as_deref(), Some("second"));
}
#[test]
fn an_explicit_notes_path_wins_over_the_default() {
let resolved = resolve_notes_path("conn-1", Some("/tmp/team/schema-notes.json"), std::path::Path::new("/data"));
assert_eq!(resolved, std::path::PathBuf::from("/tmp/team/schema-notes.json"));
}
#[test]
fn the_default_notes_path_is_keyed_by_connection_id() {
let resolved = resolve_notes_path("conn-1", None, std::path::Path::new("/data"));
assert_eq!(resolved, std::path::PathBuf::from("/data/docs-notes/conn-1.json"));
}
#[test]
fn a_blank_notes_path_falls_back_to_the_default() {
// An empty string in the config is a cleared field, not a path to a
// file named "". Treating it as explicit would resolve to garbage.
let resolved = resolve_notes_path("conn-1", Some(" "), std::path::Path::new("/data"));
assert_eq!(resolved, std::path::PathBuf::from("/data/docs-notes/conn-1.json"));
}
#[test]
fn each_save_uses_a_distinct_temp_path() {
// A temp name derived from the target alone is shared by every
// concurrent writer, so two saves interleave into one file and the
// last rename publishes the mixture.
let target = std::path::Path::new("/data/docs-notes/conn-1.json");
let first = temp_save_path(target);
let second = temp_save_path(target);
assert_ne!(first, second);
}
#[test]
fn the_temp_path_is_a_sibling_of_the_target() {
// rename is only atomic within one filesystem. A temp file in /tmp
// could be on a different mount, making the rename a copy.
let target = std::path::Path::new("/data/docs-notes/conn-1.json");
assert_eq!(temp_save_path(target).parent(), target.parent());
}
#[test]
fn a_failed_write_leaves_no_temp_file_in_the_target_directory() {
// docs_notes_path is meant to point into a user's repository, so
// debris from a failed save shows up in their git status.
let dir = temp_case_dir("failed-write-debris");
let path = dir.join("notes.json");
// A directory at the target makes the rename fail after the temp file
// has been written.
std::fs::create_dir(&path).expect("block the target path");
let file = AnnotationFile { format_version: 1, project: None, groups: Vec::new(), tables: BTreeMap::new() };
let result = save_annotations(&path, &file);
assert!(result.is_err(), "save must fail when the target is a directory");
let leftovers: Vec<_> = std::fs::read_dir(&dir)
.expect("read_dir")
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.ends_with(".tmp"))
.collect();
assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}");
}
#[test]
fn a_long_target_name_still_yields_a_usable_temp_component() {
// The wrapper costs 14 bytes. Without a budget, a 245-byte name makes
// a 259-byte component and every save fails with ENAMETOOLONG — a
// regression against the old 4-byte `.tmp` suffix.
let name = format!("{}.json", "a".repeat(240));
let target = std::path::Path::new("/data").join(&name);
let temp = temp_save_path(&target);
let component = temp.file_name().expect("file name").to_string_lossy().len();
assert!(component <= 255, "temp component is {component} bytes: {temp:?}");
}
#[test]
fn a_long_multibyte_target_name_is_truncated_on_a_char_boundary() {
// Slicing a String by bytes can split a multi-byte character and
// panic. This name is 3 bytes per character.
let name = format!("{}.json", "é".repeat(200));
let target = std::path::Path::new("/data").join(&name);
let temp = temp_save_path(&target); // must not panic
let component = temp.file_name().expect("file name").to_string_lossy().len();
assert!(component <= 255, "temp component is {component} bytes");
}
#[test]
fn a_long_target_name_still_saves_and_loads() {
// End to end: the budget is only useful if the save actually works.
let dir = temp_case_dir("long-name");
let path = dir.join(format!("{}.json", "a".repeat(200)));
let file = AnnotationFile { format_version: 1, project: None, groups: Vec::new(), tables: BTreeMap::new() };
save_annotations(&path, &file).expect("save with a long name");
assert!(load_annotations(&path).expect("load").is_some());
}
#[test]
fn an_absent_file_is_not_an_error() {
let missing = std::path::Path::new("/nonexistent/dbx-notes-does-not-exist.json");
assert!(matches!(load_annotations(missing), Ok(None)));
}
#[test]
fn a_valid_file_loads() {
let path = temp_notes(SAMPLE);
let loaded = load_annotations(&path);
let _ = std::fs::remove_file(&path);
let loaded = loaded.expect("load").expect("some");
assert_eq!(loaded.tables.len(), 1);
}
#[test]
fn a_malformed_file_is_a_hard_error_naming_the_path() {
let path = temp_notes("{ this is not json");
let error = load_annotations(&path);
let _ = std::fs::remove_file(&path);
let error = error.expect_err("must fail");
assert!(error.contains(&path.display().to_string()), "error must name the file: {error}");
}
#[test]
fn an_unsupported_format_version_is_rejected() {
let path = temp_notes(r#"{"formatVersion": 99}"#);
let error = load_annotations(&path);
let _ = std::fs::remove_file(&path);
let error = error.expect_err("must fail");
assert!(error.contains("99"), "error must name the version: {error}");
}
#[test]
fn a_future_format_version_reports_the_version_not_an_unknown_field() {
// deny_unknown_fields must NOT pre-empt the version check — a v1
// build reading a v2 file has to say so, or the version field is
// useless exactly when it is needed.
let path = temp_notes(r#"{"formatVersion": 2, "someNewField": {"a": 1}}"#);
let error = load_annotations(&path);
let _ = std::fs::remove_file(&path);
let error = error.expect_err("must fail");
assert!(error.contains("formatVersion 2"), "should name the version, got: {error}");
assert!(!error.contains("unknown field"), "should not surface a raw serde error, got: {error}");
}
use crate::docs::{DocTable, NoteSource, ProjectMeta, SchemaSnapshot, TableKind};
use crate::models::connection::DatabaseType;
fn snapshot_with(tables: Vec<DocTable>) -> SchemaSnapshot {
SchemaSnapshot {
format_version: 1,
project: ProjectMeta {
name: "conn".to_string(),
database_type: "postgres".to_string(),
database: None,
schemas: vec!["core".to_string()],
generated_at: String::new(),
note: None,
},
tables,
relationships: vec![],
groups: vec![],
enums: vec![],
warnings: vec![],
}
}
fn table_named(schema: &str, name: &str, comment: Option<&str>) -> DocTable {
DocTable {
schema: Some(schema.to_string()),
name: name.to_string(),
kind: TableKind::Table,
columns: vec![],
indexes: vec![],
foreign_keys: vec![],
group_id: None,
note: comment.map(ToOwned::to_owned),
note_source: if comment.is_some() { NoteSource::Database } else { NoteSource::None },
shadowed_note: None,
column_notes: BTreeMap::new(),
estimated_rows: None,
view_definition: None,
}
}
#[test]
fn a_local_note_shadows_the_database_comment_and_preserves_it() {
let mut snapshot = snapshot_with(vec![table_named("core", "orders", Some("Old DB comment."))]);
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
let table = &snapshot.tables[0];
assert_eq!(table.note.as_deref(), Some("One row per checkout."));
assert_eq!(table.note_source, NoteSource::Local);
assert_eq!(table.shadowed_note.as_deref(), Some("Old DB comment."));
}
#[test]
fn a_database_comment_survives_when_there_is_no_local_note() {
let mut snapshot = snapshot_with(vec![table_named("core", "users", Some("From the database."))]);
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
let table = &snapshot.tables[0];
assert_eq!(table.note.as_deref(), Some("From the database."));
assert_eq!(table.note_source, NoteSource::Database);
assert_eq!(table.shadowed_note, None);
}
#[test]
fn keys_match_case_insensitively_on_postgres() {
// The notes file says "core.orders"; the live schema reports "Core"/"Orders".
let mut snapshot = snapshot_with(vec![table_named("Core", "Orders", None)]);
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
assert_eq!(snapshot.tables[0].note.as_deref(), Some("One row per checkout."));
}
#[test]
fn column_notes_are_applied_and_marked_local() {
let mut table = table_named("core", "orders", None);
table.columns.push(crate::types::ColumnInfo {
name: "status".to_string(),
data_type: "text".to_string(),
..Default::default()
});
let mut snapshot = snapshot_with(vec![table]);
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
let note = snapshot.tables[0].column_notes.get("status").expect("column note");
assert_eq!(note.note, "State machine.");
assert_eq!(note.source, NoteSource::Local);
}
#[test]
fn the_project_note_and_name_are_applied() {
let mut snapshot = snapshot_with(vec![]);
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
assert_eq!(snapshot.project.name, "Ecommerce");
assert_eq!(snapshot.project.note.as_deref(), Some("# Overview"));
}
#[test]
fn groups_are_copied_and_membership_is_assigned() {
let mut snapshot = snapshot_with(vec![table_named("core", "orders", None)]);
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
assert_eq!(snapshot.groups.len(), 1);
assert_eq!(snapshot.groups[0].id, "order-management");
assert_eq!(snapshot.groups[0].hue, 28);
assert_eq!(snapshot.tables[0].group_id.as_deref(), Some("order-management"));
}
#[test]
fn a_table_referencing_an_undefined_group_is_left_ungrouped() {
let mut snapshot = snapshot_with(vec![table_named("core", "orders", None)]);
let mut annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
annotations.groups.clear();
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
assert_eq!(snapshot.tables[0].group_id, None, "a dangling group reference must not be assigned");
}
#[test]
fn duplicate_group_ids_collapse_to_one_entry() {
// Two TableGroup blocks sharing an id is invalid DBML.
let mut snapshot = snapshot_with(vec![table_named("core", "orders", None)]);
let mut annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
let mut dup = annotations.groups[0].clone();
dup.name = "Duplicate".to_string();
annotations.groups.push(dup);
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
assert_eq!(snapshot.groups.len(), 1, "duplicate ids must collapse");
assert_eq!(snapshot.groups[0].name, "Order Management", "first occurrence wins");
}
use crate::docs::SnapshotWarning;
#[test]
fn a_note_for_a_missing_table_is_reported_as_orphaned() {
// The notes file describes core.orders; the schema no longer has it.
let mut snapshot = snapshot_with(vec![table_named("core", "customers", None)]);
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
let orphans = detect_orphans(&snapshot, &annotations, DatabaseType::Postgres);
assert_eq!(orphans, vec!["core.orders".to_string()]);
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
let orphan_warnings: Vec<&SnapshotWarning> = snapshot
.warnings
.iter()
.filter(|warning| matches!(warning, SnapshotWarning::OrphanedNotes { .. }))
.collect();
assert_eq!(orphan_warnings.len(), 1);
match orphan_warnings[0] {
SnapshotWarning::OrphanedNotes { count } => assert_eq!(*count, 1),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn a_note_for_a_missing_column_is_reported_as_orphaned() {
// The table exists but no longer has the annotated column.
let mut table = table_named("core", "orders", None);
table.columns.push(crate::types::ColumnInfo {
name: "id".to_string(),
data_type: "integer".to_string(),
..Default::default()
});
let snapshot = snapshot_with(vec![table]);
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
let orphans = detect_orphans(&snapshot, &annotations, DatabaseType::Postgres);
assert_eq!(orphans, vec!["core.orders.status".to_string()]);
}
#[test]
fn nothing_is_orphaned_when_everything_matches() {
let mut table = table_named("core", "orders", None);
table.columns.push(crate::types::ColumnInfo {
name: "status".to_string(),
data_type: "text".to_string(),
..Default::default()
});
let mut snapshot = snapshot_with(vec![table]);
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
assert!(detect_orphans(&snapshot, &annotations, DatabaseType::Postgres).is_empty());
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
assert!(
!snapshot.warnings.iter().any(|w| matches!(w, SnapshotWarning::OrphanedNotes { .. })),
"no orphan warning when everything matches"
);
}
#[test]
fn orphan_detection_never_removes_anything_from_the_file() {
let snapshot = snapshot_with(vec![]);
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
let before = annotations.tables.len();
let _ = detect_orphans(&snapshot, &annotations, DatabaseType::Postgres);
assert_eq!(annotations.tables.len(), before, "detection must not mutate the file");
}
}

View File

@ -0,0 +1,549 @@
use std::collections::{BTreeMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use futures::stream::{self, StreamExt};
use crate::connection::AppState;
use crate::docs::dbml::{enum_type_name, is_inline_enum_spelling};
use crate::docs::{
build_relationships, DocEnum, DocTable, NoteSource, ProjectMeta, Relationship, SchemaSnapshot, SnapshotWarning,
TableKind,
};
use crate::models::connection::ConnectionConfig;
use crate::schema;
use crate::table_structure_sql::{database_type_label, supports_comments, supports_foreign_keys};
use crate::types::ColumnInfo;
/// Concurrent per-table metadata fetches. Bounded so documenting a large
/// schema cannot starve the connection pool the UI is also using.
const MAX_CONCURRENT_TABLES: usize = 8;
#[derive(Debug, Clone)]
pub struct CollectOptions {
pub database: String,
pub schemas: Vec<String>,
/// Empty means every table. Entries may be bare (`orders`) or
/// qualified (`analytics.daily_sales`).
pub tables: Vec<String>,
pub project_name: String,
}
impl CollectOptions {
pub fn includes_table(&self, schema: &str, table: &str) -> bool {
if self.tables.is_empty() {
return true;
}
let qualified = format!("{schema}.{table}");
self.tables.iter().any(|wanted| wanted == table || wanted == &qualified)
}
}
#[derive(Debug, Clone)]
pub struct CollectProgress {
pub completed: usize,
pub total: usize,
pub current: String,
}
fn table_kind_from(table_type: &str) -> TableKind {
let normalized = table_type.trim().to_ascii_uppercase().replace('_', " ");
match normalized.as_str() {
"VIEW" => TableKind::View,
"MATERIALIZED VIEW" => TableKind::MaterializedView,
_ => TableKind::Table,
}
}
/// MySQL reports `ENUM('a','b')` inline rather than as a named type, so DBML
/// needs a synthesized `{table}_{column}` name for it. PostgreSQL, by
/// contrast, reports a named enum type's own identifier in `data_type` — its
/// native name is used instead, so the user's real type identity survives
/// and a type shared by several columns is recognized as the same enum
/// (see `build_enums`, which deduplicates by name).
fn synthesize_enum(schema: Option<&str>, table: &str, column: &ColumnInfo) -> Option<DocEnum> {
let values = column.enum_values.as_ref().filter(|values| !values.is_empty())?;
Some(DocEnum {
schema: schema.map(ToOwned::to_owned),
name: enum_type_name(column, table),
values: values.clone(),
note: None,
synthesized: is_inline_enum_spelling(&column.data_type),
})
}
/// Collect every enum referenced by `tables`, deduplicated by (schema,
/// name). A named type (e.g. a PostgreSQL enum) is typically shared by
/// several columns — without deduplication each column would emit its own
/// copy of the same block, which is invalid DBML.
fn build_enums(tables: &[DocTable]) -> Vec<DocEnum> {
let mut enums = Vec::new();
let mut seen: HashSet<(Option<String>, String)> = HashSet::new();
for table in tables {
for column in &table.columns {
let Some(value) = synthesize_enum(table.schema.as_deref(), &table.name, column) else { continue };
if seen.insert((value.schema.clone(), value.name.clone())) {
enums.push(value);
}
}
}
enums
}
fn cancelled(cancel: &AtomicBool) -> bool {
cancel.load(Ordering::Relaxed)
}
/// Whether any table or column in the collected snapshot carries a
/// non-empty comment. Used to corroborate `supports_comments` — a DDL-only
/// capability flag — against what introspection actually returned.
fn any_comment_collected(tables: &[DocTable]) -> bool {
tables.iter().any(|table| {
table.note.as_deref().is_some_and(|note| !note.trim().is_empty())
|| table
.columns
.iter()
.any(|column| column.comment.as_deref().is_some_and(|comment| !comment.trim().is_empty()))
})
}
/// True when the `CommentsUnsupported` warning belongs in the snapshot: the
/// engine's DDL capability flag says it can't do comments, and collection
/// found none to contradict it.
fn should_warn_comments_unsupported(supports_comments: bool, tables: &[DocTable]) -> bool {
!supports_comments && !any_comment_collected(tables)
}
/// True when the `NoForeignKeyMetadata` warning belongs in the snapshot: the
/// engine's DDL capability flag says it can't do foreign keys, and
/// collection found no relationships to contradict it.
fn should_warn_no_foreign_key_metadata(supports_foreign_keys: bool, relationships: &[Relationship]) -> bool {
!supports_foreign_keys && relationships.is_empty()
}
/// Collect a documentation snapshot.
///
/// A per-table failure is recorded as a `TableSkipped` warning and does not
/// abort the run — a permissions gap on one table must not kill a
/// 400-table documentation build.
pub async fn collect_snapshot(
state: &AppState,
connection: &ConnectionConfig,
options: &CollectOptions,
progress: &(dyn Fn(CollectProgress) + Send + Sync),
cancel: &AtomicBool,
) -> Result<SchemaSnapshot, String> {
let mut warnings: Vec<SnapshotWarning> = Vec::new();
let engine = database_type_label(connection.db_type);
let connection_id = connection.id.as_str();
let schemas = if options.schemas.is_empty() {
match schema::list_schemas_core(state, connection_id, &options.database).await {
Ok(schemas) => schemas,
Err(error) => {
// Distinguish "enumeration failed" from "no schemas exist" —
// the latter is a legitimately empty document, the former is
// a cryptic one if it silently proceeds against schema "".
warnings.push(SnapshotWarning::TableSkipped {
table: "*".to_string(),
reason: format!("schema enumeration failed: {error}"),
});
Vec::new()
}
}
} else {
options.schemas.clone()
};
let effective_schemas = if schemas.is_empty() { vec![String::new()] } else { schemas };
// Enumerate every table first so progress has a real total.
let mut targets: Vec<(String, crate::types::TableInfo)> = Vec::new();
for schema_name in &effective_schemas {
match schema::list_tables_core(
state,
connection_id,
&options.database,
schema_name,
None,
None,
None,
None,
None,
)
.await
{
Ok(tables) => {
for info in tables {
if options.includes_table(schema_name, &info.name) {
targets.push((schema_name.clone(), info));
}
}
}
Err(error) => {
warnings.push(SnapshotWarning::TableSkipped { table: format!("{schema_name}.*"), reason: error })
}
}
}
let total = targets.len();
let collected: Vec<Result<(DocTable, Vec<SnapshotWarning>), SnapshotWarning>> =
stream::iter(targets.into_iter().enumerate())
.map(|(index, (schema_name, info))| {
let database = options.database.clone();
async move {
if cancelled(cancel) {
return Err(SnapshotWarning::TableSkipped {
table: info.name.clone(),
reason: "cancelled".to_string(),
});
}
progress(CollectProgress {
completed: index,
total,
current: format!("{schema_name}.{}", info.name),
});
let columns = schema::get_columns_core(state, connection_id, &database, &schema_name, &info.name)
.await
.map_err(|error| SnapshotWarning::TableSkipped {
table: format!("{schema_name}.{}", info.name),
reason: error,
})?;
let mut table_warnings = Vec::new();
// Indexes feed `relations.rs`'s uniqueness check, so a
// failure here must not look identical to "this table
// genuinely has no indexes" — that would silently
// downgrade a OneToOne relationship to ManyToOne.
let indexes = match schema::list_indexes_core(
state,
connection_id,
&database,
&schema_name,
&info.name,
)
.await
{
Ok(indexes) => indexes,
Err(error) => {
table_warnings.push(SnapshotWarning::TableSkipped {
table: format!("{schema_name}.{}", info.name),
reason: format!("indexes unavailable: {error}"),
});
Vec::new()
}
};
// Foreign keys also degrade to empty rather than failing the
// table, but a real query failure must not look identical to
// "this table genuinely has no foreign keys" — it is reported
// as its own warning instead of being silently discarded.
let foreign_keys =
match schema::list_foreign_keys_core(state, connection_id, &database, &schema_name, &info.name)
.await
{
Ok(keys) => keys,
Err(error) => {
table_warnings.push(SnapshotWarning::TableSkipped {
table: format!("{schema_name}.{}", info.name),
reason: format!("foreign keys unavailable: {error}"),
});
Vec::new()
}
};
Ok((
DocTable {
schema: (!schema_name.is_empty()).then(|| schema_name.clone()),
name: info.name.clone(),
kind: table_kind_from(&info.table_type),
columns,
indexes,
foreign_keys,
group_id: None,
note: info.comment.clone().filter(|value| !value.trim().is_empty()),
note_source: if info.comment.as_deref().is_some_and(|v| !v.trim().is_empty()) {
NoteSource::Database
} else {
NoteSource::None
},
shadowed_note: None,
column_notes: BTreeMap::new(),
estimated_rows: None,
view_definition: None,
},
table_warnings,
))
}
})
.buffer_unordered(MAX_CONCURRENT_TABLES)
.collect()
.await;
let mut tables = Vec::new();
for outcome in collected {
match outcome {
Ok((table, table_warnings)) => {
tables.push(table);
warnings.extend(table_warnings);
}
Err(warning) => warnings.push(warning),
}
}
tables.sort_by_key(|table| table.qualified_name());
let enums = build_enums(&tables);
let relationships = build_relationships(&tables);
// The capability flags gate DDL generation (COMMENT ON, foreign key
// clauses), not introspection — IRIS is the proven divergence: it
// reports comments on introspection despite the editor being unable to
// ALTER them, so `supports_comments` alone would be a false positive. A
// warning fires only when the flag says an engine can't AND collection
// corroborates that nothing of the kind actually came back.
if should_warn_comments_unsupported(supports_comments(connection.db_type), &tables) {
warnings.push(SnapshotWarning::CommentsUnsupported { engine: engine.clone() });
}
if should_warn_no_foreign_key_metadata(supports_foreign_keys(connection.db_type), &relationships) {
warnings.push(SnapshotWarning::NoForeignKeyMetadata { engine: engine.clone() });
}
progress(CollectProgress { completed: total, total, current: String::new() });
Ok(SchemaSnapshot {
format_version: 1,
project: ProjectMeta {
name: options.project_name.clone(),
database_type: engine,
database: (!options.database.is_empty()).then(|| options.database.clone()),
schemas: effective_schemas.into_iter().filter(|s| !s.is_empty()).collect(),
generated_at: chrono::Utc::now().to_rfc3339(),
note: None,
},
tables,
relationships,
groups: Vec::new(),
enums,
warnings,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_kind_maps_from_the_engine_reported_type() {
assert_eq!(table_kind_from("TABLE"), TableKind::Table);
assert_eq!(table_kind_from("BASE TABLE"), TableKind::Table);
assert_eq!(table_kind_from("VIEW"), TableKind::View);
assert_eq!(table_kind_from("MATERIALIZED VIEW"), TableKind::MaterializedView);
assert_eq!(table_kind_from("materialized_view"), TableKind::MaterializedView);
assert_eq!(table_kind_from("something else"), TableKind::Table);
}
#[test]
fn table_filter_is_empty_means_include_everything() {
let options = CollectOptions {
database: "shop".to_string(),
schemas: vec!["public".to_string()],
tables: vec![],
project_name: "Ecommerce".to_string(),
};
assert!(options.includes_table("public", "orders"));
assert!(options.includes_table("public", "anything"));
}
#[test]
fn table_filter_matches_bare_and_qualified_names() {
let options = CollectOptions {
database: "shop".to_string(),
schemas: vec!["public".to_string()],
tables: vec!["orders".to_string(), "analytics.daily_sales".to_string()],
project_name: "Ecommerce".to_string(),
};
assert!(options.includes_table("public", "orders"));
assert!(options.includes_table("analytics", "daily_sales"));
assert!(!options.includes_table("public", "users"));
assert!(!options.includes_table("public", "daily_sales"));
}
#[test]
fn synthesises_a_named_enum_from_an_inline_mysql_enum_column() {
let mut column = crate::types::ColumnInfo {
name: "status".to_string(),
data_type: "enum('pending','shipped')".to_string(),
..Default::default()
};
column.enum_values = Some(vec!["pending".to_string(), "shipped".to_string()]);
let synthesized = synthesize_enum(Some("public"), "orders", &column).expect("enum");
assert_eq!(synthesized.name, "orders_status");
assert_eq!(synthesized.values, vec!["pending", "shipped"]);
assert!(synthesized.synthesized);
}
#[test]
fn a_postgres_named_enum_column_keeps_its_own_type_name() {
// PostgreSQL reports the enum's own type name in `data_type`, not an
// inline `ENUM(...)` spelling. The user's real type identity must
// survive into the document instead of being replaced by a
// synthesized `{table}_{column}` name.
let mut column = crate::types::ColumnInfo {
name: "status".to_string(),
data_type: "ConversationStatus".to_string(),
..Default::default()
};
column.enum_values = Some(vec!["open".to_string(), "closed".to_string()]);
let synthesized = synthesize_enum(Some("public"), "conversations", &column).expect("enum");
assert_eq!(synthesized.name, "ConversationStatus");
assert!(!synthesized.synthesized);
}
#[test]
fn a_column_without_enum_values_synthesises_nothing() {
let column = crate::types::ColumnInfo {
name: "status".to_string(),
data_type: "text".to_string(),
..Default::default()
};
assert!(synthesize_enum(Some("public"), "orders", &column).is_none());
}
fn enum_column(name: &str, data_type: &str, values: &[&str]) -> crate::types::ColumnInfo {
crate::types::ColumnInfo {
name: name.to_string(),
data_type: data_type.to_string(),
enum_values: Some(values.iter().map(|v| v.to_string()).collect()),
..Default::default()
}
}
fn table_with_columns(schema: &str, name: &str, columns: Vec<crate::types::ColumnInfo>) -> DocTable {
DocTable {
schema: Some(schema.to_string()),
name: name.to_string(),
kind: TableKind::Table,
columns,
indexes: vec![],
foreign_keys: vec![],
group_id: None,
note: None,
note_source: NoteSource::None,
shadowed_note: None,
column_notes: BTreeMap::new(),
estimated_rows: None,
view_definition: None,
}
}
#[test]
fn a_named_enum_type_shared_by_two_columns_is_emitted_once() {
let conversations = table_with_columns(
"public",
"conversations",
vec![enum_column("status", "ConversationStatus", &["open", "closed"])],
);
let archived_conversations = table_with_columns(
"public",
"archived_conversations",
vec![enum_column("status", "ConversationStatus", &["open", "closed"])],
);
let enums = build_enums(&[conversations, archived_conversations]);
assert_eq!(enums.len(), 1, "two columns of the same named type must dedupe to one block: {enums:?}");
assert_eq!(enums[0].name, "ConversationStatus");
assert!(!enums[0].synthesized);
}
#[test]
fn postgres_reports_foreign_key_ddl_capability_true() {
assert!(supports_foreign_keys(crate::models::connection::DatabaseType::Postgres));
}
fn column_with_comment(comment: &str) -> crate::types::ColumnInfo {
crate::types::ColumnInfo {
name: "notes".to_string(),
data_type: "text".to_string(),
comment: Some(comment.to_string()),
..Default::default()
}
}
fn sample_relationship() -> Relationship {
Relationship {
id: "orders.customer_id->customers.id".to_string(),
name: None,
from: crate::docs::FieldRef {
schema: Some("public".to_string()),
table: "orders".to_string(),
column: "customer_id".to_string(),
},
to: crate::docs::FieldRef {
schema: Some("public".to_string()),
table: "customers".to_string(),
column: "id".to_string(),
},
cardinality: crate::docs::Cardinality::ManyToOne,
on_update: None,
on_delete: None,
}
}
#[test]
fn comments_unsupported_warning_fires_when_flag_is_false_and_nothing_was_collected() {
let tables = vec![table_with_columns("public", "orders", vec![])];
assert!(should_warn_comments_unsupported(false, &tables));
}
#[test]
fn comments_unsupported_warning_is_absent_when_a_table_comment_was_collected() {
// Regression: IRIS reports `comment: false` on the DDL capability
// flag (it supports %DESCRIPTION at CREATE time but DBX cannot ALTER
// it), yet IRIS still returns real comments on introspection. The
// warning must not contradict data actually present in the snapshot.
let mut table = table_with_columns("public", "orders", vec![]);
table.note = Some("Checkout rows.".to_string());
assert!(!should_warn_comments_unsupported(false, &[table]));
}
#[test]
fn comments_unsupported_warning_is_absent_when_a_column_comment_was_collected() {
let tables = vec![table_with_columns("public", "orders", vec![column_with_comment("Internal notes.")])];
assert!(!should_warn_comments_unsupported(false, &tables));
}
#[test]
fn comments_unsupported_warning_does_not_fire_when_the_capability_flag_is_true() {
let tables = vec![table_with_columns("public", "orders", vec![])];
assert!(!should_warn_comments_unsupported(true, &tables));
}
#[test]
fn no_foreign_key_warning_fires_when_flag_is_false_and_no_relationships_were_collected() {
assert!(should_warn_no_foreign_key_metadata(false, &[]));
}
#[test]
fn no_foreign_key_warning_is_absent_when_relationships_were_collected() {
// Regression: ClickHouse/Doris genuinely report zero FK metadata, so
// this must still fire for them — but an engine that DOES report
// relationships must not be flagged just because its DDL capability
// flag is false.
let relationships = vec![sample_relationship()];
assert!(!should_warn_no_foreign_key_metadata(false, &relationships));
}
#[test]
fn no_foreign_key_warning_does_not_fire_when_the_capability_flag_is_true() {
assert!(!should_warn_no_foreign_key_metadata(true, &[]));
}
}

View File

@ -0,0 +1,85 @@
/// Lightness and chroma of the light-theme `--group-c` token. Only hue
/// varies per group, which is what guarantees legible contrast.
const GROUP_LIGHTNESS: f64 = 0.55;
const GROUP_CHROMA: f64 = 0.15;
fn linear_to_srgb(channel: f64) -> f64 {
if channel <= 0.003_130_8 {
12.92 * channel
} else {
1.055 * channel.powf(1.0 / 2.4) - 0.055
}
}
fn to_byte(channel: f64) -> u8 {
(linear_to_srgb(channel).clamp(0.0, 1.0) * 255.0).round() as u8
}
/// Convert a group hue to the sRGB hex DBML expects.
///
/// OKLCH -> OKLab -> LMS -> linear sRGB -> gamma-encoded sRGB.
/// Coefficients are Björn Ottosson's published OKLab matrices.
pub fn hue_to_hex(hue: u16) -> String {
let radians = f64::from(hue % 360) * std::f64::consts::PI / 180.0;
let a = GROUP_CHROMA * radians.cos();
let b = GROUP_CHROMA * radians.sin();
let l_ = GROUP_LIGHTNESS + 0.396_337_777_4 * a + 0.215_803_757_3 * b;
let m_ = GROUP_LIGHTNESS - 0.105_561_345_8 * a - 0.063_854_172_8 * b;
let s_ = GROUP_LIGHTNESS - 0.089_484_177_5 * a - 1.291_485_548_0 * b;
let l = l_ * l_ * l_;
let m = m_ * m_ * m_;
let s = s_ * s_ * s_;
let red = 4.076_741_662_1 * l - 3.307_711_591_3 * m + 0.230_969_929_2 * s;
let green = -1.268_438_004_6 * l + 2.609_757_401_1 * m - 0.341_319_396_5 * s;
let blue = -0.004_196_086_3 * l - 0.703_418_614_7 * m + 1.707_614_701_0 * s;
format!("#{:02x}{:02x}{:02x}", to_byte(red), to_byte(green), to_byte(blue))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn produces_a_six_digit_lowercase_hex_string() {
let hex = hue_to_hex(28);
assert_eq!(hex.len(), 7, "got {hex}");
assert!(hex.starts_with('#'), "got {hex}");
assert!(hex[1..].chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()), "got {hex}");
}
#[test]
fn hue_28_is_a_warm_orange_red() {
// Sanity check against the group palette: red channel dominates.
let hex = hue_to_hex(28);
let r = u8::from_str_radix(&hex[1..3], 16).unwrap();
let g = u8::from_str_radix(&hex[3..5], 16).unwrap();
let b = u8::from_str_radix(&hex[5..7], 16).unwrap();
assert!(r > g && g > b, "expected r > g > b, got {hex}");
}
#[test]
fn hue_148_is_green_dominant() {
let hex = hue_to_hex(148);
let r = u8::from_str_radix(&hex[1..3], 16).unwrap();
let g = u8::from_str_radix(&hex[3..5], 16).unwrap();
assert!(g > r, "expected green to dominate, got {hex}");
}
#[test]
fn every_hue_is_in_range_and_never_panics() {
for hue in 0..=359u16 {
let hex = hue_to_hex(hue);
assert_eq!(hex.len(), 7, "hue {hue} produced {hex}");
}
}
#[test]
fn hue_wraps_past_360() {
assert_eq!(hue_to_hex(0), hue_to_hex(360));
assert_eq!(hue_to_hex(28), hue_to_hex(388));
}
}

View File

@ -0,0 +1,893 @@
use crate::docs::hue_to_hex;
use crate::docs::{
Cardinality, DocEnum, DocTable, FieldRef, Relationship, SchemaSnapshot, SnapshotWarning, TableGroup,
};
use crate::types::{ColumnInfo, IndexInfo};
/// DBML accepts bare identifiers matching `[A-Za-z_][A-Za-z0-9_]*`;
/// everything else needs double quotes.
pub(crate) fn quote_identifier(value: &str) -> String {
let plain = !value.is_empty()
&& value.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& value.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if plain {
value.to_string()
} else {
format!("\"{}\"", value.replace('"', "\\\""))
}
}
/// `schema.name` when qualifying, bare `name` otherwise. Both parts quoted
/// independently so an irregular schema or table name is handled correctly.
pub(crate) fn qualified(schema: Option<&str>, name: &str, qualify: bool) -> String {
match schema.filter(|s| !s.is_empty()) {
Some(schema) if qualify => format!("{}.{}", quote_identifier(schema), quote_identifier(name)),
_ => quote_identifier(name),
}
}
/// Notes always use triple quotes so an apostrophe in prose can never break
/// the file.
///
/// Two things need escaping. A literal `'''` inside the prose, obviously —
/// and also a SINGLE trailing quote, which is subtler: the note `he said '`
/// would otherwise render as `'''he said ''''`, four quotes in a row, and a
/// parser scanning for the closing `'''` would close early and strand the
/// fourth. DBML honours `\'` inside triple quotes.
pub(crate) fn render_note(value: &str) -> String {
let escaped = value.replace("'''", "\\'''");
let escaped = match escaped.strip_suffix('\'') {
Some(head) => format!("{head}\\'"),
None => escaped,
};
format!("'''{escaped}'''")
}
fn looks_like_expression(value: &str) -> bool {
let trimmed = value.trim();
if trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2 {
return false;
}
if trimmed.parse::<f64>().is_ok() {
return false;
}
if matches!(trimmed.to_ascii_lowercase().as_str(), "true" | "false" | "null") {
return false;
}
true
}
/// `default: …` for a column, or None when it has no default.
/// Expressions go in backticks, literals stay as written.
pub(crate) fn render_default(column: &ColumnInfo) -> Option<String> {
let value = column.column_default.as_deref()?.trim();
if value.is_empty() {
return None;
}
if looks_like_expression(value) {
Some(format!("default: `{value}`"))
} else {
Some(format!("default: {value}"))
}
}
/// True when `data_type` is MySQL's inline `ENUM(...)` spelling rather than
/// a genuine named type. PostgreSQL reports a named enum type's own
/// identifier in `data_type` instead (e.g. `"ConversationStatus"`) — only
/// the inline MySQL spelling needs a synthesized name.
pub(crate) fn is_inline_enum_spelling(data_type: &str) -> bool {
let trimmed = data_type.trim();
trimmed.get(..5).is_some_and(|prefix| prefix.eq_ignore_ascii_case("enum(")) && trimmed.ends_with(')')
}
/// PostgreSQL's `format_type` double-quotes an identifier that needs it
/// (e.g. `"ConversationStatus"`); strip that quoting so the bare name can be
/// used as a DBML identifier directly — `quote_identifier` re-quotes it if
/// DBML itself requires that.
pub(crate) fn unquote_pg_identifier(value: &str) -> String {
let trimmed = value.trim();
match trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
true => trimmed[1..trimmed.len() - 1].replace("\"\"", "\""),
false => trimmed.to_string(),
}
}
/// The enum name a column's `enum_values` refers to: the synthesized
/// `{table}_{column}` name for MySQL's inline `ENUM(...)` columns, or the
/// native type name for a genuine named enum type (e.g. PostgreSQL).
///
/// This is the single source of truth for that name — `synthesize_enum`
/// (collector) and `render_type` (below) both go through it, so a column's
/// type reference and its `Enum` block name can never drift apart again.
pub(crate) fn enum_type_name(column: &ColumnInfo, table_name: &str) -> String {
if is_inline_enum_spelling(&column.data_type) {
format!("{table_name}_{}", column.name)
} else {
unquote_pg_identifier(&column.data_type)
}
}
/// DBML does not validate type names, so native types pass through intact.
/// Precision is reconstructed only when the engine reported a bare type.
pub(crate) fn render_type(column: &ColumnInfo, table_schema: Option<&str>, table_name: &str, qualify: bool) -> String {
// A column carrying enum values is emitted as a named enum elsewhere in
// the document. This must produce the SAME string `render_enum` produces
// for that block — including schema qualification — or the reference
// dangles and the Enum block becomes an orphan.
if column.enum_values.as_ref().is_some_and(|values| !values.is_empty()) {
return qualified(table_schema, &enum_type_name(column, table_name), qualify);
}
let base = column.data_type.trim();
if base.contains('(') {
return base.to_string();
}
if let Some(length) = column.character_maximum_length.filter(|value| *value > 0) {
return format!("{base}({length})");
}
if let Some(precision) = column.numeric_precision.filter(|value| *value > 0) {
return match column.numeric_scale {
Some(scale) if scale > 0 => format!("{base}({precision},{scale})"),
_ => format!("{base}({precision})"),
};
}
base.to_string()
}
fn column_settings(column: &ColumnInfo, table: &DocTable) -> Vec<String> {
let mut settings = Vec::new();
if column.is_primary_key {
settings.push("pk".to_string());
}
let extra = column.extra.as_deref().unwrap_or("").to_ascii_lowercase();
if extra.contains("auto_increment") || extra.contains("identity") {
settings.push("increment".to_string());
}
if !column.is_nullable && !column.is_primary_key {
settings.push("not null".to_string());
}
if let Some(default) = render_default(column) {
settings.push(default);
}
if let Some(note) = table.column_notes.get(&column.name) {
settings.push(format!("note: {}", render_note(&note.note)));
} else if let Some(comment) = column.comment.as_deref().filter(|value| !value.trim().is_empty()) {
settings.push(format!("note: {}", render_note(comment)));
}
settings
}
fn render_index(index: &IndexInfo) -> String {
let columns = index.columns.iter().map(|c| quote_identifier(c)).collect::<Vec<_>>().join(", ");
let mut settings = vec![format!("name: '{}'", index.name.replace('\'', "\\'"))];
if index.is_unique {
settings.push("unique".to_string());
}
format!(" ({columns}) [{}]\n", settings.join(", "))
}
/// Render one `Table` block.
///
/// The primary-key index is skipped because columns already carry `pk`.
/// Indexes DBML cannot express are skipped and recorded in `warnings`
/// rather than dropped silently.
pub(crate) fn render_table(table: &DocTable, qualify: bool, warnings: &mut Vec<SnapshotWarning>) -> String {
let name = qualified(table.schema.as_deref(), &table.name, qualify);
let mut out = format!("Table {name} {{\n");
for column in &table.columns {
let settings = column_settings(column, table);
let rendered_settings = if settings.is_empty() { String::new() } else { format!(" [{}]", settings.join(", ")) };
out.push_str(&format!(
" {} {}{}\n",
quote_identifier(&column.name),
render_type(column, table.schema.as_deref(), &table.name, qualify),
rendered_settings
));
}
let emittable: Vec<&IndexInfo> = table
.indexes
.iter()
.filter(|index| {
if index.is_primary {
return false;
}
if index.filter.as_deref().is_some_and(|f| !f.trim().is_empty()) {
warnings.push(SnapshotWarning::DbmlOmitted {
table: table.qualified_name(),
item: index.name.clone(),
reason: "partial index filter has no DBML equivalent".to_string(),
});
return false;
}
if index.included_columns.as_ref().is_some_and(|columns| !columns.is_empty()) {
warnings.push(SnapshotWarning::DbmlOmitted {
table: table.qualified_name(),
item: index.name.clone(),
reason: "included columns have no DBML equivalent".to_string(),
});
return false;
}
!index.columns.is_empty()
})
.collect();
if !emittable.is_empty() {
out.push_str("\n Indexes {\n");
for index in emittable {
out.push_str(&render_index(index));
}
out.push_str(" }\n");
}
if let Some(note) = table.note.as_deref().filter(|value| !value.trim().is_empty()) {
out.push_str(&format!("\n Note: {}\n", render_note(note)));
}
out.push_str("}\n");
out
}
fn qualified_field(field: &FieldRef, qualify: bool) -> String {
let table = qualified(field.schema.as_deref(), &field.table, qualify);
format!("{table}.{}", quote_identifier(&field.column))
}
/// One `Ref` line. `>` is many-to-one, `-` is one-to-one.
pub(crate) fn render_ref(relationship: &Relationship, qualify: bool) -> String {
let operator = match relationship.cardinality {
Cardinality::ManyToOne => ">",
Cardinality::OneToOne => "-",
};
let mut actions = Vec::new();
if let Some(update) = relationship.on_update.as_deref().filter(|v| !v.trim().is_empty()) {
actions.push(format!("update: {}", update.to_lowercase()));
}
if let Some(delete) = relationship.on_delete.as_deref().filter(|v| !v.trim().is_empty()) {
actions.push(format!("delete: {}", delete.to_lowercase()));
}
let settings = if actions.is_empty() { String::new() } else { format!(" [{}]", actions.join(", ")) };
let label = match relationship.name.as_deref().filter(|v| !v.trim().is_empty()) {
Some(name) => format!("Ref {}", quote_identifier(name)),
None => "Ref".to_string(),
};
format!(
"{label}: {} {operator} {}{settings}\n",
qualified_field(&relationship.from, qualify),
qualified_field(&relationship.to, qualify)
)
}
pub(crate) fn render_enum(value: &DocEnum, qualify: bool) -> String {
let name = qualified(value.schema.as_deref(), &value.name, qualify);
let mut out = format!("Enum {name} {{\n");
for variant in &value.values {
out.push_str(&format!(" {}\n", quote_identifier(variant)));
}
if let Some(note) = value.note.as_deref().filter(|v| !v.trim().is_empty()) {
out.push_str(&format!("\n Note: {}\n", render_note(note)));
}
out.push_str("}\n");
out
}
/// A `TableGroup` block. Returns an empty string when the group has no
/// members, since DBML rejects an empty group body.
pub(crate) fn render_group(group: &TableGroup, members: &[&DocTable], qualify: bool) -> String {
if members.is_empty() {
return String::new();
}
let mut out = format!("TableGroup {} [color: {}] {{\n", quote_identifier(&group.name), hue_to_hex(group.hue));
for member in members {
let name = qualified(member.schema.as_deref(), &member.name, qualify);
out.push_str(&format!(" {name}\n"));
}
if let Some(note) = group.note.as_deref().filter(|v| !v.trim().is_empty()) {
out.push_str(&format!("\n Note: {}\n", render_note(note)));
}
out.push_str("}\n");
out
}
/// DBML text plus every construct that could not be represented in it.
#[derive(Debug, Clone)]
pub struct DbmlOutput {
pub text: String,
pub warnings: Vec<SnapshotWarning>,
}
fn render_project(snapshot: &SchemaSnapshot) -> String {
let mut out = format!("Project {} {{\n", quote_identifier(&snapshot.project.name));
out.push_str(&format!(" database_type: '{}'\n", snapshot.project.database_type.replace('\'', "\\'")));
if let Some(note) = snapshot.project.note.as_deref().filter(|v| !v.trim().is_empty()) {
out.push_str(&format!(" Note: {}\n", render_note(note)));
}
out.push_str("}\n");
out
}
/// Serialize a snapshot to DBML.
///
/// DBML is an interchange format, not a backup: check constraints, partial
/// indexes, included columns, collations and generated columns have no
/// representation. Each omission is reported in `warnings` so the HTML
/// documentation — which *is* the complete record — can mark it.
pub fn to_dbml(snapshot: &SchemaSnapshot) -> DbmlOutput {
let qualify = snapshot.project.schemas.len() > 1;
// Start from the collector's warnings so omissions discovered at collection
// time (skipped tables, unsupported comments) survive into the output
// alongside those discovered while rendering.
let mut warnings = snapshot.warnings.clone();
let mut sections: Vec<String> = vec![render_project(snapshot)];
for value in &snapshot.enums {
sections.push(render_enum(value, qualify));
}
for group in &snapshot.groups {
let members: Vec<&DocTable> =
snapshot.tables.iter().filter(|table| table.group_id.as_deref() == Some(group.id.as_str())).collect();
let rendered = render_group(group, &members, qualify);
if !rendered.is_empty() {
sections.push(rendered);
}
}
for table in &snapshot.tables {
sections.push(render_table(table, qualify, &mut warnings));
}
if !snapshot.relationships.is_empty() {
let refs: String = snapshot.relationships.iter().map(|rel| render_ref(rel, qualify)).collect();
sections.push(refs);
}
DbmlOutput { text: sections.join("\n"), warnings }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ColumnInfo;
fn col(name: &str, data_type: &str) -> ColumnInfo {
ColumnInfo { name: name.to_string(), data_type: data_type.to_string(), ..ColumnInfo::default() }
}
#[test]
fn plain_identifiers_are_not_quoted() {
assert_eq!(quote_identifier("orders"), "orders");
assert_eq!(quote_identifier("order_items"), "order_items");
assert_eq!(quote_identifier("_private"), "_private");
assert_eq!(quote_identifier("t2"), "t2");
}
#[test]
fn irregular_identifiers_are_quoted() {
assert_eq!(quote_identifier("order items"), "\"order items\"");
assert_eq!(quote_identifier("2fast"), "\"2fast\"");
assert_eq!(quote_identifier("user-profile"), "\"user-profile\"");
assert_eq!(quote_identifier(""), "\"\"");
}
#[test]
fn embedded_double_quotes_are_escaped() {
assert_eq!(quote_identifier("we\"ird"), "\"we\\\"ird\"");
}
#[test]
fn notes_always_use_triple_quotes_so_apostrophes_are_safe() {
assert_eq!(render_note("Bob's orders"), "'''Bob's orders'''");
}
#[test]
fn notes_escape_a_literal_triple_quote() {
assert_eq!(render_note("a ''' b"), "'''a \\''' b'''");
}
#[test]
fn multiline_notes_are_preserved() {
assert_eq!(render_note("line one\nline two"), "'''line one\nline two'''");
}
#[test]
fn a_note_ending_in_a_single_quote_does_not_break_the_delimiter() {
// Without escaping this yields four quotes in a row and a parser
// closes the string early.
assert_eq!(render_note("Deprecated in '24'"), "'''Deprecated in '24\\''''");
}
#[test]
fn expression_defaults_use_backticks_and_literals_use_quotes() {
let mut expression = col("created_at", "timestamptz");
expression.column_default = Some("now()".to_string());
assert_eq!(render_default(&expression).as_deref(), Some("default: `now()`"));
let mut sequence = col("id", "integer");
sequence.column_default = Some("nextval('orders_id_seq'::regclass)".to_string());
assert_eq!(render_default(&sequence).as_deref(), Some("default: `nextval('orders_id_seq'::regclass)`"));
let mut literal = col("status", "text");
literal.column_default = Some("'pending'".to_string());
assert_eq!(render_default(&literal).as_deref(), Some("default: 'pending'"));
let mut number = col("qty", "integer");
number.column_default = Some("0".to_string());
assert_eq!(render_default(&number).as_deref(), Some("default: 0"));
let mut boolean = col("active", "boolean");
boolean.column_default = Some("true".to_string());
assert_eq!(render_default(&boolean).as_deref(), Some("default: true"));
assert_eq!(render_default(&col("plain", "text")), None);
}
#[test]
fn types_pass_through_verbatim_when_already_parameterised() {
assert_eq!(render_type(&col("total", "numeric(10,2)"), None, "orders", false), "numeric(10,2)");
assert_eq!(render_type(&col("meta", "jsonb"), None, "orders", false), "jsonb");
assert_eq!(
render_type(&col("at", "timestamp with time zone"), None, "orders", false),
"timestamp with time zone"
);
}
#[test]
fn bare_types_are_reconstructed_from_precision_metadata() {
let mut varchar = col("email", "character varying");
varchar.character_maximum_length = Some(255);
assert_eq!(render_type(&varchar, None, "orders", false), "character varying(255)");
let mut decimal = col("total", "numeric");
decimal.numeric_precision = Some(10);
decimal.numeric_scale = Some(2);
assert_eq!(render_type(&decimal, None, "orders", false), "numeric(10,2)");
let mut integer = col("count", "numeric");
integer.numeric_precision = Some(8);
integer.numeric_scale = Some(0);
assert_eq!(render_type(&integer, None, "orders", false), "numeric(8)");
}
#[test]
fn an_inline_enum_column_references_the_synthesized_enum_name() {
let mut status = col("status", "enum('pending','shipped')");
status.enum_values = Some(vec!["pending".to_string(), "shipped".to_string()]);
assert_eq!(render_type(&status, None, "orders", false), "orders_status");
}
#[test]
fn a_named_enum_type_column_references_its_own_native_name_not_a_synthesized_one() {
// PostgreSQL reports the enum's own type name in `data_type` rather
// than an inline `ENUM(...)` spelling — that native name must be
// used verbatim, not the `{table}_{column}` scheme MySQL needs.
let mut status = col("status", "ConversationStatus");
status.enum_values = Some(vec!["open".to_string(), "closed".to_string()]);
assert_eq!(render_type(&status, None, "conversations", false), "ConversationStatus");
}
#[test]
fn a_quoted_native_enum_type_name_is_unquoted_for_the_reference() {
// `format_type` double-quotes an identifier that needs it (mixed
// case, here). The DBML reference must be the bare name so
// `quote_identifier` can decide on its own quoting.
let mut status = col("status", "\"ConversationStatus\"");
status.enum_values = Some(vec!["open".to_string()]);
assert_eq!(render_type(&status, None, "conversations", false), "ConversationStatus");
}
#[test]
fn a_multi_schema_enum_reference_is_qualified_like_its_enum_block() {
let mut status = col("status", "enum('pending','shipped')");
status.enum_values = Some(vec!["pending".to_string(), "shipped".to_string()]);
// qualify=true is what to_dbml sets for a multi-schema snapshot. The
// column's type must match render_enum's block name exactly, or the
// reference dangles.
let reference = render_type(&status, Some("public"), "orders", true);
let block = DocEnum {
schema: Some("public".to_string()),
name: "orders_status".to_string(),
values: vec!["pending".to_string()],
note: None,
synthesized: true,
};
let rendered_block = render_enum(&block, true);
// Anchored, not `contains`: a bare unqualified reference is a substring
// of the qualified block name, so `contains` would pass against the very
// bug this test exists to catch.
assert!(
rendered_block.starts_with(&format!("Enum {reference} {{\n")),
"column reference `{reference}` must be exactly the Enum block name in:\n{rendered_block}"
);
}
use crate::docs::{ColumnNote, DocTable, NoteSource, SnapshotWarning, TableKind};
use crate::types::IndexInfo;
use std::collections::BTreeMap;
fn doc_table(name: &str, columns: Vec<ColumnInfo>, indexes: Vec<IndexInfo>) -> DocTable {
DocTable {
schema: Some("public".to_string()),
name: name.to_string(),
kind: TableKind::Table,
columns,
indexes,
foreign_keys: vec![],
group_id: None,
note: None,
note_source: NoteSource::None,
shadowed_note: None,
column_notes: BTreeMap::new(),
estimated_rows: None,
view_definition: None,
}
}
#[test]
fn renders_a_table_with_columns_and_settings() {
let mut id = col("id", "integer");
id.is_primary_key = true;
id.extra = Some("auto_increment".to_string());
let mut user_id = col("user_id", "integer");
user_id.is_nullable = false;
// ColumnInfo::default() gives is_nullable=false (i.e. NOT NULL), so a
// genuinely nullable column has to say so explicitly.
let mut nullable = col("shipped_at", "timestamptz");
nullable.is_nullable = true;
let mut table = doc_table("orders", vec![id, user_id, nullable], vec![]);
table.note = Some("Checkout rows.".to_string());
table.column_notes.insert(
"user_id".to_string(),
ColumnNote { note: "Owning customer".to_string(), source: NoteSource::Local, shadowed: None },
);
let mut warnings = Vec::new();
let out = render_table(&table, false, &mut warnings);
assert!(out.starts_with("Table orders {\n"), "got:\n{out}");
assert!(out.contains("id integer [pk, increment]"), "got:\n{out}");
assert!(out.contains("user_id integer [not null, note: '''Owning customer''']"), "got:\n{out}");
assert!(out.contains("shipped_at timestamptz\n"), "got:\n{out}");
assert!(out.contains("Note: '''Checkout rows.'''"), "got:\n{out}");
assert!(out.ends_with("}\n"), "got:\n{out}");
}
#[test]
fn qualifies_the_table_name_when_requested() {
let table = doc_table("orders", vec![col("id", "integer")], vec![]);
let mut warnings = Vec::new();
assert!(render_table(&table, true, &mut warnings).starts_with("Table public.orders {"));
}
#[test]
fn renders_an_indexes_block() {
let index = IndexInfo {
name: "idx_orders_user_placed".to_string(),
columns: vec!["user_id".to_string(), "placed_at".to_string()],
is_unique: false,
is_primary: false,
filter: None,
index_type: None,
included_columns: None,
comment: None,
};
let table = doc_table("orders", vec![col("user_id", "integer")], vec![index]);
let mut warnings = Vec::new();
let out = render_table(&table, false, &mut warnings);
assert!(out.contains("Indexes {"), "got:\n{out}");
assert!(out.contains("(user_id, placed_at) [name: 'idx_orders_user_placed']"), "got:\n{out}");
assert!(warnings.is_empty());
}
#[test]
fn a_unique_index_carries_the_unique_setting() {
let index = IndexInfo {
name: "uq_orders_ref".to_string(),
columns: vec!["reference".to_string()],
is_unique: true,
is_primary: false,
filter: None,
index_type: None,
included_columns: None,
comment: None,
};
let table = doc_table("orders", vec![col("reference", "text")], vec![index]);
let mut warnings = Vec::new();
let out = render_table(&table, false, &mut warnings);
assert!(out.contains("(reference) [name: 'uq_orders_ref', unique]"), "got:\n{out}");
}
#[test]
fn the_primary_key_index_is_skipped_because_columns_already_carry_pk() {
let index = IndexInfo {
name: "orders_pkey".to_string(),
columns: vec!["id".to_string()],
is_unique: true,
is_primary: true,
filter: None,
index_type: None,
included_columns: None,
comment: None,
};
let mut id = col("id", "integer");
id.is_primary_key = true;
let table = doc_table("orders", vec![id], vec![index]);
let mut warnings = Vec::new();
let out = render_table(&table, false, &mut warnings);
assert!(!out.contains("orders_pkey"), "got:\n{out}");
}
#[test]
fn a_filtered_index_is_omitted_and_warned_about() {
let index = IndexInfo {
name: "idx_orders_open".to_string(),
columns: vec!["status".to_string()],
is_unique: false,
is_primary: false,
filter: Some("status <> 'cancelled'".to_string()),
index_type: None,
included_columns: None,
comment: None,
};
let table = doc_table("orders", vec![col("status", "text")], vec![index]);
let mut warnings = Vec::new();
let out = render_table(&table, false, &mut warnings);
assert!(!out.contains("idx_orders_open"), "filtered index must not be emitted, got:\n{out}");
assert_eq!(warnings.len(), 1);
match &warnings[0] {
SnapshotWarning::DbmlOmitted { table, item, reason } => {
assert_eq!(table, "public.orders");
assert_eq!(item, "idx_orders_open");
assert!(reason.contains("filter"), "got {reason}");
}
other => panic!("unexpected warning: {other:?}"),
}
}
use crate::docs::{Cardinality, DocEnum, FieldRef, Relationship, TableGroup};
fn relationship(cardinality: Cardinality) -> Relationship {
Relationship {
id: "r1".to_string(),
name: Some("fk_orders_user".to_string()),
from: FieldRef {
schema: Some("public".to_string()),
table: "orders".to_string(),
column: "user_id".to_string(),
},
to: FieldRef { schema: Some("public".to_string()), table: "users".to_string(), column: "id".to_string() },
cardinality,
on_update: None,
on_delete: Some("CASCADE".to_string()),
}
}
#[test]
fn many_to_one_uses_the_gt_operator() {
let out = render_ref(&relationship(Cardinality::ManyToOne), false);
assert_eq!(out, "Ref fk_orders_user: orders.user_id > users.id [delete: cascade]\n");
}
#[test]
fn one_to_one_uses_the_dash_operator() {
let out = render_ref(&relationship(Cardinality::OneToOne), false);
assert!(out.contains("orders.user_id - users.id"), "got {out}");
}
#[test]
fn refs_qualify_both_sides_together() {
let out = render_ref(&relationship(Cardinality::ManyToOne), true);
assert!(out.contains("public.orders.user_id > public.users.id"), "got {out}");
}
#[test]
fn referential_actions_are_lowercased_and_both_emitted() {
let mut rel = relationship(Cardinality::ManyToOne);
rel.on_update = Some("NO ACTION".to_string());
rel.on_delete = Some("RESTRICT".to_string());
let out = render_ref(&rel, false);
assert!(out.contains("[update: no action, delete: restrict]"), "got {out}");
}
#[test]
fn an_unnamed_ref_omits_the_name() {
let mut rel = relationship(Cardinality::ManyToOne);
rel.name = None;
assert!(render_ref(&rel, false).starts_with("Ref: orders.user_id"), "got {}", render_ref(&rel, false));
}
#[test]
fn renders_an_enum_block() {
let value = DocEnum {
schema: Some("public".to_string()),
name: "order_status".to_string(),
values: vec!["pending".to_string(), "shipped".to_string()],
note: None,
synthesized: false,
};
let out = render_enum(&value, false);
assert_eq!(out, "Enum order_status {\n pending\n shipped\n}\n");
}
#[test]
fn renders_a_table_group_with_colour_and_note() {
let group = TableGroup {
id: "order-management".to_string(),
name: "Order Management".to_string(),
hue: 28,
note: Some("Checkout to carrier handoff.".to_string()),
};
let orders = doc_table("orders", vec![], vec![]);
let items = doc_table("order_items", vec![], vec![]);
let out = render_group(&group, &[&orders, &items], false);
assert!(out.starts_with("TableGroup \"Order Management\" [color: #"), "got:\n{out}");
assert!(out.contains("\n orders\n"), "got:\n{out}");
assert!(out.contains("\n order_items\n"), "got:\n{out}");
assert!(out.contains("Note: '''Checkout to carrier handoff.'''"), "got:\n{out}");
}
#[test]
fn an_empty_group_renders_nothing() {
let group = TableGroup { id: "empty".to_string(), name: "Empty".to_string(), hue: 0, note: None };
assert_eq!(render_group(&group, &[], false), "");
}
use crate::docs::{ProjectMeta, SchemaSnapshot};
fn snapshot(tables: Vec<DocTable>, schemas: Vec<&str>) -> SchemaSnapshot {
SchemaSnapshot {
format_version: 1,
project: ProjectMeta {
name: "Ecommerce".to_string(),
database_type: "PostgreSQL".to_string(),
database: Some("shop".to_string()),
schemas: schemas.into_iter().map(str::to_string).collect(),
generated_at: "2026-08-02T12:00:00Z".to_string(),
note: Some("Storefront database.".to_string()),
},
tables,
relationships: vec![],
groups: vec![],
enums: vec![],
warnings: vec![],
}
}
#[test]
fn emits_a_project_block_first() {
let out = to_dbml(&snapshot(vec![], vec!["public"]));
assert!(out.text.starts_with("Project Ecommerce {\n"), "got:\n{}", out.text);
assert!(out.text.contains("database_type: 'PostgreSQL'"), "got:\n{}", out.text);
assert!(out.text.contains("Note: '''Storefront database.'''"), "got:\n{}", out.text);
}
#[test]
fn a_single_schema_snapshot_uses_bare_table_names() {
let out = to_dbml(&snapshot(vec![doc_table("orders", vec![col("id", "integer")], vec![])], vec!["public"]));
assert!(out.text.contains("Table orders {"), "got:\n{}", out.text);
assert!(!out.text.contains("Table public.orders"), "got:\n{}", out.text);
}
#[test]
fn a_multi_schema_snapshot_qualifies_every_table() {
let mut analytics = doc_table("daily_sales", vec![col("id", "integer")], vec![]);
analytics.schema = Some("analytics".to_string());
let out = to_dbml(&snapshot(
vec![doc_table("orders", vec![col("id", "integer")], vec![]), analytics],
vec!["public", "analytics"],
));
assert!(out.text.contains("Table public.orders {"), "got:\n{}", out.text);
assert!(out.text.contains("Table analytics.daily_sales {"), "got:\n{}", out.text);
}
#[test]
fn sections_appear_in_order_project_enums_groups_tables_refs() {
let mut snap = snapshot(vec![doc_table("orders", vec![col("id", "integer")], vec![])], vec!["public"]);
snap.enums.push(DocEnum {
schema: Some("public".to_string()),
name: "order_status".to_string(),
values: vec!["pending".to_string()],
note: None,
synthesized: false,
});
snap.groups.push(TableGroup {
id: "order-management".to_string(),
name: "Order Management".to_string(),
hue: 28,
note: None,
});
snap.tables[0].group_id = Some("order-management".to_string());
snap.relationships.push(relationship(Cardinality::ManyToOne));
let text = to_dbml(&snap).text;
let project = text.find("Project ").expect("project");
let enum_at = text.find("Enum ").expect("enum");
let group = text.find("TableGroup ").expect("group");
let table = text.find("Table orders").expect("table");
let reference = text.find("Ref ").expect("ref");
assert!(project < enum_at, "project before enums:\n{text}");
assert!(enum_at < group, "enums before groups:\n{text}");
assert!(group < table, "groups before tables:\n{text}");
assert!(table < reference, "tables before refs:\n{text}");
}
#[test]
fn warnings_from_table_rendering_reach_the_output() {
let index = IndexInfo {
name: "idx_partial".to_string(),
columns: vec!["status".to_string()],
is_unique: false,
is_primary: false,
filter: Some("status <> 'x'".to_string()),
index_type: None,
included_columns: None,
comment: None,
};
let out =
to_dbml(&snapshot(vec![doc_table("orders", vec![col("status", "text")], vec![index])], vec!["public"]));
assert_eq!(out.warnings.len(), 1);
}
#[test]
fn a_group_referencing_a_missing_table_is_skipped() {
let mut snap = snapshot(vec![doc_table("orders", vec![col("id", "integer")], vec![])], vec!["public"]);
snap.groups.push(TableGroup { id: "ghost".to_string(), name: "Ghost".to_string(), hue: 200, note: None });
assert!(!to_dbml(&snap).text.contains("Ghost"), "got:\n{}", to_dbml(&snap).text);
}
#[test]
fn collector_warnings_survive_into_the_output() {
let mut snap = snapshot(vec![], vec!["public"]);
snap.warnings.push(SnapshotWarning::TableSkipped {
table: "public.secret".to_string(),
reason: "permission denied".to_string(),
});
let out = to_dbml(&snap);
assert_eq!(out.warnings.len(), 1, "collector warnings must not be dropped");
match &out.warnings[0] {
SnapshotWarning::TableSkipped { table, .. } => assert_eq!(table, "public.secret"),
other => panic!("unexpected warning: {other:?}"),
}
}
}

View File

@ -0,0 +1,98 @@
use crate::models::connection::DatabaseType;
/// Fold an identifier to its canonical case for note matching.
///
/// PostgreSQL folds unquoted identifiers to lower case, Oracle to upper.
/// MySQL depends on the server's `lower_case_table_names`; we fold to lower
/// unconditionally rather than pay a `SHOW VARIABLES` round-trip per
/// collection — on the rare case-sensitive configuration the only risk is
/// matching a note to a table differing solely by case, never attaching a
/// note to an unrelated table.
///
/// ClickHouse and MongoDB are genuinely case-sensitive: two identifiers
/// differing only by case are DIFFERENT objects there, so folding them
/// would let one note attach to the wrong one. They are left untouched.
///
/// The lower-case default is correct for the remaining SQL engines
/// (SQLite, SQL Server, DuckDB, rqlite, Doris, StarRocks), all of which
/// compare identifiers case-insensitively.
pub fn fold_identifier(db_type: DatabaseType, value: &str) -> String {
match db_type {
DatabaseType::Oracle => value.to_uppercase(),
DatabaseType::ClickHouse | DatabaseType::MongoDb => value.to_string(),
_ => value.to_lowercase(),
}
}
/// `schema.table`, or bare `table` when there is no schema.
pub fn table_key(db_type: DatabaseType, schema: Option<&str>, table: &str) -> String {
match schema.filter(|value| !value.is_empty()) {
Some(schema) => {
format!("{}.{}", fold_identifier(db_type, schema), fold_identifier(db_type, table))
}
None => fold_identifier(db_type, table),
}
}
/// `schema.table.column`, or `table.column` when there is no schema.
pub fn column_key(db_type: DatabaseType, schema: Option<&str>, table: &str, column: &str) -> String {
format!("{}.{}", table_key(db_type, schema, table), fold_identifier(db_type, column))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::connection::DatabaseType;
#[test]
fn postgres_folds_to_lowercase() {
assert_eq!(fold_identifier(DatabaseType::Postgres, "Orders"), "orders");
assert_eq!(fold_identifier(DatabaseType::Postgres, "ORDERS"), "orders");
}
#[test]
fn mysql_folds_to_lowercase() {
assert_eq!(fold_identifier(DatabaseType::Mysql, "Orders"), "orders");
}
#[test]
fn table_keys_are_qualified_when_a_schema_is_present() {
assert_eq!(table_key(DatabaseType::Postgres, Some("Core"), "Orders"), "core.orders");
assert_eq!(table_key(DatabaseType::Postgres, None, "Orders"), "orders");
assert_eq!(table_key(DatabaseType::Postgres, Some(""), "Orders"), "orders");
}
#[test]
fn column_keys_extend_the_table_key() {
assert_eq!(column_key(DatabaseType::Postgres, Some("core"), "orders", "Status"), "core.orders.status");
}
#[test]
fn folding_is_idempotent() {
// Folding an already-folded value must be a no-op, or a key would
// depend on how many times it had been normalised.
for engine in [DatabaseType::Postgres, DatabaseType::Oracle, DatabaseType::ClickHouse] {
let once = fold_identifier(engine, "MixedCase");
assert_eq!(fold_identifier(engine, &once), once, "engine {engine:?}");
}
let key_once = table_key(DatabaseType::Postgres, Some("Core"), "Orders");
assert_eq!(key_once, "core.orders");
// Re-folding each segment of an existing key must not change it.
let refolded = key_once
.split('.')
.map(|segment| fold_identifier(DatabaseType::Postgres, segment))
.collect::<Vec<_>>()
.join(".");
assert_eq!(refolded, key_once);
}
#[test]
fn case_sensitive_engines_are_not_folded() {
// ClickHouse and MongoDB treat `Orders` and `orders` as DIFFERENT
// objects. Folding them would let one note attach to the wrong one.
assert_eq!(fold_identifier(DatabaseType::ClickHouse, "Orders"), "Orders");
assert_eq!(fold_identifier(DatabaseType::MongoDb, "Orders"), "Orders");
assert_eq!(table_key(DatabaseType::ClickHouse, Some("Analytics"), "Orders"), "Analytics.Orders");
}
}

View File

@ -0,0 +1,14 @@
pub mod annotations;
pub mod collector;
pub mod color;
pub mod dbml;
pub mod keys;
pub mod relations;
pub mod snapshot;
pub use collector::{collect_snapshot, CollectOptions, CollectProgress};
pub use color::hue_to_hex;
pub use dbml::{to_dbml, DbmlOutput};
pub use keys::{column_key, fold_identifier, table_key};
pub use relations::build_relationships;
pub use snapshot::*;

View File

@ -0,0 +1,392 @@
use std::collections::HashSet;
use crate::docs::{Cardinality, DocTable, FieldRef, Relationship};
use crate::types::ForeignKeyInfo;
/// True when `column` alone is guaranteed unique on `table` — either it is a
/// single-column primary key, or a single-column unique index covers it.
///
/// A composite unique index over (a, b) does NOT make `a` unique, which is
/// why the column count is checked.
fn column_is_unique(table: &DocTable, column: &str) -> bool {
let primary_columns: Vec<&str> =
table.columns.iter().filter(|c| c.is_primary_key).map(|c| c.name.as_str()).collect();
if primary_columns.len() == 1 && primary_columns[0] == column {
return true;
}
table
.indexes
.iter()
.any(|index| (index.is_unique || index.is_primary) && index.columns.len() == 1 && index.columns[0] == column)
}
fn cardinality_for(table: &DocTable, foreign_key: &ForeignKeyInfo) -> Cardinality {
if column_is_unique(table, &foreign_key.column) {
Cardinality::OneToOne
} else {
Cardinality::ManyToOne
}
}
fn relationship_id(source: &DocTable, foreign_key: &ForeignKeyInfo) -> String {
[
source.qualified_name().as_str(),
if foreign_key.name.is_empty() { "foreign_key" } else { foreign_key.name.as_str() },
foreign_key.column.as_str(),
foreign_key.ref_table.as_str(),
foreign_key.ref_column.as_str(),
]
.join(":")
}
/// Resolve a foreign key's target against the tables actually present.
///
/// Resolution order mirrors SQL's own: an explicit `ref_schema` wins; an
/// unqualified reference resolves inside the source table's own schema;
/// only then do we consider a bare-name match elsewhere — and an ambiguous
/// one is dropped rather than guessed at, because a wrong edge is worse
/// than a missing one.
fn find_target<'a>(tables: &'a [DocTable], source: &DocTable, foreign_key: &ForeignKeyInfo) -> Option<&'a DocTable> {
// An explicit ref_schema is authoritative: the database told us exactly
// which schema the key points at. If that schema was not collected, the
// edge must be DROPPED, not resolved elsewhere. Falling through would bind
// it to a same-named table in a different schema — `sales.orders` pointing
// at `archive.customers` would render as pointing at `sales.customers`,
// which is a confidently wrong diagram rather than an incomplete one.
if let Some(ref_schema) = foreign_key.ref_schema.as_deref().filter(|s| !s.is_empty()) {
return tables.iter().find(|t| t.name == foreign_key.ref_table && t.schema.as_deref() == Some(ref_schema));
}
if let Some(found) = tables.iter().find(|t| t.name == foreign_key.ref_table && t.schema == source.schema) {
return Some(found);
}
let mut matches = tables.iter().filter(|t| t.name == foreign_key.ref_table);
let first = matches.next()?;
if matches.next().is_some() {
return None;
}
Some(first)
}
/// Build the relationship set for a snapshot.
///
/// Foreign keys pointing at tables outside the collected set are dropped —
/// the target may live in a schema the user did not select, and a dangling
/// edge renders as a relationship to nothing.
pub fn build_relationships(tables: &[DocTable]) -> Vec<Relationship> {
let mut seen: HashSet<String> = HashSet::new();
let mut relationships = Vec::new();
for source in tables {
for foreign_key in &source.foreign_keys {
let Some(target) = find_target(tables, source, foreign_key) else { continue };
if !source.columns.iter().any(|c| c.name == foreign_key.column) {
continue;
}
let id = relationship_id(source, foreign_key);
if !seen.insert(id.clone()) {
continue;
}
relationships.push(Relationship {
id,
name: (!foreign_key.name.is_empty()).then(|| foreign_key.name.clone()),
from: FieldRef {
schema: source.schema.clone(),
table: source.name.clone(),
column: foreign_key.column.clone(),
},
to: FieldRef {
schema: target.schema.clone(),
table: target.name.clone(),
column: foreign_key.ref_column.clone(),
},
cardinality: cardinality_for(source, foreign_key),
on_update: foreign_key.on_update.clone(),
on_delete: foreign_key.on_delete.clone(),
});
}
}
relationships
}
#[cfg(test)]
mod tests {
use super::*;
use crate::docs::{NoteSource, TableKind};
use crate::types::{ColumnInfo, ForeignKeyInfo, IndexInfo};
use std::collections::BTreeMap;
fn column(name: &str, primary: bool) -> ColumnInfo {
ColumnInfo {
name: name.to_string(),
data_type: "integer".to_string(),
is_nullable: false,
is_primary_key: primary,
..ColumnInfo::default()
}
}
fn fk(name: &str, column: &str, ref_table: &str, ref_column: &str) -> ForeignKeyInfo {
ForeignKeyInfo {
name: name.to_string(),
column: column.to_string(),
ref_schema: Some("public".to_string()),
ref_table: ref_table.to_string(),
ref_column: ref_column.to_string(),
on_update: None,
on_delete: Some("CASCADE".to_string()),
}
}
fn table_in(schema: &str, name: &str, fks: Vec<ForeignKeyInfo>) -> DocTable {
DocTable {
schema: Some(schema.to_string()),
name: name.to_string(),
kind: TableKind::Table,
columns: vec![column("id", true), column("customer_id", false)],
indexes: Vec::new(),
foreign_keys: fks,
group_id: None,
note: None,
note_source: NoteSource::None,
shadowed_note: None,
column_notes: BTreeMap::new(),
estimated_rows: None,
view_definition: None,
}
}
#[test]
fn an_unresolvable_explicit_ref_schema_does_not_fall_back_to_another_schema() {
// sales.orders -> archive.customers, but only the `sales` schema was
// collected. `archive.customers` is absent, and `sales.customers`
// exists with the same bare name. Falling through to the source-schema
// tier would bind the edge to sales.customers — a different table than
// the foreign key names — and render a plausible but wrong diagram.
let mut fk = fk("orders_customer_fk", "customer_id", "customers", "id");
fk.ref_schema = Some("archive".to_string());
let tables = vec![table_in("sales", "orders", vec![fk]), table_in("sales", "customers", Vec::new())];
let relationships = build_relationships(&tables);
assert!(
relationships.is_empty(),
"expected the edge to be dropped, got {:?}",
relationships.iter().map(|r| (&r.from.table, &r.to.table)).collect::<Vec<_>>()
);
}
fn unique_index(name: &str, columns: &[&str]) -> IndexInfo {
IndexInfo {
name: name.to_string(),
columns: columns.iter().map(|c| c.to_string()).collect(),
is_unique: true,
is_primary: false,
filter: None,
index_type: None,
included_columns: None,
comment: None,
}
}
fn table(name: &str, columns: Vec<ColumnInfo>, indexes: Vec<IndexInfo>, fks: Vec<ForeignKeyInfo>) -> DocTable {
DocTable {
schema: Some("public".to_string()),
name: name.to_string(),
kind: TableKind::Table,
columns,
indexes,
foreign_keys: fks,
group_id: None,
note: None,
note_source: NoteSource::None,
shadowed_note: None,
column_notes: BTreeMap::new(),
estimated_rows: None,
view_definition: None,
}
}
#[test]
fn plain_foreign_key_is_many_to_one() {
let tables = vec![
table(
"orders",
vec![column("id", true), column("user_id", false)],
vec![],
vec![fk("fk_orders_user", "user_id", "users", "id")],
),
table("users", vec![column("id", true)], vec![], vec![]),
];
let rels = build_relationships(&tables);
assert_eq!(rels.len(), 1);
assert_eq!(rels[0].cardinality, Cardinality::ManyToOne);
assert_eq!(rels[0].from.table, "orders");
assert_eq!(rels[0].from.column, "user_id");
assert_eq!(rels[0].to.table, "users");
assert_eq!(rels[0].to.column, "id");
assert_eq!(rels[0].on_delete.as_deref(), Some("CASCADE"));
}
#[test]
fn foreign_key_on_a_unique_column_is_one_to_one() {
let tables = vec![
table(
"shipments",
vec![column("id", true), column("order_id", false)],
vec![unique_index("uq_shipments_order", &["order_id"])],
vec![fk("fk_shipments_order", "order_id", "orders", "id")],
),
table("orders", vec![column("id", true)], vec![], vec![]),
];
let rels = build_relationships(&tables);
assert_eq!(rels.len(), 1);
assert_eq!(rels[0].cardinality, Cardinality::OneToOne);
}
#[test]
fn foreign_key_on_a_primary_key_column_is_one_to_one() {
let tables = vec![
table(
"user_profiles",
vec![column("user_id", true)],
vec![],
vec![fk("fk_profile_user", "user_id", "users", "id")],
),
table("users", vec![column("id", true)], vec![], vec![]),
];
assert_eq!(build_relationships(&tables)[0].cardinality, Cardinality::OneToOne);
}
#[test]
fn multi_column_unique_index_does_not_make_a_single_column_unique() {
// uq(a, b) does NOT make `a` alone unique, so the FK stays many-to-one.
let tables = vec![
table(
"order_items",
vec![column("order_id", false), column("product_id", false)],
vec![unique_index("uq_items", &["order_id", "product_id"])],
vec![fk("fk_items_order", "order_id", "orders", "id")],
),
table("orders", vec![column("id", true)], vec![], vec![]),
];
assert_eq!(build_relationships(&tables)[0].cardinality, Cardinality::ManyToOne);
}
#[test]
fn self_referencing_foreign_key_is_supported() {
let tables = vec![table(
"categories",
vec![column("id", true), column("parent_id", false)],
vec![],
vec![fk("fk_cat_parent", "parent_id", "categories", "id")],
)];
let rels = build_relationships(&tables);
assert_eq!(rels.len(), 1);
assert_eq!(rels[0].from.table, "categories");
assert_eq!(rels[0].to.table, "categories");
}
#[test]
fn foreign_key_to_an_absent_table_is_dropped() {
// The target may live outside the selected schema. A dangling edge
// would render as a relationship to nothing.
let tables = vec![table(
"orders",
vec![column("user_id", false)],
vec![],
vec![fk("fk_orders_user", "user_id", "users", "id")],
)];
assert!(build_relationships(&tables).is_empty());
}
#[test]
fn relationship_ids_are_stable_and_unique() {
let tables = vec![
table(
"orders",
vec![column("user_id", false), column("merchant_id", false)],
vec![],
vec![fk("fk_a", "user_id", "users", "id"), fk("fk_b", "merchant_id", "users", "id")],
),
table("users", vec![column("id", true)], vec![], vec![]),
];
let rels = build_relationships(&tables);
assert_eq!(rels.len(), 2);
assert_ne!(rels[0].id, rels[1].id);
assert_eq!(build_relationships(&tables)[0].id, rels[0].id);
}
#[test]
fn an_unqualified_foreign_key_resolves_within_the_source_schema() {
let mut orders = table(
"orders",
vec![column("id", true), column("user_id", false)],
vec![],
vec![ForeignKeyInfo {
name: "fk_orders_user".to_string(),
column: "user_id".to_string(),
ref_schema: None,
ref_table: "users".to_string(),
ref_column: "id".to_string(),
on_update: None,
on_delete: None,
}],
);
orders.schema = Some("tenant_a".to_string());
let mut users_a = table("users", vec![column("id", true)], vec![], vec![]);
users_a.schema = Some("tenant_a".to_string());
let mut users_b = table("users", vec![column("id", true)], vec![], vec![]);
users_b.schema = Some("tenant_b".to_string());
// tenant_b listed FIRST so a naive first-match would pick the wrong one.
let rels = build_relationships(&[users_b, orders, users_a]);
assert_eq!(rels.len(), 1);
assert_eq!(rels[0].to.schema.as_deref(), Some("tenant_a"));
}
#[test]
fn an_ambiguous_bare_name_reference_is_dropped_rather_than_guessed() {
let mut orders = table(
"orders",
vec![column("id", true), column("user_id", false)],
vec![],
vec![ForeignKeyInfo {
name: "fk_orders_user".to_string(),
column: "user_id".to_string(),
ref_schema: None,
ref_table: "users".to_string(),
ref_column: "id".to_string(),
on_update: None,
on_delete: None,
}],
);
orders.schema = Some("app".to_string());
let mut users_a = table("users", vec![column("id", true)], vec![], vec![]);
users_a.schema = Some("tenant_a".to_string());
let mut users_b = table("users", vec![column("id", true)], vec![], vec![]);
users_b.schema = Some("tenant_b".to_string());
assert!(build_relationships(&[orders, users_a, users_b]).is_empty());
}
}

View File

@ -0,0 +1,315 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::types::{ColumnInfo, ForeignKeyInfo, IndexInfo};
/// The normalized description of a relational schema. Every documentation
/// output (DBML, HTML, future changelog records) is a serializer over this.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SchemaSnapshot {
/// Guards viewer/data skew. Always 1 for now; bump only on a breaking
/// change to this model.
pub format_version: u32,
pub project: ProjectMeta,
pub tables: Vec<DocTable>,
pub relationships: Vec<Relationship>,
#[serde(default)]
pub groups: Vec<TableGroup>,
#[serde(default)]
pub enums: Vec<DocEnum>,
#[serde(default)]
pub warnings: Vec<SnapshotWarning>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectMeta {
pub name: String,
pub database_type: String,
pub database: Option<String>,
pub schemas: Vec<String>,
/// RFC3339.
pub generated_at: String,
/// Markdown. Rendered as the documentation landing page.
pub note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocTable {
pub schema: Option<String>,
pub name: String,
pub kind: TableKind,
pub columns: Vec<ColumnInfo>,
pub indexes: Vec<IndexInfo>,
pub foreign_keys: Vec<ForeignKeyInfo>,
/// References `TableGroup::id`. Populated in Part 2.
pub group_id: Option<String>,
/// Merged note: local annotation, else database comment.
pub note: Option<String>,
pub note_source: NoteSource,
/// The database comment a local note is overriding, if any. Shown on
/// hover so a later `COMMENT ON` improvement is never invisible.
pub shadowed_note: Option<String>,
#[serde(default)]
pub column_notes: BTreeMap<String, ColumnNote>,
pub estimated_rows: Option<i64>,
pub view_definition: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TableGroup {
/// Stable slug. Survives a display-name change.
pub id: String,
pub name: String,
/// 0..=359. Lightness and chroma are theme-controlled in CSS, so any
/// hue is legible on both grounds by construction.
pub hue: u16,
pub note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ColumnNote {
pub note: String,
pub source: NoteSource,
pub shadowed: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocEnum {
pub schema: Option<String>,
pub name: String,
pub values: Vec<String>,
pub note: Option<String>,
/// True when synthesized from a MySQL inline `ENUM(...)` column rather
/// than read from a named database type.
#[serde(default)]
pub synthesized: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
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>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FieldRef {
pub schema: Option<String>,
pub table: String,
pub column: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum TableKind {
Table,
View,
MaterializedView,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum NoteSource {
/// Read from a database COMMENT.
Database,
/// Authored in DBX, stored in the notes file.
Local,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Cardinality {
OneToOne,
ManyToOne,
}
/// Why a snapshot is less complete than it looks. Rendered as a dismissible
/// banner rather than left for the reader to discover.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum SnapshotWarning {
TableSkipped { table: String, reason: String },
NoForeignKeyMetadata { engine: String },
CommentsUnsupported { engine: String },
OrphanedNotes { count: usize },
DbmlOmitted { table: String, item: String, reason: String },
}
/// Human-readable warning text for headless callers.
///
/// The viewer translates these through its own `docs.warnings` namespace; the
/// CLI has no i18n runtime, so the English prose lives here. Without it the
/// only thing to print is the derived `Debug` form, which exposes the struct
/// shape and reads like a panic rather than like advice.
impl std::fmt::Display for SnapshotWarning {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TableSkipped { table, reason } => {
write!(formatter, "{table} was skipped: {reason}. It is missing from this documentation.")
}
Self::NoForeignKeyMetadata { engine } => {
write!(
formatter,
"{engine} does not report foreign key metadata, so no relationships could be derived."
)
}
Self::CommentsUnsupported { engine } => write!(
formatter,
"{engine} does not support table or column comments, so every description comes from your own notes."
),
Self::OrphanedNotes { count } => write!(
formatter,
"{count} note(s) refer to a table or column that no longer exists. Nothing was deleted."
),
Self::DbmlOmitted { table, item, reason } => {
write!(formatter, "{item} on {table} is documented but omitted from the DBML: {reason}.")
}
}
}
}
impl DocTable {
/// `schema.name` when a schema is present, otherwise bare `name`.
pub fn qualified_name(&self) -> String {
match &self.schema {
Some(schema) if !schema.is_empty() => format!("{schema}.{}", self.name),
_ => self.name.clone(),
}
}
}
impl FieldRef {
pub fn qualified_table(&self) -> String {
match &self.schema {
Some(schema) if !schema.is_empty() => format!("{schema}.{}", self.table),
_ => self.table.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> SchemaSnapshot {
SchemaSnapshot {
format_version: 1,
project: ProjectMeta {
name: "Ecommerce".to_string(),
database_type: "PostgreSQL".to_string(),
database: Some("shop".to_string()),
schemas: vec!["public".to_string()],
generated_at: "2026-08-02T12:00:00Z".to_string(),
note: None,
},
tables: vec![DocTable {
schema: Some("public".to_string()),
name: "orders".to_string(),
kind: TableKind::Table,
columns: vec![],
indexes: vec![],
foreign_keys: vec![],
group_id: None,
note: Some("Checkout rows.".to_string()),
note_source: NoteSource::Database,
shadowed_note: None,
column_notes: BTreeMap::new(),
estimated_rows: Some(2_400_000),
view_definition: None,
}],
relationships: vec![],
groups: vec![],
enums: vec![],
warnings: vec![],
}
}
#[test]
fn snapshot_round_trips_through_json() {
let original = sample();
let json = serde_json::to_string(&original).expect("serialize");
let parsed: SchemaSnapshot = serde_json::from_str(&json).expect("deserialize");
assert_eq!(parsed.format_version, 1);
assert_eq!(parsed.project.name, "Ecommerce");
assert_eq!(parsed.tables.len(), 1);
assert_eq!(parsed.tables[0].name, "orders");
assert_eq!(parsed.tables[0].note.as_deref(), Some("Checkout rows."));
assert_eq!(parsed.tables[0].estimated_rows, Some(2_400_000));
}
#[test]
fn snapshot_json_uses_camel_case_keys() {
let json = serde_json::to_string(&sample()).expect("serialize");
assert!(json.contains("\"formatVersion\""), "got: {json}");
assert!(json.contains("\"generatedAt\""), "got: {json}");
assert!(json.contains("\"noteSource\""), "got: {json}");
}
#[test]
fn table_kind_serializes_as_screaming_snake_case() {
let json = serde_json::to_string(&TableKind::MaterializedView).expect("serialize");
assert_eq!(json, "\"MATERIALIZED_VIEW\"");
}
#[test]
fn every_warning_renders_as_prose() {
// `dbx dbml` prints these to stderr. The derived Debug form leaks the
// struct shape (`DbmlOmitted { table: "public.t", .. }`), which reads
// as a crash report rather than as advice, so Display is what the CLI
// must use. The wording tracks the viewer's own warning strings.
let cases = [
(
SnapshotWarning::TableSkipped { table: "public.orders".into(), reason: "permission denied".into() },
"public.orders was skipped: permission denied. It is missing from this documentation.",
),
(
SnapshotWarning::NoForeignKeyMetadata { engine: "ClickHouse".into() },
"ClickHouse does not report foreign key metadata, so no relationships could be derived.",
),
(
SnapshotWarning::CommentsUnsupported { engine: "SQLite".into() },
"SQLite does not support table or column comments, so every description comes from your own notes.",
),
(
SnapshotWarning::OrphanedNotes { count: 3 },
"3 note(s) refer to a table or column that no longer exists. Nothing was deleted.",
),
(
SnapshotWarning::DbmlOmitted {
table: "public.orders".into(),
item: "idx_partial".into(),
reason: "partial index filter has no DBML equivalent".into(),
},
"idx_partial on public.orders is documented but omitted from the DBML: partial index filter has no DBML equivalent.",
),
];
for (warning, expected) in cases {
assert_eq!(warning.to_string(), expected);
}
}
#[test]
fn a_warning_never_renders_as_its_debug_form() {
// The regression this guards: reverting the CLI to `{warning:?}` is a
// one-character edit that still compiles and still prints something.
let warning = SnapshotWarning::OrphanedNotes { count: 1 };
assert!(!warning.to_string().contains('{'), "got: {warning}");
assert_ne!(warning.to_string(), format!("{warning:?}"));
}
}

View File

@ -33,6 +33,7 @@ pub mod database_search_sql;
pub mod db;
pub mod db_admin_sql;
pub mod dml_binding;
pub mod docs;
pub mod document_ops;
pub mod driver_runtime;
pub mod external;

View File

@ -102,6 +102,10 @@ pub struct ConnectionConfig {
pub init_script: Option<String>,
#[serde(default)]
pub color: Option<String>,
/// Path to this connection's documentation notes file. Set by the
/// desktop app; the CLI takes an explicit `--notes` path instead.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub docs_notes_path: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub transport_layers: Vec<TransportLayerConfig>,
#[serde(default = "default_connect_timeout_secs")]
@ -586,6 +590,8 @@ struct ConnectionConfigData {
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub docs_notes_path: Option<String>,
#[serde(default)]
pub transport_layers: Vec<TransportLayerConfig>,
#[serde(default = "default_connect_timeout_secs")]
pub connect_timeout_secs: u64,
@ -675,6 +681,7 @@ impl From<ConnectionConfigData> for ConnectionConfig {
attached_databases: data.attached_databases,
init_script: data.init_script,
color: data.color,
docs_notes_path: data.docs_notes_path,
transport_layers: data.transport_layers,
connect_timeout_secs: data.connect_timeout_secs,
query_timeout_secs: data.query_timeout_secs,
@ -2277,6 +2284,7 @@ mod tests {
fn mysql_config(username: &str, password: &str, database: Option<&str>) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: "id".to_string(),
name: "name".to_string(),
note: String::new(),
@ -2350,6 +2358,40 @@ mod tests {
assert!(!legacy.read_only);
}
#[test]
fn docs_notes_path_defaults_to_none_and_round_trips() {
// A connection stored before this field existed must still load.
let legacy: ConnectionConfig = serde_json::from_value(serde_json::json!({
"id": "c1",
"name": "local",
"db_type": "postgres",
"host": "127.0.0.1",
"port": 5432,
"username": "postgres",
"password": "",
"database": null
}))
.expect("legacy config must still parse");
assert_eq!(legacy.docs_notes_path, None);
// And a stored path must actually survive a load — this is the half
// that fails if the field is added to ConnectionConfig only, without
// ConnectionConfigData and the From impl.
let with_path: ConnectionConfig = serde_json::from_value(serde_json::json!({
"id": "c1",
"name": "local",
"db_type": "postgres",
"host": "127.0.0.1",
"port": 5432,
"username": "postgres",
"password": "",
"database": null,
"docs_notes_path": "docs/dbx-docs.json"
}))
.expect("config with a notes path must parse");
assert_eq!(with_path.docs_notes_path.as_deref(), Some("docs/dbx-docs.json"));
}
#[test]
fn connection_note_is_optional_and_round_trips_when_present() {
let config = mysql_config("root", "secret", Some("app"));

View File

@ -156,6 +156,7 @@ mod tests {
fn connection_with_external(value: serde_json::Value) -> ConnectionConfig {
let mut cfg = ConnectionConfig {
docs_notes_path: None,
id: "c1".to_string(),
name: "mq".to_string(),
note: String::new(),

View File

@ -866,6 +866,7 @@ mod tests {
fn mq_connection(read_only: bool) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: "readonly-mq".to_string(),
name: "Read only MQ".to_string(),
note: String::new(),

View File

@ -283,6 +283,7 @@ mod tests {
fn connection_with_external(value: serde_json::Value) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: "nacos-1".to_string(),
name: "Nacos".to_string(),
note: String::new(),

View File

@ -353,6 +353,7 @@ mod tests {
let storage = crate::storage::Storage::open(&dir.join("storage.db")).await.unwrap();
let state = AppState::new(storage);
let mut cfg = crate::models::connection::ConnectionConfig {
docs_notes_path: None,
id: "nacos-1".to_string(),
name: "Nacos".to_string(),
note: String::new(),
@ -425,6 +426,7 @@ mod tests {
let storage = crate::storage::Storage::open(&dir.join("storage.db")).await.unwrap();
let state = AppState::new(storage);
let cfg = crate::models::connection::ConnectionConfig {
docs_notes_path: None,
id: "nacos-rollback".to_string(),
name: "Nacos".to_string(),
note: String::new(),

View File

@ -638,6 +638,7 @@ mod tests {
fn config() -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: "conn".to_string(),
name: "test".to_string(),
note: String::new(),

View File

@ -4478,6 +4478,7 @@ for line in sys.stdin:
fn test_connection_config(db_type: DatabaseType) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: "conn-1".to_string(),
name: "Connection".to_string(),
note: String::new(),
@ -5806,6 +5807,7 @@ for line in sys.stdin:
#[test]
fn external_driver_query_params_include_database_and_schema_context() {
let config = ConnectionConfig {
docs_notes_path: None,
id: "jdbc-1".to_string(),
name: "JDBC".to_string(),
note: String::new(),

View File

@ -2690,6 +2690,7 @@ mod tests {
fn test_connection_config(db_type: DatabaseType) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: "test".to_string(),
name: "test".to_string(),
note: String::new(),

View File

@ -4140,6 +4140,7 @@ mod tests {
fn mq_connection(id: &str, token: &str) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: id.to_string(),
name: "Pulsar".to_string(),
note: String::new(),
@ -4204,6 +4205,7 @@ mod tests {
fn nacos_connection(id: &str, password: &str) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: id.to_string(),
name: "Nacos".to_string(),
note: String::new(),

View File

@ -36,3 +36,39 @@ pub fn build_table_structure_change_sql(options: TableStructureSqlOptions) -> Ta
statements.extend(build_table_comment_sql(&options, &mut warnings));
TableStructureSqlResult { statements, warnings }
}
/// Whether this engine's structure editor can generate `COMMENT ON`/inline
/// comment DDL for tables and columns — a DDL-generation capability, not an
/// introspection one. The documentation collector uses it as a heuristic for
/// "can this engine report comments at all", but the two questions can
/// diverge: IRIS supports `%DESCRIPTION` while *defining* a table or column,
/// but DBX's editor cannot ALTER an existing one, so this returns `false`
/// for IRIS even though IRIS still reports descriptions on introspection.
/// Callers using this as an introspection signal must corroborate it against
/// what was actually collected rather than trust the flag alone.
pub(crate) fn supports_comments(database_type: crate::models::connection::DatabaseType) -> bool {
dialect::capabilities_for(Some(database_type)).comment
}
/// Whether this engine's structure editor can generate foreign key DDL — a
/// DDL-generation capability, not an introspection one, used here as a
/// heuristic for "does this engine report foreign key metadata at all".
/// Engines like ClickHouse and Doris genuinely report none, so their ER
/// diagrams have no edges by necessity rather than by accident, but a
/// mismatch analogous to the IRIS comment case is possible for any future
/// engine where DDL support and introspection support diverge — callers
/// should corroborate against what was actually collected.
pub(crate) fn supports_foreign_keys(database_type: crate::models::connection::DatabaseType) -> bool {
dialect::capabilities_for(Some(database_type)).foreign_key
}
/// Canonical display label for a database engine (e.g. `postgres`,
/// `sqlserver`, `mongodb`) — the same identifier already used throughout
/// this module's own warning prose. The documentation collector uses it for
/// `database_type` and its engine-capability warnings instead of the Rust
/// `Debug` spelling (`Postgres`, `SqlServer`, `MongoDb`), which is an
/// implementation detail, not something a consumer like dbdocs/dbdiagram
/// should key off.
pub(crate) fn database_type_label(database_type: crate::models::connection::DatabaseType) -> String {
dialect::database_label(Some(database_type))
}

View File

@ -9610,6 +9610,7 @@ SELECT 1 FROM dual"#
#[test]
fn resolve_external_transfer_catalog_for_config_accepts_starrocks_driver_profile() {
let config = crate::models::connection::ConnectionConfig {
docs_notes_path: None,
id: "sr".to_string(),
name: "sr".to_string(),
note: String::new(),

View File

@ -85,6 +85,7 @@ fn psql(container: &DockerPostgres, sql: &str) {
fn postgres_test_config(id: &str, port: u16) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: id.to_string(),
name: id.to_string(),
note: String::new(),

View File

@ -0,0 +1,231 @@
// Gating and connection setup copied from live_postgres_docs_annotations.rs.
//
// This is not a test of correctness — it is a generator. Its job is to write
// `apps/desktop/src/docs/fixtures/keycloak.snapshot.json` from a REAL Rust
// snapshot so that `fixtureConformance.spec.ts` can fail whenever the
// hand-maintained `types.ts` drifts from this crate's serialized shape.
use dbx_core::connection::AppState;
use dbx_core::docs::annotations::{
AnnotationFile, ColumnAnnotation, GroupAnnotation, ProjectAnnotation, TableAnnotation,
};
use dbx_core::models::connection::{ConnectionConfig, DatabaseType};
use dbx_core::storage::Storage;
use std::collections::BTreeMap;
fn live_postgres_config(
id: &str,
host: &str,
port: u16,
user: &str,
password: &str,
database: &str,
) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: id.to_string(),
name: id.to_string(),
note: String::new(),
db_type: DatabaseType::Postgres,
driver_profile: None,
driver_label: None,
url_params: None,
agent_java_options: Vec::new(),
host: host.to_string(),
port,
username: user.to_string(),
password: password.to_string(),
database: Some(database.to_string()),
visible_databases: None,
visible_schemas: None,
attached_databases: Vec::new(),
init_script: None,
color: None,
transport_layers: Vec::new(),
connect_timeout_secs: 10,
query_timeout_secs: 30,
idle_timeout_secs: 60,
keepalive_interval_secs: 0,
ssl: false,
ca_cert_path: String::new(),
client_cert_path: String::new(),
client_key_path: String::new(),
sysdba: false,
oracle_connection_type: None,
connection_string: None,
redis_connection_mode: None,
redis_sentinel_master: String::new(),
redis_sentinel_nodes: String::new(),
redis_sentinel_username: String::new(),
redis_sentinel_password: String::new(),
redis_sentinel_tls: false,
redis_cluster_nodes: String::new(),
redis_key_separator: dbx_core::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
show_system_schemas: false,
database_info: None,
}
}
/// Which tables to keep in the committed fixture.
///
/// Keycloak's public schema has 90 tables; committing all of them with full
/// column/index/FK data would bloat the fixture for no gain. This is an
/// explicit allowlist rather than "the first N alphabetically" because the
/// conformance test needs a CONNECTED foreign-key subgraph — an alphabetical
/// slice can easily keep a table while dropping everything it references,
/// leaving `relationships` empty and `Relationship`/`FieldRef` unexercised.
///
/// The set is chosen for shape coverage: `realm` and `client` are wide
/// (53 and 26 columns), `protocol_mapper` has two foreign keys to DIFFERENT
/// tables, and `composite_role` has two to the SAME table.
const KEEP_TABLES: &[&str] = &[
"realm",
"client",
"client_attributes",
"client_scope",
"client_scope_attributes",
"protocol_mapper",
"protocol_mapper_config",
"user_entity",
"credential",
"federated_identity",
"keycloak_role",
"composite_role",
];
/// The table the fixture's annotations attach to. Must be in `KEEP_TABLES`.
const ANCHOR_TABLE: &str = "client";
const ANCHOR_COLUMN: &str = "client_id";
const TABLE_NOTE: &str = "Every registered OIDC/SAML client. Owned by the platform team.";
const COLUMN_NOTE: &str = "The public client identifier callers send at the token endpoint.";
const PROJECT_NOTE: &str = "# Keycloak\n\nFixture generated for the docs viewer conformance test.";
const GROUP_ID: &str = "client-registry";
const GROUP_NAME: &str = "Client Registry";
const ORPHAN_TABLE_KEY: &str = "public.no_such_table_xyz";
#[tokio::test]
#[ignore = "requires DBX_LIVE_POSTGRES_HOST/PORT/USER/PASSWORD/DATABASE pointing at a live db"]
async fn dump_keycloak_fixture() {
let host = std::env::var("DBX_LIVE_POSTGRES_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port = std::env::var("DBX_LIVE_POSTGRES_PORT").ok().and_then(|value| value.parse().ok()).unwrap_or(5432);
let user = std::env::var("DBX_LIVE_POSTGRES_USER").unwrap_or_else(|_| "postgres".to_string());
let password = std::env::var("DBX_LIVE_POSTGRES_PASSWORD").unwrap_or_default();
let database = std::env::var("DBX_LIVE_POSTGRES_DATABASE").unwrap_or_else(|_| "postgres".to_string());
let suffix = uuid::Uuid::new_v4().simple().to_string();
let connection_id = format!("dump-docs-fixture-{}", &suffix[..8]);
let config = live_postgres_config(&connection_id, &host, port, &user, &password, &database);
let dir = std::env::temp_dir().join(format!("dbx-dump-docs-fixture-{suffix}"));
std::fs::create_dir_all(&dir).unwrap();
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
let state = AppState::new(storage);
state.configs.write().await.insert(config.id.clone(), config.clone());
let options = dbx_core::docs::CollectOptions {
database: database.clone(),
schemas: vec!["public".to_string()],
tables: vec![],
project_name: "Keycloak".to_string(),
};
let snapshot_result = dbx_core::docs::collect_snapshot(
&state,
&config,
&options,
&|_progress| {},
&std::sync::atomic::AtomicBool::new(false),
)
.await;
let _ = std::fs::remove_dir_all(&dir);
let mut snapshot = snapshot_result.expect("collect");
assert!(!snapshot.tables.is_empty(), "expected the keycloak schema to have tables");
// Every field the viewer must render: a table note (LOCAL source), a
// column note, a group with a hue, and an annotation targeting a table
// that does not exist (so an `orphanedNotes` warning appears).
let mut tables = BTreeMap::new();
tables.insert(
format!("public.{ANCHOR_TABLE}"),
TableAnnotation {
group: Some(GROUP_ID.to_string()),
note: Some(TABLE_NOTE.to_string()),
columns: BTreeMap::from([(ANCHOR_COLUMN.to_string(), ColumnAnnotation { note: COLUMN_NOTE.to_string() })]),
},
);
tables.insert(
ORPHAN_TABLE_KEY.to_string(),
TableAnnotation {
note: Some("Orphaned annotation — the table it references does not exist.".to_string()),
..Default::default()
},
);
let annotations = AnnotationFile {
format_version: 1,
project: Some(ProjectAnnotation { name: Some("Keycloak".to_string()), note: Some(PROJECT_NOTE.to_string()) }),
groups: vec![GroupAnnotation {
id: GROUP_ID.to_string(),
name: GROUP_NAME.to_string(),
hue: 210,
note: Some("Tables describing registered clients and their protocol mappers.".to_string()),
}],
tables,
};
dbx_core::docs::annotations::apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
let anchor = snapshot.tables.iter().find(|table| table.name == ANCHOR_TABLE).expect("anchor table collected");
assert_eq!(anchor.note_source, dbx_core::docs::NoteSource::Local, "annotation setup must have taken effect");
assert!(
snapshot
.warnings
.iter()
.any(|warning| matches!(warning, dbx_core::docs::SnapshotWarning::OrphanedNotes { .. })),
"annotation setup must produce an orphanedNotes warning"
);
// Trim to the allowlist, then rebuild relationships over the surviving
// set. Rebuilding matters: the first pass resolved foreign keys against
// all 90 tables, so edges pointing at dropped tables would otherwise
// survive as relationships to nothing.
snapshot.tables.retain(|table| KEEP_TABLES.contains(&table.name.as_str()));
assert_eq!(
snapshot.tables.len(),
KEEP_TABLES.len(),
"every allowlisted table must exist in the schema; got {:?}",
snapshot.tables.iter().map(|table| &table.name).collect::<Vec<_>>()
);
snapshot.relationships = dbx_core::docs::build_relationships(&snapshot.tables);
assert!(!snapshot.relationships.is_empty(), "the allowlist must form a connected foreign-key subgraph");
let json = serde_json::to_string_pretty(&snapshot).expect("serialize snapshot");
let out_path = std::env::var("DBX_FIXTURE_OUT").map(std::path::PathBuf::from).unwrap_or_else(|_| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../apps/desktop/src/docs/fixtures/keycloak.snapshot.json")
});
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent).expect("create fixture directory");
}
std::fs::write(&out_path, &json).expect("write fixture file");
println!("Wrote {} bytes to {}", json.len(), out_path.display());
println!("Tables kept: {}", snapshot.tables.len());
}

View File

@ -0,0 +1,185 @@
// Gating and connection setup copied from live_postgres_docs_snapshot.rs.
//
// This is the end-to-end proof for Part 2: a hand-authored notes file, read
// off disk, merged into a snapshot collected from a REAL database, and
// showing up verbatim in the generated DBML.
use dbx_core::connection::AppState;
use dbx_core::docs::{NoteSource, SnapshotWarning};
use dbx_core::models::connection::{ConnectionConfig, DatabaseType};
use dbx_core::storage::Storage;
fn live_postgres_config(
id: &str,
host: &str,
port: u16,
user: &str,
password: &str,
database: &str,
) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: id.to_string(),
name: id.to_string(),
note: String::new(),
db_type: DatabaseType::Postgres,
driver_profile: None,
driver_label: None,
url_params: None,
agent_java_options: Vec::new(),
host: host.to_string(),
port,
username: user.to_string(),
password: password.to_string(),
database: Some(database.to_string()),
visible_databases: None,
visible_schemas: None,
attached_databases: Vec::new(),
init_script: None,
color: None,
transport_layers: Vec::new(),
connect_timeout_secs: 10,
query_timeout_secs: 30,
idle_timeout_secs: 60,
keepalive_interval_secs: 0,
ssl: false,
ca_cert_path: String::new(),
client_cert_path: String::new(),
client_key_path: String::new(),
sysdba: false,
oracle_connection_type: None,
connection_string: None,
redis_connection_mode: None,
redis_sentinel_master: String::new(),
redis_sentinel_nodes: String::new(),
redis_sentinel_username: String::new(),
redis_sentinel_password: String::new(),
redis_sentinel_tls: false,
redis_cluster_nodes: String::new(),
redis_key_separator: dbx_core::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
show_system_schemas: false,
database_info: None,
}
}
#[tokio::test]
#[ignore = "requires DBX_LIVE_POSTGRES_HOST/PORT/USER/PASSWORD/DATABASE pointing at a live db"]
async fn annotations_reach_the_generated_dbml() {
let host = std::env::var("DBX_LIVE_POSTGRES_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port = std::env::var("DBX_LIVE_POSTGRES_PORT").ok().and_then(|value| value.parse().ok()).unwrap_or(5432);
let user = std::env::var("DBX_LIVE_POSTGRES_USER").unwrap_or_else(|_| "postgres".to_string());
let password = std::env::var("DBX_LIVE_POSTGRES_PASSWORD").unwrap_or_default();
let database = std::env::var("DBX_LIVE_POSTGRES_DATABASE").unwrap_or_else(|_| "postgres".to_string());
let suffix = uuid::Uuid::new_v4().simple().to_string();
let connection_id = format!("live-postgres-docs-annotations-{}", &suffix[..8]);
let config = live_postgres_config(&connection_id, &host, port, &user, &password, &database);
const TABLE_NOTE: &str = "Every tenant's client roster. Owned by the billing team.";
const COLUMN_NOTE: &str = "Stable natural key used by the legacy billing export.";
const PROJECT_NOTE: &str = "# Keycloak\n\nGenerated for live annotation verification.";
const GROUP_NAME: &str = "Core Accounts";
let notes_json = format!(
r#"{{
"formatVersion": 1,
"project": {{ "name": "Keycloak", "note": {project_note:?} }},
"groups": [
{{ "id": "core-accounts", "name": {group_name:?}, "hue": 210, "note": "Tables owned by the accounts team." }}
],
"tables": {{
"public.clients": {{
"group": "core-accounts",
"note": {table_note:?},
"columns": {{ "name": {{ "note": {column_note:?} }} }}
}},
"public.no_such_table_xyz": {{
"note": "Orphaned annotation — the table it references does not exist."
}}
}}
}}"#,
project_note = PROJECT_NOTE,
group_name = GROUP_NAME,
table_note = TABLE_NOTE,
column_note = COLUMN_NOTE,
);
let notes_path = std::env::temp_dir().join(format!("dbx-live-docs-notes-{}.json", uuid::Uuid::new_v4()));
std::fs::write(&notes_path, &notes_json).expect("write temp notes file");
let dir = std::env::temp_dir().join(format!("dbx-live-postgres-docs-annotations-{suffix}"));
std::fs::create_dir_all(&dir).unwrap();
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
let state = AppState::new(storage);
state.configs.write().await.insert(config.id.clone(), config.clone());
let options = dbx_core::docs::CollectOptions {
database: database.clone(),
schemas: vec!["public".to_string()],
tables: vec![],
project_name: "live-test".to_string(),
};
let snapshot_result = dbx_core::docs::collect_snapshot(
&state,
&config,
&options,
&|_progress| {},
&std::sync::atomic::AtomicBool::new(false),
)
.await;
let annotations_result = dbx_core::docs::annotations::load_annotations(&notes_path);
// Clean up temp resources before any assertion that can panic.
let _ = std::fs::remove_file(&notes_path);
let _ = std::fs::remove_dir_all(&dir);
let mut snapshot = snapshot_result.expect("collect");
let annotations = annotations_result.expect("load annotations").expect("annotations file present");
dbx_core::docs::annotations::apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
let clients = snapshot.tables.iter().find(|table| table.name == "clients").expect("clients table collected");
assert_eq!(clients.note.as_deref(), Some(TABLE_NOTE), "table note must be the annotated text");
assert_eq!(clients.note_source, NoteSource::Local, "annotated note must be sourced as Local");
let column_note =
clients.column_notes.get("name").expect("column note for `name` present, keyed by real column name");
assert_eq!(column_note.note, COLUMN_NOTE);
assert_eq!(column_note.source, NoteSource::Local);
assert_eq!(snapshot.groups.len(), 1, "expected the one group from the notes file");
let group = &snapshot.groups[0];
assert_eq!(group.name, GROUP_NAME);
assert_eq!(clients.group_id.as_deref(), Some(group.id.as_str()), "clients.group_id must point at the group");
let orphan_count = snapshot
.warnings
.iter()
.find_map(|warning| match warning {
SnapshotWarning::OrphanedNotes { count } => Some(*count),
_ => None,
})
.expect("an OrphanedNotes warning must be present for the no_such_table_xyz annotation");
assert_eq!(orphan_count, 1, "exactly one annotation targets a nonexistent table");
let dbml = dbx_core::docs::to_dbml(&snapshot);
println!("{}", dbml.text);
assert!(dbml.text.contains(TABLE_NOTE), "generated DBML must contain the table note text:\n{}", dbml.text);
assert!(dbml.text.contains(GROUP_NAME), "generated DBML must contain the group name:\n{}", dbml.text);
}

View File

@ -0,0 +1,134 @@
// Gating and connection setup copied from live_postgres_query_result_export.rs.
use dbx_core::connection::AppState;
use dbx_core::models::connection::{ConnectionConfig, DatabaseType};
use dbx_core::storage::Storage;
fn live_postgres_config(
id: &str,
host: &str,
port: u16,
user: &str,
password: &str,
database: &str,
) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: id.to_string(),
name: id.to_string(),
note: String::new(),
db_type: DatabaseType::Postgres,
driver_profile: None,
driver_label: None,
url_params: None,
agent_java_options: Vec::new(),
host: host.to_string(),
port,
username: user.to_string(),
password: password.to_string(),
database: Some(database.to_string()),
visible_databases: None,
visible_schemas: None,
attached_databases: Vec::new(),
init_script: None,
color: None,
transport_layers: Vec::new(),
connect_timeout_secs: 10,
query_timeout_secs: 30,
idle_timeout_secs: 60,
keepalive_interval_secs: 0,
ssl: false,
ca_cert_path: String::new(),
client_cert_path: String::new(),
client_key_path: String::new(),
sysdba: false,
oracle_connection_type: None,
connection_string: None,
redis_connection_mode: None,
redis_sentinel_master: String::new(),
redis_sentinel_nodes: String::new(),
redis_sentinel_username: String::new(),
redis_sentinel_password: String::new(),
redis_sentinel_tls: false,
redis_cluster_nodes: String::new(),
redis_key_separator: dbx_core::models::connection::default_redis_key_separator(),
redis_scan_page_size: None,
redis_database_aliases: Default::default(),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
show_system_schemas: false,
database_info: None,
}
}
#[tokio::test]
#[ignore = "requires DBX_LIVE_POSTGRES_HOST/PORT/USER/PASSWORD/DATABASE pointing at a live db"]
async fn collects_a_snapshot_and_serializes_valid_dbml() {
let host = std::env::var("DBX_LIVE_POSTGRES_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port = std::env::var("DBX_LIVE_POSTGRES_PORT").ok().and_then(|value| value.parse().ok()).unwrap_or(5432);
let user = std::env::var("DBX_LIVE_POSTGRES_USER").unwrap_or_else(|_| "postgres".to_string());
let password = std::env::var("DBX_LIVE_POSTGRES_PASSWORD").unwrap_or_default();
let database = std::env::var("DBX_LIVE_POSTGRES_DATABASE").unwrap_or_else(|_| "postgres".to_string());
let suffix = uuid::Uuid::new_v4().simple().to_string();
let connection_id = format!("live-postgres-docs-snapshot-{}", &suffix[..8]);
let config = live_postgres_config(&connection_id, &host, port, &user, &password, &database);
let dir = std::env::temp_dir().join(format!("dbx-live-postgres-docs-snapshot-{suffix}"));
std::fs::create_dir_all(&dir).unwrap();
let storage = Storage::open(&dir.join("storage.db")).await.unwrap();
let state = AppState::new(storage);
state.configs.write().await.insert(config.id.clone(), config.clone());
let options = dbx_core::docs::CollectOptions {
database: database.clone(),
schemas: vec!["public".to_string()],
tables: vec![],
project_name: "live-test".to_string(),
};
let snapshot = dbx_core::docs::collect_snapshot(
&state,
&config,
&options,
&|_progress| {},
&std::sync::atomic::AtomicBool::new(false),
)
.await;
let _ = std::fs::remove_dir_all(&dir);
let snapshot = snapshot.expect("collect");
assert_eq!(snapshot.format_version, 1);
assert!(!snapshot.tables.is_empty(), "expected at least one table");
let dbml = dbx_core::docs::to_dbml(&snapshot);
println!("{}", dbml.text);
assert!(dbml.text.starts_with("Project "), "got:\n{}", dbml.text);
for table in &snapshot.tables {
assert!(
dbml.text.contains(&format!("Table {}", table.name))
|| dbml.text.contains(&format!("Table {}.{}", table.schema.clone().unwrap_or_default(), table.name)),
"table {} missing from DBML",
table.name
);
}
// Braces must balance, or the DBML is unparseable.
let opens = dbml.text.matches('{').count();
let closes = dbml.text.matches('}').count();
assert_eq!(opens, closes, "unbalanced braces in:\n{}", dbml.text);
assert!(dbml.text.ends_with('\n'), "DBML document should end with a newline:\n{}", dbml.text);
}

View File

@ -18,6 +18,7 @@ fn live_postgres_config(
database: &str,
) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: id.to_string(),
name: id.to_string(),
note: String::new(),

View File

@ -10,6 +10,7 @@ use serde_json::json;
fn postgres_test_config(id: &str, database: &str) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: id.to_string(),
name: id.to_string(),
note: String::new(),

View File

@ -19,6 +19,7 @@ use tokio_util::sync::CancellationToken;
fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connection::ConnectionConfig {
dbx_core::models::connection::ConnectionConfig {
docs_notes_path: None,
id: id.to_string(),
name: id.to_string(),
note: String::new(),

View File

@ -8,6 +8,7 @@ use std::time::{Duration, Instant};
fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connection::ConnectionConfig {
dbx_core::models::connection::ConnectionConfig {
docs_notes_path: None,
id: id.to_string(),
name: id.to_string(),
note: String::new(),

View File

@ -74,6 +74,19 @@ fn effective_mcp_policy_with_legacy_allow_writes(
policy
}
/// Wire-level options for a documentation snapshot. Mirrors
/// `dbx_core::docs::CollectOptions` minus the fields the backend fills in.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocsSnapshotOptions {
#[serde(default)]
pub schemas: Vec<String>,
#[serde(default)]
pub tables: Vec<String>,
#[serde(default)]
pub project_name: Option<String>,
}
#[async_trait]
pub trait DbxBackend: Send + Sync {
async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicy, String>;
@ -152,6 +165,15 @@ pub trait DbxBackend: Send + Sync {
let _ = (path, body);
Err("DBX is not running. Please start DBX first.".to_string())
}
async fn collect_docs_snapshot(
&self,
connection: &ConnectionConfig,
database: &str,
options: DocsSnapshotOptions,
) -> Result<dbx_core::docs::SchemaSnapshot, String> {
let _ = (connection, database, options);
Err("Documentation snapshots are not supported by this backend.".to_string())
}
}
pub struct LocalBackend {
@ -438,6 +460,28 @@ impl DbxBackend for LocalBackend {
dbx_core::schema::get_columns_core(&self.state, &connection.id, database, schema, table).await
}
async fn collect_docs_snapshot(
&self,
connection: &ConnectionConfig,
database: &str,
options: DocsSnapshotOptions,
) -> Result<dbx_core::docs::SchemaSnapshot, String> {
let collect_options = dbx_core::docs::CollectOptions {
database: database.to_string(),
schemas: options.schemas,
tables: options.tables,
project_name: options.project_name.unwrap_or_else(|| connection.name.clone()),
};
dbx_core::docs::collect_snapshot(
&self.state,
connection,
&collect_options,
&|_progress| {},
&std::sync::atomic::AtomicBool::new(false),
)
.await
}
async fn execute_redis_command(
&self,
connection: &ConnectionConfig,
@ -743,6 +787,30 @@ impl DbxBackend for WebBackend {
.map_err(|error| format!("Invalid column list response: {error}"))
}
async fn collect_docs_snapshot(
&self,
connection: &ConnectionConfig,
database: &str,
options: DocsSnapshotOptions,
) -> Result<dbx_core::docs::SchemaSnapshot, String> {
self.ensure_connected(connection).await?;
self.request(
reqwest::Method::POST,
"/api/docs/snapshot",
Some(json!({
"connectionId": connection.id,
"database": database,
"schemas": options.schemas,
"tables": options.tables,
"projectName": options.project_name.clone().unwrap_or_else(|| connection.name.clone()),
})),
)
.await?
.json()
.await
.map_err(|error| format!("Invalid docs snapshot response: {error}"))
}
async fn execute_redis_command(
&self,
connection: &ConnectionConfig,
@ -1447,4 +1515,55 @@ mod tests {
assert_eq!(local_plugin_dir(&explicit, data_dir), PathBuf::from("D:/DBX/plugins-custom"));
assert_eq!(local_plugin_dir(&legacy, data_dir), PathBuf::from("D:/DBX/drivers/plugins"));
}
struct StubBackend;
#[async_trait]
impl DbxBackend for StubBackend {
async fn load_mcp_global_policy(&self) -> Result<McpGlobalPolicy, String> {
Err("unused".to_string())
}
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String> {
Ok(vec![])
}
async fn execute_agent_tool(
&self,
_connection: &ConnectionConfig,
_database: &str,
_tool_name: &str,
_arguments: Value,
_permissions: AgentSqlPermissions,
) -> ToolResult {
unimplemented!("not exercised by this test")
}
async fn add_connection_for_mcp(&self, config: ConnectionConfig) -> Result<ConnectionConfig, String> {
Ok(config)
}
async fn remove_connection_for_mcp(&self, _connection_id: &str) -> Result<bool, String> {
Ok(false)
}
}
#[tokio::test]
async fn collect_docs_snapshot_defaults_to_unsupported() {
let backend = StubBackend;
let connection = new_connection_config(
"c1".to_string(),
"local".to_string(),
DatabaseType::Postgres,
"127.0.0.1".to_string(),
5432,
"user".to_string(),
"password".to_string(),
None,
false,
None,
)
.unwrap();
let result = backend.collect_docs_snapshot(&connection, "shop", DocsSnapshotOptions::default()).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("not supported"));
}
}

View File

@ -377,6 +377,10 @@ async fn main() {
.route("/schema/extensions", get(routes::schema::list_extensions))
.route("/schema/available-extensions", get(routes::schema::list_available_extensions))
.route("/schema/ddl", get(routes::schema::get_ddl))
.route("/docs/snapshot", post(routes::docs::collect_snapshot))
.route("/docs/annotations/load", post(routes::docs::load_annotations))
.route("/docs/annotations/apply", post(routes::docs::apply_annotations))
.route("/docs/annotations/save", post(routes::docs::save_annotations))
.route("/dialect/data-types", get(routes::dialect::list_data_types))
.route("/schema-diff/prepare", post(routes::schema_diff::prepare_schema_diff))
.route("/schema-diff/generate-sync-sql", post(routes::schema_diff::generate_schema_sync_sql))

View File

@ -505,6 +505,7 @@ mod tests {
fn sqlite_config(id: &str, path: &str) -> ConnectionConfig {
ConnectionConfig {
docs_notes_path: None,
id: id.to_string(),
name: "SQLite".to_string(),
note: String::new(),

Some files were not shown because too many files have changed in this diff Show More