offline-sync-sdk 0.2.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.
- offline_sync_sdk-0.2.0.dist-info/METADATA +447 -0
- offline_sync_sdk-0.2.0.dist-info/RECORD +22 -0
- offline_sync_sdk-0.2.0.dist-info/WHEEL +4 -0
- offline_sync_sdk-0.2.0.dist-info/entry_points.txt +3 -0
- sync_sdk/__init__.py +39 -0
- sync_sdk/adapters/__init__.py +1 -0
- sync_sdk/adapters/direct.py +15 -0
- sync_sdk/adapters/http.py +71 -0
- sync_sdk/adapters/mongodb.py +473 -0
- sync_sdk/adapters/opentelemetry.py +231 -0
- sync_sdk/adapters/sqlalchemy.py +691 -0
- sync_sdk/adapters/sqlalchemy_config.py +145 -0
- sync_sdk/client.py +198 -0
- sync_sdk/gateway.py +163 -0
- sync_sdk/integrations/__init__.py +1 -0
- sync_sdk/integrations/fastapi.py +32 -0
- sync_sdk/models.py +41 -0
- sync_sdk/ports.py +94 -0
- sync_sdk/py.typed +1 -0
- sync_sdk/registry.py +44 -0
- sync_sdk/store.py +147 -0
- sync_sdk/telemetry.py +131 -0
sync_sdk/registry.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Explicit opt-in entities and application-owned validation/conflict policy."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
from .models import Mutation
|
|
9
|
+
|
|
10
|
+
ConflictPolicy = Callable[[Mutation, int], str | None]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def optimistic_version(mutation: Mutation, current_version: int) -> str | None:
|
|
14
|
+
if mutation.base_version is not None and mutation.base_version != current_version:
|
|
15
|
+
return "Version mismatch"
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Entity:
|
|
21
|
+
name: str
|
|
22
|
+
schema: type[BaseModel] | None = None
|
|
23
|
+
conflict_policy: ConflictPolicy = optimistic_version
|
|
24
|
+
|
|
25
|
+
def validate(self, mutation: Mutation) -> Mutation:
|
|
26
|
+
if self.schema is None or mutation.operation == "delete":
|
|
27
|
+
return mutation
|
|
28
|
+
payload = self.schema.model_validate(mutation.payload).model_dump(mode="json")
|
|
29
|
+
return mutation.model_copy(update={"payload": payload})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EntityRegistry:
|
|
33
|
+
def __init__(self, entities: Iterable[Entity] = ()):
|
|
34
|
+
self._entities: dict[str, Entity] = {}
|
|
35
|
+
for entity in entities:
|
|
36
|
+
self.register(entity)
|
|
37
|
+
|
|
38
|
+
def register(self, entity: Entity) -> None:
|
|
39
|
+
if not entity.name or entity.name in self._entities:
|
|
40
|
+
raise ValueError("Entity names must be nonempty and unique")
|
|
41
|
+
self._entities[entity.name] = entity
|
|
42
|
+
|
|
43
|
+
def get(self, name: str) -> Entity | None:
|
|
44
|
+
return self._entities.get(name)
|
sync_sdk/store.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Durable local state. Transactions contain no network calls or await points."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .models import ChangeBatch, Mutation, MutationResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SQLiteStore:
|
|
11
|
+
"""Use one database per client and remote dataset; one sync worker per file."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, path: str | Path, *, client_id: str):
|
|
14
|
+
self.db = sqlite3.connect(str(path))
|
|
15
|
+
self.db.row_factory = sqlite3.Row
|
|
16
|
+
self.db.execute("PRAGMA journal_mode=WAL")
|
|
17
|
+
self.db.execute("PRAGMA synchronous=FULL")
|
|
18
|
+
self.db.executescript("""
|
|
19
|
+
CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
20
|
+
CREATE TABLE IF NOT EXISTS outbox (
|
|
21
|
+
position INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
22
|
+
mutation_id TEXT UNIQUE NOT NULL, body TEXT NOT NULL,
|
|
23
|
+
status TEXT NOT NULL DEFAULT 'pending', result TEXT
|
|
24
|
+
);
|
|
25
|
+
CREATE TABLE IF NOT EXISTS records (
|
|
26
|
+
entity TEXT NOT NULL, entity_id TEXT NOT NULL,
|
|
27
|
+
payload TEXT NOT NULL, version INTEGER NOT NULL,
|
|
28
|
+
PRIMARY KEY(entity, entity_id)
|
|
29
|
+
);
|
|
30
|
+
""")
|
|
31
|
+
with self.db:
|
|
32
|
+
self.db.execute(
|
|
33
|
+
"INSERT OR IGNORE INTO metadata VALUES ('client_id', ?)", (client_id,)
|
|
34
|
+
)
|
|
35
|
+
if self._get("client_id") != client_id:
|
|
36
|
+
self.db.close()
|
|
37
|
+
raise ValueError("Database belongs to a different client_id")
|
|
38
|
+
self.client_id = client_id
|
|
39
|
+
|
|
40
|
+
def _get(self, key: str, default: str = "0") -> str:
|
|
41
|
+
row = self.db.execute(
|
|
42
|
+
"SELECT value FROM metadata WHERE key=?", (key,)
|
|
43
|
+
).fetchone()
|
|
44
|
+
return row[0] if row else default
|
|
45
|
+
|
|
46
|
+
def _set(self, key: str, value: str) -> None:
|
|
47
|
+
self.db.execute("INSERT OR REPLACE INTO metadata VALUES (?, ?)", (key, value))
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def cursor(self) -> int:
|
|
51
|
+
return int(self._get("cursor"))
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def retry_state(self) -> tuple[int, float]:
|
|
55
|
+
return int(self._get("attempts")), float(self._get("retry_at"))
|
|
56
|
+
|
|
57
|
+
def schedule_retry(self, attempts: int, retry_at: float) -> None:
|
|
58
|
+
with self.db:
|
|
59
|
+
self._set("attempts", str(attempts))
|
|
60
|
+
self._set("retry_at", str(retry_at))
|
|
61
|
+
|
|
62
|
+
def enqueue(self, mutation: Mutation) -> None:
|
|
63
|
+
if mutation.client_id != self.client_id:
|
|
64
|
+
raise ValueError("Mutation client_id does not match database")
|
|
65
|
+
body = mutation.model_dump_json()
|
|
66
|
+
with self.db:
|
|
67
|
+
row = self.db.execute(
|
|
68
|
+
"SELECT body FROM outbox WHERE mutation_id=?",
|
|
69
|
+
(str(mutation.mutation_id),),
|
|
70
|
+
).fetchone()
|
|
71
|
+
if row and row[0] != body:
|
|
72
|
+
raise ValueError("Cannot reuse a mutation_id with different content")
|
|
73
|
+
self.db.execute(
|
|
74
|
+
"INSERT OR IGNORE INTO outbox (mutation_id, body) VALUES (?, ?)",
|
|
75
|
+
(str(mutation.mutation_id), body),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def pending(self, limit: int) -> list[Mutation]:
|
|
79
|
+
rows = self.db.execute(
|
|
80
|
+
"SELECT body FROM outbox WHERE status='pending' ORDER BY position LIMIT ?",
|
|
81
|
+
(limit,),
|
|
82
|
+
)
|
|
83
|
+
return [Mutation.model_validate_json(row[0]) for row in rows]
|
|
84
|
+
|
|
85
|
+
def pending_count(self) -> int:
|
|
86
|
+
row = self.db.execute(
|
|
87
|
+
"SELECT COUNT(*) FROM outbox WHERE status='pending'"
|
|
88
|
+
).fetchone()
|
|
89
|
+
return int(row[0])
|
|
90
|
+
|
|
91
|
+
def results(self) -> list[MutationResult]:
|
|
92
|
+
rows = self.db.execute(
|
|
93
|
+
"SELECT result FROM outbox WHERE result IS NOT NULL ORDER BY position"
|
|
94
|
+
)
|
|
95
|
+
return [MutationResult.model_validate_json(row[0]) for row in rows]
|
|
96
|
+
|
|
97
|
+
def acknowledge(self, results: list[MutationResult]) -> None:
|
|
98
|
+
with self.db:
|
|
99
|
+
for result in results:
|
|
100
|
+
self.db.execute(
|
|
101
|
+
"UPDATE outbox SET status=?, result=? WHERE mutation_id=? AND status='pending'",
|
|
102
|
+
(result.status, result.model_dump_json(), str(result.mutation_id)),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def apply_page(self, page: ChangeBatch) -> None:
|
|
106
|
+
"""Validate progress, then commit the entire page and checkpoint atomically."""
|
|
107
|
+
cursor = self.cursor
|
|
108
|
+
for change in page.changes:
|
|
109
|
+
if change.sequence <= cursor:
|
|
110
|
+
raise ValueError("Change sequences must strictly increase")
|
|
111
|
+
cursor = change.sequence
|
|
112
|
+
if page.cursor != cursor or (page.has_more and not page.changes):
|
|
113
|
+
raise ValueError("Invalid change feed checkpoint")
|
|
114
|
+
with self.db:
|
|
115
|
+
for change in page.changes:
|
|
116
|
+
if change.operation == "delete":
|
|
117
|
+
self.db.execute(
|
|
118
|
+
"DELETE FROM records WHERE entity=? AND entity_id=?",
|
|
119
|
+
(change.entity, change.entity_id),
|
|
120
|
+
)
|
|
121
|
+
else:
|
|
122
|
+
self.db.execute(
|
|
123
|
+
"INSERT OR REPLACE INTO records VALUES (?, ?, ?, ?)",
|
|
124
|
+
(
|
|
125
|
+
change.entity,
|
|
126
|
+
change.entity_id,
|
|
127
|
+
json.dumps(change.payload),
|
|
128
|
+
change.version,
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
self._set("cursor", str(page.cursor))
|
|
132
|
+
|
|
133
|
+
def get_record(self, entity: str, entity_id: str) -> dict | None:
|
|
134
|
+
row = self.db.execute(
|
|
135
|
+
"SELECT payload, version FROM records WHERE entity=? AND entity_id=?",
|
|
136
|
+
(entity, entity_id),
|
|
137
|
+
).fetchone()
|
|
138
|
+
return {"payload": json.loads(row[0]), "version": row[1]} if row else None
|
|
139
|
+
|
|
140
|
+
def close(self) -> None:
|
|
141
|
+
self.db.close()
|
|
142
|
+
|
|
143
|
+
def __enter__(self):
|
|
144
|
+
return self
|
|
145
|
+
|
|
146
|
+
def __exit__(self, *args):
|
|
147
|
+
self.close()
|
sync_sdk/telemetry.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Small observability interface used by the sync pipeline."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator, Mapping
|
|
4
|
+
from contextlib import AbstractContextManager, contextmanager, suppress
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
AttributeValue = str | bool | int | float
|
|
8
|
+
Attributes = Mapping[str, AttributeValue]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TelemetrySpan(Protocol):
|
|
12
|
+
def set_attribute(self, name: str, value: AttributeValue) -> None: ...
|
|
13
|
+
|
|
14
|
+
def add_event(self, name: str, attributes: Attributes | None = None) -> None: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PipelineTelemetry(Protocol):
|
|
18
|
+
"""Receives traces and metrics from the client and gateway."""
|
|
19
|
+
|
|
20
|
+
def span(
|
|
21
|
+
self, name: str, attributes: Attributes | None = None
|
|
22
|
+
) -> AbstractContextManager[TelemetrySpan]: ...
|
|
23
|
+
|
|
24
|
+
def count(
|
|
25
|
+
self, name: str, value: int = 1, attributes: Attributes | None = None
|
|
26
|
+
) -> None: ...
|
|
27
|
+
|
|
28
|
+
def record(
|
|
29
|
+
self, name: str, value: float, attributes: Attributes | None = None
|
|
30
|
+
) -> None: ...
|
|
31
|
+
|
|
32
|
+
def gauge(
|
|
33
|
+
self, name: str, value: float, attributes: Attributes | None = None
|
|
34
|
+
) -> None: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _NoOpSpan:
|
|
38
|
+
def set_attribute(self, name: str, value: AttributeValue) -> None:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
def add_event(self, name: str, attributes: Attributes | None = None) -> None:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _SafeSpan:
|
|
46
|
+
def __init__(self, span: TelemetrySpan):
|
|
47
|
+
self._span = span
|
|
48
|
+
|
|
49
|
+
def set_attribute(self, name: str, value: AttributeValue) -> None:
|
|
50
|
+
with suppress(Exception):
|
|
51
|
+
self._span.set_attribute(name, value)
|
|
52
|
+
|
|
53
|
+
def add_event(self, name: str, attributes: Attributes | None = None) -> None:
|
|
54
|
+
with suppress(Exception):
|
|
55
|
+
self._span.add_event(name, attributes)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class NoOpTelemetry:
|
|
59
|
+
"""Default telemetry target. It does no work and needs no extra package."""
|
|
60
|
+
|
|
61
|
+
@contextmanager
|
|
62
|
+
def span(
|
|
63
|
+
self, name: str, attributes: Attributes | None = None
|
|
64
|
+
) -> Iterator[_NoOpSpan]:
|
|
65
|
+
yield _NoOpSpan()
|
|
66
|
+
|
|
67
|
+
def count(
|
|
68
|
+
self, name: str, value: int = 1, attributes: Attributes | None = None
|
|
69
|
+
) -> None:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
def record(
|
|
73
|
+
self, name: str, value: float, attributes: Attributes | None = None
|
|
74
|
+
) -> None:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
def gauge(
|
|
78
|
+
self, name: str, value: float, attributes: Attributes | None = None
|
|
79
|
+
) -> None:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SafeTelemetry:
|
|
84
|
+
"""Stops monitoring errors from stopping data sync."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, telemetry: PipelineTelemetry | None = None):
|
|
87
|
+
self._telemetry = telemetry or NoOpTelemetry()
|
|
88
|
+
|
|
89
|
+
@contextmanager
|
|
90
|
+
def span(
|
|
91
|
+
self, name: str, attributes: Attributes | None = None
|
|
92
|
+
) -> Iterator[TelemetrySpan]:
|
|
93
|
+
try:
|
|
94
|
+
manager = self._telemetry.span(name, attributes)
|
|
95
|
+
span = manager.__enter__()
|
|
96
|
+
except Exception: # noqa: BLE001 -- monitoring must not stop sync
|
|
97
|
+
yield _NoOpSpan()
|
|
98
|
+
return
|
|
99
|
+
try:
|
|
100
|
+
yield _SafeSpan(span)
|
|
101
|
+
except BaseException as error:
|
|
102
|
+
with suppress(Exception):
|
|
103
|
+
manager.__exit__(type(error), error, error.__traceback__)
|
|
104
|
+
raise
|
|
105
|
+
else:
|
|
106
|
+
with suppress(Exception):
|
|
107
|
+
manager.__exit__(None, None, None)
|
|
108
|
+
|
|
109
|
+
def count(
|
|
110
|
+
self, name: str, value: int = 1, attributes: Attributes | None = None
|
|
111
|
+
) -> None:
|
|
112
|
+
with suppress(Exception):
|
|
113
|
+
self._telemetry.count(name, value, attributes)
|
|
114
|
+
|
|
115
|
+
def record(
|
|
116
|
+
self, name: str, value: float, attributes: Attributes | None = None
|
|
117
|
+
) -> None:
|
|
118
|
+
with suppress(Exception):
|
|
119
|
+
self._telemetry.record(name, value, attributes)
|
|
120
|
+
|
|
121
|
+
def gauge(
|
|
122
|
+
self, name: str, value: float, attributes: Attributes | None = None
|
|
123
|
+
) -> None:
|
|
124
|
+
with suppress(Exception):
|
|
125
|
+
self._telemetry.gauge(name, value, attributes)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def safe_telemetry(telemetry: PipelineTelemetry | None) -> SafeTelemetry:
|
|
129
|
+
if isinstance(telemetry, SafeTelemetry):
|
|
130
|
+
return telemetry
|
|
131
|
+
return SafeTelemetry(telemetry)
|