truthound-dashboard 1.4.4__py3-none-any.whl → 1.5.0__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 (205) hide show
  1. truthound_dashboard/api/alerts.py +75 -86
  2. truthound_dashboard/api/anomaly.py +7 -13
  3. truthound_dashboard/api/cross_alerts.py +38 -52
  4. truthound_dashboard/api/drift.py +49 -59
  5. truthound_dashboard/api/drift_monitor.py +234 -79
  6. truthound_dashboard/api/enterprise_sampling.py +498 -0
  7. truthound_dashboard/api/history.py +57 -5
  8. truthound_dashboard/api/lineage.py +3 -48
  9. truthound_dashboard/api/maintenance.py +104 -49
  10. truthound_dashboard/api/mask.py +1 -2
  11. truthound_dashboard/api/middleware.py +2 -1
  12. truthound_dashboard/api/model_monitoring.py +435 -311
  13. truthound_dashboard/api/notifications.py +227 -191
  14. truthound_dashboard/api/notifications_advanced.py +21 -20
  15. truthound_dashboard/api/observability.py +586 -0
  16. truthound_dashboard/api/plugins.py +2 -433
  17. truthound_dashboard/api/profile.py +199 -37
  18. truthound_dashboard/api/quality_reporter.py +701 -0
  19. truthound_dashboard/api/reports.py +7 -16
  20. truthound_dashboard/api/router.py +66 -0
  21. truthound_dashboard/api/rule_suggestions.py +5 -5
  22. truthound_dashboard/api/scan.py +17 -19
  23. truthound_dashboard/api/schedules.py +85 -50
  24. truthound_dashboard/api/schema_evolution.py +6 -6
  25. truthound_dashboard/api/schema_watcher.py +667 -0
  26. truthound_dashboard/api/sources.py +98 -27
  27. truthound_dashboard/api/tiering.py +1323 -0
  28. truthound_dashboard/api/triggers.py +14 -11
  29. truthound_dashboard/api/validations.py +12 -11
  30. truthound_dashboard/api/versioning.py +1 -6
  31. truthound_dashboard/core/__init__.py +129 -3
  32. truthound_dashboard/core/actions/__init__.py +62 -0
  33. truthound_dashboard/core/actions/custom.py +426 -0
  34. truthound_dashboard/core/actions/notifications.py +910 -0
  35. truthound_dashboard/core/actions/storage.py +472 -0
  36. truthound_dashboard/core/actions/webhook.py +281 -0
  37. truthound_dashboard/core/anomaly.py +262 -67
  38. truthound_dashboard/core/anomaly_explainer.py +4 -3
  39. truthound_dashboard/core/backends/__init__.py +67 -0
  40. truthound_dashboard/core/backends/base.py +299 -0
  41. truthound_dashboard/core/backends/errors.py +191 -0
  42. truthound_dashboard/core/backends/factory.py +423 -0
  43. truthound_dashboard/core/backends/mock_backend.py +451 -0
  44. truthound_dashboard/core/backends/truthound_backend.py +718 -0
  45. truthound_dashboard/core/checkpoint/__init__.py +87 -0
  46. truthound_dashboard/core/checkpoint/adapters.py +814 -0
  47. truthound_dashboard/core/checkpoint/checkpoint.py +491 -0
  48. truthound_dashboard/core/checkpoint/runner.py +270 -0
  49. truthound_dashboard/core/connections.py +437 -10
  50. truthound_dashboard/core/converters/__init__.py +14 -0
  51. truthound_dashboard/core/converters/truthound.py +620 -0
  52. truthound_dashboard/core/cross_alerts.py +540 -320
  53. truthound_dashboard/core/datasource_factory.py +1672 -0
  54. truthound_dashboard/core/drift_monitor.py +216 -20
  55. truthound_dashboard/core/enterprise_sampling.py +1291 -0
  56. truthound_dashboard/core/interfaces/__init__.py +225 -0
  57. truthound_dashboard/core/interfaces/actions.py +652 -0
  58. truthound_dashboard/core/interfaces/base.py +247 -0
  59. truthound_dashboard/core/interfaces/checkpoint.py +676 -0
  60. truthound_dashboard/core/interfaces/protocols.py +664 -0
  61. truthound_dashboard/core/interfaces/reporters.py +650 -0
  62. truthound_dashboard/core/interfaces/routing.py +646 -0
  63. truthound_dashboard/core/interfaces/triggers.py +619 -0
  64. truthound_dashboard/core/lineage.py +407 -71
  65. truthound_dashboard/core/model_monitoring.py +431 -3
  66. truthound_dashboard/core/notifications/base.py +4 -0
  67. truthound_dashboard/core/notifications/channels.py +501 -1203
  68. truthound_dashboard/core/notifications/deduplication/__init__.py +81 -115
  69. truthound_dashboard/core/notifications/deduplication/service.py +131 -348
  70. truthound_dashboard/core/notifications/dispatcher.py +202 -11
  71. truthound_dashboard/core/notifications/escalation/__init__.py +119 -106
  72. truthound_dashboard/core/notifications/escalation/engine.py +168 -358
  73. truthound_dashboard/core/notifications/routing/__init__.py +88 -128
  74. truthound_dashboard/core/notifications/routing/engine.py +90 -317
  75. truthound_dashboard/core/notifications/stats_aggregator.py +246 -1
  76. truthound_dashboard/core/notifications/throttling/__init__.py +67 -50
  77. truthound_dashboard/core/notifications/throttling/builder.py +117 -255
  78. truthound_dashboard/core/notifications/truthound_adapter.py +842 -0
  79. truthound_dashboard/core/phase5/collaboration.py +1 -1
  80. truthound_dashboard/core/plugins/lifecycle/__init__.py +0 -13
  81. truthound_dashboard/core/quality_reporter.py +1359 -0
  82. truthound_dashboard/core/report_history.py +0 -6
  83. truthound_dashboard/core/reporters/__init__.py +175 -14
  84. truthound_dashboard/core/reporters/adapters.py +943 -0
  85. truthound_dashboard/core/reporters/base.py +0 -3
  86. truthound_dashboard/core/reporters/builtin/__init__.py +18 -0
  87. truthound_dashboard/core/reporters/builtin/csv_reporter.py +111 -0
  88. truthound_dashboard/core/reporters/builtin/html_reporter.py +270 -0
  89. truthound_dashboard/core/reporters/builtin/json_reporter.py +127 -0
  90. truthound_dashboard/core/reporters/compat.py +266 -0
  91. truthound_dashboard/core/reporters/csv_reporter.py +2 -35
  92. truthound_dashboard/core/reporters/factory.py +526 -0
  93. truthound_dashboard/core/reporters/interfaces.py +745 -0
  94. truthound_dashboard/core/reporters/registry.py +1 -10
  95. truthound_dashboard/core/scheduler.py +165 -0
  96. truthound_dashboard/core/schema_evolution.py +3 -3
  97. truthound_dashboard/core/schema_watcher.py +1528 -0
  98. truthound_dashboard/core/services.py +595 -76
  99. truthound_dashboard/core/store_manager.py +810 -0
  100. truthound_dashboard/core/streaming_anomaly.py +169 -4
  101. truthound_dashboard/core/tiering.py +1309 -0
  102. truthound_dashboard/core/triggers/evaluators.py +178 -8
  103. truthound_dashboard/core/truthound_adapter.py +2620 -197
  104. truthound_dashboard/core/unified_alerts.py +23 -20
  105. truthound_dashboard/db/__init__.py +8 -0
  106. truthound_dashboard/db/database.py +8 -2
  107. truthound_dashboard/db/models.py +944 -25
  108. truthound_dashboard/db/repository.py +2 -0
  109. truthound_dashboard/main.py +11 -0
  110. truthound_dashboard/schemas/__init__.py +177 -16
  111. truthound_dashboard/schemas/base.py +44 -23
  112. truthound_dashboard/schemas/collaboration.py +19 -6
  113. truthound_dashboard/schemas/cross_alerts.py +19 -3
  114. truthound_dashboard/schemas/drift.py +61 -55
  115. truthound_dashboard/schemas/drift_monitor.py +67 -23
  116. truthound_dashboard/schemas/enterprise_sampling.py +653 -0
  117. truthound_dashboard/schemas/lineage.py +0 -33
  118. truthound_dashboard/schemas/mask.py +10 -8
  119. truthound_dashboard/schemas/model_monitoring.py +89 -10
  120. truthound_dashboard/schemas/notifications_advanced.py +13 -0
  121. truthound_dashboard/schemas/observability.py +453 -0
  122. truthound_dashboard/schemas/plugins.py +0 -280
  123. truthound_dashboard/schemas/profile.py +154 -247
  124. truthound_dashboard/schemas/quality_reporter.py +403 -0
  125. truthound_dashboard/schemas/reports.py +2 -2
  126. truthound_dashboard/schemas/rule_suggestion.py +8 -1
  127. truthound_dashboard/schemas/scan.py +4 -24
  128. truthound_dashboard/schemas/schedule.py +11 -3
  129. truthound_dashboard/schemas/schema_watcher.py +727 -0
  130. truthound_dashboard/schemas/source.py +17 -2
  131. truthound_dashboard/schemas/tiering.py +822 -0
  132. truthound_dashboard/schemas/triggers.py +16 -0
  133. truthound_dashboard/schemas/unified_alerts.py +7 -0
  134. truthound_dashboard/schemas/validation.py +0 -13
  135. truthound_dashboard/schemas/validators/base.py +41 -21
  136. truthound_dashboard/schemas/validators/business_rule_validators.py +244 -0
  137. truthound_dashboard/schemas/validators/localization_validators.py +273 -0
  138. truthound_dashboard/schemas/validators/ml_feature_validators.py +308 -0
  139. truthound_dashboard/schemas/validators/profiling_validators.py +275 -0
  140. truthound_dashboard/schemas/validators/referential_validators.py +312 -0
  141. truthound_dashboard/schemas/validators/registry.py +93 -8
  142. truthound_dashboard/schemas/validators/timeseries_validators.py +389 -0
  143. truthound_dashboard/schemas/versioning.py +1 -6
  144. truthound_dashboard/static/index.html +2 -2
  145. truthound_dashboard-1.5.0.dist-info/METADATA +309 -0
  146. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/RECORD +149 -148
  147. truthound_dashboard/core/plugins/hooks/__init__.py +0 -63
  148. truthound_dashboard/core/plugins/hooks/decorators.py +0 -367
  149. truthound_dashboard/core/plugins/hooks/manager.py +0 -403
  150. truthound_dashboard/core/plugins/hooks/protocols.py +0 -265
  151. truthound_dashboard/core/plugins/lifecycle/hot_reload.py +0 -584
  152. truthound_dashboard/core/reporters/junit_reporter.py +0 -233
  153. truthound_dashboard/core/reporters/markdown_reporter.py +0 -207
  154. truthound_dashboard/core/reporters/pdf_reporter.py +0 -209
  155. truthound_dashboard/static/assets/_baseUniq-BcrSP13d.js +0 -1
  156. truthound_dashboard/static/assets/arc-DlYjKwIL.js +0 -1
  157. truthound_dashboard/static/assets/architectureDiagram-VXUJARFQ-Bb2drbQM.js +0 -36
  158. truthound_dashboard/static/assets/blockDiagram-VD42YOAC-BlsPG1CH.js +0 -122
  159. truthound_dashboard/static/assets/c4Diagram-YG6GDRKO-B9JdUoaC.js +0 -10
  160. truthound_dashboard/static/assets/channel-Q6mHF1Hd.js +0 -1
  161. truthound_dashboard/static/assets/chunk-4BX2VUAB-DmyoPVuJ.js +0 -1
  162. truthound_dashboard/static/assets/chunk-55IACEB6-Bcz6Siv8.js +0 -1
  163. truthound_dashboard/static/assets/chunk-B4BG7PRW-Br3G5Rum.js +0 -165
  164. truthound_dashboard/static/assets/chunk-DI55MBZ5-DuM9c23u.js +0 -220
  165. truthound_dashboard/static/assets/chunk-FMBD7UC4-DNU-5mvT.js +0 -15
  166. truthound_dashboard/static/assets/chunk-QN33PNHL-Im2yNcmS.js +0 -1
  167. truthound_dashboard/static/assets/chunk-QZHKN3VN-kZr8XFm1.js +0 -1
  168. truthound_dashboard/static/assets/chunk-TZMSLE5B-Q__360q_.js +0 -1
  169. truthound_dashboard/static/assets/classDiagram-2ON5EDUG-vtixxUyK.js +0 -1
  170. truthound_dashboard/static/assets/classDiagram-v2-WZHVMYZB-vtixxUyK.js +0 -1
  171. truthound_dashboard/static/assets/clone-BOt2LwD0.js +0 -1
  172. truthound_dashboard/static/assets/cose-bilkent-S5V4N54A-CBDw6iac.js +0 -1
  173. truthound_dashboard/static/assets/dagre-6UL2VRFP-XdKqmmY9.js +0 -4
  174. truthound_dashboard/static/assets/diagram-PSM6KHXK-DAZ8nx9V.js +0 -24
  175. truthound_dashboard/static/assets/diagram-QEK2KX5R-BRvDTbGD.js +0 -43
  176. truthound_dashboard/static/assets/diagram-S2PKOQOG-bQcczUkl.js +0 -24
  177. truthound_dashboard/static/assets/erDiagram-Q2GNP2WA-DPje7VMN.js +0 -60
  178. truthound_dashboard/static/assets/flowDiagram-NV44I4VS-B7BVtFVS.js +0 -162
  179. truthound_dashboard/static/assets/ganttDiagram-JELNMOA3-D6WKSS7U.js +0 -267
  180. truthound_dashboard/static/assets/gitGraphDiagram-NY62KEGX-D3vtVd3y.js +0 -65
  181. truthound_dashboard/static/assets/graph-BKgNKZVp.js +0 -1
  182. truthound_dashboard/static/assets/index-C6JSrkHo.css +0 -1
  183. truthound_dashboard/static/assets/index-DkU82VsU.js +0 -1800
  184. truthound_dashboard/static/assets/infoDiagram-WHAUD3N6-DnNCT429.js +0 -2
  185. truthound_dashboard/static/assets/journeyDiagram-XKPGCS4Q-DGiMozqS.js +0 -139
  186. truthound_dashboard/static/assets/kanban-definition-3W4ZIXB7-BV2gUgli.js +0 -89
  187. truthound_dashboard/static/assets/katex-Cu_Erd72.js +0 -261
  188. truthound_dashboard/static/assets/layout-DI2MfQ5G.js +0 -1
  189. truthound_dashboard/static/assets/min-DYdgXVcT.js +0 -1
  190. truthound_dashboard/static/assets/mindmap-definition-VGOIOE7T-C7x4ruxz.js +0 -68
  191. truthound_dashboard/static/assets/pieDiagram-ADFJNKIX-CAJaAB9f.js +0 -30
  192. truthound_dashboard/static/assets/quadrantDiagram-AYHSOK5B-DeqwDI46.js +0 -7
  193. truthound_dashboard/static/assets/requirementDiagram-UZGBJVZJ-e3XDpZIM.js +0 -64
  194. truthound_dashboard/static/assets/sankeyDiagram-TZEHDZUN-CNnAv5Ux.js +0 -10
  195. truthound_dashboard/static/assets/sequenceDiagram-WL72ISMW-Dsne-Of3.js +0 -145
  196. truthound_dashboard/static/assets/stateDiagram-FKZM4ZOC-Ee0sQXyb.js +0 -1
  197. truthound_dashboard/static/assets/stateDiagram-v2-4FDKWEC3-B26KqW_W.js +0 -1
  198. truthound_dashboard/static/assets/timeline-definition-IT6M3QCI-DZYi2yl3.js +0 -61
  199. truthound_dashboard/static/assets/treemap-KMMF4GRG-CY3f8In2.js +0 -128
  200. truthound_dashboard/static/assets/unmerged_dictionaries-Dd7xcPWG.js +0 -1
  201. truthound_dashboard/static/assets/xychartDiagram-PRI3JC2R-CS7fydZZ.js +0 -7
  202. truthound_dashboard-1.4.4.dist-info/METADATA +0 -507
  203. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/WHEEL +0 -0
  204. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/entry_points.txt +0 -0
  205. {truthound_dashboard-1.4.4.dist-info → truthound_dashboard-1.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1528 @@
1
+ """Schema Watcher Service.
2
+
3
+ This module provides schema monitoring functionality using truthound's
4
+ schema evolution module (truthound.profiler.evolution).
5
+
6
+ Architecture:
7
+ API Endpoints
8
+
9
+ SchemaWatcherService
10
+
11
+ SchemaEvolutionAdapter (truthound_adapter.py)
12
+
13
+ truthound.profiler.evolution
14
+ - SchemaEvolutionDetector
15
+ - SchemaHistory
16
+ - SchemaWatcher
17
+ - ColumnRenameDetector
18
+ - BreakingChangeAlertManager
19
+ - ImpactAnalyzer
20
+
21
+ Features:
22
+ - Schema change detection with breaking change identification
23
+ - Column rename detection using multiple similarity algorithms
24
+ - Version history with semantic/incremental/timestamp/git strategies
25
+ - Continuous monitoring with configurable polling
26
+ - Impact analysis for affected consumers
27
+ - Alert management with acknowledgment and resolution
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import logging
33
+ from datetime import datetime, timedelta
34
+ from pathlib import Path
35
+ from typing import Any
36
+ from uuid import uuid4
37
+
38
+ from sqlalchemy import func, select
39
+ from sqlalchemy.ext.asyncio import AsyncSession
40
+ from sqlalchemy.orm import selectinload
41
+
42
+ from truthound_dashboard.db.models import (
43
+ SchemaWatcherAlertModel,
44
+ SchemaWatcherAlertSeverity as DBAlertSeverity,
45
+ SchemaWatcherAlertStatus as DBAlertStatus,
46
+ SchemaWatcherModel,
47
+ SchemaWatcherRunModel,
48
+ SchemaWatcherRunStatus as DBRunStatus,
49
+ SchemaWatcherStatus as DBStatus,
50
+ Source as SourceModel,
51
+ )
52
+ from truthound_dashboard.schemas.schema_watcher import (
53
+ CompatibilityLevel,
54
+ ImpactScope,
55
+ RenameConfidence,
56
+ SchemaChangeDetail,
57
+ SchemaChangeSeverity,
58
+ SchemaChangeType,
59
+ SchemaDetectionResponse,
60
+ SchemaDiffResponse,
61
+ SchemaHistoryResponse,
62
+ SchemaVersionResponse,
63
+ SchemaVersionSummary,
64
+ SchemaWatcherAlertResponse,
65
+ SchemaWatcherAlertSeverity,
66
+ SchemaWatcherAlertStatus,
67
+ SchemaWatcherAlertSummary,
68
+ SchemaWatcherCheckNowResponse,
69
+ SchemaWatcherCreate,
70
+ SchemaWatcherResponse,
71
+ SchemaWatcherRunResponse,
72
+ SchemaWatcherRunStatus,
73
+ SchemaWatcherRunSummary,
74
+ SchemaWatcherSchedulerStatus,
75
+ SchemaWatcherStatistics,
76
+ SchemaWatcherStatus,
77
+ SchemaWatcherSummary,
78
+ SchemaWatcherUpdate,
79
+ RenameDetectionDetail,
80
+ RenameDetectionResponse,
81
+ VersionStrategy,
82
+ )
83
+
84
+ logger = logging.getLogger(__name__)
85
+
86
+ # Storage paths
87
+ SCHEMA_HISTORY_BASE_PATH = Path("./data/schema_history")
88
+ ALERT_STORAGE_PATH = Path("./data/schema_alerts.json")
89
+
90
+
91
+ def _generate_id() -> str:
92
+ """Generate a unique ID."""
93
+ return str(uuid4())
94
+
95
+
96
+ def _map_db_status(status: DBStatus | str) -> SchemaWatcherStatus:
97
+ """Map DB status enum to schema enum."""
98
+ value = status.value if hasattr(status, "value") else status
99
+ return SchemaWatcherStatus(value)
100
+
101
+
102
+ def _map_schema_status(status: SchemaWatcherStatus | str) -> DBStatus:
103
+ """Map schema status enum to DB enum."""
104
+ value = status.value if hasattr(status, "value") else status
105
+ return DBStatus(value)
106
+
107
+
108
+ def _map_db_alert_status(status: DBAlertStatus | str) -> SchemaWatcherAlertStatus:
109
+ """Map DB alert status enum to schema enum."""
110
+ value = status.value if hasattr(status, "value") else status
111
+ return SchemaWatcherAlertStatus(value)
112
+
113
+
114
+ def _map_db_alert_severity(severity: DBAlertSeverity | str) -> SchemaWatcherAlertSeverity:
115
+ """Map DB alert severity enum to schema enum."""
116
+ value = severity.value if hasattr(severity, "value") else severity
117
+ return SchemaWatcherAlertSeverity(value)
118
+
119
+
120
+ def _map_db_run_status(status: DBRunStatus | str) -> SchemaWatcherRunStatus:
121
+ """Map DB run status enum to schema enum."""
122
+ value = status.value if hasattr(status, "value") else status
123
+ return SchemaWatcherRunStatus(value)
124
+
125
+
126
+ class SchemaWatcherService:
127
+ """Service for schema watcher operations.
128
+
129
+ This service provides comprehensive schema monitoring using truthound's
130
+ schema evolution module. It manages:
131
+ - Watcher configuration and lifecycle
132
+ - Schema change detection
133
+ - Version history
134
+ - Alerts and impact analysis
135
+ - Background polling
136
+
137
+ The service uses SchemaEvolutionAdapter to interact with truthound,
138
+ maintaining loose coupling with the underlying library.
139
+ """
140
+
141
+ def __init__(self, session: AsyncSession) -> None:
142
+ """Initialize service.
143
+
144
+ Args:
145
+ session: Async database session.
146
+ """
147
+ self._session = session
148
+ self._adapter = None # Lazy initialization
149
+
150
+ @property
151
+ def adapter(self):
152
+ """Get schema evolution adapter (lazy initialization)."""
153
+ if self._adapter is None:
154
+ from truthound_dashboard.core.truthound_adapter import (
155
+ get_schema_evolution_adapter,
156
+ )
157
+ self._adapter = get_schema_evolution_adapter()
158
+ return self._adapter
159
+
160
+ # =========================================================================
161
+ # Watcher CRUD
162
+ # =========================================================================
163
+
164
+ async def create_watcher(
165
+ self,
166
+ data: SchemaWatcherCreate,
167
+ ) -> SchemaWatcherResponse:
168
+ """Create a new schema watcher.
169
+
170
+ Args:
171
+ data: Watcher creation data.
172
+
173
+ Returns:
174
+ Created watcher response.
175
+
176
+ Raises:
177
+ ValueError: If source not found.
178
+ """
179
+ # Verify source exists
180
+ source = await self._session.get(SourceModel, data.source_id)
181
+ if not source:
182
+ raise ValueError(f"Source '{data.source_id}' not found")
183
+
184
+ now = datetime.utcnow()
185
+ next_check = now + timedelta(seconds=data.poll_interval_seconds)
186
+
187
+ watcher = SchemaWatcherModel(
188
+ id=_generate_id(),
189
+ name=data.name,
190
+ source_id=data.source_id,
191
+ status=DBStatus.ACTIVE,
192
+ poll_interval_seconds=data.poll_interval_seconds,
193
+ only_breaking=data.only_breaking,
194
+ enable_rename_detection=data.enable_rename_detection,
195
+ rename_similarity_threshold=data.rename_similarity_threshold,
196
+ version_strategy=data.version_strategy.value,
197
+ notify_on_change=data.notify_on_change,
198
+ track_history=data.track_history,
199
+ next_check_at=next_check,
200
+ watcher_config=data.config,
201
+ created_at=now,
202
+ updated_at=now,
203
+ )
204
+
205
+ self._session.add(watcher)
206
+ await self._session.commit()
207
+ await self._session.refresh(watcher)
208
+
209
+ # Initialize schema history for this watcher
210
+ if data.track_history:
211
+ history_path = SCHEMA_HISTORY_BASE_PATH / watcher.id
212
+ history_path.mkdir(parents=True, exist_ok=True)
213
+ await self.adapter.create_history(
214
+ history_id=watcher.id,
215
+ storage_path=str(history_path),
216
+ version_strategy=data.version_strategy.value,
217
+ )
218
+
219
+ return self._to_watcher_response(watcher, source)
220
+
221
+ async def get_watcher(self, watcher_id: str) -> SchemaWatcherResponse | None:
222
+ """Get a watcher by ID.
223
+
224
+ Args:
225
+ watcher_id: Watcher ID.
226
+
227
+ Returns:
228
+ Watcher response or None if not found.
229
+ """
230
+ stmt = (
231
+ select(SchemaWatcherModel)
232
+ .options(selectinload(SchemaWatcherModel.source))
233
+ .where(SchemaWatcherModel.id == watcher_id)
234
+ )
235
+ result = await self._session.execute(stmt)
236
+ watcher = result.scalar_one_or_none()
237
+
238
+ if not watcher:
239
+ return None
240
+
241
+ return self._to_watcher_response(watcher, watcher.source)
242
+
243
+ async def list_watchers(
244
+ self,
245
+ *,
246
+ status: SchemaWatcherStatus | None = None,
247
+ source_id: str | None = None,
248
+ limit: int = 50,
249
+ offset: int = 0,
250
+ ) -> tuple[list[SchemaWatcherSummary], int]:
251
+ """List watchers with optional filters.
252
+
253
+ Args:
254
+ status: Filter by status.
255
+ source_id: Filter by source.
256
+ limit: Maximum results.
257
+ offset: Skip first N results.
258
+
259
+ Returns:
260
+ Tuple of (watchers, total_count).
261
+ """
262
+ stmt = select(SchemaWatcherModel).options(
263
+ selectinload(SchemaWatcherModel.source)
264
+ )
265
+
266
+ if status:
267
+ stmt = stmt.where(
268
+ SchemaWatcherModel.status == _map_schema_status(status)
269
+ )
270
+ if source_id:
271
+ stmt = stmt.where(SchemaWatcherModel.source_id == source_id)
272
+
273
+ # Count total
274
+ count_stmt = select(func.count()).select_from(stmt.subquery())
275
+ count_result = await self._session.execute(count_stmt)
276
+ total = count_result.scalar() or 0
277
+
278
+ # Apply pagination
279
+ stmt = (
280
+ stmt.order_by(SchemaWatcherModel.created_at.desc())
281
+ .limit(limit)
282
+ .offset(offset)
283
+ )
284
+
285
+ result = await self._session.execute(stmt)
286
+ watchers = result.scalars().all()
287
+
288
+ summaries = [self._to_watcher_summary(w, w.source) for w in watchers]
289
+ return summaries, total
290
+
291
+ async def update_watcher(
292
+ self,
293
+ watcher_id: str,
294
+ data: SchemaWatcherUpdate,
295
+ ) -> SchemaWatcherResponse | None:
296
+ """Update a watcher.
297
+
298
+ Args:
299
+ watcher_id: Watcher ID.
300
+ data: Update data.
301
+
302
+ Returns:
303
+ Updated watcher response or None if not found.
304
+ """
305
+ stmt = (
306
+ select(SchemaWatcherModel)
307
+ .options(selectinload(SchemaWatcherModel.source))
308
+ .where(SchemaWatcherModel.id == watcher_id)
309
+ )
310
+ result = await self._session.execute(stmt)
311
+ watcher = result.scalar_one_or_none()
312
+
313
+ if not watcher:
314
+ return None
315
+
316
+ # Update fields
317
+ update_data = data.model_dump(exclude_unset=True)
318
+ for key, value in update_data.items():
319
+ if key == "version_strategy" and value:
320
+ setattr(watcher, key, value.value)
321
+ elif hasattr(watcher, key):
322
+ setattr(watcher, key, value)
323
+
324
+ watcher.updated_at = datetime.utcnow()
325
+
326
+ # Recalculate next_check_at if poll_interval changed
327
+ if data.poll_interval_seconds and watcher.status == DBStatus.ACTIVE:
328
+ watcher.next_check_at = datetime.utcnow() + timedelta(
329
+ seconds=data.poll_interval_seconds
330
+ )
331
+
332
+ await self._session.commit()
333
+ await self._session.refresh(watcher)
334
+
335
+ return self._to_watcher_response(watcher, watcher.source)
336
+
337
+ async def delete_watcher(self, watcher_id: str) -> bool:
338
+ """Delete a watcher and all related data.
339
+
340
+ Args:
341
+ watcher_id: Watcher ID.
342
+
343
+ Returns:
344
+ True if deleted, False if not found.
345
+ """
346
+ watcher = await self._session.get(SchemaWatcherModel, watcher_id)
347
+ if not watcher:
348
+ return False
349
+
350
+ # Delete related alerts and runs (cascade should handle this)
351
+ await self._session.delete(watcher)
352
+ await self._session.commit()
353
+
354
+ # Clean up adapter resources
355
+ try:
356
+ await self.adapter.delete_watcher(watcher_id)
357
+ except ValueError:
358
+ pass # Watcher not in adapter (not started)
359
+
360
+ return True
361
+
362
+ async def set_watcher_status(
363
+ self,
364
+ watcher_id: str,
365
+ status: SchemaWatcherStatus,
366
+ ) -> SchemaWatcherResponse | None:
367
+ """Change watcher status.
368
+
369
+ Args:
370
+ watcher_id: Watcher ID.
371
+ status: New status.
372
+
373
+ Returns:
374
+ Updated watcher response or None if not found.
375
+ """
376
+ stmt = (
377
+ select(SchemaWatcherModel)
378
+ .options(selectinload(SchemaWatcherModel.source))
379
+ .where(SchemaWatcherModel.id == watcher_id)
380
+ )
381
+ result = await self._session.execute(stmt)
382
+ watcher = result.scalar_one_or_none()
383
+
384
+ if not watcher:
385
+ return None
386
+
387
+ old_status = watcher.status
388
+ watcher.status = _map_schema_status(status)
389
+ watcher.updated_at = datetime.utcnow()
390
+
391
+ # Update next_check_at based on status change
392
+ if status == SchemaWatcherStatus.ACTIVE:
393
+ watcher.next_check_at = datetime.utcnow() + timedelta(
394
+ seconds=watcher.poll_interval_seconds
395
+ )
396
+ watcher.error_count = 0
397
+ elif status in (SchemaWatcherStatus.PAUSED, SchemaWatcherStatus.STOPPED):
398
+ watcher.next_check_at = None
399
+
400
+ await self._session.commit()
401
+ await self._session.refresh(watcher)
402
+
403
+ # Update adapter state
404
+ try:
405
+ if status == SchemaWatcherStatus.ACTIVE and old_status != DBStatus.ACTIVE:
406
+ await self.adapter.resume_watcher(watcher_id)
407
+ elif status == SchemaWatcherStatus.PAUSED:
408
+ await self.adapter.pause_watcher(watcher_id)
409
+ elif status == SchemaWatcherStatus.STOPPED:
410
+ await self.adapter.stop_watcher(watcher_id)
411
+ except ValueError:
412
+ pass # Watcher not in adapter
413
+
414
+ return self._to_watcher_response(watcher, watcher.source)
415
+
416
+ # =========================================================================
417
+ # Schema Detection
418
+ # =========================================================================
419
+
420
+ async def detect_changes(
421
+ self,
422
+ current_schema: dict[str, Any],
423
+ baseline_schema: dict[str, Any],
424
+ *,
425
+ detect_renames: bool = True,
426
+ rename_similarity_threshold: float = 0.8,
427
+ ) -> SchemaDetectionResponse:
428
+ """Detect schema changes between two schemas.
429
+
430
+ Uses truthound's SchemaEvolutionDetector for comprehensive change
431
+ detection including additions, removals, type changes, and renames.
432
+
433
+ Args:
434
+ current_schema: Current schema {column: type}.
435
+ baseline_schema: Baseline schema {column: type}.
436
+ detect_renames: Enable rename detection.
437
+ rename_similarity_threshold: Similarity threshold for renames.
438
+
439
+ Returns:
440
+ SchemaDetectionResponse with all detected changes.
441
+ """
442
+ result = await self.adapter.detect_changes(
443
+ current_schema,
444
+ baseline_schema,
445
+ detect_renames=detect_renames,
446
+ rename_similarity_threshold=rename_similarity_threshold,
447
+ )
448
+
449
+ changes = [
450
+ SchemaChangeDetail(
451
+ change_type=SchemaChangeType(c.change_type),
452
+ column_name=c.column_name,
453
+ old_value=c.old_value,
454
+ new_value=c.new_value,
455
+ severity=SchemaChangeSeverity(c.severity),
456
+ breaking=c.breaking,
457
+ description=c.description,
458
+ migration_hint=c.migration_hint,
459
+ )
460
+ for c in result.changes
461
+ ]
462
+
463
+ return SchemaDetectionResponse(
464
+ total_changes=result.total_changes,
465
+ breaking_changes=result.breaking_changes,
466
+ compatibility_level=CompatibilityLevel(result.compatibility_level),
467
+ changes=changes,
468
+ )
469
+
470
+ async def detect_renames(
471
+ self,
472
+ added_columns: dict[str, str],
473
+ removed_columns: dict[str, str],
474
+ *,
475
+ similarity_threshold: float = 0.8,
476
+ require_type_match: bool = True,
477
+ allow_compatible_types: bool = True,
478
+ algorithm: str = "composite",
479
+ ) -> RenameDetectionResponse:
480
+ """Detect column renames.
481
+
482
+ Uses truthound's ColumnRenameDetector with configurable similarity
483
+ algorithms.
484
+
485
+ Args:
486
+ added_columns: Added columns {name: type}.
487
+ removed_columns: Removed columns {name: type}.
488
+ similarity_threshold: Similarity threshold (0.5-1.0).
489
+ require_type_match: Require matching types.
490
+ allow_compatible_types: Allow compatible types.
491
+ algorithm: Similarity algorithm.
492
+
493
+ Returns:
494
+ RenameDetectionResponse with detected renames.
495
+ """
496
+ result = await self.adapter.detect_renames(
497
+ added_columns,
498
+ removed_columns,
499
+ similarity_threshold=similarity_threshold,
500
+ require_type_match=require_type_match,
501
+ allow_compatible_types=allow_compatible_types,
502
+ algorithm=algorithm,
503
+ )
504
+
505
+ confirmed = [
506
+ RenameDetectionDetail(
507
+ old_name=r.old_name,
508
+ new_name=r.new_name,
509
+ similarity=r.similarity,
510
+ confidence=RenameConfidence(r.confidence),
511
+ reasons=r.reasons,
512
+ )
513
+ for r in result.confirmed_renames
514
+ ]
515
+
516
+ possible = [
517
+ RenameDetectionDetail(
518
+ old_name=r.old_name,
519
+ new_name=r.new_name,
520
+ similarity=r.similarity,
521
+ confidence=RenameConfidence(r.confidence),
522
+ reasons=r.reasons,
523
+ )
524
+ for r in result.possible_renames
525
+ ]
526
+
527
+ return RenameDetectionResponse(
528
+ confirmed_renames=confirmed,
529
+ possible_renames=possible,
530
+ unmatched_added=result.unmatched_added,
531
+ unmatched_removed=result.unmatched_removed,
532
+ )
533
+
534
+ # =========================================================================
535
+ # Version History
536
+ # =========================================================================
537
+
538
+ async def save_schema_version(
539
+ self,
540
+ watcher_id: str,
541
+ schema: dict[str, Any],
542
+ *,
543
+ version: str | None = None,
544
+ metadata: dict[str, Any] | None = None,
545
+ ) -> SchemaVersionResponse:
546
+ """Save a schema version to history.
547
+
548
+ Args:
549
+ watcher_id: Watcher ID (used as history ID).
550
+ schema: Schema dictionary.
551
+ version: Optional explicit version.
552
+ metadata: Optional metadata.
553
+
554
+ Returns:
555
+ SchemaVersionResponse with version info.
556
+ """
557
+ result = await self.adapter.save_schema_version(
558
+ history_id=watcher_id,
559
+ schema=schema,
560
+ version=version,
561
+ metadata=metadata,
562
+ )
563
+
564
+ changes = None
565
+ if result.changes_from_parent:
566
+ changes = [
567
+ SchemaChangeDetail(
568
+ change_type=SchemaChangeType(c.change_type),
569
+ column_name=c.column_name,
570
+ old_value=c.old_value,
571
+ new_value=c.new_value,
572
+ severity=SchemaChangeSeverity(c.severity),
573
+ breaking=c.breaking,
574
+ description=c.description,
575
+ migration_hint=c.migration_hint,
576
+ )
577
+ for c in result.changes_from_parent
578
+ ]
579
+
580
+ return SchemaVersionResponse(
581
+ id=result.id,
582
+ version=result.version,
583
+ schema=result.schema,
584
+ metadata=result.metadata,
585
+ created_at=datetime.fromisoformat(result.created_at) if result.created_at else None,
586
+ has_breaking_changes=result.has_breaking_changes,
587
+ changes_from_parent=changes,
588
+ )
589
+
590
+ async def get_schema_version(
591
+ self,
592
+ watcher_id: str,
593
+ version: str,
594
+ ) -> SchemaVersionResponse | None:
595
+ """Get a specific schema version.
596
+
597
+ Args:
598
+ watcher_id: Watcher ID.
599
+ version: Version string or ID.
600
+
601
+ Returns:
602
+ SchemaVersionResponse or None.
603
+ """
604
+ result = await self.adapter.get_schema_version(watcher_id, version)
605
+ if not result:
606
+ return None
607
+
608
+ return SchemaVersionResponse(
609
+ id=result.id,
610
+ version=result.version,
611
+ schema=result.schema,
612
+ metadata=result.metadata,
613
+ created_at=datetime.fromisoformat(result.created_at) if result.created_at else None,
614
+ has_breaking_changes=result.has_breaking_changes,
615
+ )
616
+
617
+ async def list_schema_versions(
618
+ self,
619
+ watcher_id: str,
620
+ *,
621
+ limit: int = 50,
622
+ ) -> list[SchemaVersionSummary]:
623
+ """List schema versions.
624
+
625
+ Args:
626
+ watcher_id: Watcher ID.
627
+ limit: Maximum versions.
628
+
629
+ Returns:
630
+ List of SchemaVersionSummary.
631
+ """
632
+ versions = await self.adapter.list_schema_versions(
633
+ history_id=watcher_id,
634
+ limit=limit,
635
+ )
636
+
637
+ return [
638
+ SchemaVersionSummary(
639
+ id=v.id,
640
+ version=v.version,
641
+ column_count=len(v.schema) if v.schema else 0,
642
+ created_at=datetime.fromisoformat(v.created_at) if v.created_at else None,
643
+ has_breaking_changes=v.has_breaking_changes,
644
+ )
645
+ for v in versions
646
+ ]
647
+
648
+ async def diff_versions(
649
+ self,
650
+ watcher_id: str,
651
+ from_version: str,
652
+ to_version: str | None = None,
653
+ ) -> SchemaDiffResponse:
654
+ """Get diff between schema versions.
655
+
656
+ Args:
657
+ watcher_id: Watcher ID.
658
+ from_version: Source version.
659
+ to_version: Target version (None = latest).
660
+
661
+ Returns:
662
+ SchemaDiffResponse with changes.
663
+ """
664
+ result = await self.adapter.diff_versions(
665
+ history_id=watcher_id,
666
+ from_version=from_version,
667
+ to_version=to_version,
668
+ )
669
+
670
+ changes = [
671
+ SchemaChangeDetail(
672
+ change_type=SchemaChangeType(c.change_type),
673
+ column_name=c.column_name,
674
+ old_value=c.old_value,
675
+ new_value=c.new_value,
676
+ severity=SchemaChangeSeverity(c.severity),
677
+ breaking=c.breaking,
678
+ description=c.description,
679
+ migration_hint=c.migration_hint,
680
+ )
681
+ for c in result.changes
682
+ ]
683
+
684
+ return SchemaDiffResponse(
685
+ from_version=result.from_version,
686
+ to_version=result.to_version,
687
+ changes=changes,
688
+ text_diff=result.text_diff,
689
+ )
690
+
691
+ async def rollback_version(
692
+ self,
693
+ watcher_id: str,
694
+ to_version: str,
695
+ *,
696
+ reason: str | None = None,
697
+ ) -> SchemaVersionResponse:
698
+ """Rollback to a previous version.
699
+
700
+ Args:
701
+ watcher_id: Watcher ID.
702
+ to_version: Version to rollback to.
703
+ reason: Reason for rollback.
704
+
705
+ Returns:
706
+ New SchemaVersionResponse after rollback.
707
+ """
708
+ result = await self.adapter.rollback_version(
709
+ history_id=watcher_id,
710
+ to_version=to_version,
711
+ reason=reason,
712
+ )
713
+
714
+ return SchemaVersionResponse(
715
+ id=result.id,
716
+ version=result.version,
717
+ schema=result.schema,
718
+ metadata=result.metadata,
719
+ created_at=datetime.fromisoformat(result.created_at) if result.created_at else None,
720
+ has_breaking_changes=result.has_breaking_changes,
721
+ )
722
+
723
+ # =========================================================================
724
+ # Check Now (Immediate Execution)
725
+ # =========================================================================
726
+
727
+ async def check_now(
728
+ self,
729
+ watcher_id: str,
730
+ ) -> SchemaWatcherCheckNowResponse:
731
+ """Execute immediate check for a watcher.
732
+
733
+ This performs a full schema check:
734
+ 1. Get current schema from source
735
+ 2. Compare with previous version
736
+ 3. Save new version if changes detected
737
+ 4. Create alert if breaking changes
738
+ 5. Update watcher state
739
+
740
+ Args:
741
+ watcher_id: Watcher ID.
742
+
743
+ Returns:
744
+ SchemaWatcherCheckNowResponse with results.
745
+
746
+ Raises:
747
+ ValueError: If watcher not found.
748
+ """
749
+ from truthound_dashboard.core.truthound_adapter import get_adapter
750
+
751
+ # Get watcher
752
+ stmt = (
753
+ select(SchemaWatcherModel)
754
+ .options(selectinload(SchemaWatcherModel.source))
755
+ .where(SchemaWatcherModel.id == watcher_id)
756
+ )
757
+ result = await self._session.execute(stmt)
758
+ watcher = result.scalar_one_or_none()
759
+
760
+ if not watcher:
761
+ raise ValueError(f"Watcher '{watcher_id}' not found")
762
+
763
+ source = watcher.source
764
+ if not source:
765
+ raise ValueError(f"Source for watcher '{watcher_id}' not found")
766
+
767
+ # Create run record
768
+ run = SchemaWatcherRunModel(
769
+ id=_generate_id(),
770
+ watcher_id=watcher_id,
771
+ source_id=source.id,
772
+ started_at=datetime.utcnow(),
773
+ status=DBRunStatus.RUNNING,
774
+ )
775
+ self._session.add(run)
776
+ await self._session.flush()
777
+
778
+ try:
779
+ # Learn current schema from source
780
+ adapter = get_adapter()
781
+ learn_result = await adapter.learn(
782
+ source.path,
783
+ infer_constraints=True,
784
+ categorical_threshold=watcher.watcher_config.get(
785
+ "categorical_threshold", 20
786
+ ) if watcher.watcher_config else 20,
787
+ )
788
+ current_schema = learn_result.schema.get("columns", {})
789
+
790
+ # Get previous version
791
+ previous_version = await self.adapter.get_latest_version(watcher_id)
792
+
793
+ changes_detected = 0
794
+ breaking_detected = 0
795
+ alert_id = None
796
+ version_id = None
797
+
798
+ if previous_version:
799
+ # Detect changes
800
+ detection = await self.detect_changes(
801
+ current_schema,
802
+ previous_version.schema,
803
+ detect_renames=watcher.enable_rename_detection,
804
+ rename_similarity_threshold=watcher.rename_similarity_threshold,
805
+ )
806
+
807
+ changes_detected = detection.total_changes
808
+ breaking_detected = detection.breaking_changes
809
+
810
+ # Save new version if changes
811
+ if changes_detected > 0:
812
+ new_version = await self.save_schema_version(
813
+ watcher_id,
814
+ current_schema,
815
+ metadata={"source_id": source.id, "run_id": run.id},
816
+ )
817
+ version_id = new_version.id
818
+
819
+ # Create alert if needed
820
+ should_alert = (
821
+ not watcher.only_breaking or breaking_detected > 0
822
+ )
823
+ if should_alert:
824
+ alert = await self._create_alert(
825
+ watcher=watcher,
826
+ source=source,
827
+ from_version_id=previous_version.id,
828
+ to_version_id=new_version.id,
829
+ detection=detection,
830
+ )
831
+ alert_id = alert.id
832
+
833
+ watcher.last_change_at = datetime.utcnow()
834
+ watcher.change_count += 1
835
+ else:
836
+ # First version
837
+ new_version = await self.save_schema_version(
838
+ watcher_id,
839
+ current_schema,
840
+ metadata={"source_id": source.id, "run_id": run.id, "initial": True},
841
+ )
842
+ version_id = new_version.id
843
+
844
+ # Update run
845
+ run.status = DBRunStatus.COMPLETED
846
+ run.completed_at = datetime.utcnow()
847
+ run.changes_detected = changes_detected
848
+ run.breaking_detected = breaking_detected
849
+ run.version_created_id = version_id
850
+ run.alert_created_id = alert_id
851
+ run.duration_ms = (
852
+ run.completed_at - run.started_at
853
+ ).total_seconds() * 1000
854
+
855
+ # Update watcher
856
+ watcher.last_check_at = datetime.utcnow()
857
+ watcher.check_count += 1
858
+ watcher.next_check_at = datetime.utcnow() + timedelta(
859
+ seconds=watcher.poll_interval_seconds
860
+ )
861
+ watcher.error_count = 0
862
+ watcher.last_error = None
863
+ watcher.updated_at = datetime.utcnow()
864
+
865
+ await self._session.commit()
866
+
867
+ return SchemaWatcherCheckNowResponse(
868
+ watcher_id=watcher_id,
869
+ run_id=run.id,
870
+ status=SchemaWatcherRunStatus.COMPLETED,
871
+ changes_detected=changes_detected,
872
+ breaking_detected=breaking_detected,
873
+ alert_created_id=alert_id,
874
+ version_created_id=version_id,
875
+ duration_ms=run.duration_ms,
876
+ message=f"Check completed: {changes_detected} changes, {breaking_detected} breaking",
877
+ )
878
+
879
+ except Exception as e:
880
+ # Update run as failed
881
+ run.status = DBRunStatus.FAILED
882
+ run.completed_at = datetime.utcnow()
883
+ run.error_message = str(e)
884
+ run.duration_ms = (
885
+ run.completed_at - run.started_at
886
+ ).total_seconds() * 1000
887
+
888
+ # Update watcher error state
889
+ watcher.error_count += 1
890
+ watcher.last_error = str(e)
891
+ watcher.updated_at = datetime.utcnow()
892
+
893
+ # Set to error status after 3 consecutive failures
894
+ if watcher.error_count >= 3:
895
+ watcher.status = DBStatus.ERROR
896
+
897
+ await self._session.commit()
898
+
899
+ return SchemaWatcherCheckNowResponse(
900
+ watcher_id=watcher_id,
901
+ run_id=run.id,
902
+ status=SchemaWatcherRunStatus.FAILED,
903
+ changes_detected=0,
904
+ breaking_detected=0,
905
+ duration_ms=run.duration_ms,
906
+ message=f"Check failed: {str(e)}",
907
+ )
908
+
909
+ async def _create_alert(
910
+ self,
911
+ watcher: SchemaWatcherModel,
912
+ source: SourceModel,
913
+ from_version_id: str | None,
914
+ to_version_id: str,
915
+ detection: SchemaDetectionResponse,
916
+ ) -> SchemaWatcherAlertModel:
917
+ """Create an alert for detected changes.
918
+
919
+ Args:
920
+ watcher: Watcher model.
921
+ source: Source model.
922
+ from_version_id: Previous version ID.
923
+ to_version_id: New version ID.
924
+ detection: Detection result.
925
+
926
+ Returns:
927
+ Created alert model.
928
+ """
929
+ # Determine severity
930
+ if detection.breaking_changes >= 3:
931
+ severity = DBAlertSeverity.CRITICAL
932
+ elif detection.breaking_changes >= 1:
933
+ severity = DBAlertSeverity.HIGH
934
+ elif detection.total_changes >= 5:
935
+ severity = DBAlertSeverity.MEDIUM
936
+ else:
937
+ severity = DBAlertSeverity.LOW
938
+
939
+ # Generate title
940
+ if detection.breaking_changes > 0:
941
+ title = f"🚨 {detection.breaking_changes} breaking changes in {source.name}"
942
+ else:
943
+ title = f"Schema changed: {detection.total_changes} changes in {source.name}"
944
+
945
+ # Generate recommendations
946
+ recommendations = []
947
+ for change in detection.changes[:5]:
948
+ if change.breaking:
949
+ recommendations.append(f"Review: {change.description}")
950
+ if change.migration_hint:
951
+ recommendations.append(f"Hint: {change.migration_hint}")
952
+
953
+ # Serialize changes
954
+ changes_summary = {
955
+ "total_changes": detection.total_changes,
956
+ "breaking_changes": detection.breaking_changes,
957
+ "compatibility_level": detection.compatibility_level.value,
958
+ "changes": [
959
+ {
960
+ "change_type": c.change_type.value,
961
+ "column_name": c.column_name,
962
+ "old_value": c.old_value,
963
+ "new_value": c.new_value,
964
+ "severity": c.severity.value,
965
+ "breaking": c.breaking,
966
+ "description": c.description,
967
+ }
968
+ for c in detection.changes
969
+ ],
970
+ }
971
+
972
+ alert = SchemaWatcherAlertModel(
973
+ id=_generate_id(),
974
+ watcher_id=watcher.id,
975
+ source_id=source.id,
976
+ from_version_id=from_version_id,
977
+ to_version_id=to_version_id,
978
+ title=title,
979
+ severity=severity,
980
+ status=DBAlertStatus.OPEN,
981
+ total_changes=detection.total_changes,
982
+ breaking_changes=detection.breaking_changes,
983
+ changes_summary=changes_summary,
984
+ impact_scope="local", # Can be enhanced with ImpactAnalyzer
985
+ recommendations=recommendations,
986
+ created_at=datetime.utcnow(),
987
+ updated_at=datetime.utcnow(),
988
+ )
989
+
990
+ self._session.add(alert)
991
+ await self._session.flush()
992
+
993
+ return alert
994
+
995
+ # =========================================================================
996
+ # Alerts
997
+ # =========================================================================
998
+
999
+ async def get_alert(self, alert_id: str) -> SchemaWatcherAlertResponse | None:
1000
+ """Get an alert by ID.
1001
+
1002
+ Args:
1003
+ alert_id: Alert ID.
1004
+
1005
+ Returns:
1006
+ Alert response or None.
1007
+ """
1008
+ alert = await self._session.get(SchemaWatcherAlertModel, alert_id)
1009
+ if not alert:
1010
+ return None
1011
+
1012
+ # Load related data
1013
+ source = await self._session.get(SourceModel, alert.source_id)
1014
+ watcher = await self._session.get(SchemaWatcherModel, alert.watcher_id)
1015
+
1016
+ return self._to_alert_response(alert, source, watcher)
1017
+
1018
+ async def list_alerts(
1019
+ self,
1020
+ *,
1021
+ watcher_id: str | None = None,
1022
+ status: SchemaWatcherAlertStatus | None = None,
1023
+ severity: SchemaWatcherAlertSeverity | None = None,
1024
+ limit: int = 50,
1025
+ offset: int = 0,
1026
+ ) -> tuple[list[SchemaWatcherAlertSummary], int]:
1027
+ """List alerts with filters.
1028
+
1029
+ Args:
1030
+ watcher_id: Filter by watcher.
1031
+ status: Filter by status.
1032
+ severity: Filter by severity.
1033
+ limit: Maximum results.
1034
+ offset: Skip first N results.
1035
+
1036
+ Returns:
1037
+ Tuple of (alerts, total_count).
1038
+ """
1039
+ stmt = select(SchemaWatcherAlertModel)
1040
+
1041
+ if watcher_id:
1042
+ stmt = stmt.where(SchemaWatcherAlertModel.watcher_id == watcher_id)
1043
+ if status:
1044
+ stmt = stmt.where(
1045
+ SchemaWatcherAlertModel.status == DBAlertStatus(status.value)
1046
+ )
1047
+ if severity:
1048
+ stmt = stmt.where(
1049
+ SchemaWatcherAlertModel.severity == DBAlertSeverity(severity.value)
1050
+ )
1051
+
1052
+ # Count total
1053
+ count_stmt = select(func.count()).select_from(stmt.subquery())
1054
+ count_result = await self._session.execute(count_stmt)
1055
+ total = count_result.scalar() or 0
1056
+
1057
+ # Apply pagination
1058
+ stmt = (
1059
+ stmt.order_by(SchemaWatcherAlertModel.created_at.desc())
1060
+ .limit(limit)
1061
+ .offset(offset)
1062
+ )
1063
+
1064
+ result = await self._session.execute(stmt)
1065
+ alerts = result.scalars().all()
1066
+
1067
+ # Load source names
1068
+ summaries = []
1069
+ for alert in alerts:
1070
+ source = await self._session.get(SourceModel, alert.source_id)
1071
+ summaries.append(self._to_alert_summary(alert, source))
1072
+
1073
+ return summaries, total
1074
+
1075
+ async def acknowledge_alert(
1076
+ self,
1077
+ alert_id: str,
1078
+ *,
1079
+ acknowledged_by: str | None = None,
1080
+ ) -> SchemaWatcherAlertResponse | None:
1081
+ """Acknowledge an alert.
1082
+
1083
+ Args:
1084
+ alert_id: Alert ID.
1085
+ acknowledged_by: Who acknowledged.
1086
+
1087
+ Returns:
1088
+ Updated alert or None.
1089
+ """
1090
+ alert = await self._session.get(SchemaWatcherAlertModel, alert_id)
1091
+ if not alert:
1092
+ return None
1093
+
1094
+ alert.status = DBAlertStatus.ACKNOWLEDGED
1095
+ alert.acknowledged_at = datetime.utcnow()
1096
+ alert.acknowledged_by = acknowledged_by
1097
+ alert.updated_at = datetime.utcnow()
1098
+
1099
+ await self._session.commit()
1100
+ await self._session.refresh(alert)
1101
+
1102
+ source = await self._session.get(SourceModel, alert.source_id)
1103
+ watcher = await self._session.get(SchemaWatcherModel, alert.watcher_id)
1104
+
1105
+ return self._to_alert_response(alert, source, watcher)
1106
+
1107
+ async def resolve_alert(
1108
+ self,
1109
+ alert_id: str,
1110
+ *,
1111
+ resolved_by: str | None = None,
1112
+ resolution_notes: str | None = None,
1113
+ ) -> SchemaWatcherAlertResponse | None:
1114
+ """Resolve an alert.
1115
+
1116
+ Args:
1117
+ alert_id: Alert ID.
1118
+ resolved_by: Who resolved.
1119
+ resolution_notes: Notes about resolution.
1120
+
1121
+ Returns:
1122
+ Updated alert or None.
1123
+ """
1124
+ alert = await self._session.get(SchemaWatcherAlertModel, alert_id)
1125
+ if not alert:
1126
+ return None
1127
+
1128
+ alert.status = DBAlertStatus.RESOLVED
1129
+ alert.resolved_at = datetime.utcnow()
1130
+ alert.resolved_by = resolved_by
1131
+ alert.resolution_notes = resolution_notes
1132
+ alert.updated_at = datetime.utcnow()
1133
+
1134
+ await self._session.commit()
1135
+ await self._session.refresh(alert)
1136
+
1137
+ source = await self._session.get(SourceModel, alert.source_id)
1138
+ watcher = await self._session.get(SchemaWatcherModel, alert.watcher_id)
1139
+
1140
+ return self._to_alert_response(alert, source, watcher)
1141
+
1142
+ # =========================================================================
1143
+ # Runs
1144
+ # =========================================================================
1145
+
1146
+ async def get_run(self, run_id: str) -> SchemaWatcherRunResponse | None:
1147
+ """Get a run by ID.
1148
+
1149
+ Args:
1150
+ run_id: Run ID.
1151
+
1152
+ Returns:
1153
+ Run response or None.
1154
+ """
1155
+ run = await self._session.get(SchemaWatcherRunModel, run_id)
1156
+ if not run:
1157
+ return None
1158
+
1159
+ source = await self._session.get(SourceModel, run.source_id)
1160
+ watcher = await self._session.get(SchemaWatcherModel, run.watcher_id)
1161
+
1162
+ return self._to_run_response(run, source, watcher)
1163
+
1164
+ async def list_runs(
1165
+ self,
1166
+ *,
1167
+ watcher_id: str | None = None,
1168
+ status: SchemaWatcherRunStatus | None = None,
1169
+ limit: int = 50,
1170
+ offset: int = 0,
1171
+ ) -> tuple[list[SchemaWatcherRunSummary], int]:
1172
+ """List runs with filters.
1173
+
1174
+ Args:
1175
+ watcher_id: Filter by watcher.
1176
+ status: Filter by status.
1177
+ limit: Maximum results.
1178
+ offset: Skip first N results.
1179
+
1180
+ Returns:
1181
+ Tuple of (runs, total_count).
1182
+ """
1183
+ stmt = select(SchemaWatcherRunModel)
1184
+
1185
+ if watcher_id:
1186
+ stmt = stmt.where(SchemaWatcherRunModel.watcher_id == watcher_id)
1187
+ if status:
1188
+ stmt = stmt.where(
1189
+ SchemaWatcherRunModel.status == DBRunStatus(status.value)
1190
+ )
1191
+
1192
+ # Count total
1193
+ count_stmt = select(func.count()).select_from(stmt.subquery())
1194
+ count_result = await self._session.execute(count_stmt)
1195
+ total = count_result.scalar() or 0
1196
+
1197
+ # Apply pagination
1198
+ stmt = (
1199
+ stmt.order_by(SchemaWatcherRunModel.started_at.desc())
1200
+ .limit(limit)
1201
+ .offset(offset)
1202
+ )
1203
+
1204
+ result = await self._session.execute(stmt)
1205
+ runs = result.scalars().all()
1206
+
1207
+ summaries = [self._to_run_summary(r) for r in runs]
1208
+ return summaries, total
1209
+
1210
+ # =========================================================================
1211
+ # Statistics
1212
+ # =========================================================================
1213
+
1214
+ async def get_statistics(self) -> SchemaWatcherStatistics:
1215
+ """Get overall statistics.
1216
+
1217
+ Returns:
1218
+ SchemaWatcherStatistics with aggregate metrics.
1219
+ """
1220
+ # Watcher counts
1221
+ watcher_counts = {}
1222
+ for status in DBStatus:
1223
+ stmt = select(func.count()).where(
1224
+ SchemaWatcherModel.status == status
1225
+ )
1226
+ result = await self._session.execute(stmt)
1227
+ watcher_counts[status.value] = result.scalar() or 0
1228
+
1229
+ # Alert counts
1230
+ alert_counts = {}
1231
+ for status in DBAlertStatus:
1232
+ stmt = select(func.count()).where(
1233
+ SchemaWatcherAlertModel.status == status
1234
+ )
1235
+ result = await self._session.execute(stmt)
1236
+ alert_counts[status.value] = result.scalar() or 0
1237
+
1238
+ # Run counts
1239
+ run_counts = {}
1240
+ for status in DBRunStatus:
1241
+ stmt = select(func.count()).where(
1242
+ SchemaWatcherRunModel.status == status
1243
+ )
1244
+ result = await self._session.execute(stmt)
1245
+ run_counts[status.value] = result.scalar() or 0
1246
+
1247
+ # Total changes
1248
+ stmt = select(func.sum(SchemaWatcherRunModel.changes_detected))
1249
+ result = await self._session.execute(stmt)
1250
+ total_changes = result.scalar() or 0
1251
+
1252
+ stmt = select(func.sum(SchemaWatcherRunModel.breaking_detected))
1253
+ result = await self._session.execute(stmt)
1254
+ total_breaking = result.scalar() or 0
1255
+
1256
+ # Calculate detection rate
1257
+ total_checks = sum(watcher_counts.values())
1258
+ total_with_changes = run_counts.get("completed", 0)
1259
+ detection_rate = (
1260
+ total_with_changes / total_checks if total_checks > 0 else 0.0
1261
+ )
1262
+
1263
+ return SchemaWatcherStatistics(
1264
+ total_watchers=sum(watcher_counts.values()),
1265
+ active_watchers=watcher_counts.get("active", 0),
1266
+ paused_watchers=watcher_counts.get("paused", 0),
1267
+ error_watchers=watcher_counts.get("error", 0),
1268
+ total_alerts=sum(alert_counts.values()),
1269
+ open_alerts=alert_counts.get("open", 0),
1270
+ acknowledged_alerts=alert_counts.get("acknowledged", 0),
1271
+ resolved_alerts=alert_counts.get("resolved", 0),
1272
+ total_runs=sum(run_counts.values()),
1273
+ successful_runs=run_counts.get("completed", 0),
1274
+ failed_runs=run_counts.get("failed", 0),
1275
+ total_changes_detected=total_changes,
1276
+ total_breaking_changes=total_breaking,
1277
+ avg_detection_rate=detection_rate,
1278
+ )
1279
+
1280
+ async def get_scheduler_status(self) -> SchemaWatcherSchedulerStatus:
1281
+ """Get scheduler status.
1282
+
1283
+ Returns:
1284
+ SchemaWatcherSchedulerStatus.
1285
+ """
1286
+ # Count active watchers
1287
+ stmt = select(func.count()).where(
1288
+ SchemaWatcherModel.status == DBStatus.ACTIVE
1289
+ )
1290
+ result = await self._session.execute(stmt)
1291
+ active_count = result.scalar() or 0
1292
+
1293
+ # Get next scheduled check
1294
+ stmt = (
1295
+ select(SchemaWatcherModel.next_check_at)
1296
+ .where(SchemaWatcherModel.status == DBStatus.ACTIVE)
1297
+ .where(SchemaWatcherModel.next_check_at.isnot(None))
1298
+ .order_by(SchemaWatcherModel.next_check_at.asc())
1299
+ .limit(1)
1300
+ )
1301
+ result = await self._session.execute(stmt)
1302
+ next_check = result.scalar_one_or_none()
1303
+
1304
+ # Count pending checks (next_check_at <= now)
1305
+ stmt = select(func.count()).where(
1306
+ SchemaWatcherModel.status == DBStatus.ACTIVE,
1307
+ SchemaWatcherModel.next_check_at <= datetime.utcnow(),
1308
+ )
1309
+ result = await self._session.execute(stmt)
1310
+ pending = result.scalar() or 0
1311
+
1312
+ return SchemaWatcherSchedulerStatus(
1313
+ is_running=active_count > 0,
1314
+ active_watchers=active_count,
1315
+ next_check_at=next_check,
1316
+ pending_checks=pending,
1317
+ )
1318
+
1319
+ # =========================================================================
1320
+ # Response Converters
1321
+ # =========================================================================
1322
+
1323
+ def _to_watcher_response(
1324
+ self,
1325
+ watcher: SchemaWatcherModel,
1326
+ source: SourceModel | None,
1327
+ ) -> SchemaWatcherResponse:
1328
+ """Convert watcher model to response."""
1329
+ return SchemaWatcherResponse(
1330
+ id=watcher.id,
1331
+ name=watcher.name,
1332
+ source_id=watcher.source_id,
1333
+ status=_map_db_status(watcher.status),
1334
+ poll_interval_seconds=watcher.poll_interval_seconds,
1335
+ only_breaking=watcher.only_breaking,
1336
+ enable_rename_detection=watcher.enable_rename_detection,
1337
+ rename_similarity_threshold=watcher.rename_similarity_threshold,
1338
+ version_strategy=VersionStrategy(watcher.version_strategy),
1339
+ notify_on_change=watcher.notify_on_change,
1340
+ track_history=watcher.track_history,
1341
+ last_check_at=watcher.last_check_at,
1342
+ last_change_at=watcher.last_change_at,
1343
+ next_check_at=watcher.next_check_at,
1344
+ check_count=watcher.check_count,
1345
+ change_count=watcher.change_count,
1346
+ error_count=watcher.error_count,
1347
+ last_error=watcher.last_error,
1348
+ config=watcher.watcher_config,
1349
+ is_active=watcher.is_active,
1350
+ is_healthy=watcher.is_healthy,
1351
+ detection_rate=watcher.detection_rate,
1352
+ source_name=source.name if source else None,
1353
+ created_at=watcher.created_at,
1354
+ updated_at=watcher.updated_at,
1355
+ )
1356
+
1357
+ def _to_watcher_summary(
1358
+ self,
1359
+ watcher: SchemaWatcherModel,
1360
+ source: SourceModel | None,
1361
+ ) -> SchemaWatcherSummary:
1362
+ """Convert watcher model to summary."""
1363
+ return SchemaWatcherSummary(
1364
+ id=watcher.id,
1365
+ name=watcher.name,
1366
+ source_id=watcher.source_id,
1367
+ source_name=source.name if source else None,
1368
+ status=_map_db_status(watcher.status),
1369
+ poll_interval_seconds=watcher.poll_interval_seconds,
1370
+ check_count=watcher.check_count,
1371
+ change_count=watcher.change_count,
1372
+ last_check_at=watcher.last_check_at,
1373
+ next_check_at=watcher.next_check_at,
1374
+ created_at=watcher.created_at,
1375
+ )
1376
+
1377
+ def _to_alert_response(
1378
+ self,
1379
+ alert: SchemaWatcherAlertModel,
1380
+ source: SourceModel | None,
1381
+ watcher: SchemaWatcherModel | None,
1382
+ ) -> SchemaWatcherAlertResponse:
1383
+ """Convert alert model to response."""
1384
+ # Calculate time metrics
1385
+ time_to_acknowledge = None
1386
+ time_to_resolve = None
1387
+ if alert.acknowledged_at and alert.created_at:
1388
+ time_to_acknowledge = (
1389
+ alert.acknowledged_at - alert.created_at
1390
+ ).total_seconds()
1391
+ if alert.resolved_at and alert.created_at:
1392
+ time_to_resolve = (
1393
+ alert.resolved_at - alert.created_at
1394
+ ).total_seconds()
1395
+
1396
+ return SchemaWatcherAlertResponse(
1397
+ id=alert.id,
1398
+ watcher_id=alert.watcher_id,
1399
+ source_id=alert.source_id,
1400
+ from_version_id=alert.from_version_id,
1401
+ to_version_id=alert.to_version_id,
1402
+ title=alert.title,
1403
+ severity=_map_db_alert_severity(alert.severity),
1404
+ status=_map_db_alert_status(alert.status),
1405
+ total_changes=alert.total_changes,
1406
+ breaking_changes=alert.breaking_changes,
1407
+ changes_summary=alert.changes_summary,
1408
+ impact_scope=ImpactScope(alert.impact_scope) if alert.impact_scope else None,
1409
+ affected_consumers=alert.affected_consumers,
1410
+ recommendations=alert.recommendations,
1411
+ acknowledged_at=alert.acknowledged_at,
1412
+ acknowledged_by=alert.acknowledged_by,
1413
+ resolved_at=alert.resolved_at,
1414
+ resolved_by=alert.resolved_by,
1415
+ resolution_notes=alert.resolution_notes,
1416
+ is_open=alert.is_open,
1417
+ has_breaking_changes=alert.has_breaking_changes,
1418
+ time_to_acknowledge=time_to_acknowledge,
1419
+ time_to_resolve=time_to_resolve,
1420
+ source_name=source.name if source else None,
1421
+ watcher_name=watcher.name if watcher else None,
1422
+ created_at=alert.created_at,
1423
+ updated_at=alert.updated_at,
1424
+ )
1425
+
1426
+ def _to_alert_summary(
1427
+ self,
1428
+ alert: SchemaWatcherAlertModel,
1429
+ source: SourceModel | None,
1430
+ ) -> SchemaWatcherAlertSummary:
1431
+ """Convert alert model to summary."""
1432
+ return SchemaWatcherAlertSummary(
1433
+ id=alert.id,
1434
+ watcher_id=alert.watcher_id,
1435
+ source_id=alert.source_id,
1436
+ title=alert.title,
1437
+ severity=_map_db_alert_severity(alert.severity),
1438
+ status=_map_db_alert_status(alert.status),
1439
+ total_changes=alert.total_changes,
1440
+ breaking_changes=alert.breaking_changes,
1441
+ created_at=alert.created_at,
1442
+ source_name=source.name if source else None,
1443
+ )
1444
+
1445
+ def _to_run_response(
1446
+ self,
1447
+ run: SchemaWatcherRunModel,
1448
+ source: SourceModel | None,
1449
+ watcher: SchemaWatcherModel | None,
1450
+ ) -> SchemaWatcherRunResponse:
1451
+ """Convert run model to response."""
1452
+ return SchemaWatcherRunResponse(
1453
+ id=run.id,
1454
+ watcher_id=run.watcher_id,
1455
+ source_id=run.source_id,
1456
+ started_at=run.started_at,
1457
+ completed_at=run.completed_at,
1458
+ status=_map_db_run_status(run.status),
1459
+ changes_detected=run.changes_detected,
1460
+ breaking_detected=run.breaking_detected,
1461
+ version_created_id=run.version_created_id,
1462
+ alert_created_id=run.alert_created_id,
1463
+ duration_ms=run.duration_ms,
1464
+ error_message=run.error_message,
1465
+ metadata=run.run_metadata,
1466
+ is_successful=run.is_successful,
1467
+ has_changes=run.has_changes,
1468
+ source_name=source.name if source else None,
1469
+ watcher_name=watcher.name if watcher else None,
1470
+ )
1471
+
1472
+ def _to_run_summary(
1473
+ self,
1474
+ run: SchemaWatcherRunModel,
1475
+ ) -> SchemaWatcherRunSummary:
1476
+ """Convert run model to summary."""
1477
+ return SchemaWatcherRunSummary(
1478
+ id=run.id,
1479
+ watcher_id=run.watcher_id,
1480
+ source_id=run.source_id,
1481
+ started_at=run.started_at,
1482
+ status=_map_db_run_status(run.status),
1483
+ changes_detected=run.changes_detected,
1484
+ breaking_detected=run.breaking_detected,
1485
+ duration_ms=run.duration_ms,
1486
+ )
1487
+
1488
+
1489
+ # =============================================================================
1490
+ # Background Processing
1491
+ # =============================================================================
1492
+
1493
+
1494
+ async def process_due_watchers(session: AsyncSession) -> int:
1495
+ """Process all watchers due for checking.
1496
+
1497
+ This function is called by the scheduler to run periodic checks.
1498
+
1499
+ Args:
1500
+ session: Database session.
1501
+
1502
+ Returns:
1503
+ Number of watchers processed.
1504
+ """
1505
+ service = SchemaWatcherService(session)
1506
+
1507
+ # Get due watchers
1508
+ stmt = (
1509
+ select(SchemaWatcherModel)
1510
+ .where(
1511
+ SchemaWatcherModel.status == DBStatus.ACTIVE,
1512
+ SchemaWatcherModel.next_check_at <= datetime.utcnow(),
1513
+ )
1514
+ .order_by(SchemaWatcherModel.next_check_at.asc())
1515
+ )
1516
+
1517
+ result = await session.execute(stmt)
1518
+ watchers = result.scalars().all()
1519
+
1520
+ processed = 0
1521
+ for watcher in watchers:
1522
+ try:
1523
+ await service.check_now(watcher.id)
1524
+ processed += 1
1525
+ except Exception as e:
1526
+ logger.error(f"Error processing watcher {watcher.id}: {e}")
1527
+
1528
+ return processed