pretensor 0.1.0a0__py3-none-any.whl

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 (138) hide show
  1. pretensor/__init__.py +50 -0
  2. pretensor/cli/__init__.py +1 -0
  3. pretensor/cli/commands/connections/__init__.py +7 -0
  4. pretensor/cli/commands/connections/add_remove.py +122 -0
  5. pretensor/cli/commands/connections/register.py +12 -0
  6. pretensor/cli/commands/export.py +120 -0
  7. pretensor/cli/commands/index.py +551 -0
  8. pretensor/cli/commands/list.py +65 -0
  9. pretensor/cli/commands/quickstart.py +171 -0
  10. pretensor/cli/commands/reindex.py +581 -0
  11. pretensor/cli/commands/semantic.py +190 -0
  12. pretensor/cli/commands/serve.py +129 -0
  13. pretensor/cli/commands/sync_grants.py +149 -0
  14. pretensor/cli/commands/validate.py +156 -0
  15. pretensor/cli/config_file.py +412 -0
  16. pretensor/cli/constants.py +10 -0
  17. pretensor/cli/dbt_enrichment.py +96 -0
  18. pretensor/cli/main.py +81 -0
  19. pretensor/cli/paths.py +43 -0
  20. pretensor/cli/plugin.py +52 -0
  21. pretensor/config.py +145 -0
  22. pretensor/connectors/SKILL.md +16 -0
  23. pretensor/connectors/__init__.py +29 -0
  24. pretensor/connectors/base.py +165 -0
  25. pretensor/connectors/bigquery.py +463 -0
  26. pretensor/connectors/inspect.py +321 -0
  27. pretensor/connectors/lineage_sqlglot.py +91 -0
  28. pretensor/connectors/models.py +130 -0
  29. pretensor/connectors/pg_array_parse.py +53 -0
  30. pretensor/connectors/postgres.py +921 -0
  31. pretensor/connectors/registry.py +51 -0
  32. pretensor/connectors/snapshot.py +244 -0
  33. pretensor/connectors/snowflake.py +840 -0
  34. pretensor/core/__init__.py +1 -0
  35. pretensor/core/builder.py +268 -0
  36. pretensor/core/dsn_crypto.py +40 -0
  37. pretensor/core/ids.py +68 -0
  38. pretensor/core/portable_export.py +253 -0
  39. pretensor/core/registry.py +204 -0
  40. pretensor/core/schema.py +368 -0
  41. pretensor/core/store.py +1343 -0
  42. pretensor/enrichment/__init__.py +1 -0
  43. pretensor/enrichment/dbt/__init__.py +30 -0
  44. pretensor/enrichment/dbt/lineage.py +100 -0
  45. pretensor/enrichment/dbt/manifest.py +298 -0
  46. pretensor/enrichment/dbt/metadata.py +263 -0
  47. pretensor/enrichment/dbt/pipeline.py +77 -0
  48. pretensor/enrichment/dbt/resolution.py +101 -0
  49. pretensor/enrichment/dbt/signals.py +305 -0
  50. pretensor/entities/__init__.py +27 -0
  51. pretensor/entities/builder.py +63 -0
  52. pretensor/entities/classifier.py +359 -0
  53. pretensor/entities/llm_extract.py +66 -0
  54. pretensor/graph_models/__init__.py +14 -0
  55. pretensor/graph_models/base.py +11 -0
  56. pretensor/graph_models/edge.py +35 -0
  57. pretensor/graph_models/entity.py +21 -0
  58. pretensor/graph_models/node.py +75 -0
  59. pretensor/graph_models/relationship.py +27 -0
  60. pretensor/intelligence/__init__.py +33 -0
  61. pretensor/intelligence/cluster_labeler.py +335 -0
  62. pretensor/intelligence/clustering.py +162 -0
  63. pretensor/intelligence/combining.py +32 -0
  64. pretensor/intelligence/discovery.py +114 -0
  65. pretensor/intelligence/embeddings.py +198 -0
  66. pretensor/intelligence/graph_export.py +139 -0
  67. pretensor/intelligence/heuristic.py +540 -0
  68. pretensor/intelligence/join_paths/__init__.py +130 -0
  69. pretensor/intelligence/join_paths/on_demand.py +516 -0
  70. pretensor/intelligence/join_paths/storage.py +70 -0
  71. pretensor/intelligence/llm_infer.py +78 -0
  72. pretensor/intelligence/llm_runtime.py +60 -0
  73. pretensor/intelligence/metric_templates.py +193 -0
  74. pretensor/intelligence/pipeline.py +254 -0
  75. pretensor/intelligence/schema_classification.py +306 -0
  76. pretensor/intelligence/scoring.py +66 -0
  77. pretensor/intelligence/shadow_alias.py +101 -0
  78. pretensor/intelligence/statistical.py +50 -0
  79. pretensor/intelligence/steps.py +161 -0
  80. pretensor/introspection/__init__.py +6 -0
  81. pretensor/introspection/inspector.py +5 -0
  82. pretensor/introspection/models/__init__.py +0 -0
  83. pretensor/introspection/models/base.py +5 -0
  84. pretensor/introspection/models/config.py +236 -0
  85. pretensor/introspection/models/dsn.py +442 -0
  86. pretensor/introspection/models/plan.py +116 -0
  87. pretensor/introspection/models/schema.py +10 -0
  88. pretensor/introspection/models/semantic.py +121 -0
  89. pretensor/introspection/models/validation.py +116 -0
  90. pretensor/introspection/snapshot.py +46 -0
  91. pretensor/mcp/__init__.py +16 -0
  92. pretensor/mcp/config_json.py +24 -0
  93. pretensor/mcp/payload_types.py +248 -0
  94. pretensor/mcp/resources/__init__.py +19 -0
  95. pretensor/mcp/resources/markdown.py +420 -0
  96. pretensor/mcp/server.py +723 -0
  97. pretensor/mcp/service.py +47 -0
  98. pretensor/mcp/service_context.py +126 -0
  99. pretensor/mcp/service_registry.py +242 -0
  100. pretensor/mcp/tool_registry.py +126 -0
  101. pretensor/mcp/tools/__init__.py +1 -0
  102. pretensor/mcp/tools/compile_metric.py +87 -0
  103. pretensor/mcp/tools/context.py +894 -0
  104. pretensor/mcp/tools/cypher.py +333 -0
  105. pretensor/mcp/tools/detect_changes.py +226 -0
  106. pretensor/mcp/tools/impact.py +165 -0
  107. pretensor/mcp/tools/list.py +110 -0
  108. pretensor/mcp/tools/schema.py +128 -0
  109. pretensor/mcp/tools/search.py +108 -0
  110. pretensor/mcp/tools/traverse.py +753 -0
  111. pretensor/mcp/tools/validate_sql.py +74 -0
  112. pretensor/observability.py +203 -0
  113. pretensor/search/__init__.py +6 -0
  114. pretensor/search/base.py +80 -0
  115. pretensor/search/index.py +435 -0
  116. pretensor/semantic/__init__.py +24 -0
  117. pretensor/semantic/base.py +123 -0
  118. pretensor/semantic/compiler.py +346 -0
  119. pretensor/semantic/yaml_layer.py +182 -0
  120. pretensor/skills/__init__.py +5 -0
  121. pretensor/skills/generator.py +235 -0
  122. pretensor/staleness/__init__.py +15 -0
  123. pretensor/staleness/graph_patcher.py +355 -0
  124. pretensor/staleness/impact_analyzer.py +162 -0
  125. pretensor/staleness/snapshot_store.py +38 -0
  126. pretensor/validation/__init__.py +9 -0
  127. pretensor/validation/query_validator.py +436 -0
  128. pretensor/visibility/__init__.py +23 -0
  129. pretensor/visibility/config.py +126 -0
  130. pretensor/visibility/filter.py +143 -0
  131. pretensor/visibility/kuzu_helpers.py +32 -0
  132. pretensor/visibility/runtime.py +36 -0
  133. pretensor/visibility/sync_grants.py +188 -0
  134. pretensor-0.1.0a0.dist-info/METADATA +175 -0
  135. pretensor-0.1.0a0.dist-info/RECORD +138 -0
  136. pretensor-0.1.0a0.dist-info/WHEEL +4 -0
  137. pretensor-0.1.0a0.dist-info/entry_points.txt +2 -0
  138. pretensor-0.1.0a0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,581 @@
1
+ """``pretensor reindex`` command (snapshot diff + graph patch)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from pretensor.cli import constants as cli_constants
14
+ from pretensor.cli.config_file import (
15
+ get_cli_config,
16
+ resolve_optional_str_option,
17
+ resolve_path_option,
18
+ )
19
+ from pretensor.cli.dbt_enrichment import (
20
+ apply_dbt_enrichment_cli,
21
+ preload_dbt_manifest,
22
+ )
23
+ from pretensor.cli.paths import default_connection_name, keystore_path
24
+ from pretensor.config import PretensorConfig
25
+ from pretensor.connectors.inspect import inspect
26
+ from pretensor.connectors.snapshot import diff_snapshots
27
+ from pretensor.core.dsn_crypto import DSNEncryptor
28
+ from pretensor.core.registry import DatabaseNotFoundError, GraphRegistry
29
+ from pretensor.core.store import KuzuStore
30
+ from pretensor.intelligence.discovery import RelationshipDiscovery
31
+ from pretensor.intelligence.metric_templates import MetricTemplateBuilder
32
+ from pretensor.intelligence.pipeline import run_intelligence_layer_sync
33
+ from pretensor.introspection.models.dsn import (
34
+ connection_config_from_registry_dsn,
35
+ connection_config_from_source,
36
+ connection_config_from_url,
37
+ dsn_from_source,
38
+ registry_dialect_for,
39
+ validate_source_env_vars,
40
+ )
41
+ from pretensor.observability import log_timed_operation
42
+ from pretensor.skills.generator import SkillGenerator
43
+ from pretensor.staleness.graph_patcher import GraphPatcher
44
+ from pretensor.staleness.impact_analyzer import ImpactAnalyzer
45
+ from pretensor.staleness.snapshot_store import SnapshotStore
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ def register_reindex_command(app: typer.Typer, *, console: Console) -> None:
51
+ @app.command("reindex")
52
+ def reindex_command(
53
+ dsn: str | None = typer.Argument(
54
+ None,
55
+ help=(
56
+ "Database DSN URL (must match the indexed connection unless --dialect is set). "
57
+ "Mutually exclusive with --source and --all."
58
+ ),
59
+ ),
60
+ source: str | None = typer.Option(
61
+ None,
62
+ "--source",
63
+ "-s",
64
+ help="Named source from .pretensor/config.yaml sources: block.",
65
+ ),
66
+ all_sources: bool = typer.Option(
67
+ False,
68
+ "--all",
69
+ help="Reindex all sources defined in .pretensor/config.yaml.",
70
+ ),
71
+ dialect: str | None = typer.Option(
72
+ None,
73
+ "--dialect",
74
+ help="Override connector for this run: postgres, postgresql, snowflake, or bigquery.",
75
+ ),
76
+ database: str | None = typer.Option(
77
+ None,
78
+ "--database",
79
+ "-d",
80
+ help="Registry connection name (defaults to database name from the DSN).",
81
+ ),
82
+ dry_run: bool = typer.Option(
83
+ False,
84
+ "--dry-run",
85
+ help="Show schema diff and planned mutations without writing the graph.",
86
+ ),
87
+ recompute_intelligence: bool = typer.Option(
88
+ False,
89
+ "--recompute-intelligence",
90
+ help="After patching, re-run relationship discovery and clustering/join paths.",
91
+ ),
92
+ state_dir: Path = typer.Option(
93
+ cli_constants.DEFAULT_STATE_DIR,
94
+ "--state-dir",
95
+ help="Directory for registry.json, snapshots, and graph files.",
96
+ file_okay=False,
97
+ dir_okay=True,
98
+ writable=True,
99
+ resolve_path=True,
100
+ ),
101
+ skills_target: str = typer.Option(
102
+ "claude",
103
+ "--skills-target",
104
+ help=(
105
+ "Where to write the generated skill after a successful patch: "
106
+ "claude, cursor, all, or a file path."
107
+ ),
108
+ ),
109
+ dbt_manifest: Path | None = typer.Option(
110
+ None,
111
+ "--dbt-manifest",
112
+ help=(
113
+ "Path to dbt target/manifest.json. Run `dbt compile` or `dbt run` first "
114
+ "so the manifest exists; enrichment runs after the graph is updated."
115
+ ),
116
+ file_okay=True,
117
+ dir_okay=False,
118
+ resolve_path=True,
119
+ ),
120
+ dbt_sources: Path | None = typer.Option(
121
+ None,
122
+ "--dbt-sources",
123
+ help=(
124
+ "Path to dbt sources.json (e.g. from `dbt source freshness`). "
125
+ "Defaults to target/sources.json next to the manifest when that file exists."
126
+ ),
127
+ file_okay=True,
128
+ dir_okay=False,
129
+ resolve_path=True,
130
+ ),
131
+ ctx: typer.Context = typer.Option(
132
+ None,
133
+ hidden=True,
134
+ ),
135
+ ) -> None:
136
+ """Re-introspect the DB, diff against the last snapshot, and patch the Kuzu graph."""
137
+ cli_config = get_cli_config(ctx)
138
+
139
+ # --- mutual exclusion -------------------------------------------------
140
+ modes = sum([dsn is not None, source is not None, all_sources])
141
+ if modes > 1:
142
+ console.print(
143
+ "[red]DSN argument, --source, and --all are mutually exclusive.[/red]"
144
+ )
145
+ raise typer.Exit(1)
146
+
147
+ state_dir = resolve_path_option(
148
+ ctx,
149
+ param_name="state_dir",
150
+ cli_value=state_dir,
151
+ config_value=cli_config.state_dir,
152
+ )
153
+
154
+ # --- --all: iterate all configured sources ----------------------------
155
+ if all_sources:
156
+ if not cli_config.sources:
157
+ console.print(
158
+ "[red]No sources defined in config.[/red] "
159
+ "Add a `sources:` section to .pretensor/config.yaml."
160
+ )
161
+ raise typer.Exit(1)
162
+ results: list[tuple[str, bool, str]] = []
163
+ for src_name in cli_config.sources:
164
+ console.print(
165
+ f"\n[bold]{'─' * 40}[/bold]\n"
166
+ f"[bold blue]Reindexing source:[/bold blue] {src_name}\n"
167
+ )
168
+ src_cfg = cli_config.sources[src_name]
169
+ missing_vars = validate_source_env_vars(src_cfg)
170
+ if missing_vars:
171
+ msg = f"missing env vars: {', '.join(missing_vars)}"
172
+ console.print(f"[yellow]Skipping {src_name}:[/yellow] {msg}")
173
+ logger.warning("Skipping source %s: %s", src_name, msg)
174
+ results.append((src_name, False, f"skipped ({msg})"))
175
+ continue
176
+ try:
177
+ _run_single_reindex(
178
+ console=console,
179
+ cli_config=cli_config,
180
+ ctx=ctx,
181
+ dsn=None,
182
+ source_name=src_name,
183
+ dialect=dialect,
184
+ database=src_name,
185
+ dry_run=dry_run,
186
+ recompute_intelligence=recompute_intelligence,
187
+ state_dir=state_dir,
188
+ skills_target=skills_target,
189
+ dbt_manifest=dbt_manifest,
190
+ dbt_sources=dbt_sources,
191
+ )
192
+ results.append((src_name, True, "ok"))
193
+ except typer.Exit:
194
+ logger.warning("Source %s exited with failure", src_name)
195
+ results.append((src_name, False, "failed"))
196
+ except Exception as e:
197
+ logger.exception("Error reindexing source %s", src_name)
198
+ console.print(f"[red]Error reindexing {src_name}:[/red] {e}")
199
+ results.append((src_name, False, str(e)))
200
+
201
+ console.print(f"\n[bold]{'─' * 40}[/bold]")
202
+ console.print("[bold]Summary:[/bold]")
203
+ failed = 0
204
+ for src_name, ok, msg in results:
205
+ status = "[green]✓[/green]" if ok else "[red]✗[/red]"
206
+ console.print(f" {status} {src_name}: {msg}")
207
+ if not ok:
208
+ failed += 1
209
+ if failed:
210
+ raise typer.Exit(1)
211
+ return
212
+
213
+ # --- --source: single named source ------------------------------------
214
+ if source is not None:
215
+ if source not in cli_config.sources:
216
+ available = ", ".join(cli_config.sources) or "(none)"
217
+ console.print(
218
+ f"[red]Unknown source {source!r}.[/red] "
219
+ f"Available: {available}"
220
+ )
221
+ raise typer.Exit(1)
222
+ _run_single_reindex(
223
+ console=console,
224
+ cli_config=cli_config,
225
+ ctx=ctx,
226
+ dsn=None,
227
+ source_name=source,
228
+ dialect=dialect,
229
+ database=source,
230
+ dry_run=dry_run,
231
+ recompute_intelligence=recompute_intelligence,
232
+ state_dir=state_dir,
233
+ skills_target=skills_target,
234
+ dbt_manifest=dbt_manifest,
235
+ dbt_sources=dbt_sources,
236
+ )
237
+ return
238
+
239
+ # --- DSN argument (original path) or no mode (use registry) -----------
240
+ if dsn is None:
241
+ # No DSN, no source, no --all: need at least a database name
242
+ database = resolve_optional_str_option(
243
+ ctx,
244
+ param_name="database",
245
+ cli_value=database,
246
+ config_value=cli_config.connection_defaults.name,
247
+ )
248
+ if not database:
249
+ console.print(
250
+ "[red]Provide a DSN argument, --source <name>, --all, "
251
+ "or --database <name>.[/red]"
252
+ )
253
+ raise typer.Exit(1)
254
+
255
+ _run_single_reindex(
256
+ console=console,
257
+ cli_config=cli_config,
258
+ ctx=ctx,
259
+ dsn=dsn,
260
+ source_name=None,
261
+ dialect=dialect,
262
+ database=database,
263
+ dry_run=dry_run,
264
+ recompute_intelligence=recompute_intelligence,
265
+ state_dir=state_dir,
266
+ skills_target=skills_target,
267
+ dbt_manifest=dbt_manifest,
268
+ dbt_sources=dbt_sources,
269
+ )
270
+
271
+
272
+ def _run_single_reindex(
273
+ *,
274
+ console: Console,
275
+ cli_config: object,
276
+ ctx: typer.Context | None,
277
+ dsn: str | None,
278
+ source_name: str | None,
279
+ dialect: str | None,
280
+ database: str | None,
281
+ dry_run: bool,
282
+ recompute_intelligence: bool,
283
+ state_dir: Path,
284
+ skills_target: str,
285
+ dbt_manifest: Path | None,
286
+ dbt_sources: Path | None,
287
+ ) -> None:
288
+ """Core reindex logic shared by DSN, --source, and --all paths."""
289
+ from pretensor.cli.config_file import PretensorCliConfig
290
+
291
+ assert isinstance(cli_config, PretensorCliConfig)
292
+
293
+ database = resolve_optional_str_option(
294
+ ctx,
295
+ param_name="database",
296
+ cli_value=database,
297
+ config_value=cli_config.connection_defaults.name,
298
+ )
299
+ dialect = resolve_optional_str_option(
300
+ ctx,
301
+ param_name="dialect",
302
+ cli_value=dialect,
303
+ config_value=cli_config.connection_defaults.dialect,
304
+ )
305
+ total_started = time.perf_counter()
306
+
307
+ # Resolve connection_name and DSN
308
+ if source_name is not None and source_name in cli_config.sources:
309
+ src_cfg = cli_config.sources[source_name]
310
+ connection_name = source_name
311
+ try:
312
+ dsn_resolved = dsn_from_source(source_name, src_cfg)
313
+ except ValueError as e:
314
+ console.print(f"[red]Cannot build DSN from source:[/red] {e}")
315
+ raise typer.Exit(1) from e
316
+ elif dsn is not None:
317
+ connection_name = database or default_connection_name(dsn)
318
+ dsn_resolved = dsn
319
+ else:
320
+ connection_name = database or ""
321
+ dsn_resolved = None
322
+
323
+ preloaded_manifest = None
324
+ preloaded_sources: Path | None = None
325
+ if dbt_manifest is not None:
326
+ preloaded_manifest, preloaded_sources = preload_dbt_manifest(
327
+ dbt_manifest, dbt_sources, console=console
328
+ )
329
+ reg_path = state_dir / cli_constants.REGISTRY_FILENAME
330
+ if not reg_path.exists():
331
+ console.print("[red]No registry found.[/red] Run `pretensor index` first.")
332
+ raise typer.Exit(1)
333
+ reg = GraphRegistry(reg_path).load()
334
+ ks = keystore_path(state_dir)
335
+ try:
336
+ entry = reg.require(connection_name)
337
+ except DatabaseNotFoundError:
338
+ console.print(
339
+ f"[red]Unknown connection {connection_name!r}.[/red] "
340
+ "Use `pretensor list` or `--database`."
341
+ )
342
+ raise typer.Exit(1)
343
+
344
+ # For source-based reindex, use source config directly; otherwise use registry DSN
345
+ if source_name is not None and source_name in cli_config.sources:
346
+ src_cfg = cli_config.sources[source_name]
347
+ try:
348
+ config = connection_config_from_source(source_name, src_cfg)
349
+ except ValueError as e:
350
+ console.print(f"[red]Invalid source config:[/red] {e}")
351
+ raise typer.Exit(1) from e
352
+ dsn_for_registry = dsn_resolved or ""
353
+ else:
354
+ decryptor = DSNEncryptor(ks) if entry.dsn_encrypted else None
355
+ dsn_for_reindex = entry.plaintext_dsn(decryptor)
356
+ dsn_for_registry = dsn_resolved or dsn_for_reindex
357
+
358
+ if dialect is not None:
359
+ config = connection_config_from_url(
360
+ dsn_for_reindex, connection_name, dialect_override=dialect
361
+ )
362
+ else:
363
+ config = connection_config_from_registry_dsn(
364
+ dsn_for_reindex, connection_name, entry.dialect
365
+ )
366
+
367
+ snap_store = SnapshotStore(state_dir)
368
+ old_snapshot = snap_store.load(connection_name)
369
+ if old_snapshot is None:
370
+ console.print(
371
+ "[red]No saved snapshot for this connection.[/red] "
372
+ "Run `pretensor index` once, then use `reindex`."
373
+ )
374
+ raise typer.Exit(1)
375
+
376
+ console.print(f"[bold blue]Re-introspecting[/bold blue] '{connection_name}'...")
377
+ try:
378
+ with log_timed_operation(
379
+ logger,
380
+ event="reindex.inspect",
381
+ connection_name=connection_name,
382
+ dialect=str(config.type),
383
+ ):
384
+ new_snapshot = inspect(config)
385
+ except Exception as e:
386
+ console.print(
387
+ f"[red]Cannot connect to database:[/red] {e}\n"
388
+ "Check that the database is reachable and the DSN is correct."
389
+ )
390
+ raise typer.Exit(1) from e
391
+
392
+ changes = diff_snapshots(old_snapshot, new_snapshot)
393
+ graph_path = Path(entry.unified_graph_path or entry.graph_path)
394
+ if not graph_path.exists():
395
+ console.print(f"[red]Graph file missing:[/red] {graph_path}")
396
+ raise typer.Exit(1)
397
+
398
+ try:
399
+ store = KuzuStore(graph_path)
400
+ except Exception as e:
401
+ console.print(
402
+ f"[red]Cannot open graph file {graph_path}:[/red] {e}\n"
403
+ "The file may be corrupt. Run `pretensor index` to rebuild it."
404
+ )
405
+ raise typer.Exit(1) from e
406
+ try:
407
+ analyzer = ImpactAnalyzer(store)
408
+ impact = analyzer.analyze(
409
+ changes,
410
+ connection_name=connection_name,
411
+ database_key=str(entry.database),
412
+ )
413
+ if changes:
414
+ console.print(
415
+ f"\n[bold]Schema changes[/bold] for {connection_name!r} "
416
+ f"({len(changes)} item(s)):"
417
+ )
418
+ for ch in changes:
419
+ col = f".{ch.column_name}" if ch.column_name else ""
420
+ console.print(
421
+ f" {ch.change_type.value} {ch.target.value}: "
422
+ f"{ch.schema_name}.{ch.table_name}{col}"
423
+ + (f" — {ch.details}" if ch.details else "")
424
+ )
425
+ console.print(f"\n[dim]{impact.summary}[/dim]\n")
426
+ else:
427
+ console.print("[green]No schema changes since last snapshot.[/green]")
428
+
429
+ patcher = GraphPatcher(store)
430
+ with log_timed_operation(
431
+ logger,
432
+ event="reindex.apply_patch",
433
+ connection_name=connection_name,
434
+ dry_run=dry_run,
435
+ change_count=len(changes),
436
+ ):
437
+ patch = patcher.apply(changes, new_snapshot, dry_run=dry_run)
438
+ if changes and dry_run:
439
+ console.print("[bold]Planned graph updates (dry-run):[/bold]")
440
+ console.print(
441
+ f" tables +{patch.tables_added} / -{patch.tables_removed}, "
442
+ f"columns +{patch.columns_added} / -{patch.columns_removed} / "
443
+ f"~{patch.columns_updated}, "
444
+ f"FK edges removed (column touch): {patch.fk_edges_removed}, "
445
+ f"inferred removed: {patch.inferred_joins_removed}"
446
+ )
447
+ console.print("\n[yellow]Run without --dry-run to apply.[/yellow]")
448
+ return
449
+
450
+ if changes and not dry_run:
451
+ MetricTemplateBuilder.mark_stale_for_database(
452
+ store, str(new_snapshot.database)
453
+ )
454
+
455
+ if not changes:
456
+ if preloaded_manifest is not None and not dry_run:
457
+ apply_dbt_enrichment_cli(
458
+ manifest=preloaded_manifest,
459
+ sources_path=preloaded_sources,
460
+ store=store,
461
+ connection_name=connection_name,
462
+ console=console,
463
+ )
464
+ indexed_at = datetime.now(timezone.utc)
465
+ enc = DSNEncryptor(ks) if ks.exists() and entry.dsn_encrypted else None
466
+ reg.upsert(
467
+ connection_name=connection_name,
468
+ database=new_snapshot.database,
469
+ dsn=dsn_for_registry,
470
+ graph_path=graph_path,
471
+ unified_graph_path=Path(entry.unified_graph_path)
472
+ if entry.unified_graph_path
473
+ else None,
474
+ indexed_at=indexed_at,
475
+ encrypt_dsn=bool(entry.dsn_encrypted),
476
+ encryptor=enc,
477
+ dialect=registry_dialect_for(config.type),
478
+ table_count=entry.table_count,
479
+ dbt_manifest_path=(
480
+ str(dbt_manifest) if dbt_manifest is not None
481
+ else entry.dbt_manifest_path
482
+ ),
483
+ llm_enrichment_ran=entry.llm_enrichment_ran,
484
+ )
485
+ reg.save()
486
+ snap_store.save(connection_name, new_snapshot)
487
+ console.print("[dim]Registry timestamp refreshed.[/dim]")
488
+ return
489
+
490
+ if recompute_intelligence and not dry_run:
491
+ with log_timed_operation(
492
+ logger,
493
+ event="reindex.relationship_discovery",
494
+ connection_name=connection_name,
495
+ ):
496
+ RelationshipDiscovery(store).discover(new_snapshot)
497
+ with log_timed_operation(
498
+ logger,
499
+ event="reindex.intelligence_sync",
500
+ connection_name=connection_name,
501
+ ):
502
+ run_intelligence_layer_sync(
503
+ store,
504
+ new_snapshot.database,
505
+ config=PretensorConfig(graph=cli_config.graph),
506
+ )
507
+
508
+ if preloaded_manifest is not None and not dry_run:
509
+ apply_dbt_enrichment_cli(
510
+ manifest=preloaded_manifest,
511
+ sources_path=preloaded_sources,
512
+ store=store,
513
+ connection_name=connection_name,
514
+ console=console,
515
+ )
516
+
517
+ snap_store.save(connection_name, new_snapshot)
518
+ indexed_at = datetime.now(timezone.utc)
519
+ tc_rows = store.query_all_rows(
520
+ """
521
+ MATCH (t:SchemaTable {connection_name: $cn})
522
+ RETURN count(*)
523
+ """,
524
+ {"cn": connection_name},
525
+ )
526
+ table_count = (
527
+ int(tc_rows[0][0]) if tc_rows and tc_rows[0][0] is not None else 0
528
+ )
529
+ enc = DSNEncryptor(ks) if ks.exists() and entry.dsn_encrypted else None
530
+ reg.upsert(
531
+ connection_name=connection_name,
532
+ database=new_snapshot.database,
533
+ dsn=dsn_for_registry,
534
+ graph_path=graph_path,
535
+ unified_graph_path=Path(entry.unified_graph_path)
536
+ if entry.unified_graph_path
537
+ else None,
538
+ indexed_at=indexed_at,
539
+ encrypt_dsn=bool(entry.dsn_encrypted),
540
+ encryptor=enc,
541
+ dialect=registry_dialect_for(config.type),
542
+ table_count=table_count,
543
+ dbt_manifest_path=(
544
+ str(dbt_manifest) if dbt_manifest is not None
545
+ else entry.dbt_manifest_path
546
+ ),
547
+ llm_enrichment_ran=entry.llm_enrichment_ran,
548
+ )
549
+ reg.save()
550
+
551
+ skill_store = KuzuStore(graph_path)
552
+ try:
553
+ refreshed = reg.get(connection_name)
554
+ if refreshed is not None:
555
+ SkillGenerator.write_for_index(
556
+ store=skill_store,
557
+ entry=refreshed,
558
+ skills_target=skills_target,
559
+ )
560
+ finally:
561
+ skill_store.close()
562
+
563
+ console.print(f"[green]Patched graph:[/green] {graph_path}")
564
+ console.print(
565
+ f"[green]Snapshot saved:[/green] {snap_store.path_for(connection_name)}"
566
+ )
567
+ logger.info(
568
+ "reindex completed in %.2fms",
569
+ (time.perf_counter() - total_started) * 1000,
570
+ extra={
571
+ "event": "reindex.total",
572
+ "status": "ok",
573
+ "duration_ms": (time.perf_counter() - total_started) * 1000,
574
+ "connection_name": connection_name,
575
+ "dry_run": dry_run,
576
+ "change_count": len(changes),
577
+ "recompute_intelligence": recompute_intelligence,
578
+ },
579
+ )
580
+ finally:
581
+ store.close()