diff --git a/Readme.md b/Readme.md
index ee68f5deb..5e8fd6395 100644
--- a/Readme.md
+++ b/Readme.md
@@ -166,7 +166,7 @@ possible solutions.
* [BuildingRunning][BuildingRunning]: Building and running the db-sync node.
* [Docker][Docker]: Instruction for docker-compose, and building the images using nix.
-* [ERD][ERD]: The entity relationship diagram.
+* [ERD][ERD]: The entity relationship diagram. Regenerate with `scripts/generate-erd.sh`.
* [Example SQL queries][ExampleQueries]: Some example SQL and Haskell/Esqueleto queries.
* [OffChainPoolData][OffChainPoolData]: Explanation of how stake pool offchain data is retried.
* [Schema Documentation][Schema Documentation]: The database schema documentation.
@@ -180,7 +180,7 @@ possible solutions.
[BuildingRunning]: doc/building-running.md
[Docker]: doc/docker.md
[Running]: doc/running.md
-[ERD]: doc/ERD.png
+[ERD]: doc/ERD.svg
[ExampleQueries]: doc/interesting-queries.md
[PostgresView]: https://www.postgresql.org/docs/current/sql-createview.html
[OffChainPoolData]: doc/pool-offchain-data.md
diff --git a/doc/ERD.png b/doc/ERD.png
deleted file mode 100644
index 66a2c5558..000000000
Binary files a/doc/ERD.png and /dev/null differ
diff --git a/doc/ERD.svg b/doc/ERD.svg
new file mode 100644
index 000000000..bf178e10a
--- /dev/null
+++ b/doc/ERD.svg
@@ -0,0 +1,2884 @@
+
+
+
+
+
diff --git a/flake.nix b/flake.nix
index 2b46193cb..7dab820c1 100644
--- a/flake.nix
+++ b/flake.nix
@@ -247,6 +247,8 @@
git
protobuf
snappy
+ graphviz # scripts/generate-erd.sh (ER diagram rendering)
+ python3 # scripts/generate-erd.sh (ER diagram generator)
] ++ lib.optionals (system == "x86_64-linux") [
liburing # io_uring is linux-only
];
diff --git a/scripts/gen-erd.py b/scripts/gen-erd.py
new file mode 100755
index 000000000..a4fec41bd
--- /dev/null
+++ b/scripts/gen-erd.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+# Emit a graphviz .dot ER diagram from extracted schema data.
+# Usage: gen-erd.py COLUMNS_FILE PKS_FILE FK_FILE OUT_DOT
+# COLUMNS_FILE lines: table|ordinal|column|type
+# PKS_FILE lines: table|column
+# FK_FILE lines: child|column|parent (logical FKs, e.g. parsed from migrations)
+import sys, html
+
+cols_file, pks_file, fk_file, out_dot = sys.argv[1:5]
+
+cols = {}
+for line in open(cols_file):
+ line = line.rstrip("\n")
+ if not line: continue
+ t, _o, c, ty = line.split("|", 3)
+ cols.setdefault(t, []).append((c, ty))
+tables = set(cols)
+
+pk = {}
+for line in open(pks_file):
+ line = line.rstrip("\n")
+ if not line: continue
+ t, c = line.split("|", 1)
+ pk.setdefault(t, set()).add(c)
+
+edges, fkcols, seen = [], {}, set()
+for line in open(fk_file):
+ line = line.rstrip("\n")
+ if not line: continue
+ child, col, parent = line.split("|", 2)
+ if child in tables and parent in tables and any(c == col for c, _ in cols[child]):
+ if (child, col, parent) in seen: continue
+ seen.add((child, col, parent))
+ edges.append((child, col, parent))
+ fkcols.setdefault(child, set()).add(col)
+
+def domain(t):
+ if t.startswith("off_chain"): return "offchain"
+ if t.startswith("ma_") or t == "multi_asset": return "multiasset"
+ if t.startswith("pool_") or t in {"delisted_pool", "reserved_pool_ticker"}: return "pool"
+ if t.startswith("stake_") or t in {"delegation", "reward", "reward_rest",
+ "epoch_stake", "epoch_stake_progress", "treasury", "reserve", "pot_transfer"}: return "stake"
+ if t.startswith("drep_") or t.startswith("committee") or t in {"constitution",
+ "new_committee", "gov_action_proposal", "voting_procedure", "voting_anchor",
+ "treasury_withdrawal", "delegation_vote", "param_proposal", "event_info"}: return "gov"
+ if t.startswith("epoch") or t in {"ada_pots", "cost_model"}: return "epoch"
+ if t in {"block", "tx", "tx_out", "tx_in", "collateral_tx_in", "collateral_tx_out",
+ "reference_tx_in", "tx_metadata", "tx_cbor", "datum", "redeemer", "redeemer_data",
+ "script", "extra_key_witness", "withdrawal", "slot_leader", "reverse_index", "meta"}: return "core"
+ return "misc"
+
+FILL = {"core": ("#BDD7EE", "#EAF3FB"), "stake": ("#C6E0B4", "#EDF6E7"),
+ "pool": ("#FFE699", "#FFF7DF"), "gov": ("#D9C2EC", "#F1E9F8"),
+ "offchain": ("#F8CBAD", "#FCE9DE"), "epoch": ("#B4E5E0", "#E3F6F4"),
+ "multiasset": ("#FFD9CC", "#FFEDE6"), "misc": ("#D9D9D9", "#F2F2F2")}
+ORDER = ["core", "stake", "pool", "gov", "offchain", "epoch", "multiasset", "misc"]
+LABELS = {"core": "blocks / tx / outputs", "stake": "stake / rewards", "pool": "pools",
+ "gov": "governance", "offchain": "off-chain metadata", "epoch": "epoch / protocol",
+ "multiasset": "multi-asset", "misc": "bookkeeping"}
+
+def esc(s): return html.escape(s, quote=True)
+
+def node(t):
+ hc, bc = FILL[domain(t)]
+ rows = [f'
{esc(t)}
']
+ for c, ty in cols[t]:
+ mark = "PK " if c in pk.get(t, set()) else ("FK " if c in fkcols.get(t, set()) else " ")
+ b0, b1 = ("", "") if mark.strip() else ("", "")
+ rows.append(f'
'
+ f'{mark}{b0}{esc(c)}{b1} : {esc(ty)}
')
+ return (f' "{t}" [label=<
{"".join(rows)}
>];')
+
+def legend():
+ rows = ['
Legend
']
+ for d in ORDER:
+ hc, _ = FILL[d]
+ rows.append(f'
'
+ f'
{LABELS[d]}
')
+ rows.append('
'
+ 'PK = primary key, FK = foreign key. Crow foot = many side. '
+ 'FK constraints are dropped at run time for insert speed; edges are the logical references.'
+ '
')
+ return (' "legend" [shape=plaintext, label=<
{"".join(rows)}
>];')
+
+out = ["digraph erd {",
+ ' graph [layout=sfdp, overlap=prism, splines=true, K=1.2, repulsiveforce=1.6, '
+ 'fontname="Helvetica", bgcolor="white", pad=0.4];',
+ ' node [shape=plaintext, fontname="Helvetica"];',
+ ' edge [color="#8C8C8C", arrowsize=0.8, dir=both, arrowtail=crow, arrowhead=none, penwidth=1.0];']
+for t in sorted(tables):
+ out.append(node(t))
+out.append(legend())
+for child, col, parent in edges:
+ out.append(f' "{child}":"{col}" -> "{parent}":"id" ;')
+out.append("}")
+open(out_dot, "w").write("\n".join(out))
+sys.stderr.write(f"tables={len(tables)} edges={len(edges)} -> {out_dot}\n")
diff --git a/scripts/generate-erd.sh b/scripts/generate-erd.sh
new file mode 100755
index 000000000..d41c14831
--- /dev/null
+++ b/scripts/generate-erd.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+# Generate the entity-relationship diagram doc/ERD.svg from the current schema.
+#
+# It builds a throwaway database, applies every migration in schema/, reads the
+# resulting tables/columns, takes the foreign-key relationships from the migration
+# SQL (the FK constraints are dropped at run time for insert speed, so we read the
+# declarations rather than the live constraints), and renders a colour-coded SVG.
+#
+# Usage: ./scripts/generate-erd.sh
+# Output: doc/ERD.svg
+#
+# Requirements: postgresql client (createdb/psql/dropdb), graphviz (dot), python3.
+# In the nix dev shell these are already provided. Connection uses the standard
+# PG* environment variables (PGHOST, PGUSER, ...); the default local socket works.
+#
+# Config via env:
+# ERD_DB name of the throwaway database (default: cardano_db_sync_erd_tmp)
+# SCHEMA_DIR migrations directory (default: schema)
+# OUT output file (default: doc/ERD.svg)
+
+set -euo pipefail
+
+export PGOPTIONS="${PGOPTIONS:-} -c client_min_messages=warning" # quiet migration NOTICEs
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+SCHEMA_DIR="${SCHEMA_DIR:-$REPO_DIR/schema}"
+OUT="${OUT:-$REPO_DIR/doc/ERD.svg}"
+ERD_DB="${ERD_DB:-cardano_db_sync_erd_tmp}"
+
+work="$(mktemp -d)"
+cleanup() { dropdb --if-exists "$ERD_DB" >/dev/null 2>&1 || true; rm -rf "$work"; }
+trap cleanup EXIT
+
+echo "Creating throwaway database '$ERD_DB' and applying migrations..."
+dropdb --if-exists "$ERD_DB" >/dev/null 2>&1 || true
+createdb "$ERD_DB"
+for f in $(ls "$SCHEMA_DIR"/migration-*.sql | sort); do
+ psql -q -v ON_ERROR_STOP=1 -d "$ERD_DB" -f "$f" >/dev/null
+done
+
+echo "Reading tables, columns and primary keys..."
+psql -d "$ERD_DB" -tAF'|' -c "
+ SELECT c.table_name, c.ordinal_position, c.column_name, c.data_type
+ FROM information_schema.columns c
+ JOIN information_schema.tables t
+ ON t.table_schema = c.table_schema AND t.table_name = c.table_name
+ AND t.table_type = 'BASE TABLE'
+ WHERE c.table_schema = 'public'
+ ORDER BY c.table_name, c.ordinal_position" > "$work/columns.txt"
+psql -d "$ERD_DB" -tAF'|' -c "
+ SELECT tc.table_name, kcu.column_name
+ FROM information_schema.table_constraints tc
+ JOIN information_schema.key_column_usage kcu ON kcu.constraint_name = tc.constraint_name
+ WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = 'public'" > "$work/pks.txt"
+
+echo "Collecting foreign-key relationships from the migration SQL..."
+grep -rhoE 'ALTER TABLE "[a-z_]+" ADD CONSTRAINT "[^"]+" FOREIGN KEY\("[a-z_]+"\) REFERENCES "[a-z_]+"' "$SCHEMA_DIR"/*.sql \
+ | sed -E 's/ALTER TABLE "([a-z_]+)" ADD CONSTRAINT "[^"]+" FOREIGN KEY\("([a-z_]+)"\) REFERENCES "([a-z_]+)"/\1|\2|\3/' \
+ | sort -u > "$work/fk.txt"
+
+echo "Generating graphviz source and rendering $OUT ..."
+python3 "$SCRIPT_DIR/gen-erd.py" "$work/columns.txt" "$work/pks.txt" "$work/fk.txt" "$work/erd.dot"
+dot -Tsvg "$work/erd.dot" -o "$OUT"
+
+echo "Done: $OUT"