ripple-sql 0.1.3__tar.gz → 0.1.5__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 (77) hide show
  1. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/CHANGELOG.md +14 -1
  2. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/PKG-INFO +1 -1
  3. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/pyproject.toml +1 -1
  4. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/answer_page.py +4 -0
  5. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/home.py +108 -14
  6. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/server.py +0 -1
  7. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/static/answer.css +64 -11
  8. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/static/answer.html +82 -23
  9. ripple_sql-0.1.5/src/ripple/static/explore.js +328 -0
  10. ripple_sql-0.1.5/src/ripple/static/find.js +72 -0
  11. ripple_sql-0.1.5/src/ripple/static/focus.js +162 -0
  12. ripple_sql-0.1.3/src/ripple/static/explore.js +0 -190
  13. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/.gitignore +0 -0
  14. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/LICENSE +0 -0
  15. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/README.md +0 -0
  16. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/__init__.py +0 -0
  17. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/answer.py +0 -0
  18. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/cache.py +0 -0
  19. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/ci.py +0 -0
  20. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/ci_signature.py +0 -0
  21. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/cli.py +0 -0
  22. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/doctor.py +0 -0
  23. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/__init__.py +0 -0
  24. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/budget.py +0 -0
  25. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/column_lineage.py +0 -0
  26. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/column_ref.py +0 -0
  27. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/cte_tracing.py +0 -0
  28. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/dependencies.py +0 -0
  29. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/dialect.py +0 -0
  30. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/dispatch.py +0 -0
  31. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/extraction.py +0 -0
  32. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/generators.py +0 -0
  33. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/jinja.py +0 -0
  34. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/json_sources.py +0 -0
  35. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/macro_source.py +0 -0
  36. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/pipeline.py +0 -0
  37. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/preprocess.py +0 -0
  38. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/safe_gen.py +0 -0
  39. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/schema_qualification.py +0 -0
  40. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/scope.py +0 -0
  41. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/select_sources.py +0 -0
  42. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/sql_script.py +0 -0
  43. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/statement.py +0 -0
  44. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/tech_debt.py +0 -0
  45. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/tsql_catalog.py +0 -0
  46. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/tsql_scalar_vars.py +0 -0
  47. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/tsql_tvf.py +0 -0
  48. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/tsql_xml.py +0 -0
  49. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/types.py +0 -0
  50. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/unused_deps.py +0 -0
  51. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/engine/validation.py +0 -0
  52. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/graph.py +0 -0
  53. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/loaders/__init__.py +0 -0
  54. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/loaders/dbt.py +0 -0
  55. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/loaders/dbt_config.py +0 -0
  56. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/loaders/identity.py +0 -0
  57. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/loaders/sidecar.py +0 -0
  58. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/loaders/sqldir.py +0 -0
  59. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/loaders/types.py +0 -0
  60. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/lookml.py +0 -0
  61. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/mcp_server.py +0 -0
  62. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/names.py +0 -0
  63. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/project.py +0 -0
  64. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/py.typed +0 -0
  65. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/render.py +0 -0
  66. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/render_shims.py +0 -0
  67. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/schemas.py +0 -0
  68. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/semantic.py +0 -0
  69. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/sourcefiles.py +0 -0
  70. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/star_resolution.py +0 -0
  71. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/static/answer_twin.js +0 -0
  72. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/usage/__init__.py +0 -0
  73. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/usage/cli.py +0 -0
  74. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/usage/collect.py +0 -0
  75. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/usage/discover.py +0 -0
  76. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/usage/ingest.py +0 -0
  77. {ripple_sql-0.1.3 → ripple_sql-0.1.5}/src/ripple/usage/report.py +0 -0
@@ -6,6 +6,17 @@ Notable changes to Ripple. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.5] - 2026-09-09
10
+
11
+ ### Added
12
+ - Every column under a model on the page carries the number of models that read it directly, the same number a model chip carries, so the cost of a change shows before the question is asked. A column no link touches (a constant, or one the engine could not trace) is drawn loose, dashed and faint, with "no link found" in the key; r/dataengineering asked for "a clear view of mapped vs unmapped fields (with some kind of legend)", and a traced-looking constant would have been a lie.
13
+
14
+ ## [0.1.4] - 2026-09-09
15
+
16
+ ### Changed
17
+ - A large project opens on an index, not a wall of chips. Past forty models the home page lists where things live (a dbt folder, a schema, a BigQuery dataset) in the order the data flows, each with the models most read by other models in view and the rest behind "more"; hovering a group tints what feeds it and what reads it. Clicking any model opens its own neighbourhood: what feeds it on the left, what reads it on the right, wires between them, another step on request, its columns under it to ask. The address bar carries the model, so Back and a shared link land on the same view. Measured on Mozilla's bigquery-etl (2,365 tables) and a synthetic 5,500-model project: the old page showed 6 percent of the models in a page ten screens tall; the new one shows every group on one screen and any model in one click.
18
+ - The question box finds as you type: models and columns from the map the page already holds, eight at a time, arrows to pick, Enter to go. A datalist did this before and put every column on the page as an option; at 5,500 models that was 99,000 elements and most of a 5.3 MB page. The page is 3.0 MB now, and 1.4 MB for Mozilla.
19
+
9
20
  ## [0.1.3] - 2026-09-09
10
21
 
11
22
  ### Changed
@@ -243,7 +254,9 @@ First public release.
243
254
  ### Removed
244
255
  - The dark whole-graph canvas that `ripple serve` used to open (`static/index.html`). Every surface now draws one answer.
245
256
 
246
- [Unreleased]: https://github.com/bteh/ripple/compare/v0.1.3...HEAD
257
+ [Unreleased]: https://github.com/bteh/ripple/compare/v0.1.5...HEAD
258
+ [0.1.5]: https://github.com/bteh/ripple/releases/tag/v0.1.5
259
+ [0.1.4]: https://github.com/bteh/ripple/releases/tag/v0.1.4
247
260
  [0.1.3]: https://github.com/bteh/ripple/releases/tag/v0.1.3
248
261
  [0.1.2]: https://github.com/bteh/ripple/releases/tag/v0.1.2
249
262
  [0.1.1]: https://github.com/bteh/ripple/releases/tag/v0.1.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ripple-sql
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Offline column-level SQL lineage. See what breaks before you merge.
5
5
  Project-URL: Homepage, https://github.com/bteh/ripple
6
6
  Project-URL: Repository, https://github.com/bteh/ripple
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ripple-sql"
3
- version = "0.1.3"
3
+ version = "0.1.5"
4
4
  description = "Offline column-level SQL lineage. See what breaks before you merge."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -24,6 +24,8 @@ TEMPLATE = Path(__file__).parent / "static" / "answer.html"
24
24
  STYLE = Path(__file__).parent / "static" / "answer.css"
25
25
  TWIN = Path(__file__).parent / "static" / "answer_twin.js"
26
26
  EXPLORE = Path(__file__).parent / "static" / "explore.js"
27
+ FOCUS = Path(__file__).parent / "static" / "focus.js"
28
+ FIND = Path(__file__).parent / "static" / "find.js"
27
29
  EMBED_BUDGET_ENV = "RIPPLE_EMBED_GRAPH_BYTES"
28
30
  DEFAULT_EMBED_BUDGET = 5_000_000
29
31
  TRUST_CODE = {"verified": "v", "high_confidence": "h", "moderate": "m", "review_required": "r"}
@@ -161,6 +163,8 @@ def _page(data: dict, title: str) -> str:
161
163
  .replace("/*CSS*/", STYLE.read_text(encoding="utf-8"))
162
164
  .replace("/*TWIN*/", TWIN.read_text(encoding="utf-8"))
163
165
  .replace("/*EXPLORE*/", EXPLORE.read_text(encoding="utf-8"))
166
+ .replace("/*FOCUS*/", FOCUS.read_text(encoding="utf-8"))
167
+ .replace("/*FIND*/", FIND.read_text(encoding="utf-8"))
164
168
  )
165
169
  return html.replace("__TITLE__", _escape(title)).replace("__DATA__", blob)
166
170
 
@@ -39,19 +39,6 @@ def starters(edges, limit: int = STARTERS) -> list[dict]:
39
39
  return [{"id": cid, "readers": len(models)} for cid, models in ranked[:limit]]
40
40
 
41
41
 
42
- def column_ids(edges) -> list[str]:
43
- """Every column that takes part in a value link, for the question box's
44
- suggestions. Dashboard entries and star placeholders stay out."""
45
- ids: set[str] = set()
46
- for e in edges:
47
- if e.kind != "value":
48
- continue
49
- for model, column in ((e.src_model, e.src_column), (e.dst_model, e.dst_column)):
50
- if column != "*" and not model.startswith(DASHBOARD_PREFIXES):
51
- ids.add(f"{model}.{column}")
52
- return sorted(ids)
53
-
54
-
55
42
  def coverage_row(stats: dict, unresolved: list[dict]) -> dict:
56
43
  """Rung one: links that need review and the external tables behind them."""
57
44
  links = _plural(stats["edges"], "column link")
@@ -143,10 +130,19 @@ def model_map(nodes: list[dict], edges) -> dict:
143
130
  }
144
131
  fed = {e.dst_model for e in edges}
145
132
  seen: dict[str, dict[str, None]] = {}
133
+ readers: dict[tuple[str, str], set[str]] = {}
134
+ linked: set[tuple[str, str]] = set()
146
135
  for e in edges:
147
136
  for model, column in ((e.src_model, e.src_column), (e.dst_model, e.dst_column)):
148
137
  if _is_column(column):
149
138
  seen.setdefault(model, {})[column] = None
139
+ if e.kind != "value" or not (_is_column(e.src_column) and _is_column(e.dst_column)):
140
+ continue
141
+ linked.add((e.src_model, e.src_column))
142
+ linked.add((e.dst_model, e.dst_column))
143
+ # a dashboard number is not a reader; only models count (as in starters)
144
+ if not e.dst_model.startswith(DASHBOARD_PREFIXES):
145
+ readers.setdefault((e.src_model, e.src_column), set()).add(e.dst_model)
150
146
  for name in sorted({e.src_model for e in edges} | fed):
151
147
  if name in members:
152
148
  known = members[name]["columns"]
@@ -164,10 +160,108 @@ def model_map(nodes: list[dict], edges) -> dict:
164
160
  "status": None,
165
161
  "path": None,
166
162
  }
163
+ for m in members.values():
164
+ # per column, aligned with `columns`: how many models read it directly;
165
+ # -1 when no link touches it at all (a constant, or one the engine
166
+ # could not trace), so the page never draws such a column as traced
167
+ m["reads"] = [
168
+ len(readers.get((m["id"], c), ())) if (m["id"], c) in linked else -1
169
+ for c in m["columns"]
170
+ ]
167
171
  links = _links(edges)
168
172
  layers = _layers(members, links)
169
173
  models = [{**m, "layer": layers[m["id"]]} for m in members.values()]
170
- return {"models": _ordered(models, links), "links": links}
174
+ _name_groups(models)
175
+ ordered = _ordered(models, links)
176
+ return {"models": ordered, "links": links, "groups": _groups(ordered, links)}
177
+
178
+
179
+ def group_of(member: dict) -> str:
180
+ """Where a model lives, as a person would say it: the folder of a model,
181
+ the schema of a source table, "dashboards" for a dashboard number.
182
+
183
+ A file named after its model sits in its folder (`models/marts/orders.sql`
184
+ is in `models/marts`). A file with a generic name names its folder as the
185
+ table (`telemetry_derived/clients_v1/query.sql`), so the group is the
186
+ folder above. A model with no path is in "models"."""
187
+ if member["kind"] == "dashboard":
188
+ return "dashboards"
189
+ if member["kind"] == "source":
190
+ schema, dot, _ = member["id"].rpartition(".")
191
+ return schema if dot else "sources"
192
+ path = member.get("path")
193
+ if not path:
194
+ return "models"
195
+ parts = path.replace("\\", "/").split("/")
196
+ stem = parts[-1].rsplit(".", 1)[0]
197
+ folders = parts[:-1]
198
+ if folders and stem != member["id"]:
199
+ folders = folders[:-1]
200
+ return "/".join(folders) or "models"
201
+
202
+
203
+ def _shorten(names: set[str], sep: str) -> dict[str, str]:
204
+ """Drop the leading segments every name shares, so `models/staging` and
205
+ `models/marts` read as `staging` and `marts`. A lone name keeps itself."""
206
+ parts = {n: n.split(sep) for n in names}
207
+ while True:
208
+ deep = [p for p in parts.values() if len(p) > 1]
209
+ if len(deep) < 2 or len({p[0] for p in deep}) != 1:
210
+ break
211
+ parts = {n: p[1:] if len(p) > 1 else p for n, p in parts.items()}
212
+ return {n: sep.join(p) for n, p in parts.items()}
213
+
214
+
215
+ SOURCE_SCHEMAS = 6
216
+
217
+
218
+ def _name_groups(models: list[dict]) -> None:
219
+ """Set each model's group, shortened per kind, in place. A project that
220
+ reads from more than a handful of schemas gets one "sources" group: a
221
+ row per schema would be a page of one-table rows, and the chip already
222
+ carries the schema in its name."""
223
+ raw = {m["id"]: group_of(m) for m in models}
224
+ short: dict[str, str] = {}
225
+ for kind, sep in (("model", "/"), ("source", ".")):
226
+ names = {raw[m["id"]] for m in models if m["kind"] == kind}
227
+ if kind == "source" and len(names) > SOURCE_SCHEMAS:
228
+ short.update({n: "sources" for n in names})
229
+ continue
230
+ short.update(_shorten(names, sep))
231
+ for m in models:
232
+ m["group"] = short.get(raw[m["id"]], raw[m["id"]])
233
+
234
+
235
+ GROUP_RANK = {"source": 0, "model": 1, "dashboard": 2}
236
+
237
+
238
+ def _groups(models: list[dict], links: list[dict]) -> list[dict]:
239
+ """The groups in the order the data flows: source schemas, then model
240
+ folders by the mean layer of their models, then dashboards. Each carries
241
+ how many models it holds and how many models outside it read one of its."""
242
+ by: dict[str, dict] = {}
243
+ of: dict[str, str] = {}
244
+ for m in models:
245
+ g = by.setdefault(
246
+ m["group"],
247
+ {"id": m["group"], "kind": m["kind"], "n": 0, "readers": set(), "layers": []},
248
+ )
249
+ g["n"] += 1
250
+ g["layers"].append(m["layer"])
251
+ if GROUP_RANK[m["kind"]] > GROUP_RANK[g["kind"]]:
252
+ g["kind"] = m["kind"]
253
+ of[m["id"]] = m["group"]
254
+ for link in links:
255
+ src, dst = of.get(link["src"]), of.get(link["dst"])
256
+ if src and dst and src != dst:
257
+ by[src]["readers"].add(link["dst"])
258
+ out = sorted(
259
+ by.values(),
260
+ key=lambda g: (GROUP_RANK[g["kind"]], sum(g["layers"]) / len(g["layers"]), g["id"]),
261
+ )
262
+ return [
263
+ {"id": g["id"], "kind": g["kind"], "n": g["n"], "readers": len(g["readers"])} for g in out
264
+ ]
171
265
 
172
266
 
173
267
  def _ordered(models: list[dict], links: list[dict]) -> list[dict]:
@@ -117,7 +117,6 @@ def home_page(service) -> str:
117
117
  "review": stats["review_required_edges"],
118
118
  "ladder": home.ladder_rows(stats, graph.unresolved_tables(), read_store(root)),
119
119
  "starters": home.starters(graph.edges),
120
- "columns": home.column_ids(graph.edges),
121
120
  "map": home.model_map(graph.to_dict()["nodes"], graph.edges),
122
121
  }
123
122
  return answer_page.render_live(info, live)
@@ -28,7 +28,8 @@ h1 code{font:500 24px/1 var(--mono); letter-spacing:0}
28
28
 
29
29
  .ask{display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin:28px 0 0; padding:16px 0 14px; border-top:1px solid var(--line); border-bottom:1px solid var(--line)}
30
30
  .ask input,.ask select{font:400 14px var(--mono); padding:8px 10px; border:1px solid var(--line-strong); border-radius:var(--r); background:var(--surface); color:var(--ink); min-width:0; height:36px}
31
- .ask input{flex:1 1 280px}
31
+ .ask .findwrap{flex:1 1 280px; position:relative; min-width:0}
32
+ .ask .findwrap input{width:100%}
32
33
  .ask input::placeholder{color:var(--faint)}
33
34
  .ask select{font-family:var(--sans); padding-right:28px; appearance:none; -webkit-appearance:none;
34
35
  background-image:linear-gradient(45deg,transparent 50%,var(--muted) 50%),linear-gradient(135deg,var(--muted) 50%,transparent 50%);
@@ -76,6 +77,7 @@ h1 code{font:500 24px/1 var(--mono); letter-spacing:0}
76
77
  .key i.r{border-top-style:dashed; border-color:var(--review)}
77
78
  .key s{display:inline-block; width:11px; height:11px; border-radius:2px; background:var(--ink); border:1px solid var(--ink); text-decoration:none}
78
79
  .key s.src{background:var(--well); border:1px dashed var(--line-strong)}
80
+ .key s.unk{background:transparent; border:1px dashed var(--faint)}
79
81
  .key small.n{font:400 11.5px/1 var(--sans); color:var(--faint); border:1px solid var(--line-strong); border-radius:var(--r); padding:2px 5px; background:var(--surface)}
80
82
  .hint{margin-left:auto; font-size:12.5px; color:var(--faint)}
81
83
  .rows{padding:0 22px 14px; font-size:13.5px; color:var(--muted)}
@@ -109,6 +111,9 @@ pre{margin:0; padding:16px 22px 18px; overflow-x:auto; font:400 13px/1.65 var(--
109
111
  .cols{display:flex; flex-direction:column; gap:5px; margin:0 0 6px 10px; padding-left:10px; border-left:1px solid var(--line)}
110
112
  .cols .gname{max-width:36ch; white-space:normal; line-height:1.45; padding-top:2px}
111
113
  .cols .node{cursor:pointer}
114
+ .cols .node small{font:400 11px/1 var(--sans); color:var(--faint); margin-left:5px}
115
+ .cols .node.unk{color:var(--muted)}
116
+ .cols .node.unk:hover{border-color:var(--accent)}
112
117
  .cols .node:hover{border-color:var(--accent); color:var(--accent-strong)}
113
118
  .more-chips{font:500 12.5px var(--sans); color:var(--accent); background:none; border:0; padding:4px 0; cursor:pointer; text-align:left}
114
119
  .more-chips:hover{text-decoration:underline}
@@ -120,19 +125,63 @@ pre{margin:0; padding:16px 22px 18px; overflow-x:auto; font:400 13px/1.65 var(--
120
125
  .key s.up{background:#E4F1F4; border:1px solid var(--accent)}
121
126
  .key s.sel{background:var(--accent); border-color:var(--accent)}
122
127
  .key s.down{background:var(--well); border:1px solid var(--ink)}
123
- /* compact: the map of a large project, layers as wrapped grids, no wires */
124
- .map.compact .board{overflow-x:visible; padding:18px 22px 8px}
125
- .map.compact .board-inner{flex-direction:column; gap:22px; min-width:0}
126
- .map.compact .wires{display:none}
127
- .map.compact .lvl{min-width:0; gap:10px}
128
- .map.compact .chips{display:flex; flex-wrap:wrap; gap:6px 8px; align-items:flex-start}
129
- .map.compact .grp{display:contents}
130
- .map.compact .grp.open{display:flex; flex-basis:100%; flex-direction:column; gap:6px}
131
- .map.compact .grp.miss{display:none}
132
- .map.compact .mdl{max-width:100%; overflow-wrap:anywhere; text-align:left; line-height:1.25}
128
+ /* the index: a large project as rows of where things live, hubs first */
129
+ .map.compact .board{overflow-x:visible; padding:6px 22px 10px}
130
+ .map.compact .board-inner{display:block; min-width:0}
131
+ .map.compact.focused .board{overflow-x:auto; padding:22px 22px 20px}
132
+ .map.compact.focused .board-inner{display:flex; gap:32px; align-items:flex-start; min-width:max-content}
133
+ .index{display:grid; grid-template-columns:minmax(150px,220px) 1fr; column-gap:22px}
134
+ .grow{display:contents}
135
+ .grow > *{padding:12px 0 11px; border-top:1px solid var(--line)}
136
+ .grow:first-child > *{border-top:0}
137
+ .grow.miss{display:none}
138
+ .gcell{display:flex; flex-direction:column; align-items:flex-start; gap:3px; margin:0; padding-left:8px; padding-right:8px; margin-left:-8px; font:400 13px/1.3 var(--mono); color:var(--ink); background:none; border:0; border-top:1px solid var(--line); border-radius:var(--r); text-align:left; cursor:pointer; overflow-wrap:anywhere; transition:background .12s var(--ease)}
139
+ .grow:first-child .gcell{border-top:0}
140
+ .gcell .gid{display:block}
141
+ .gcell .gpath{display:block; font-size:11.5px; color:var(--faint)}
142
+ .gcell small{font:400 12px/1.3 var(--sans); color:var(--muted)}
143
+ .grow.sel .gpath{color:rgba(255,255,255,.72)}
144
+ .gcell:hover{background:var(--well)}
145
+ .grow.sel .gcell{background:var(--accent); color:#fff}
146
+ .grow.sel .gcell small{color:rgba(255,255,255,.72)}
147
+ .grow.up .gcell{background:#E4F1F4; box-shadow:inset 3px 0 0 var(--accent)}
148
+ .grow.down .gcell{background:var(--well); box-shadow:inset 3px 0 0 var(--ink)}
149
+ .index .chips{display:flex; flex-wrap:wrap; gap:6px 8px; align-items:center}
150
+ .index .mdl{max-width:100%; overflow-wrap:anywhere; text-align:left; line-height:1.25}
151
+ .index .gname{flex-basis:100%}
133
152
  .map.compact .cols{flex-direction:row; flex-wrap:wrap; gap:6px 8px; margin:0 0 8px 14px; padding:8px 0 8px 10px}
134
153
  .map.compact .cols .gname{flex-basis:100%; max-width:60ch}
135
154
  .map.compact.focus .mdl:not(.on){opacity:.28}
155
+ /* focus: one model between what feeds it and what reads it */
156
+ .map-head{display:flex; flex-wrap:wrap; align-items:baseline; gap:4px 8px; padding:12px 22px 0; font:400 13px var(--sans); color:var(--muted)}
157
+ .crumb-home{font:500 13px var(--sans); color:var(--accent); background:none; border:0; padding:0; cursor:pointer}
158
+ .crumb-home:hover{text-decoration:underline}
159
+ .crumb-sep{color:var(--faint)}
160
+ .crumb-group{font:400 13px var(--mono); color:var(--muted)}
161
+ .crumb-here{font:500 13px var(--mono); color:var(--ink); overflow-wrap:anywhere}
162
+ .crumb-counts{margin-left:auto; color:var(--faint); font-variant-numeric:tabular-nums}
163
+ .map.compact.focused .lvl{min-width:150px; max-width:300px}
164
+ .map.compact.focused .lvl.asked{max-width:330px}
165
+ .map.compact.focused .lvl.up .lhead{color:var(--accent)}
166
+ .map.compact.focused .lvl.asked .lhead{color:var(--muted); font-weight:500; border-bottom-color:var(--line)}
167
+ .map.compact.focused .grp.open{display:flex; flex-direction:column}
168
+ .mdl.anchor{position:relative; font-weight:500}
169
+ .mdl.anchor::before,.mdl.anchor::after{content:""; position:absolute; inset:-5px; border:1px solid var(--accent-ring); border-radius:7px; pointer-events:none}
170
+ .mdl.anchor::after{inset:-10px; border-radius:11px; border-color:rgba(10,124,147,.14)}
171
+ .map.compact.focused .cols{flex-direction:row; flex-wrap:wrap; gap:6px 8px; margin:4px 0 6px 10px; padding:6px 0 0 10px; max-width:320px}
172
+ .map.compact.focused .cols .gname{flex-basis:100%; max-width:40ch}
173
+ .hop{align-self:flex-start; display:inline-flex; align-items:baseline; gap:6px; font:500 12.5px var(--sans); color:var(--accent); background:none; border:1px dashed var(--line-strong); border-radius:var(--r); padding:6px 9px; margin-top:2px; cursor:pointer; white-space:nowrap}
174
+ .hop small{font:400 11.5px var(--sans); color:var(--faint)}
175
+ .hop:hover{border-color:var(--accent)}
176
+ .more-groups{padding:14px 0 4px}
177
+ .wires path.o{stroke:var(--accent-soft); stroke-width:1.5}
178
+ /* the question box finds as you type */
179
+ .find{position:absolute; z-index:20; left:0; right:0; top:calc(100% + 4px); margin:0; padding:4px 0; list-style:none; background:var(--surface); border:1px solid var(--line-strong); border-radius:var(--r); box-shadow:0 8px 24px rgba(14,27,38,.10)}
180
+ .find li{display:flex; align-items:baseline; justify-content:space-between; gap:12px; padding:7px 10px; font:400 13px var(--mono); color:var(--ink); cursor:pointer}
181
+ .find li b{font-weight:600; color:var(--accent-strong)}
182
+ .find li small{font:400 12px var(--sans); color:var(--faint); white-space:nowrap}
183
+ .find li[aria-selected="true"]{background:var(--well)}
184
+ .find .fid{overflow-wrap:anywhere}
136
185
  .map .hidden-note{color:var(--muted)}
137
186
 
138
187
  .ladder{margin:26px 0 0; display:grid; grid-template-columns:max-content 1fr; gap:12px 20px; align-items:baseline}
@@ -166,6 +215,10 @@ pre{margin:0; padding:16px 22px 18px; overflow-x:auto; font:400 13px/1.65 var(--
166
215
  .lvl{min-width:0}
167
216
  .lvl+.lvl::before{content:'↓'; color:var(--faint); font-size:14px; margin-top:-14px}
168
217
  .hint{margin-left:0; flex-basis:100%}
218
+ .index{grid-template-columns:1fr}
219
+ .grow > *{border-top:0; padding:6px 0}
220
+ .grow .gcell{border-top:1px solid var(--line); padding-top:12px}
221
+ .crumb-counts{margin-left:0; flex-basis:100%}
169
222
  .ladder{grid-template-columns:1fr; gap:4px 0}
170
223
  .ladder .rung{margin-top:10px}
171
224
  }
@@ -18,8 +18,7 @@
18
18
  <div class="change" id="change" hidden><span class="lab">In this change</span></div>
19
19
 
20
20
  <form class="ask" id="ask" hidden>
21
- <input id="q" list="cols" placeholder="ask about another column: model.column" autocomplete="off" spellcheck="false">
22
- <datalist id="cols"></datalist>
21
+ <div class="findwrap"><input id="q" placeholder="ask about another column: model.column" autocomplete="off" spellcheck="false"><ul class="find" id="find" hidden></ul></div>
23
22
  <select id="dir" aria-label="direction"><option value="breaks">what breaks</option><option value="trace">where it comes from</option></select>
24
23
  <button type="submit">Ask</button>
25
24
  <p class="fine" id="askfine">Answered inside this file. Nothing sent.</p>
@@ -27,6 +26,7 @@
27
26
  </form>
28
27
 
29
28
  <section class="map" id="map" hidden aria-label="every model in this project">
29
+ <div class="map-head" id="maphead" hidden></div>
30
30
  <div class="board"><div class="board-inner" id="mapboard"><svg class="wires" id="mapwires" aria-hidden="true"></svg></div></div>
31
31
  <div class="board-foot">
32
32
  <span class="key" id="key-feeds"><i></i> feeds</span>
@@ -35,6 +35,7 @@
35
35
  <span class="key"><i class="r"></i> needs review</span>
36
36
  <span class="key" id="key-chain" hidden><s class="up"></s> feeds it <s class="sel"></s> chosen <s class="down"></s> reads it</span>
37
37
  <span class="key"><small class="n">3</small> models that read it</span>
38
+ <span class="key"><s class="unk"></s> no link found</span>
38
39
  <span class="hidden-note" id="maphidden" hidden></span>
39
40
  <span class="hint" id="maphint">Hover lights the chain. Click a model for its columns, a column to ask.</span>
40
41
  </div>
@@ -76,6 +77,10 @@
76
77
 
77
78
  /*EXPLORE*/
78
79
 
80
+ /*FOCUS*/
81
+
82
+ /*FIND*/
83
+
79
84
  function show(a, text){
80
85
  current = a;
81
86
  demo.hidden = false;
@@ -223,12 +228,14 @@
223
228
  }
224
229
 
225
230
  // SERVE START
226
- var LIVE_FINE = 'Answered from your files as they are now, on this machine.';
231
+ var LIVE_FINE = 'Read from your SQL files as they are now, on this machine. No warehouse login, nothing sent.';
227
232
  // ripple serve: the question box asks the local server, which answers from
228
233
  // the files as they are now. Relative path only; the file page never runs this.
234
+ var HINT_SMALL = 'Hover lights the chain. Click a model for its columns, a column to ask.';
235
+ var HINT_INDEX = 'Hover a group to see what it feeds. Click a model for what feeds it and what reads it; type to find anything.';
236
+ var HINT_FOCUS = 'Click a model to move to it, a column to ask. Esc goes back to all models.';
237
+ function hint(text){ document.getElementById('maphint').textContent = text; }
229
238
  if (L){
230
- var list0 = document.getElementById('cols');
231
- L.columns.forEach(function(id){ var o = document.createElement('option'); o.value = id; list0.appendChild(o); });
232
239
  var form0 = document.getElementById('ask'); form0.hidden = false;
233
240
  var strip0 = document.getElementById('starters');
234
241
  if (L.starters.length){
@@ -242,17 +249,38 @@
242
249
  }
243
250
  if (L.map && L.map.models.length){
244
251
  var hiddenNote = document.getElementById('maphidden');
245
- mapApi = renderMap(mapEl, L.map, {esc: esc,
252
+ mapApi = renderMap(mapEl, L.map, {esc: esc, reduce: reduce,
246
253
  onAsk: function(id){ document.getElementById('q').value = id; ask(id, document.getElementById('dir').value); },
254
+ onFocus: function(id){ go(id, true); },
255
+ onHome: function(){ goHome(true); },
247
256
  onFilter: function(n){ hiddenNote.hidden = !n; hiddenNote.textContent = n ? plural(n, 'model') + ' hidden by the filter' : ''; }});
248
257
  if (mapApi.compact){
249
258
  document.getElementById('key-feeds').hidden = true;
250
259
  document.getElementById('key-chain').hidden = false;
251
- document.getElementById('maphint').textContent = 'Hover lights the chain, click keeps it. Click a column to ask; type to narrow the map.';
252
260
  }
253
261
  document.getElementById('q').addEventListener('input', function(){ if (!mapEl.hidden) mapApi.filter(this.value.trim()); });
254
- document.getElementById('tomap').addEventListener('click', function(){ home(); history.replaceState(null, '', location.pathname); });
262
+ document.getElementById('tomap').addEventListener('click', function(){ goHome(true); });
255
263
  }
264
+ // the question box finds models and columns from the map as you type
265
+ var found = null;
266
+ attachFind(document.getElementById('q'), {esc: esc, list: document.getElementById('find'),
267
+ candidates: function(){
268
+ if (found) return found;
269
+ found = [];
270
+ if (!L.map) return found;
271
+ var feeds = {};
272
+ L.map.links.forEach(function(l){ feeds[l.src] = (feeds[l.src] || 0) + 1; });
273
+ L.map.models.forEach(function(m){
274
+ found.push({id: m.id, kind: m.kind, feeds: feeds[m.id] || 0});
275
+ if (m.kind !== 'dashboard') m.columns.forEach(function(c){ found.push({id: m.id + '.' + c, kind: 'column', feeds: feeds[m.id] || 0}); });
276
+ });
277
+ return found;
278
+ },
279
+ onPick: function(c){
280
+ if (c.kind === 'column' || !mapApi){ ask(c.id, document.getElementById('dir').value); return; }
281
+ document.getElementById('q').value = '';
282
+ go(c.id, true);
283
+ }});
256
284
  var ladder = document.getElementById('ladder'); ladder.hidden = false;
257
285
  ['Coverage', 'Usage'].forEach(function(name, i){
258
286
  var row = L.ladder[i], p = document.createElement('p');
@@ -269,10 +297,34 @@
269
297
  form0.addEventListener('submit', function(ev){
270
298
  ev.preventDefault();
271
299
  var target = document.getElementById('q').value.trim();
272
- if (target) ask(target, document.getElementById('dir').value);
300
+ if (!target) return;
301
+ if (mapApi && !/\./.test(target) && mapApi.focus(target)){ document.getElementById('q').value = ''; go(target, true); return; }
302
+ ask(target, document.getElementById('dir').value);
273
303
  });
274
304
  }
275
- function ask(target, dir){
305
+ // one model's neighbourhood, the index of all models, or an answer: the
306
+ // address bar says which, so Back and a shared link land on the same view
307
+ function go(id, push){
308
+ if (current || mapEl.hidden) home();
309
+ if (!mapApi.focus(id)){ fine.textContent = 'No model called ' + id + ' in this project.'; return; }
310
+ hint(mapApi.compact ? HINT_FOCUS : HINT_SMALL);
311
+ if (push) history.pushState({m: id}, '', '?m=' + encodeURIComponent(id));
312
+ }
313
+ function goHome(push){
314
+ if (current || mapEl.hidden) home();
315
+ if (mapApi){ mapApi.home(); hint(mapApi.compact ? HINT_INDEX : HINT_SMALL); requestAnimationFrame(mapApi.draw); }
316
+ if (push) history.pushState({}, '', location.pathname);
317
+ }
318
+ function route(params){
319
+ var q = params.get('q'), m = params.get('m');
320
+ if (q){
321
+ document.getElementById('q').value = q;
322
+ document.getElementById('dir').value = params.get('dir') === 'trace' ? 'trace' : 'breaks';
323
+ ask(q, document.getElementById('dir').value, true);
324
+ } else if (m && mapApi) go(m, false);
325
+ else goHome(false);
326
+ }
327
+ function ask(target, dir, silent){
276
328
  fine.textContent = 'asking the local server…';
277
329
  fetch(L.api + '/' + dir + '?target=' + encodeURIComponent(target), {cache: 'no-store'}).then(function(r){
278
330
  return r.json().then(function(j){ return [r.ok, j]; });
@@ -286,7 +338,7 @@
286
338
  }
287
339
  fine.textContent = LIVE_FINE;
288
340
  show(j.answer, j.text);
289
- history.replaceState(null, '', '?q=' + encodeURIComponent(target) + '&dir=' + dir);
341
+ if (!silent) history.pushState({q: target}, '', '?q=' + encodeURIComponent(target) + '&dir=' + dir);
290
342
  }).catch(function(){ fine.textContent = 'The local server did not answer. Is ripple serve still running?'; });
291
343
  }
292
344
  // SERVE END
@@ -295,22 +347,29 @@
295
347
  var G = DATA.graph, index = G ? buildIndex(G) : null;
296
348
  function offline(target, dir){ return dir === 'trace' ? computeTrace(index, target, noun) : computeBreaks(index, target, noun); }
297
349
  if (G){
298
- var list = document.getElementById('cols');
299
- Object.keys(index.ids).sort().forEach(function(id){ if (id.indexOf('(') < 0 && id.slice(-2) !== '.*'){ var o = document.createElement('option'); o.value = id; list.appendChild(o); } });
300
350
  var form = document.getElementById('ask'); form.hidden = false;
301
351
  document.getElementById('q').value = '';
302
- form.addEventListener('submit', function(ev){
303
- ev.preventDefault();
304
- var target = document.getElementById('q').value.trim(), dir = document.getElementById('dir').value;
305
- if (!target) return;
306
- var found = resolveTarget(index, target), fine2 = document.getElementById('askfine');
307
- if (!found.id){
308
- fine2.textContent = found.error + '.' + (found.candidates && found.candidates.length ? ' Did you mean: ' + found.candidates.join(', ') + '?' : '');
352
+ var ids0 = null;
353
+ attachFind(document.getElementById('q'), {esc: esc, list: document.getElementById('find'),
354
+ candidates: function(){
355
+ if (!ids0) ids0 = Object.keys(index.ids).sort().filter(function(id){ return id.indexOf('(') < 0 && id.slice(-2) !== '.*'; }).map(function(id){ return {id: id, kind: 'column'}; });
356
+ return ids0;
357
+ },
358
+ onPick: function(c){ askOffline(c.id, document.getElementById('dir').value); }});
359
+ function askOffline(target, dir){
360
+ var hit = resolveTarget(index, target), fine2 = document.getElementById('askfine');
361
+ if (!hit.id){
362
+ fine2.textContent = hit.error + '.' + (hit.candidates && hit.candidates.length ? ' Did you mean: ' + hit.candidates.join(', ') + '?' : '');
309
363
  return;
310
364
  }
311
365
  fine2.textContent = 'Answered inside this file. Nothing sent.';
312
- var a = offline(found.id, dir);
366
+ var a = offline(hit.id, dir);
313
367
  show(a, dir === 'trace' ? traceText(a) : breaksText(a));
368
+ }
369
+ form.addEventListener('submit', function(ev){
370
+ ev.preventDefault();
371
+ var target = document.getElementById('q').value.trim();
372
+ if (target) askOffline(target, document.getElementById('dir').value);
314
373
  });
315
374
  }
316
375
 
@@ -355,8 +414,8 @@
355
414
  var dir0 = params.get('dir') === 'trace' ? 'trace' : 'breaks';
356
415
  var linked = (G && asked) ? resolveTarget(index, asked) : null;
357
416
  if (L){
358
- home();
359
- if (asked){ document.getElementById('q').value = asked; document.getElementById('dir').value = dir0; ask(asked, dir0); }
417
+ route(params);
418
+ window.addEventListener('popstate', function(){ route(new URLSearchParams(location.search)); });
360
419
  } else if (linked && linked.id){
361
420
  document.getElementById('q').value = asked; document.getElementById('dir').value = dir0;
362
421
  var a0 = offline(linked.id, dir0);