dbdocs 1.6.3__tar.gz → 1.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. {dbdocs-1.6.3 → dbdocs-1.8.0}/PKG-INFO +15 -5
  2. {dbdocs-1.6.3 → dbdocs-1.8.0}/README.md +9 -1
  3. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/cli/main.py +36 -5
  4. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/core/artifacts.py +18 -3
  5. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/core/config.py +16 -0
  6. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/column_lineage.py +7 -12
  7. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/erd.py +25 -23
  8. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/css/style.css +4 -2
  9. dbdocs-1.8.0/dbdocs/site/bundle/assets/graph/index.css +1 -0
  10. dbdocs-1.8.0/dbdocs/site/bundle/assets/graph/index.js +62 -0
  11. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/data/data.js +1 -1
  12. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/ui/ui.js +4 -1
  13. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/deploy.py +8 -2
  14. dbdocs-1.8.0/dbdocs/sources/__init__.py +0 -0
  15. dbdocs-1.8.0/dbdocs/sources/factory.py +24 -0
  16. dbdocs-1.8.0/dbdocs/sources/info_schema/__init__.py +0 -0
  17. dbdocs-1.8.0/dbdocs/sources/info_schema/builder.py +65 -0
  18. dbdocs-1.8.0/dbdocs/sources/info_schema/reader.py +45 -0
  19. dbdocs-1.8.0/dbdocs/sources/info_schema/synth.py +604 -0
  20. {dbdocs-1.6.3 → dbdocs-1.8.0}/pyproject.toml +6 -2
  21. dbdocs-1.6.3/dbdocs/site/bundle/assets/graph/index.css +0 -1
  22. dbdocs-1.6.3/dbdocs/site/bundle/assets/graph/index.js +0 -62
  23. {dbdocs-1.6.3 → dbdocs-1.8.0}/.gitignore +0 -0
  24. {dbdocs-1.6.3 → dbdocs-1.8.0}/LICENSE +0 -0
  25. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/__init__.py +0 -0
  26. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/__main__.py +0 -0
  27. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/cli/__init__.py +0 -0
  28. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/core/__init__.py +0 -0
  29. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/core/exceptions.py +0 -0
  30. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/core/log.py +0 -0
  31. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/__init__.py +0 -0
  32. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/_sqlglot_lineage.py +0 -0
  33. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/graph.py +0 -0
  34. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/__init__.py +0 -0
  35. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/dimensions.py +0 -0
  36. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/extractor.py +0 -0
  37. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/__init__.py +0 -0
  38. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/base.py +0 -0
  39. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/__init__.py +0 -0
  40. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/documentation.py +0 -0
  41. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/governance.py +0 -0
  42. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/modeling.py +0 -0
  43. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/performance.py +0 -0
  44. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/structure.py +0 -0
  45. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/dimensions/testing.py +0 -0
  46. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/health/rules/registry.py +0 -0
  47. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/nodes.py +0 -0
  48. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/extract/tests_index.py +0 -0
  49. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/main.py +0 -0
  50. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/__init__.py +0 -0
  51. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/api_schema.py +0 -0
  52. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/builder.py +0 -0
  53. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/css/icons.css +0 -0
  54. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/favicon.svg +0 -0
  55. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/app.js +0 -0
  56. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/service/service.js +0 -0
  57. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/ui/dom.js +0 -0
  58. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/js/ui/overlays.js +0 -0
  59. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/vendor/marked.min.js +0 -0
  60. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/assets/vendor/minisearch.min.js +0 -0
  61. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/bundle/index.html +0 -0
  62. {dbdocs-1.6.3 → dbdocs-1.8.0}/dbdocs/site/inject.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: dbdocs
3
- Version: 1.6.3
3
+ Version: 1.8.0
4
4
  Summary: Alternative dbt docs site: Catalog + ERD + column-level lineage
5
5
  Project-URL: Homepage, https://github.com/datnguye/dbt-docs
6
6
  Project-URL: Repository, https://github.com/datnguye/dbt-docs
@@ -14,12 +14,14 @@ Classifier: Topic :: Software Development :: Documentation
14
14
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
15
  Classifier: Topic :: Software Development :: Quality Assurance
16
16
  Requires-Python: >=3.10
17
- Requires-Dist: artifact-parser[dbt]>=1.0.0
17
+ Requires-Dist: artifact-parser[dbt]>=1.1.0
18
18
  Requires-Dist: click>=8.1
19
- Requires-Dist: dbterd>=1.28
19
+ Requires-Dist: dbterd>=1.35.1
20
20
  Requires-Dist: pydantic>=2.13.4
21
21
  Requires-Dist: pyyaml>=6.0
22
22
  Requires-Dist: sqlglot>=25
23
+ Provides-Extra: info-schema
24
+ Requires-Dist: duckdb>=1.1; extra == 'info-schema'
23
25
  Description-Content-Type: text/markdown
24
26
 
25
27
  <p align="center">
@@ -71,12 +73,20 @@ Requires Python 3.10+.
71
73
 
72
74
  ```bash
73
75
  dbt docs generate # writes target/manifest.json + target/catalog.json
74
- dbdocs generate # builds ./site/ with index.html + dbdocs-data.json.gz
76
+ dbdocs generate # builds target/site/ with index.html + dbdocs-data.json.gz
75
77
  dbdocs serve # static http server on http://127.0.0.1:8000
76
78
  ```
77
79
 
78
80
  The site must be served over HTTP (not opened as a local file) because it fetches the data payload at load time. `dbdocs serve` handles that locally; any static host works for deployment.
79
81
 
82
+ ### On dbt 2.0
83
+
84
+ dbt 2.0 stops writing `catalog.json` and can write a typed parquet **information schema** instead — on dbt Fusion with `--static-analysis strict`, complete with column types and dbt's own column-level lineage. dbdocs builds from either; to read the info schema, `pip install "dbdocs[info-schema]"` and set `info_schema: true` in `dbdocs.yml`:
85
+
86
+ ![dbt Fusion writes the info schema, dbdocs builds and serves the site from it](docs/assets/img/dbt-2-info-schema.gif)
87
+
88
+ The [dbt 2.0 & Info Schema](https://dbdocs.datnguye.me/latest/nav/guide/dbt-2-info-schema.html) guide walks through it.
89
+
80
90
  Full walkthrough, configuration, and architecture live in the **[documentation](https://dbdocs.datnguye.me/)**.
81
91
 
82
92
  ## Contributing
@@ -47,12 +47,20 @@ Requires Python 3.10+.
47
47
 
48
48
  ```bash
49
49
  dbt docs generate # writes target/manifest.json + target/catalog.json
50
- dbdocs generate # builds ./site/ with index.html + dbdocs-data.json.gz
50
+ dbdocs generate # builds target/site/ with index.html + dbdocs-data.json.gz
51
51
  dbdocs serve # static http server on http://127.0.0.1:8000
52
52
  ```
53
53
 
54
54
  The site must be served over HTTP (not opened as a local file) because it fetches the data payload at load time. `dbdocs serve` handles that locally; any static host works for deployment.
55
55
 
56
+ ### On dbt 2.0
57
+
58
+ dbt 2.0 stops writing `catalog.json` and can write a typed parquet **information schema** instead — on dbt Fusion with `--static-analysis strict`, complete with column types and dbt's own column-level lineage. dbdocs builds from either; to read the info schema, `pip install "dbdocs[info-schema]"` and set `info_schema: true` in `dbdocs.yml`:
59
+
60
+ ![dbt Fusion writes the info schema, dbdocs builds and serves the site from it](docs/assets/img/dbt-2-info-schema.gif)
61
+
62
+ The [dbt 2.0 & Info Schema](https://dbdocs.datnguye.me/latest/nav/guide/dbt-2-info-schema.html) guide walks through it.
63
+
56
64
  Full walkthrough, configuration, and architecture live in the **[documentation](https://dbdocs.datnguye.me/)**.
57
65
 
58
66
  ## Contributing
@@ -9,11 +9,31 @@ from dbdocs.core.config import DbDocsConfig
9
9
  from dbdocs.core.exceptions import DbDocsError
10
10
  from dbdocs.core.log import logger
11
11
  from dbdocs.site import deploy as deploy_module
12
- from dbdocs.site.builder import ReportBuilder
12
+ from dbdocs.sources.factory import builder_for
13
13
 
14
14
  __version__ = importlib.metadata.version("dbdocs")
15
15
 
16
16
 
17
+ def info_schema_options(command):
18
+ command = click.option(
19
+ "--info-schema-dir",
20
+ default=None,
21
+ help="Directory of the info-schema parquet files (default: <target_dir>/info_schema/v1).",
22
+ )(command)
23
+ return click.option(
24
+ "--info-schema/--no-info-schema",
25
+ default=None,
26
+ help="Read dbt 2.0's parquet information schema as the base artifact source.",
27
+ )(command)
28
+
29
+
30
+ def _apply_info_schema(config: DbDocsConfig, info_schema, info_schema_dir) -> None:
31
+ if info_schema is not None:
32
+ config.info_schema = info_schema
33
+ if info_schema_dir is not None:
34
+ config.info_schema_dir = info_schema_dir
35
+
36
+
17
37
  # dbdocs
18
38
  @click.group(
19
39
  context_settings={"help_option_names": ["-h", "--help"]},
@@ -37,6 +57,7 @@ def dbdocs(ctx, config_path):
37
57
 
38
58
 
39
59
  @dbdocs.command(name="generate")
60
+ @info_schema_options
40
61
  @click.option("-o", "--output-dir", default=None, help="Where to write the site (default: config).")
41
62
  @click.option(
42
63
  "--dialect", default=None, help="SQL dialect for column lineage (default: adapter_type)."
@@ -49,14 +70,15 @@ def dbdocs(ctx, config_path):
49
70
  ),
50
71
  )
51
72
  @click.pass_obj
52
- def generate(config: DbDocsConfig, output_dir, dialect, run_results):
73
+ def generate(config: DbDocsConfig, output_dir, dialect, run_results, info_schema, info_schema_dir):
53
74
  """Build the site from dbt artifacts (served over HTTP; data loaded externally)."""
54
75
  if dialect is not None:
55
76
  config.dialect = dialect
56
77
  if run_results is not None:
57
78
  config.run_results = run_results
79
+ _apply_info_schema(config, info_schema, info_schema_dir)
58
80
  try:
59
- ReportBuilder(config).generate(output_dir=output_dir)
81
+ builder_for(config).generate(output_dir=output_dir)
60
82
  except DbDocsError as exc:
61
83
  raise click.ClickException(str(exc)) from exc
62
84
  # The builder already logs "Generated site at <path> (N nodes, M edges)".
@@ -84,15 +106,24 @@ def serve(config: DbDocsConfig, port):
84
106
  "--delete", "delete", is_flag=True, default=False, help="Delete this version instead."
85
107
  )
86
108
  @click.option("--push/--no-push", default=False, help="Publish to the gh-pages branch.")
109
+ @info_schema_options
87
110
  @click.pass_obj
88
- def deploy(config: DbDocsConfig, version, alias, title, delete, push):
111
+ def deploy(config: DbDocsConfig, version, alias, title, delete, push, info_schema, info_schema_dir):
89
112
  """Generate a versioned build and update the version index (or --delete one)."""
113
+ _apply_info_schema(config, info_schema, info_schema_dir)
90
114
  try:
91
115
  if delete:
92
116
  deploy_module.delete(config, version=version, push=push)
93
117
  click.echo(f"Deleted version {version}")
94
118
  return
95
- out = deploy_module.deploy(config, version=version, alias=alias, push=push, title=title)
119
+ out = deploy_module.deploy(
120
+ config,
121
+ version=version,
122
+ alias=alias,
123
+ push=push,
124
+ title=title,
125
+ builder=builder_for(config),
126
+ )
96
127
  except DbDocsError as exc:
97
128
  raise click.ClickException(str(exc)) from exc
98
129
  click.echo(f"Deployed version {version} into {out}")
@@ -1,10 +1,10 @@
1
1
  """Loading dbt artifacts (manifest/catalog) via the dbterd parser.
2
2
 
3
- dbterd parses ``manifest.json`` / ``catalog.json`` into ``dbt_artifacts_parser``
3
+ dbterd parses ``manifest.json`` / ``catalog.json`` into ``artifact_parser``
4
4
  Pydantic models. Two cross-cutting gotchas live here so the rest of dbdocs never
5
5
  has to think about them:
6
6
 
7
- * **Schema field aliasing.** ``dbt_artifacts_parser`` aliases the ``schema``
7
+ * **Schema field aliasing.** ``artifact_parser`` aliases the ``schema``
8
8
  field to ``schema_`` to avoid clobbering Pydantic's ``BaseModel.schema()`` —
9
9
  so ``node.schema`` is a *bound method*, not the value. Always read
10
10
  ``node.schema_``; :func:`db_schema` centralizes that.
@@ -19,6 +19,8 @@ from typing import Any
19
19
 
20
20
  from dbterd.helpers import file
21
21
 
22
+ from dbdocs.core.log import logger
23
+
22
24
  #: Bucket label used when a node/source has no database or schema set.
23
25
  UNKNOWN = "_unknown"
24
26
 
@@ -54,10 +56,23 @@ def artifact_version(target_path: str, artifact: str) -> "int | None":
54
56
 
55
57
 
56
58
  def load_artifacts(target_path: str) -> "tuple[Any, Any]":
57
- """Return the dbterd-parsed ``(manifest, catalog)`` for a dbt target dir."""
59
+ """Return the dbterd-parsed ``(manifest, catalog)`` for a dbt target dir.
60
+
61
+ ``catalog.json`` is optional (dbt 2.0's ``docs generate`` does not write it):
62
+ when absent, ``catalog`` is ``None`` and every consumer falls back to the
63
+ manifest — columns lose their warehouse type and column-level lineage can't
64
+ resolve ``SELECT *`` / unqualified columns.
65
+ """
58
66
  manifest = file.read_manifest(
59
67
  path=target_path, version=artifact_version(target_path, "manifest")
60
68
  )
69
+ if not (Path(target_path) / "catalog.json").is_file():
70
+ logger.warning(
71
+ "No catalog.json in %s — building from the manifest only "
72
+ "(no warehouse column types; reduced column-level lineage).",
73
+ target_path,
74
+ )
75
+ return manifest, None
61
76
  catalog = file.read_catalog(path=target_path, version=artifact_version(target_path, "catalog"))
62
77
  return manifest, catalog
63
78
 
@@ -81,6 +81,13 @@ class DbDocsConfig:
81
81
  #: dotted import path whose ``register_rule`` calls add custom rules). Entry
82
82
  #: points under ``dbdocs.health_rules`` are also discovered automatically.
83
83
  health: dict = field(default_factory=dict)
84
+ #: Opt in to reading dbt 2.0's parquet information schema as the base artifact
85
+ #: source (``manifest.json`` / ``catalog.json`` / ``run_results.json`` in
86
+ #: ``target_dir`` then only enrich it). Needs ``dbdocs[info-schema]``.
87
+ info_schema: bool = False
88
+ #: Directory holding the information-schema parquet files; ``None`` ⇒
89
+ #: ``<target_dir>/info_schema/v1``.
90
+ info_schema_dir: "str | None" = None
84
91
  #: The pinned About link shows by default; set false to hide it.
85
92
  show_about: bool = True
86
93
  #: Links rendered on the About page as external CTA buttons. Each entry is a
@@ -129,6 +136,8 @@ class DbDocsConfig:
129
136
  "favicon",
130
137
  "run_results",
131
138
  "health",
139
+ "info_schema",
140
+ "info_schema_dir",
132
141
  )
133
142
 
134
143
  def render_context(self) -> dict:
@@ -164,3 +173,10 @@ class DbDocsConfig:
164
173
  A relative path that would escape the cwd raises ``DbDocsConfigError``.
165
174
  """
166
175
  return str(_resolve_within_cwd(self.output_dir, "output_dir"))
176
+
177
+ @property
178
+ def info_schema_path(self) -> str:
179
+ """Directory of the information-schema parquet files (see ``info_schema_dir``)."""
180
+ if self.info_schema_dir:
181
+ return str(_resolve_within_cwd(self.info_schema_dir, "info_schema_dir"))
182
+ return str(Path(self.target_path) / "info_schema" / "v1")
@@ -61,10 +61,11 @@ def _leaf_columns(root: Node, relation_to_node: dict) -> list:
61
61
  """Collect distinct upstream ``{node, column}`` leaves of a lineage tree.
62
62
 
63
63
  A leaf is a node whose source is a real ``Table`` (not a CTE/subquery scope)
64
- that maps back to a dbt node. The root itself is skipped.
64
+ that maps back to a dbt node. The root itself is skipped. Leaves are sorted
65
+ by ``(node, column)``: the tree's walk order follows sqlglot's internal set
66
+ iteration, which varies with ``PYTHONHASHSEED``.
65
67
  """
66
- seen = set()
67
- upstream = []
68
+ leaves = set()
68
69
  for node in root.walk():
69
70
  if node is root:
70
71
  continue
@@ -72,15 +73,9 @@ def _leaf_columns(root: Node, relation_to_node: dict) -> list:
72
73
  if not isinstance(source, exp.Table):
73
74
  continue
74
75
  mapped = _map_table(source, relation_to_node)
75
- if mapped is None:
76
- continue
77
- column = node_name(node.name)
78
- key = (mapped, column)
79
- if key in seen:
80
- continue
81
- seen.add(key)
82
- upstream.append({"node": mapped, "column": column})
83
- return upstream
76
+ if mapped is not None:
77
+ leaves.add((mapped, node_name(node.name)))
78
+ return [{"node": mapped, "column": column} for mapped, column in sorted(leaves)]
84
79
 
85
80
 
86
81
  def _extract_model_columns(
@@ -1,15 +1,19 @@
1
1
  """Structured ERD data via dbterd's official ``json`` target.
2
2
 
3
- dbterd 1.28.0 ships a built-in, schema-validated ``json`` target that emits
3
+ dbterd ships a built-in, schema-validated ``json`` target that emits
4
4
  ``{nodes, edges, metadata}``. Each node carries ``id`` (the dbt unique_id),
5
5
  ``name``, ``schema_name``, ``database``, ``resource_type``, and ``columns``
6
6
  (with ``data_type``, ``is_primary_key``, ``is_foreign_key``). Each edge carries
7
7
  ``id``, ``from_id`` (FK/child side), ``to_id`` (referenced/parent side),
8
- ``from_columns``, ``to_columns``, ``label``, and ``cardinality``.
9
-
10
- ``build_erd_data`` maps that shape into the SPA's ``{nodes, edges}`` — the
11
- React Flow bundle reads ``nodes`` (entities + column flags) and ``edges``
12
- (FK relationships, ``source``/``target`` keyed by dbt unique_id).
8
+ ``from_columns``, ``to_columns``, ``label``, ``relationship_type``, and
9
+ ``cardinality``.
10
+
11
+ ``build_erd_data`` maps that shape into the SPA's ``{nodes, edges}`` using
12
+ dbterd's own field names throughout — no renaming. The React Flow bundle reads
13
+ ``nodes`` (entities + column flags) and ``edges`` (FK relationships, keyed by
14
+ dbt unique_id in ``from_id``/``to_id``) and maps ``from_id``/``to_id`` to React
15
+ Flow's ``source``/``target`` on its own side, with the parent/referenced node
16
+ as ``to_id``.
13
17
  """
14
18
 
15
19
  import json
@@ -54,17 +58,14 @@ def build_erd_data(erd: DbtErd) -> dict:
54
58
  When ``entity_name_format`` is configured, dbterd emits edge ``from_id`` /
55
59
  ``to_id`` as the formatted entity name (e.g. ``orders``) rather than the
56
60
  full unique_id (e.g. ``model.jaffle_shop.orders``). A ``name_to_id`` map
57
- resolves those short names back to the node id before building edges, so the
58
- SPA's ``source``/``target`` always reference a valid node ``id``.
61
+ resolves those short names back to the node id before building edges, so
62
+ the emitted ``from_id``/``to_id`` always reference a valid node ``id``.
59
63
 
60
64
  Some dbterd algos (e.g. ``model_contract``) do not set ``is_foreign_key``
61
65
  on node columns even when those columns appear in FK edges. After building
62
66
  edges, any column named in an edge's ``from_columns`` (the FK/child side)
63
- has its ``is_foreign_key`` flag back-filled to ``True`` on the target node.
64
-
65
- SPA edge direction: ``source`` is the referenced (parent) side, ``target``
66
- is the FK (child) side — matching dbterd's ``to_id`` and ``from_id``
67
- respectively.
67
+ has its ``is_foreign_key`` flag back-filled to ``True`` on the ``from_id``
68
+ node.
68
69
  """
69
70
  payload = json.loads(erd.get_erd())
70
71
  raw_nodes = payload.get("nodes", [])
@@ -95,13 +96,13 @@ def _backfill_fk_flags(nodes: "list[dict]", edges: "list[dict]") -> None:
95
96
  """
96
97
  nodes_by_id = {n["id"]: n for n in nodes}
97
98
  for edge in edges:
98
- target_node = nodes_by_id.get(edge["target"])
99
- if target_node is None:
99
+ from_node = nodes_by_id.get(edge["from_id"])
100
+ if from_node is None:
100
101
  continue
101
102
  fk_cols = {c.lower() for c in edge.get("from_columns", [])}
102
103
  if not fk_cols:
103
104
  continue
104
- for col in target_node["columns"]:
105
+ for col in from_node["columns"]:
105
106
  if col["name"].lower() in fk_cols:
106
107
  col["is_foreign_key"] = True
107
108
 
@@ -111,12 +112,12 @@ def _build_node(node: dict) -> dict:
111
112
  "id": node["id"],
112
113
  "label": node.get("name") or "",
113
114
  "database": node.get("database") or "",
114
- "schema": node.get("schema_name") or "",
115
+ "schema_name": node.get("schema_name") or "",
115
116
  "resource_type": node.get("resource_type") or "model",
116
117
  "columns": [
117
118
  {
118
119
  "name": c["name"],
119
- "type": c.get("data_type") or "",
120
+ "data_type": c.get("data_type") or "",
120
121
  "description": c.get("description") or "",
121
122
  "is_primary_key": bool(c.get("is_primary_key")),
122
123
  "is_foreign_key": bool(c.get("is_foreign_key")),
@@ -135,16 +136,17 @@ def _resolve_edge_id(raw: str, node_ids: "set[str]", name_to_id: "dict[str, str]
135
136
 
136
137
 
137
138
  def _build_edge(edge: dict, index: int, node_ids: "set[str]", name_to_id: "dict[str, str]") -> dict:
138
- # from_id is the FK/child side; to_id is the referenced/parent side.
139
- # SPA convention: source = parent (to_id), target = child (from_id).
139
+ # from_id is the FK/child side; to_id is the referenced/parent side —
140
+ # dbterd's own convention, carried through unchanged.
140
141
  raw_from = edge.get("from_id") or ""
141
142
  raw_to = edge.get("to_id") or ""
142
143
  return {
143
144
  "id": edge.get("id") or f"e{index}",
144
- "source": _resolve_edge_id(raw_to, node_ids, name_to_id),
145
- "target": _resolve_edge_id(raw_from, node_ids, name_to_id),
145
+ "from_id": _resolve_edge_id(raw_from, node_ids, name_to_id),
146
+ "to_id": _resolve_edge_id(raw_to, node_ids, name_to_id),
146
147
  "from_columns": edge.get("from_columns") or [],
147
148
  "to_columns": edge.get("to_columns") or [],
148
149
  "label": edge.get("label") or "",
149
- "type": edge.get("cardinality") or "",
150
+ "relationship_type": edge.get("relationship_type") or "",
151
+ "cardinality": edge.get("cardinality") or "",
150
152
  }
@@ -464,8 +464,10 @@ details.node-section:last-of-type { border-bottom: none; }
464
464
  /* Graph host (React Flow bundle mounts here) */
465
465
  .graph-host { width: 100%; }
466
466
  /* Fullscreen: the host fills the screen; its inner .dbd-graph grows to match. */
467
- .graph-host:fullscreen { background: var(--bg); padding: 0; }
468
- .graph-host:fullscreen .dbd-graph { height: 100vh; border: none; border-radius: 0; }
467
+ .graph-host:fullscreen,
468
+ .erd-section-host:fullscreen { background: var(--bg); padding: 0; }
469
+ .graph-host:fullscreen .dbd-graph,
470
+ .erd-section-host:fullscreen .dbd-graph { height: 100vh; border: none; border-radius: 0; }
469
471
 
470
472
  .page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
471
473
  .page-head h2 { border-bottom: none; padding-bottom: 0; }
@@ -0,0 +1 @@
1
+ .erd-table{background:var(--erd-node-bg, #1e1e1e);color:var(--erd-node-fg, #d4d4d4);border:1px solid var(--erd-border, #007acc);border-radius:6px;font-family:var(--erd-font-mono, monospace);font-size:12px;min-width:220px;box-shadow:0 1px 3px var(--erd-shadow, rgba(0, 0, 0, .25))}.erd-table[data-resource=source]{border-style:dashed}.erd-table[data-filter=match]{border-color:var(--erd-accent, #e5c07b);box-shadow:0 0 0 2px var(--erd-accent, #e5c07b),0 1px 4px var(--erd-shadow, rgba(0, 0, 0, .35))}.erd-table[data-filter=dim]{opacity:.32;filter:saturate(.6);transition:opacity .12s ease,filter .12s ease}.erd-table[data-filter=dim]:hover{opacity:.85;filter:none}.erd-table[data-active=true]{border-color:var(--erd-border, #007acc);box-shadow:0 0 0 2px var(--erd-border, #007acc),0 2px 8px var(--erd-shadow, rgba(0, 0, 0, .4))}.erd-table[data-active=true][data-filter=match]{box-shadow:0 0 0 2px var(--erd-border, #007acc),inset 0 0 0 1px var(--erd-accent, #e5c07b)}.erd-table-header{display:flex;align-items:center;gap:6px;box-sizing:border-box;height:32px;padding:6px 10px;background:var(--erd-header-bg, #252526);border-bottom:1px solid var(--erd-border, #007acc);cursor:pointer;-webkit-user-select:none;user-select:none}.erd-table-header:hover{background:var(--erd-hover-bg, #2a2d2e)}.erd-table-header:focus-visible{outline:2px solid var(--erd-border, #007acc);outline-offset:-2px}.erd-table-name{font-weight:600}.erd-table-schema{margin-left:auto;color:var(--erd-muted-fg, #808080);font-size:11px}.erd-table-columns{list-style:none;margin:0;padding:4px 0}.erd-column{display:grid;grid-template-columns:28px 1fr auto;align-items:center;gap:6px;box-sizing:border-box;height:22px;padding:0 10px;position:relative}.erd-column[data-highlighted=true]{background:color-mix(in srgb,var(--erd-accent, #e5c07b) 16%,transparent);box-shadow:inset 2px 0 0 var(--erd-accent, #e5c07b)}.erd-column[data-highlighted=true] .erd-column-name{color:var(--erd-accent, #e5c07b);font-weight:600}.erd-column-badge{font-size:10px;font-weight:700;text-align:center}.badge-pk{color:var(--erd-accent, #e5c07b)}.badge-fk{color:var(--erd-fk, #61afef)}.badge-none{color:transparent}.erd-column-type{color:var(--erd-muted-fg, #808080)}.erd-table-icon{display:inline-flex;align-items:center;color:var(--erd-icon, #FF694A)}.erd-table-more{box-sizing:border-box;height:22px;padding:0 10px;border-top:1px solid var(--erd-divider, #3c3c3c);display:flex;align-items:center;color:var(--erd-muted-fg, #808080);font-size:11px;font-style:italic}.erd-table[data-dimmed=true]{opacity:.25;transition:opacity .12s ease}.erd-table-expand{display:block;width:100%;box-sizing:border-box;height:24px;padding:0 10px;background:transparent;color:var(--erd-link-fg, #3794ff);border:none;border-top:1px dashed var(--erd-divider, #3c3c3c);text-align:center;cursor:pointer;font-size:11px;font-family:inherit}.erd-table-expand:hover{background:var(--erd-hover-bg, #2a2d2e)}.erd-table .react-flow__handle.erd-hidden-handle{width:1px;height:1px;min-width:0;min-height:0;border:none;background:transparent;opacity:0;pointer-events:none}.erd-flow{width:100%;height:100%;min-height:0;position:relative}.erd-flow .react-flow{width:100%;height:100%}.react-flow{direction:ltr;--xy-edge-stroke-default: #b1b1b7;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #555;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(255, 255, 255, .5);--xy-minimap-background-color-default: #fff;--xy-minimap-mask-background-color-default: rgba(240, 240, 240, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #e2e2e2;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: transparent;--xy-background-pattern-dots-color-default: #91919a;--xy-background-pattern-lines-color-default: #eee;--xy-background-pattern-cross-color-default: #e2e2e2;background-color:var(--xy-background-color, var(--xy-background-color-default));--xy-node-color-default: inherit;--xy-node-border-default: 1px solid #1a192b;--xy-node-background-color-default: #fff;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #1a192b;--xy-node-border-radius-default: 3px;--xy-handle-background-color-default: #1a192b;--xy-handle-border-color-default: #fff;--xy-selection-background-color-default: rgba(0, 89, 220, .08);--xy-selection-border-default: 1px dotted rgba(0, 89, 220, .8);--xy-controls-button-background-color-default: #fefefe;--xy-controls-button-background-color-hover-default: #f4f4f4;--xy-controls-button-color-default: inherit;--xy-controls-button-color-hover-default: inherit;--xy-controls-button-border-color-default: #eee;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #ffffff;--xy-edge-label-color-default: inherit;--xy-resize-background-color-default: #3367d9}.react-flow.dark{--xy-edge-stroke-default: #3e3e3e;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #727272;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(150, 150, 150, .25);--xy-minimap-background-color-default: #141414;--xy-minimap-mask-background-color-default: rgba(60, 60, 60, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #2b2b2b;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: #141414;--xy-background-pattern-dots-color-default: #777;--xy-background-pattern-lines-color-default: #777;--xy-background-pattern-cross-color-default: #777;--xy-node-color-default: #f8f8f8;--xy-node-border-default: 1px solid #3c3c3c;--xy-node-background-color-default: #1e1e1e;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(255, 255, 255, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #999;--xy-handle-background-color-default: #bebebe;--xy-handle-border-color-default: #1e1e1e;--xy-selection-background-color-default: rgba(200, 200, 220, .08);--xy-selection-border-default: 1px dotted rgba(200, 200, 220, .8);--xy-controls-button-background-color-default: #2b2b2b;--xy-controls-button-background-color-hover-default: #3e3e3e;--xy-controls-button-color-default: #f8f8f8;--xy-controls-button-color-hover-default: #fff;--xy-controls-button-border-color-default: #5b5b5b;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #141414;--xy-edge-label-color-default: #f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props, var(--xy-background-color, var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{position:absolute;width:100%;height:100%;top:0;left:0}.react-flow__pane{z-index:1;touch-action:none}.react-flow__pane.draggable{cursor:grab}.react-flow__pane.dragging{cursor:grabbing}.react-flow__pane.selection{cursor:pointer}.react-flow__viewport{transform-origin:0 0;z-index:2;pointer-events:none}.react-flow__renderer{z-index:4}.react-flow__selection{z-index:6}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible{outline:none}.react-flow__edge-path{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default));stroke-width:var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default));fill:none}.react-flow__connection-path{stroke:var(--xy-connectionline-stroke, var(--xy-connectionline-stroke-default));stroke-width:var(--xy-connectionline-stroke-width, var(--xy-connectionline-stroke-width-default));fill:none}.react-flow .react-flow__edges{position:absolute}.react-flow .react-flow__edges svg{overflow:visible;position:absolute;pointer-events:none}.react-flow__edge{pointer-events:visibleStroke}.react-flow__edge.selectable{cursor:pointer}.react-flow__edge.animated path{stroke-dasharray:5;animation:dashdraw .5s linear infinite}.react-flow__edge.animated path.react-flow__edge-interaction{stroke-dasharray:none;animation:none}.react-flow__edge.inactive{pointer-events:none}.react-flow__edge.selected,.react-flow__edge:focus,.react-flow__edge:focus-visible{outline:none}.react-flow__edge.selected .react-flow__edge-path,.react-flow__edge.selectable:focus .react-flow__edge-path,.react-flow__edge.selectable:focus-visible .react-flow__edge-path{stroke:var(--xy-edge-stroke-selected, var(--xy-edge-stroke-selected-default))}.react-flow__edge-textwrapper{pointer-events:all}.react-flow__edge .react-flow__edge-text{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__arrowhead polyline{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__arrowhead polyline.arrowclosed{fill:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__connection{pointer-events:none}.react-flow__connection .animated{stroke-dasharray:5;animation:dashdraw .5s linear infinite}svg.react-flow__connectionline{z-index:1001;overflow:visible;position:absolute}.react-flow__nodes{pointer-events:none;transform-origin:0 0}.react-flow__node{position:absolute;-webkit-user-select:none;-moz-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:default}.react-flow__node.selectable{cursor:pointer}.react-flow__node.draggable{cursor:grab;pointer-events:all}.react-flow__node.draggable.dragging{cursor:grabbing}.react-flow__nodesselection{z-index:3;transform-origin:left top;pointer-events:none}.react-flow__nodesselection-rect{position:absolute;pointer-events:all;cursor:grab}.react-flow__handle{position:absolute;pointer-events:none;min-width:5px;min-height:5px;width:6px;height:6px;background-color:var(--xy-handle-background-color, var(--xy-handle-background-color-default));border:1px solid var(--xy-handle-border-color, var(--xy-handle-border-color-default));border-radius:100%}.react-flow__handle.connectingfrom{pointer-events:all}.react-flow__handle.connectionindicator{pointer-events:all;cursor:crosshair}.react-flow__handle-bottom{top:auto;left:50%;bottom:0;transform:translate(-50%,50%)}.react-flow__handle-top{top:0;left:50%;transform:translate(-50%,-50%)}.react-flow__handle-left{top:50%;left:0;transform:translate(-50%,-50%)}.react-flow__handle-right{top:50%;right:0;transform:translate(50%,-50%)}.react-flow__edgeupdater{cursor:move;pointer-events:all}.react-flow__pane.selection .react-flow__panel{pointer-events:none}.react-flow__panel{position:absolute;z-index:5;margin:15px}.react-flow__panel.top{top:0}.react-flow__panel.bottom{bottom:0}.react-flow__panel.top.center,.react-flow__panel.bottom.center{left:50%;transform:translate(-15px) translate(-50%)}.react-flow__panel.left{left:0}.react-flow__panel.right{right:0}.react-flow__panel.left.center,.react-flow__panel.right.center{top:50%;transform:translateY(-15px) translateY(-50%)}.react-flow__attribution{font-size:10px;background:var(--xy-attribution-background-color, var(--xy-attribution-background-color-default));padding:2px 3px;margin:0}.react-flow__attribution a{text-decoration:none;color:#999}@keyframes dashdraw{0%{stroke-dashoffset:10}}.react-flow__edgelabel-renderer{position:absolute;width:100%;height:100%;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;left:0;top:0}.react-flow__viewport-portal{position:absolute;width:100%;height:100%;left:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__minimap{background:var( --xy-minimap-background-color-props, var(--xy-minimap-background-color, var(--xy-minimap-background-color-default)) )}.react-flow__minimap-svg{display:block}.react-flow__minimap-mask{fill:var( --xy-minimap-mask-background-color-props, var(--xy-minimap-mask-background-color, var(--xy-minimap-mask-background-color-default)) );stroke:var( --xy-minimap-mask-stroke-color-props, var(--xy-minimap-mask-stroke-color, var(--xy-minimap-mask-stroke-color-default)) );stroke-width:var( --xy-minimap-mask-stroke-width-props, var(--xy-minimap-mask-stroke-width, var(--xy-minimap-mask-stroke-width-default)) )}.react-flow__minimap-node{fill:var( --xy-minimap-node-background-color-props, var(--xy-minimap-node-background-color, var(--xy-minimap-node-background-color-default)) );stroke:var( --xy-minimap-node-stroke-color-props, var(--xy-minimap-node-stroke-color, var(--xy-minimap-node-stroke-color-default)) );stroke-width:var( --xy-minimap-node-stroke-width-props, var(--xy-minimap-node-stroke-width, var(--xy-minimap-node-stroke-width-default)) )}.react-flow__background-pattern.dots{fill:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-dots-color-default)) )}.react-flow__background-pattern.lines{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-lines-color-default)) )}.react-flow__background-pattern.cross{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-cross-color-default)) )}.react-flow__controls{display:flex;flex-direction:column;box-shadow:var(--xy-controls-box-shadow, var(--xy-controls-box-shadow-default))}.react-flow__controls.horizontal{flex-direction:row}.react-flow__controls-button{display:flex;justify-content:center;align-items:center;height:26px;width:26px;padding:4px;border:none;background:var(--xy-controls-button-background-color, var(--xy-controls-button-background-color-default));border-bottom:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) );color:var( --xy-controls-button-color-props, var(--xy-controls-button-color, var(--xy-controls-button-color-default)) );cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__controls-button svg{width:100%;max-width:12px;max-height:12px;fill:currentColor}.react-flow__edge.updating .react-flow__edge-path{stroke:#777}.react-flow__edge-text{font-size:10px}.react-flow__node.selectable:focus,.react-flow__node.selectable:focus-visible{outline:none}.react-flow__node-input,.react-flow__node-default,.react-flow__node-output,.react-flow__node-group{padding:10px;border-radius:var(--xy-node-border-radius, var(--xy-node-border-radius-default));width:150px;font-size:12px;color:var(--xy-node-color, var(--xy-node-color-default));text-align:center;border:var(--xy-node-border, var(--xy-node-border-default));background-color:var(--xy-node-background-color, var(--xy-node-background-color-default))}.react-flow__node-input.selectable:hover,.react-flow__node-default.selectable:hover,.react-flow__node-output.selectable:hover,.react-flow__node-group.selectable:hover{box-shadow:var(--xy-node-boxshadow-hover, var(--xy-node-boxshadow-hover-default))}.react-flow__node-input.selectable.selected,.react-flow__node-input.selectable:focus,.react-flow__node-input.selectable:focus-visible,.react-flow__node-default.selectable.selected,.react-flow__node-default.selectable:focus,.react-flow__node-default.selectable:focus-visible,.react-flow__node-output.selectable.selected,.react-flow__node-output.selectable:focus,.react-flow__node-output.selectable:focus-visible,.react-flow__node-group.selectable.selected,.react-flow__node-group.selectable:focus,.react-flow__node-group.selectable:focus-visible{box-shadow:var(--xy-node-boxshadow-selected, var(--xy-node-boxshadow-selected-default))}.react-flow__node-group{background-color:var(--xy-node-group-background-color, var(--xy-node-group-background-color-default))}.react-flow__nodesselection-rect,.react-flow__selection{background:var(--xy-selection-background-color, var(--xy-selection-background-color-default));border:var(--xy-selection-border, var(--xy-selection-border-default))}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible,.react-flow__selection:focus,.react-flow__selection:focus-visible{outline:none}.react-flow__controls-button:hover{background:var( --xy-controls-button-background-color-hover-props, var(--xy-controls-button-background-color-hover, var(--xy-controls-button-background-color-hover-default)) );color:var( --xy-controls-button-color-hover-props, var(--xy-controls-button-color-hover, var(--xy-controls-button-color-hover-default)) )}.react-flow__controls-button:disabled{pointer-events:none}.react-flow__controls-button:disabled svg{fill-opacity:.4}.react-flow__controls-button:last-child{border-bottom:none}.react-flow__controls.horizontal .react-flow__controls-button{border-bottom:none;border-right:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) )}.react-flow__controls.horizontal .react-flow__controls-button:last-child{border-right:none}.react-flow__resize-control{position:absolute}.react-flow__resize-control.left,.react-flow__resize-control.right{cursor:ew-resize}.react-flow__resize-control.top,.react-flow__resize-control.bottom{cursor:ns-resize}.react-flow__resize-control.top.left,.react-flow__resize-control.bottom.right{cursor:nwse-resize}.react-flow__resize-control.bottom.left,.react-flow__resize-control.top.right{cursor:nesw-resize}.react-flow__resize-control.handle{width:5px;height:5px;border:1px solid #fff;border-radius:1px;background-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));translate:-50% -50%}.react-flow__resize-control.handle.left{left:0;top:50%}.react-flow__resize-control.handle.right{left:100%;top:50%}.react-flow__resize-control.handle.top{left:50%;top:0}.react-flow__resize-control.handle.bottom{left:50%;top:100%}.react-flow__resize-control.handle.top.left,.react-flow__resize-control.handle.bottom.left{left:0}.react-flow__resize-control.handle.top.right,.react-flow__resize-control.handle.bottom.right{left:100%}.react-flow__resize-control.line{border-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));border-width:0;border-style:solid}.react-flow__resize-control.line.left,.react-flow__resize-control.line.right{width:1px;transform:translate(-50%);top:0;height:100%}.react-flow__resize-control.line.left{left:0;border-left-width:1px}.react-flow__resize-control.line.right{left:100%;border-right-width:1px}.react-flow__resize-control.line.top,.react-flow__resize-control.line.bottom{height:1px;transform:translateY(-50%);left:0;width:100%}.react-flow__resize-control.line.top{top:0;border-top-width:1px}.react-flow__resize-control.line.bottom{border-bottom-width:1px;top:100%}.react-flow__edge-textbg{fill:var(--xy-edge-label-background-color, var(--xy-edge-label-background-color-default))}.react-flow__edge-text{fill:var(--xy-edge-label-color, var(--xy-edge-label-color-default))}.dbd-graph{display:flex;flex-direction:column;height:72vh;border:1px solid var(--border, #e3e6ea);border-radius:8px;overflow:hidden;background:var(--bg-soft, #f5f6f8)}.dbd-canvas{flex:1;min-height:0}.dbd-graph-empty{display:flex;align-items:center;justify-content:center;height:100%;padding:24px}.dbd-graph-empty p{max-width:32rem;text-align:center;color:var(--text-soft, #5a6473)}.dbd-erd-bar{display:flex;justify-content:flex-end;padding:6px 8px;border-bottom:1px solid var(--border, #e3e6ea);background:var(--bg-elev, #fff)}.dbd-lock-btn{padding:4px 12px;border:1px solid var(--border, #e3e6ea);border-radius:6px;background:var(--bg-soft, #f5f6f8);color:var(--text-soft, #5b636c);font-size:12px;cursor:pointer}.dbd-lock-btn:hover{border-color:var(--accent, #2f6feb)}.dbd-lock-btn.on{color:var(--accent, #2f6feb);border-color:var(--accent, #2f6feb)}.react-flow__edge{cursor:pointer}.react-flow__edge:hover .react-flow__edge-path{stroke-width:2.5}.react-flow__attribution{display:none}.react-flow__controls{box-shadow:0 1px 4px #00000040;border-radius:6px;overflow:hidden}.react-flow__controls-button{background:var(--bg-elev, #fff);border-bottom:1px solid var(--border, #e3e6ea);color:var(--text, #1c2024);fill:var(--text, #1c2024);width:26px;height:26px}.react-flow__controls-button:hover{background:var(--bg-soft, #f5f6f8)}.react-flow__controls-button svg{fill:currentColor;max-width:14px;max-height:14px}.react-flow__minimap{background:var(--bg-elev, #fff);border:1px solid var(--border, #e3e6ea);border-radius:6px}.react-flow__minimap-mask{fill:#7f7f7f2e}.react-flow__background{color:var(--border, #e3e6ea)}.dbd-toolbar{display:flex;flex-wrap:wrap;gap:10px;padding:8px 10px;border-bottom:1px solid var(--border, #e3e6ea);background:var(--bg-elev, #fff)}.dbd-toolbar input,.dbd-toolbar select{padding:5px 9px;border:1px solid var(--border, #e3e6ea);border-radius:6px;background:var(--bg-soft, #f5f6f8);color:var(--text, #1c2024);font-size:13px}.dbd-layer-seg{display:flex;border:1px solid var(--border, #e3e6ea);border-radius:6px;overflow:hidden;background:var(--bg-soft, #f5f6f8)}.dbd-layer-seg button{padding:5px 10px;border:none;border-right:1px solid var(--border, #e3e6ea);background:transparent;color:var(--text-soft, #5b636c);font-size:13px;cursor:pointer;white-space:nowrap}.dbd-layer-seg button:last-child{border-right:none}.dbd-layer-seg button:hover{background:var(--bg-soft, #f0f1f3);color:var(--text, #1c2024)}.dbd-layer-seg button.active{background:var(--accent, #2f6feb);color:#fff}.dbd-multi{position:relative}.dbd-multi-trigger{display:flex;align-items:center;gap:4px;padding:5px 9px;border:1px solid var(--border, #e3e6ea);border-radius:6px;background:var(--bg-soft, #f5f6f8);color:var(--text, #1c2024);font-size:13px;cursor:pointer;white-space:nowrap}.dbd-multi-trigger:hover,.dbd-multi-trigger[aria-expanded=true]{border-color:var(--accent, #2f6feb)}.dbd-multi-arrow{font-size:10px;color:var(--text-soft, #5b636c)}.dbd-multi-panel{position:absolute;top:calc(100% + 4px);left:0;z-index:100;min-width:180px;padding:4px 0;border:1px solid var(--border, #e3e6ea);border-radius:6px;background:var(--bg-elev, #fff);box-shadow:0 4px 12px #0000001f}.dbd-multi-option{display:flex;align-items:center;gap:7px;padding:5px 12px;cursor:pointer;font-size:13px;color:var(--text, #1c2024);-webkit-user-select:none;user-select:none}.dbd-multi-option:hover{background:var(--bg-soft, #f5f6f8)}.dbd-multi-all{border-bottom:1px solid var(--border, #e3e6ea);margin-bottom:2px;font-weight:600}.dbd-multi-label{flex:1}.dbd-multi-count{color:var(--text-soft, #5b636c);font-size:11px}.dbd-multi-group{padding:4px 0 2px}.dbd-multi-group:not(:first-child){border-top:1px solid var(--border, #e3e6ea)}.dbd-multi-group-legend{padding:4px 12px 2px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-soft, #5b636c)}.dbd-node{display:flex;align-items:center;gap:7px;min-width:150px;padding:7px 12px;border:1px solid var(--border, #e3e6ea);border-radius:6px;background:var(--bg-elev, #fff);color:var(--text, #1c2024);font-size:12px}.dbd-node-bar{width:4px;height:18px;border-radius:2px}.dbd-node-label{font-weight:600;flex:1}.dbd-node-badge{padding:1px 5px;border-radius:3px;font-size:9px;font-weight:700;color:#fff;text-transform:uppercase;letter-spacing:.04em;flex-shrink:0}.dbd-dim{opacity:.25}.dbd-graph .erd-table{min-width:210px;border-radius:8px;overflow:hidden;font-family:inherit;box-shadow:none}.dbd-graph .erd-table[data-resource=source]{border-style:solid}.dbd-graph .erd-table[data-focus=true]{box-shadow:0 0 0 2px var(--accent, #2f6feb)}.dbd-graph .erd-table-header{gap:8px;border-bottom:none;border-top:3px solid var(--border, #888)}.dbd-graph .erd-table[data-resource=model] .erd-table-header{border-top-color:var(--model, #2f6feb)}.dbd-graph .erd-table[data-resource=source] .erd-table-header{border-top-color:var(--source, #16a34a)}.dbd-graph .erd-table[data-resource=seed] .erd-table-header{border-top-color:var(--seed, #b45309)}.dbd-graph .erd-table[data-resource=snapshot] .erd-table-header{border-top-color:var(--snapshot, #7c3aed)}.dbd-graph .erd-table-icon{display:none}.dbd-graph .erd-table-name{font-weight:700}.dbd-graph .erd-column{grid-template-columns:auto 1fr auto;gap:8px;border-top:1px solid var(--border, #e3e6ea)}.dbd-graph .erd-column-name{font-family:monospace}.dbd-graph .erd-column-type{font-size:11px}.dbd-graph .erd-column-badge{display:inline-flex;justify-content:center;min-width:22px;padding:0 5px;border-radius:4px;color:#fff}.dbd-graph .erd-column-badge.badge-pk{background:var(--seed, #b45309);color:#fff}.dbd-graph .erd-column-badge.badge-fk{background:var(--accent, #2f6feb);color:#fff}.dbd-graph .erd-column-badge.badge-none{background:transparent}.dbd-graph .react-flow__edge:hover{--erd-edge-width: 2.5}