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.
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)