topos-node 0.1.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 (249) hide show
  1. shared/__init__.py +59 -0
  2. shared/filtering.py +640 -0
  3. shared/schema_registry.py +229 -0
  4. topos/__init__.py +5 -0
  5. topos/__version__.py +6 -0
  6. topos/analytics/__init__.py +15 -0
  7. topos/analytics/duckdb_adapter.py +48 -0
  8. topos/analytics/messenger_communities.py +349 -0
  9. topos/analytics/messenger_graph.py +522 -0
  10. topos/analytics/messenger_labels.py +321 -0
  11. topos/analytics/profiles.py +22 -0
  12. topos/analytics/query_engine.py +64 -0
  13. topos/analytics/raw_queries.py +174 -0
  14. topos/api/__init__.py +1 -0
  15. topos/api/analytics.py +52 -0
  16. topos/api/app_registry.py +31 -0
  17. topos/api/backup.py +15 -0
  18. topos/api/compute_remote.py +175 -0
  19. topos/api/data_commit.py +158 -0
  20. topos/api/data_explorer_table_prefs.py +81 -0
  21. topos/api/db.py +10 -0
  22. topos/api/device.py +25 -0
  23. topos/api/enrichment.py +959 -0
  24. topos/api/filter_lab.py +195 -0
  25. topos/api/health.py +61 -0
  26. topos/api/ingestion_api.py +37 -0
  27. topos/api/ingestion_compat.py +21 -0
  28. topos/api/ingestion_sources.py +600 -0
  29. topos/api/llm.py +76 -0
  30. topos/api/local_mcp.py +46 -0
  31. topos/api/messenger_analytics.py +385 -0
  32. topos/api/query_api.py +13 -0
  33. topos/api/sanitization_ollama_config.py +64 -0
  34. topos/api/source_install.py +324 -0
  35. topos/api/sources.py +13 -0
  36. topos/api/sync.py +10 -0
  37. topos/api/ui_config.py +83 -0
  38. topos/api/uma_data.py +311 -0
  39. topos/api/usage.py +49 -0
  40. topos/api/user_identity.py +46 -0
  41. topos/app.py +239 -0
  42. topos/auth.py +17 -0
  43. topos/canonicalization/__init__.py +1 -0
  44. topos/canonicalization/mappers/__init__.py +22 -0
  45. topos/canonicalization/mappers/base.py +26 -0
  46. topos/canonicalization/mappers/chatgpt_mapper.py +40 -0
  47. topos/canonicalization/mappers/grok_mapper.py +17 -0
  48. topos/canonicalization/mappers/messenger_mapper.py +58 -0
  49. topos/canonicalization/models.py +31 -0
  50. topos/canonicalization/resolver.py +23 -0
  51. topos/cli/__init__.py +1 -0
  52. topos/cli/__main__.py +6 -0
  53. topos/cli/commands.py +132 -0
  54. topos/config/__init__.py +1 -0
  55. topos/config/sanitization_ollama.py +189 -0
  56. topos/config/settings.py +310 -0
  57. topos/contacts/__init__.py +5 -0
  58. topos/contacts/identity.py +24 -0
  59. topos/control_plane_client.py +300 -0
  60. topos/core/__init__.py +1 -0
  61. topos/core/api_models.py +128 -0
  62. topos/core/connection_resilience.py +99 -0
  63. topos/core/device_helpers.py +8 -0
  64. topos/core/errors.py +13 -0
  65. topos/core/events.py +12 -0
  66. topos/core/handlers.py +5625 -0
  67. topos/core/logging.py +175 -0
  68. topos/core/metrics.py +21 -0
  69. topos/core/startup_banner.py +62 -0
  70. topos/core/state.py +682 -0
  71. topos/core/table_layers.py +45 -0
  72. topos/core/types.py +13 -0
  73. topos/data_explorer_table_prefs.py +150 -0
  74. topos/engine/__init__.py +29 -0
  75. topos/engine/backends/__init__.py +50 -0
  76. topos/engine/backends/base.py +21 -0
  77. topos/engine/backends/huggingface.py +151 -0
  78. topos/engine/backends/ollama.py +181 -0
  79. topos/engine/backends/stub.py +22 -0
  80. topos/engine/engine.py +165 -0
  81. topos/engine/intake.py +32 -0
  82. topos/engine/queue_manager.py +112 -0
  83. topos/engine/registration.py +126 -0
  84. topos/engine/result_formatter.py +38 -0
  85. topos/engine/router.py +19 -0
  86. topos/engine/scoped_token.py +82 -0
  87. topos/engine/tasks.py +154 -0
  88. topos/engine/transport.py +44 -0
  89. topos/engine/usage_guard.py +100 -0
  90. topos/engine/usage_observation.py +129 -0
  91. topos/engine/validator.py +23 -0
  92. topos/enrichment/__init__.py +1 -0
  93. topos/enrichment/derived_tables.py +214 -0
  94. topos/enrichment/jobs/__init__.py +30 -0
  95. topos/enrichment/jobs/base.py +54 -0
  96. topos/enrichment/jobs/canonical/__init__.py +1 -0
  97. topos/enrichment/jobs/canonical/embeddings_job.py +27 -0
  98. topos/enrichment/jobs/canonical/emo_27_job.py +97 -0
  99. topos/enrichment/jobs/canonical/entities_job.py +27 -0
  100. topos/enrichment/jobs/canonical/sentiment_job.py +27 -0
  101. topos/enrichment/jobs/canonical/topics_job.py +27 -0
  102. topos/enrichment/jobs/raw/__init__.py +1 -0
  103. topos/enrichment/jobs/raw/attachments_job.py +12 -0
  104. topos/enrichment/jobs/raw/language_job.py +12 -0
  105. topos/enrichment/jobs/raw/time_normalization_job.py +12 -0
  106. topos/enrichment/jobs/raw/tool_calls_job.py +12 -0
  107. topos/enrichment/models/__init__.py +1 -0
  108. topos/enrichment/models/manager.py +8 -0
  109. topos/enrichment/models/registry.py +71 -0
  110. topos/enrichment/models/versioning.py +8 -0
  111. topos/enrichment/orchestrator.py +177 -0
  112. topos/enrichment/processor.py +17 -0
  113. topos/enrichment/progress_bar.py +122 -0
  114. topos/enrichment/website_classifier.py +31 -0
  115. topos/filter_lab/__init__.py +1 -0
  116. topos/filter_lab/bundles.py +300 -0
  117. topos/filter_lab/schema.py +86 -0
  118. topos/filter_lab/service.py +167 -0
  119. topos/filter_lab/store.py +374 -0
  120. topos/filter_lab/worker.py +250 -0
  121. topos/hosted_pool_lease.py +153 -0
  122. topos/ingestion/__init__.py +1 -0
  123. topos/ingestion/checkpoints/__init__.py +6 -0
  124. topos/ingestion/checkpoints/checkpoint_store.py +24 -0
  125. topos/ingestion/checkpoints/sqlite_checkpoint_store.py +82 -0
  126. topos/ingestion/ingest_helpers.py +504 -0
  127. topos/ingestion/jobs.py +91 -0
  128. topos/ingestion/local_sync.py +823 -0
  129. topos/ingestion/log_preview.py +21 -0
  130. topos/ingestion/manager.py +1100 -0
  131. topos/ingestion/parser.py +174 -0
  132. topos/ingestion/parsers/__init__.py +32 -0
  133. topos/ingestion/parsers/base.py +24 -0
  134. topos/ingestion/parsers/browser_parser.py +171 -0
  135. topos/ingestion/parsers/calendar_parser.py +21 -0
  136. topos/ingestion/parsers/chatgpt_conversation_flattener.py +266 -0
  137. topos/ingestion/parsers/chatgpt_parser.py +67 -0
  138. topos/ingestion/parsers/grok_parser.py +21 -0
  139. topos/ingestion/parsers/messenger_parser.py +97 -0
  140. topos/ingestion/progress.py +54 -0
  141. topos/ingestion/sources/__init__.py +20 -0
  142. topos/ingestion/sources/base.py +39 -0
  143. topos/ingestion/sources/calendar.py +29 -0
  144. topos/ingestion/sources/chatgpt.py +29 -0
  145. topos/ingestion/sources/contact_importers.py +274 -0
  146. topos/ingestion/sources/grok.py +29 -0
  147. topos/ingestion/sources/imessage_reader.py +479 -0
  148. topos/ingestion/sources/signal_export_parser.py +132 -0
  149. topos/ingestion/sources/signal_reader.py +491 -0
  150. topos/ingestion/state_machine.py +70 -0
  151. topos/ingestion/triggers/__init__.py +1 -0
  152. topos/ingestion/triggers/file_trigger.py +36 -0
  153. topos/ingestion/triggers/sqlite_trigger.py +18 -0
  154. topos/ingestion/validation/__init__.py +1 -0
  155. topos/ingestion/validation/base.py +27 -0
  156. topos/ingestion/validation/schema_registry.py +111 -0
  157. topos/ingestion/validation/schema_validator.py +13 -0
  158. topos/lineage/__init__.py +1 -0
  159. topos/lineage/provenance.py +9 -0
  160. topos/lineage/tracker.py +9 -0
  161. topos/mcp_stdio_proxy.py +83 -0
  162. topos/observability/__init__.py +1 -0
  163. topos/observability/alerts.py +7 -0
  164. topos/observability/metrics.py +25 -0
  165. topos/observability/tracing.py +18 -0
  166. topos/openai_client.py +69 -0
  167. topos/projections/__init__.py +1 -0
  168. topos/projections/vector_index/__init__.py +1 -0
  169. topos/projections/vector_index/base.py +21 -0
  170. topos/projections/vector_index/builders.py +11 -0
  171. topos/projections/vector_index/health_checks.py +5 -0
  172. topos/rate_limit.py +43 -0
  173. topos/sanitization/__init__.py +16 -0
  174. topos/sanitization/ollama_transforms.py +276 -0
  175. topos/scope_resolution.py +89 -0
  176. topos/services/__init__.py +1 -0
  177. topos/services/container.py +46 -0
  178. topos/services/embeddings/__init__.py +1 -0
  179. topos/services/embeddings/base.py +7 -0
  180. topos/services/embeddings/local.py +9 -0
  181. topos/services/embeddings/remote.py +9 -0
  182. topos/services/interfaces.py +40 -0
  183. topos/services/llm/__init__.py +1 -0
  184. topos/services/llm/base.py +7 -0
  185. topos/services/llm/openai.py +126 -0
  186. topos/services/local.py +123 -0
  187. topos/services/postgres.py +385 -0
  188. topos/sources/__init__.py +6 -0
  189. topos/sources/definitions.py +114 -0
  190. topos/sources/install_service.py +836 -0
  191. topos/sources/registry.py +263 -0
  192. topos/sources/runtime_install.py +427 -0
  193. topos/storage/__init__.py +1 -0
  194. topos/storage/canonical/__init__.py +18 -0
  195. topos/storage/canonical/ai_chat/__init__.py +22 -0
  196. topos/storage/canonical/ai_chat/canonicalizer.py +147 -0
  197. topos/storage/canonical/ai_chat/mapper.py +168 -0
  198. topos/storage/canonical/ai_chat/model.py +87 -0
  199. topos/storage/canonical/ai_chat/tables.py +179 -0
  200. topos/storage/canonical/canonical_store.py +24 -0
  201. topos/storage/canonical/conversations_tables.py +1020 -0
  202. topos/storage/canonical/mapping_store.py +30 -0
  203. topos/storage/canonical/postgres.py +10 -0
  204. topos/storage/db/__init__.py +1 -0
  205. topos/storage/db/client.py +8 -0
  206. topos/storage/db/migrations/__init__.py +1 -0
  207. topos/storage/db/migrations/stage9_column_renames.py +78 -0
  208. topos/storage/db/paths.py +122 -0
  209. topos/storage/db/postgres.py +240 -0
  210. topos/storage/db/schema.py +6 -0
  211. topos/storage/enrichment/__init__.py +1 -0
  212. topos/storage/enrichment/canonical_enrichment_store.py +7 -0
  213. topos/storage/enrichment/raw_enrichment_store.py +18 -0
  214. topos/storage/normalized/__init__.py +1 -0
  215. topos/storage/normalized/normalized_store.py +24 -0
  216. topos/storage/oplog/__init__.py +1 -0
  217. topos/storage/oplog/decision.py +6 -0
  218. topos/storage/oplog/oplog_store.py +17 -0
  219. topos/storage/oplog/postgres.py +10 -0
  220. topos/storage/projections/__init__.py +1 -0
  221. topos/storage/projections/index_ops_store.py +6 -0
  222. topos/storage/projections/vector_index_store.py +6 -0
  223. topos/storage/raw/__init__.py +1 -0
  224. topos/storage/raw/browser_flat_tables.py +303 -0
  225. topos/storage/raw/file_store.py +100 -0
  226. topos/storage/raw/raw_store.py +29 -0
  227. topos/storage/raw/raw_tables_manager.py +295 -0
  228. topos/storage/raw/sqlite_raw_store.py +17 -0
  229. topos/storage/security/encryption.py +21 -0
  230. topos/storage/signal_identity.py +71 -0
  231. topos/storage/source_settings.py +116 -0
  232. topos/storage/user_identity.py +69 -0
  233. topos/sync/__init__.py +5 -0
  234. topos/sync/client.py +272 -0
  235. topos/sync_handlers.py +70 -0
  236. topos/testing/__init__.py +1 -0
  237. topos/testing/lifespan.py +7 -0
  238. topos/uma_contact_enrichment.py +1032 -0
  239. topos/uma_filters.py +669 -0
  240. topos/uma_resource_id.py +24 -0
  241. topos/uma_rpt.py +69 -0
  242. topos/utils/base_object.py +61 -0
  243. topos/websocket_client.py +21 -0
  244. topos_node-0.1.0.dist-info/METADATA +199 -0
  245. topos_node-0.1.0.dist-info/RECORD +249 -0
  246. topos_node-0.1.0.dist-info/WHEEL +5 -0
  247. topos_node-0.1.0.dist-info/entry_points.txt +2 -0
  248. topos_node-0.1.0.dist-info/licenses/LICENSE +201 -0
  249. topos_node-0.1.0.dist-info/top_level.txt +2 -0
topos/sync/client.py ADDED
@@ -0,0 +1,272 @@
1
+ """Sync client for connecting to control plane sync relay."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import json
8
+ import logging
9
+ import ssl
10
+ from typing import Any, Callable, Dict, Optional
11
+
12
+ import certifi
13
+ from websockets.asyncio.client import connect
14
+ from websockets.exceptions import ConnectionClosed
15
+
16
+ from ..config.settings import settings
17
+ from ..core.connection_resilience import (
18
+ ConnectionSnapshot,
19
+ ConnectionState,
20
+ ExponentialBackoff,
21
+ FailureCategory,
22
+ ResilienceConfig,
23
+ classify_connection_error,
24
+ is_fatal_connection_category,
25
+ utc_now_iso,
26
+ )
27
+
28
+ logger = logging.getLogger("topos.sync")
29
+
30
+
31
+ class SyncClient:
32
+ """Client for syncing encrypted ops with the control plane relay."""
33
+
34
+ def __init__(
35
+ self,
36
+ sync_url: str,
37
+ api_key: str,
38
+ user_id: str,
39
+ dataset_id: str,
40
+ on_op_received: Callable[[Dict[str, Any]], Any],
41
+ verify_ssl: bool = True,
42
+ ):
43
+ self.sync_url = sync_url
44
+ self.api_key = api_key
45
+ self.user_id = user_id
46
+ self.dataset_id = dataset_id
47
+ self.on_op_received = on_op_received
48
+ self.verify_ssl = verify_ssl
49
+
50
+ self._ws = None
51
+ self._task: Optional[asyncio.Task] = None
52
+ self._stop = asyncio.Event()
53
+ self._connected = False
54
+ self._last_op_ts: Optional[str] = None
55
+ self._state: ConnectionState = "idle"
56
+ self._state_changed_at: str | None = utc_now_iso()
57
+ self._last_connected_at: str | None = None
58
+ self._last_disconnected_at: str | None = None
59
+ self._last_failure_category: FailureCategory = "none"
60
+ self._last_failure_reason: str = ""
61
+ self._attempt = 0
62
+ self._consecutive_failures = 0
63
+ self._ready = asyncio.Event()
64
+ self._backoff = ExponentialBackoff(
65
+ ResilienceConfig(
66
+ initial_backoff_s=max(0.1, float(settings.connection_retry_initial_seconds)),
67
+ max_backoff_s=max(1.0, float(settings.connection_retry_max_seconds)),
68
+ jitter_ratio=max(0.0, float(settings.connection_retry_jitter_ratio)),
69
+ )
70
+ )
71
+
72
+ def _set_state(self, state: ConnectionState) -> None:
73
+ if self._state == state:
74
+ return
75
+ self._state = state
76
+ self._state_changed_at = utc_now_iso()
77
+
78
+ def get_connection_status(self) -> dict[str, Any]:
79
+ snapshot = ConnectionSnapshot(
80
+ state=self._state,
81
+ connected=self._connected,
82
+ attempt=self._attempt,
83
+ consecutive_failures=self._consecutive_failures,
84
+ last_failure_category=self._last_failure_category,
85
+ last_failure_reason=self._last_failure_reason,
86
+ last_state_change_at=self._state_changed_at,
87
+ last_connected_at=self._last_connected_at,
88
+ last_disconnected_at=self._last_disconnected_at,
89
+ )
90
+ return snapshot.to_dict()
91
+
92
+ def start(self) -> None:
93
+ if self._task and not self._task.done():
94
+ logger.warning("Sync client already started")
95
+ return
96
+ self._stop.clear()
97
+ self._task = asyncio.create_task(self._run())
98
+ logger.info("Sync client started")
99
+
100
+ async def wait_until_connected(self, timeout_s: float | None = None) -> bool:
101
+ timeout = float(timeout_s) if timeout_s is not None else float(settings.connection_readiness_timeout_seconds)
102
+ try:
103
+ await asyncio.wait_for(self._ready.wait(), timeout=max(0.1, timeout))
104
+ return True
105
+ except TimeoutError:
106
+ return False
107
+
108
+ async def stop(self) -> None:
109
+ self._set_state("stopping")
110
+ self._stop.set()
111
+ if self._ws:
112
+ await self._ws.close()
113
+ if self._task:
114
+ try:
115
+ await asyncio.wait_for(self._task, timeout=5.0)
116
+ except asyncio.TimeoutError:
117
+ self._task.cancel()
118
+ try:
119
+ await self._task
120
+ except asyncio.CancelledError:
121
+ pass
122
+ self._connected = False
123
+ self._ready.clear()
124
+ self._set_state("idle")
125
+ logger.info("Sync client stopped")
126
+
127
+ async def _run(self) -> None:
128
+ headers = {"Authorization": f"Bearer {self.api_key}"}
129
+ ssl_context = None
130
+ if self.sync_url.startswith("wss://"):
131
+ ssl_context = ssl.create_default_context(cafile=certifi.where())
132
+ if not self.verify_ssl:
133
+ ssl_context.check_hostname = False
134
+ ssl_context.verify_mode = ssl.CERT_NONE
135
+
136
+ while not self._stop.is_set():
137
+ self._set_state("connecting")
138
+ self._attempt += 1
139
+ try:
140
+ async with connect(self.sync_url, additional_headers=headers, ssl=ssl_context) as ws:
141
+ self._ws = ws
142
+ self._connected = True
143
+ self._set_state("connected")
144
+ self._ready.set()
145
+ self._backoff.reset()
146
+ self._last_failure_category = "none"
147
+ self._last_failure_reason = ""
148
+ self._consecutive_failures = 0
149
+ self._last_connected_at = utc_now_iso()
150
+ logger.info("Sync client connected to relay")
151
+
152
+ await self._send_connect()
153
+
154
+ async for raw in ws:
155
+ if self._stop.is_set():
156
+ break
157
+ try:
158
+ data = json.loads(raw)
159
+ await self._handle_message(data)
160
+ except Exception as exc: # noqa: BLE001
161
+ logger.warning("Failed to handle sync message: %s", exc)
162
+ except ConnectionClosed as exc:
163
+ self._record_failure(exc)
164
+ except Exception as exc: # noqa: BLE001
165
+ self._record_failure(exc)
166
+ finally:
167
+ self._ws = None
168
+ self._connected = False
169
+ self._ready.clear()
170
+ if not self._stop.is_set() and self._state != "stopping":
171
+ self._last_disconnected_at = utc_now_iso()
172
+ if self._stop.is_set():
173
+ break
174
+ delay = self._backoff.next_delay()
175
+ self._set_state("degraded" if is_fatal_connection_category(self._last_failure_category) else "backing_off")
176
+ logger.warning(
177
+ "Sync reconnect scheduled state=%s attempt=%d failures=%d category=%s delay_s=%.2f reason=%s",
178
+ self._state,
179
+ self._attempt,
180
+ self._consecutive_failures,
181
+ self._last_failure_category,
182
+ delay,
183
+ self._last_failure_reason,
184
+ )
185
+ await self._wait_for_stop_or_timeout(delay)
186
+ self._set_state("idle")
187
+
188
+ def _record_failure(self, exc: BaseException) -> None:
189
+ category, reason = classify_connection_error(exc)
190
+ self._last_failure_category = category
191
+ self._last_failure_reason = reason
192
+ self._consecutive_failures += 1
193
+ if not self._stop.is_set():
194
+ logger.warning(
195
+ "Sync connection failed category=%s failures=%d reason=%s",
196
+ category,
197
+ self._consecutive_failures,
198
+ reason,
199
+ )
200
+
201
+ async def _wait_for_stop_or_timeout(self, timeout_s: float) -> None:
202
+ try:
203
+ await asyncio.wait_for(self._stop.wait(), timeout=timeout_s)
204
+ except TimeoutError:
205
+ return
206
+
207
+ async def _send_connect(self) -> None:
208
+ message = {
209
+ "type": "sync_connect",
210
+ "user_id": self.user_id,
211
+ "dataset_id": self.dataset_id,
212
+ "last_op_ts": self._last_op_ts,
213
+ }
214
+ await self._ws.send(json.dumps(message))
215
+ logger.debug("Sent sync_connect for user: %s, dataset: %s", self.user_id, self.dataset_id)
216
+
217
+ async def _handle_message(self, data: Dict[str, Any]) -> None:
218
+ msg_type = data.get("type")
219
+
220
+ if msg_type == "sync_connected":
221
+ logger.info("Sync connected for dataset: %s", data.get("dataset_id"))
222
+ elif msg_type == "sync_op":
223
+ op = data.get("op")
224
+ if op:
225
+ result = self.on_op_received(op)
226
+ if asyncio.iscoroutine(result):
227
+ await result
228
+ self._last_op_ts = op.get("hlc_ts")
229
+ try:
230
+ await self._send_json_with_retry({"type": "sync_cursor", "op": op})
231
+ except Exception as exc: # noqa: BLE001
232
+ logger.debug("Failed to send sync_cursor: %s", exc)
233
+ elif msg_type == "sync_ack":
234
+ logger.debug("Op acknowledged: %s", data.get("op_id"))
235
+ elif msg_type == "error":
236
+ logger.error("Sync error: %s", data.get("error"))
237
+
238
+ async def _send_json_with_retry(self, payload: Dict[str, Any]) -> None:
239
+ attempts = max(1, int(settings.sync_cursor_retry_attempts))
240
+ delay = max(0.0, float(settings.sync_cursor_retry_delay_seconds))
241
+ last_exc: Exception | None = None
242
+ for attempt in range(1, attempts + 1):
243
+ if not self._ws:
244
+ break
245
+ try:
246
+ await self._ws.send(json.dumps(payload))
247
+ return
248
+ except Exception as exc: # noqa: BLE001
249
+ last_exc = exc
250
+ if attempt < attempts:
251
+ await asyncio.sleep(delay)
252
+ if last_exc:
253
+ raise last_exc
254
+
255
+ async def send_op(self, op: Dict[str, Any]) -> None:
256
+ if not self._connected or not self._ws:
257
+ logger.warning("Sync client not connected, cannot send op")
258
+ return
259
+
260
+ op_copy = op.copy()
261
+ if "ciphertext" in op_copy and isinstance(op_copy["ciphertext"], bytes):
262
+ op_copy["ciphertext"] = base64.b64encode(op_copy["ciphertext"]).decode("utf-8")
263
+
264
+ message = {"type": "sync_op", "op": op_copy}
265
+ try:
266
+ await self._send_json_with_retry(message)
267
+ logger.debug("Sent op to relay: %s", op.get("op_id", "unknown")[:8])
268
+ except Exception as exc: # noqa: BLE001
269
+ logger.error("Failed to send op to relay: %s", exc)
270
+
271
+ def is_connected(self) -> bool:
272
+ return self._connected
topos/sync_handlers.py ADDED
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import logging
5
+ from datetime import datetime, timezone
6
+ from typing import Any, Dict
7
+
8
+ from .core import state
9
+
10
+ logger = logging.getLogger("topos.sync_handlers")
11
+
12
+
13
+ async def handle_sync_op(op: Dict[str, Any]) -> None:
14
+ if not state.oplog_manager or not state.projection_manager or not state.db_conn:
15
+ logger.error("Database not initialized, cannot handle sync op")
16
+ return
17
+
18
+ op_id = op.get("op_id")
19
+ if not op_id:
20
+ logger.warning("Received sync op without op_id")
21
+ return
22
+
23
+ existing = state.oplog_manager.get_op(op_id) if state.oplog_manager else None
24
+ if existing:
25
+ state.set_engine_config_value(state.db_conn, "last_sync_at", datetime.now(timezone.utc).isoformat())
26
+ state.set_engine_config_value(state.db_conn, "last_received_hlc_ts", op.get("hlc_ts", ""))
27
+ state.set_engine_config_value(state.db_conn, "last_received_op_id", op_id)
28
+ return
29
+
30
+ ciphertext_b64 = op.get("ciphertext", "")
31
+ if isinstance(ciphertext_b64, str):
32
+ try:
33
+ ciphertext = base64.b64decode(ciphertext_b64)
34
+ except Exception as exc:
35
+ logger.error("Failed to decode ciphertext for op %s: %s", op_id[:8], exc)
36
+ return
37
+ else:
38
+ ciphertext = ciphertext_b64
39
+
40
+ try:
41
+ state.db_conn.execute(
42
+ """
43
+ INSERT OR IGNORE INTO oplog (
44
+ op_id, dataset_id, actor_id, hlc_ts,
45
+ protocol_version, op_type, payload_version, crypto_version, ciphertext
46
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
47
+ """,
48
+ (
49
+ op.get("op_id"),
50
+ op.get("dataset_id"),
51
+ op.get("actor_id"),
52
+ op.get("hlc_ts"),
53
+ op.get("protocol_version", 1),
54
+ op.get("op_type"),
55
+ op.get("payload_version", 1),
56
+ op.get("crypto_version", 1),
57
+ ciphertext,
58
+ ),
59
+ )
60
+ state.db_conn.commit()
61
+
62
+ decrypted_op = state.oplog_manager.get_op(op_id) if state.oplog_manager else None
63
+ if decrypted_op and state.projection_manager:
64
+ state.projection_manager.apply_op(decrypted_op)
65
+ state.set_engine_config_value(state.db_conn, "last_sync_at", datetime.now(timezone.utc).isoformat())
66
+ state.set_engine_config_value(state.db_conn, "last_received_hlc_ts", op.get("hlc_ts", ""))
67
+ state.set_engine_config_value(state.db_conn, "last_received_op_id", op_id)
68
+ except Exception as exc:
69
+ logger.error("Failed to apply sync op %s: %s", op_id[:8], exc)
70
+ state.db_conn.rollback()
@@ -0,0 +1 @@
1
+ """Testing helpers for Topos."""
@@ -0,0 +1,7 @@
1
+ """ASGI lifespan helper for FastAPI tests (compat across FastAPI/Starlette versions)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from asgi_lifespan import LifespanManager
6
+
7
+ __all__ = ["LifespanManager"]