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
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Application configuration for SQL schemas and their sync registrations."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, create_model
|
|
7
|
+
from sqlalchemy import JSON, Connection, MetaData, Table
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
|
9
|
+
|
|
10
|
+
from ..gateway import SyncGateway
|
|
11
|
+
from ..registry import ConflictPolicy, Entity, EntityRegistry, optimistic_version
|
|
12
|
+
from ..telemetry import PipelineTelemetry
|
|
13
|
+
from .sqlalchemy import (
|
|
14
|
+
SQLAlchemyChangeCapture,
|
|
15
|
+
SQLAlchemyStore,
|
|
16
|
+
TableBinding,
|
|
17
|
+
initialize_sync_metadata,
|
|
18
|
+
sync_metadata,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SQLAlchemyConfig:
|
|
23
|
+
"""Declare participating tables once; create them only when explicitly asked.
|
|
24
|
+
|
|
25
|
+
Native SQLAlchemy tables retain application-defined columns, indexes, foreign
|
|
26
|
+
keys, constraints, and defaults. Registration and build() perform no I/O.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._bindings: dict[str, TableBinding] = {}
|
|
31
|
+
self._entities: dict[str, Entity] = {}
|
|
32
|
+
|
|
33
|
+
def register_table(
|
|
34
|
+
self,
|
|
35
|
+
name: str,
|
|
36
|
+
table: Table,
|
|
37
|
+
*,
|
|
38
|
+
fields: Mapping[str, str] | None = None,
|
|
39
|
+
schema: type[BaseModel] | None = None,
|
|
40
|
+
key_parsers: Mapping[str, Callable[[str], Any]] | None = None,
|
|
41
|
+
value_decoders: Mapping[str, Callable[[Any], Any]] | None = None,
|
|
42
|
+
conflict_policy: ConflictPolicy = optimistic_version,
|
|
43
|
+
) -> TableBinding:
|
|
44
|
+
"""Register a new or existing table and return its key encoder.
|
|
45
|
+
|
|
46
|
+
Omit fields to sync every non-primary-key column, or explicitly select a
|
|
47
|
+
subset. schema is application-owned validation (including defaults).
|
|
48
|
+
"""
|
|
49
|
+
if not name or len(name) > 191 or name in self._bindings:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
"Entity names must be unique and contain 1 to 191 characters"
|
|
52
|
+
)
|
|
53
|
+
if table.name in sync_metadata.tables:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"Sync metadata tables cannot be registered as business data"
|
|
56
|
+
)
|
|
57
|
+
if any(
|
|
58
|
+
binding.table.fullname == table.fullname
|
|
59
|
+
for binding in self._bindings.values()
|
|
60
|
+
):
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"Register a physical table once to preserve one version history per row"
|
|
63
|
+
)
|
|
64
|
+
if fields is None:
|
|
65
|
+
fields = {
|
|
66
|
+
column.name: column.name
|
|
67
|
+
for column in table.columns
|
|
68
|
+
if not column.primary_key
|
|
69
|
+
}
|
|
70
|
+
binding = TableBinding(
|
|
71
|
+
table, fields=fields, key_parsers=key_parsers, value_decoders=value_decoders
|
|
72
|
+
)
|
|
73
|
+
if schema is None:
|
|
74
|
+
schema = self._payload_schema(name, table, fields)
|
|
75
|
+
self._bindings[name] = binding
|
|
76
|
+
self._entities[name] = Entity(name, schema, conflict_policy)
|
|
77
|
+
return binding
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _payload_schema(
|
|
81
|
+
name: str, table: Table, fields: Mapping[str, str]
|
|
82
|
+
) -> type[BaseModel]:
|
|
83
|
+
definitions: dict[str, Any] = {}
|
|
84
|
+
for field, column_name in fields.items():
|
|
85
|
+
column = table.c[column_name]
|
|
86
|
+
annotation: Any = (
|
|
87
|
+
Any if isinstance(column.type, JSON) else column.type.python_type
|
|
88
|
+
)
|
|
89
|
+
default: Any = ...
|
|
90
|
+
if column.nullable:
|
|
91
|
+
annotation = annotation | None
|
|
92
|
+
if column.default is not None and column.default.is_scalar:
|
|
93
|
+
default = cast(Any, column.default).arg
|
|
94
|
+
definitions[field] = (annotation, default)
|
|
95
|
+
return create_model(
|
|
96
|
+
f"{name}Payload", __config__=ConfigDict(extra="forbid"), **definitions
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def build(
|
|
100
|
+
self,
|
|
101
|
+
session_factory: Callable[[], AsyncSession],
|
|
102
|
+
*,
|
|
103
|
+
telemetry: PipelineTelemetry | None = None,
|
|
104
|
+
) -> SyncGateway:
|
|
105
|
+
"""Snapshot this configuration into an embeddable gateway."""
|
|
106
|
+
return SyncGateway(
|
|
107
|
+
SQLAlchemyStore(session_factory, self._bindings, telemetry=telemetry),
|
|
108
|
+
EntityRegistry(self._entities.values()),
|
|
109
|
+
telemetry=telemetry,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def build_change_capture(
|
|
113
|
+
self, session_factory: Callable[[], AsyncSession]
|
|
114
|
+
) -> SQLAlchemyChangeCapture:
|
|
115
|
+
"""Build a polling worker for writes made outside the sync gateway."""
|
|
116
|
+
normalizers = {
|
|
117
|
+
name: (
|
|
118
|
+
lambda payload, schema=entity.schema: schema.model_validate(
|
|
119
|
+
payload
|
|
120
|
+
).model_dump(mode="json")
|
|
121
|
+
)
|
|
122
|
+
for name, entity in self._entities.items()
|
|
123
|
+
if entity.schema is not None
|
|
124
|
+
}
|
|
125
|
+
return SQLAlchemyChangeCapture(session_factory, self._bindings, normalizers)
|
|
126
|
+
|
|
127
|
+
async def create_tables(self, engine: AsyncEngine) -> None:
|
|
128
|
+
"""Explicit setup: create registered business tables and sync metadata.
|
|
129
|
+
|
|
130
|
+
Existing tables are left intact. This is not schema migration, ALTER,
|
|
131
|
+
backfill, or change capture. Use application migrations for schema changes.
|
|
132
|
+
Foreign-key prerequisites must be registered or already exist. Run setup
|
|
133
|
+
once before serving traffic, rather than from concurrent workers.
|
|
134
|
+
"""
|
|
135
|
+
tables = [binding.table for binding in self._bindings.values()]
|
|
136
|
+
async with engine.begin() as connection:
|
|
137
|
+
await connection.run_sync(self._create_tables, tables)
|
|
138
|
+
await initialize_sync_metadata(engine)
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _create_tables(connection: Connection, tables: list[Table]) -> None:
|
|
142
|
+
# Native create_all handles dependency ordering and deferred ALTER FKs.
|
|
143
|
+
# Its explicit tables argument prevents creation of unregistered tables.
|
|
144
|
+
metadata = tables[0].metadata if tables else MetaData()
|
|
145
|
+
metadata.create_all(connection, tables=tables, checkfirst=True)
|
sync_sdk/client.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Bounded sync cycles and an optional reconnect loop."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import random
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import TYPE_CHECKING, Literal
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from .models import Mutation
|
|
13
|
+
from .ports import LocalStore, RetryableError, SyncTransport
|
|
14
|
+
from .telemetry import PipelineTelemetry, safe_telemetry
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class SyncReport:
|
|
22
|
+
pushed: int = 0
|
|
23
|
+
pulled: int = 0
|
|
24
|
+
retry_at: float | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SyncClient:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
store: LocalStore,
|
|
31
|
+
http: "httpx.AsyncClient | None" = None,
|
|
32
|
+
*,
|
|
33
|
+
transport: SyncTransport | None = None,
|
|
34
|
+
batch_size: int = 100,
|
|
35
|
+
max_batches: int = 10,
|
|
36
|
+
backoff_base: float = 1,
|
|
37
|
+
backoff_cap: float = 60,
|
|
38
|
+
clock: Callable[[], float] = time.time,
|
|
39
|
+
jitter: Callable[[], float] = random.random,
|
|
40
|
+
telemetry: PipelineTelemetry | None = None,
|
|
41
|
+
):
|
|
42
|
+
if not 1 <= batch_size <= 1000 or max_batches < 1:
|
|
43
|
+
raise ValueError("Invalid batch limits")
|
|
44
|
+
if backoff_base <= 0 or backoff_cap < backoff_base:
|
|
45
|
+
raise ValueError("Invalid backoff limits")
|
|
46
|
+
if (http is None) == (transport is None):
|
|
47
|
+
raise ValueError("Provide exactly one of transport or http")
|
|
48
|
+
if http is not None:
|
|
49
|
+
from .adapters.http import HTTPTransport
|
|
50
|
+
|
|
51
|
+
transport = HTTPTransport(http, clock=clock)
|
|
52
|
+
assert transport is not None
|
|
53
|
+
self.store, self.transport = store, transport
|
|
54
|
+
self.batch_size, self.max_batches = batch_size, max_batches
|
|
55
|
+
self.backoff_base, self.backoff_cap = backoff_base, backoff_cap
|
|
56
|
+
self.clock, self.jitter = clock, jitter
|
|
57
|
+
self.telemetry = safe_telemetry(telemetry)
|
|
58
|
+
self._lock = asyncio.Lock()
|
|
59
|
+
|
|
60
|
+
def enqueue(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
entity: str,
|
|
64
|
+
entity_id: str,
|
|
65
|
+
operation: Literal["create", "update", "delete"],
|
|
66
|
+
payload: dict | None = None,
|
|
67
|
+
base_version: int | None = None,
|
|
68
|
+
) -> Mutation:
|
|
69
|
+
mutation = Mutation(
|
|
70
|
+
mutation_id=uuid4(),
|
|
71
|
+
client_id=self.store.client_id,
|
|
72
|
+
entity=entity,
|
|
73
|
+
entity_id=entity_id,
|
|
74
|
+
operation=operation,
|
|
75
|
+
payload=payload or {},
|
|
76
|
+
base_version=base_version,
|
|
77
|
+
created_at=datetime.now(UTC),
|
|
78
|
+
)
|
|
79
|
+
self.store.enqueue(mutation)
|
|
80
|
+
self.telemetry.gauge("sync.client.queue.depth", self.store.pending_count())
|
|
81
|
+
return mutation
|
|
82
|
+
|
|
83
|
+
async def sync_once(self) -> SyncReport:
|
|
84
|
+
"""Push and pull bounded batches; save retry time after connection failures."""
|
|
85
|
+
async with self._lock:
|
|
86
|
+
started = time.perf_counter()
|
|
87
|
+
queue_depth = self.store.pending_count()
|
|
88
|
+
self.telemetry.gauge("sync.client.queue.depth", queue_depth)
|
|
89
|
+
with self.telemetry.span(
|
|
90
|
+
"sync.client.sync", {"sync.queue.depth": queue_depth}
|
|
91
|
+
) as sync_span:
|
|
92
|
+
attempts, retry_at = self.store.retry_state
|
|
93
|
+
pushed = pulled = 0
|
|
94
|
+
outcome = "ok"
|
|
95
|
+
try:
|
|
96
|
+
if retry_at > self.clock():
|
|
97
|
+
outcome = "waiting"
|
|
98
|
+
sync_span.set_attribute("sync.retry.at", retry_at)
|
|
99
|
+
return SyncReport(retry_at=retry_at)
|
|
100
|
+
|
|
101
|
+
for _ in range(self.max_batches):
|
|
102
|
+
batch = self.store.pending(self.batch_size)
|
|
103
|
+
if not batch:
|
|
104
|
+
break
|
|
105
|
+
with self.telemetry.span(
|
|
106
|
+
"sync.client.push", {"sync.batch.size": len(batch)}
|
|
107
|
+
):
|
|
108
|
+
results = await self.transport.push(batch)
|
|
109
|
+
if len(results) != len(batch) or {
|
|
110
|
+
result.mutation_id for result in results
|
|
111
|
+
} != {mutation.mutation_id for mutation in batch}:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
"Mutation acknowledgements do not match submitted batch"
|
|
114
|
+
)
|
|
115
|
+
self.store.acknowledge(results)
|
|
116
|
+
pushed += len(results)
|
|
117
|
+
for result in results:
|
|
118
|
+
self.telemetry.count(
|
|
119
|
+
"sync.client.mutations",
|
|
120
|
+
attributes={"sync.mutation.status": result.status},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
for _ in range(self.max_batches):
|
|
124
|
+
with self.telemetry.span(
|
|
125
|
+
"sync.client.pull", {"sync.cursor": self.store.cursor}
|
|
126
|
+
):
|
|
127
|
+
page = await self.transport.pull(
|
|
128
|
+
self.store.cursor, self.batch_size
|
|
129
|
+
)
|
|
130
|
+
self.store.apply_page(page)
|
|
131
|
+
pulled += len(page.changes)
|
|
132
|
+
self.telemetry.count(
|
|
133
|
+
"sync.client.changes.pulled", len(page.changes)
|
|
134
|
+
)
|
|
135
|
+
if not page.has_more:
|
|
136
|
+
break
|
|
137
|
+
except RetryableError as error:
|
|
138
|
+
outcome = "retry"
|
|
139
|
+
ceiling = min(
|
|
140
|
+
self.backoff_cap, self.backoff_base * 2 ** min(attempts, 30)
|
|
141
|
+
)
|
|
142
|
+
delay = max(
|
|
143
|
+
error.retry_after, ceiling * (0.5 + 0.5 * self.jitter())
|
|
144
|
+
)
|
|
145
|
+
retry_at = self.clock() + delay
|
|
146
|
+
self.store.schedule_retry(attempts + 1, retry_at)
|
|
147
|
+
failure_attributes = {"sync.failure.type": error.reason}
|
|
148
|
+
self.telemetry.count(
|
|
149
|
+
"sync.client.transport.failures",
|
|
150
|
+
attributes=failure_attributes,
|
|
151
|
+
)
|
|
152
|
+
if error.reason == "connection":
|
|
153
|
+
self.telemetry.count("sync.client.connection.failures")
|
|
154
|
+
self.telemetry.count(
|
|
155
|
+
"sync.client.retries", attributes=failure_attributes
|
|
156
|
+
)
|
|
157
|
+
self.telemetry.record("sync.client.retry.delay", delay)
|
|
158
|
+
sync_span.add_event(
|
|
159
|
+
"sync.retry.scheduled",
|
|
160
|
+
{
|
|
161
|
+
"sync.retry.attempt": attempts + 1,
|
|
162
|
+
"sync.retry.delay": delay,
|
|
163
|
+
"sync.failure.type": error.reason,
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
return SyncReport(pushed, pulled, retry_at)
|
|
167
|
+
except Exception:
|
|
168
|
+
outcome = "error"
|
|
169
|
+
self.telemetry.count("sync.client.failures")
|
|
170
|
+
raise
|
|
171
|
+
else:
|
|
172
|
+
self.store.schedule_retry(0, 0)
|
|
173
|
+
return SyncReport(pushed, pulled)
|
|
174
|
+
finally:
|
|
175
|
+
depth = self.store.pending_count()
|
|
176
|
+
self.telemetry.gauge("sync.client.queue.depth", depth)
|
|
177
|
+
self.telemetry.record(
|
|
178
|
+
"sync.client.sync.duration",
|
|
179
|
+
(time.perf_counter() - started) * 1000,
|
|
180
|
+
{"sync.outcome": outcome},
|
|
181
|
+
)
|
|
182
|
+
sync_span.set_attribute("sync.outcome", outcome)
|
|
183
|
+
sync_span.set_attribute("sync.mutations.pushed", pushed)
|
|
184
|
+
sync_span.set_attribute("sync.changes.pulled", pulled)
|
|
185
|
+
sync_span.set_attribute("sync.queue.depth", depth)
|
|
186
|
+
|
|
187
|
+
async def run(self, *, poll_interval: float = 1) -> None:
|
|
188
|
+
"""Reconnect until cancelled; the caller owns HTTP and SQLite lifetimes."""
|
|
189
|
+
if poll_interval <= 0:
|
|
190
|
+
raise ValueError("poll_interval must be positive")
|
|
191
|
+
while True:
|
|
192
|
+
report = await self.sync_once()
|
|
193
|
+
delay = (
|
|
194
|
+
max(0, report.retry_at - self.clock())
|
|
195
|
+
if report.retry_at is not None
|
|
196
|
+
else poll_interval
|
|
197
|
+
)
|
|
198
|
+
await asyncio.sleep(delay)
|
sync_sdk/gateway.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Reusable mutation pipeline with no database or web framework imports."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from pydantic import ValidationError
|
|
6
|
+
|
|
7
|
+
from .models import ChangeBatch, Mutation, MutationResult
|
|
8
|
+
from .ports import GatewayStore, GatewayTransaction, MutationRejected
|
|
9
|
+
from .registry import EntityRegistry
|
|
10
|
+
from .telemetry import PipelineTelemetry, safe_telemetry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SyncGateway:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
store: GatewayStore,
|
|
17
|
+
entities: EntityRegistry,
|
|
18
|
+
*,
|
|
19
|
+
telemetry: PipelineTelemetry | None = None,
|
|
20
|
+
):
|
|
21
|
+
self.store, self.entities = store, entities
|
|
22
|
+
self.telemetry = safe_telemetry(telemetry)
|
|
23
|
+
|
|
24
|
+
async def apply_mutations(self, mutations: list[Mutation]) -> list[MutationResult]:
|
|
25
|
+
started = time.perf_counter()
|
|
26
|
+
results: list[MutationResult] = []
|
|
27
|
+
outcome = "ok"
|
|
28
|
+
with self.telemetry.span(
|
|
29
|
+
"sync.gateway.apply", {"sync.batch.size": len(mutations)}
|
|
30
|
+
) as span:
|
|
31
|
+
try:
|
|
32
|
+
async with self.store.transaction() as transaction:
|
|
33
|
+
for mutation in mutations:
|
|
34
|
+
result, is_new = await self._apply_mutation(
|
|
35
|
+
transaction, mutation
|
|
36
|
+
)
|
|
37
|
+
if is_new:
|
|
38
|
+
await transaction.save_result(mutation, result)
|
|
39
|
+
results.append(result)
|
|
40
|
+
except Exception:
|
|
41
|
+
outcome = "error"
|
|
42
|
+
self.telemetry.count("sync.gateway.batch.failures")
|
|
43
|
+
raise
|
|
44
|
+
finally:
|
|
45
|
+
self.telemetry.record(
|
|
46
|
+
"sync.gateway.batch.duration",
|
|
47
|
+
(time.perf_counter() - started) * 1000,
|
|
48
|
+
{"sync.outcome": outcome},
|
|
49
|
+
)
|
|
50
|
+
span.set_attribute("sync.outcome", outcome)
|
|
51
|
+
|
|
52
|
+
for mutation, result in zip(mutations, results, strict=True):
|
|
53
|
+
attributes = {
|
|
54
|
+
"sync.entity": mutation.entity,
|
|
55
|
+
"sync.operation": mutation.operation,
|
|
56
|
+
"sync.mutation.status": result.status,
|
|
57
|
+
}
|
|
58
|
+
self.telemetry.count("sync.gateway.mutations", attributes=attributes)
|
|
59
|
+
span.add_event("sync.mutation.result", attributes)
|
|
60
|
+
if result.status == "conflict":
|
|
61
|
+
self.telemetry.count(
|
|
62
|
+
"sync.gateway.conflicts",
|
|
63
|
+
attributes={"sync.entity": mutation.entity},
|
|
64
|
+
)
|
|
65
|
+
elif result.status == "rejected":
|
|
66
|
+
self.telemetry.count(
|
|
67
|
+
"sync.gateway.failed_mutations",
|
|
68
|
+
attributes={
|
|
69
|
+
"sync.entity": mutation.entity,
|
|
70
|
+
"sync.mutation.status": result.status,
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
return results
|
|
74
|
+
|
|
75
|
+
async def _apply_mutation(
|
|
76
|
+
self, transaction: GatewayTransaction, mutation: Mutation
|
|
77
|
+
) -> tuple[MutationResult, bool]:
|
|
78
|
+
existing = await transaction.get_result(mutation.mutation_id)
|
|
79
|
+
if existing is not None:
|
|
80
|
+
return existing, False
|
|
81
|
+
|
|
82
|
+
entity = self.entities.get(mutation.entity)
|
|
83
|
+
rejection = None
|
|
84
|
+
if entity is None:
|
|
85
|
+
rejection = "Entity is not registered"
|
|
86
|
+
else:
|
|
87
|
+
try:
|
|
88
|
+
mutation = entity.validate(mutation)
|
|
89
|
+
except ValidationError:
|
|
90
|
+
rejection = "Payload failed entity validation"
|
|
91
|
+
|
|
92
|
+
if rejection:
|
|
93
|
+
return (
|
|
94
|
+
MutationResult(
|
|
95
|
+
mutation_id=mutation.mutation_id,
|
|
96
|
+
status="rejected",
|
|
97
|
+
message=rejection,
|
|
98
|
+
),
|
|
99
|
+
True,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
assert entity is not None
|
|
103
|
+
version = await transaction.get_version(mutation.entity, mutation.entity_id)
|
|
104
|
+
conflict = entity.conflict_policy(mutation, version)
|
|
105
|
+
if conflict:
|
|
106
|
+
return (
|
|
107
|
+
MutationResult(
|
|
108
|
+
mutation_id=mutation.mutation_id,
|
|
109
|
+
status="conflict",
|
|
110
|
+
server_version=version,
|
|
111
|
+
message=conflict,
|
|
112
|
+
),
|
|
113
|
+
True,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
version += 1
|
|
117
|
+
try:
|
|
118
|
+
await transaction.write(mutation, version)
|
|
119
|
+
except MutationRejected as error:
|
|
120
|
+
return (
|
|
121
|
+
MutationResult(
|
|
122
|
+
mutation_id=mutation.mutation_id,
|
|
123
|
+
status="rejected",
|
|
124
|
+
message=str(error),
|
|
125
|
+
),
|
|
126
|
+
True,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
await transaction.append_change(mutation, version)
|
|
130
|
+
return (
|
|
131
|
+
MutationResult(
|
|
132
|
+
mutation_id=mutation.mutation_id,
|
|
133
|
+
status="applied",
|
|
134
|
+
server_version=version,
|
|
135
|
+
),
|
|
136
|
+
True,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
async def get_changes(self, cursor: int, limit: int = 100) -> ChangeBatch:
|
|
140
|
+
if cursor < 0 or not 1 <= limit <= 1000:
|
|
141
|
+
raise ValueError("Invalid cursor or page limit")
|
|
142
|
+
|
|
143
|
+
started = time.perf_counter()
|
|
144
|
+
with self.telemetry.span(
|
|
145
|
+
"sync.gateway.pull", {"sync.cursor": cursor, "sync.page.limit": limit}
|
|
146
|
+
) as span:
|
|
147
|
+
try:
|
|
148
|
+
page = await self.store.get_changes(cursor, limit)
|
|
149
|
+
except Exception:
|
|
150
|
+
self.telemetry.count("sync.gateway.pull.failures")
|
|
151
|
+
raise
|
|
152
|
+
finally:
|
|
153
|
+
self.telemetry.record(
|
|
154
|
+
"sync.gateway.pull.duration",
|
|
155
|
+
(time.perf_counter() - started) * 1000,
|
|
156
|
+
)
|
|
157
|
+
span.set_attribute("sync.changes.returned", len(page.changes))
|
|
158
|
+
span.set_attribute("sync.has_more", page.has_more)
|
|
159
|
+
self.telemetry.count("sync.gateway.changes.returned", len(page.changes))
|
|
160
|
+
return page
|
|
161
|
+
|
|
162
|
+
async def health_check(self) -> bool:
|
|
163
|
+
return await self.store.health_check()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional application framework integrations."""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Mount sync routes in a developer's existing FastAPI application."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, Query
|
|
7
|
+
|
|
8
|
+
from ..models import ChangeBatch, Mutation, MutationResult
|
|
9
|
+
from ..ports import RemoteStore
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_sync_router(
|
|
13
|
+
get_gateway: Callable[..., RemoteStore], *, prefix: str = "/v1/sync"
|
|
14
|
+
) -> APIRouter:
|
|
15
|
+
"""Dependency providers may apply application authentication/tenant scoping."""
|
|
16
|
+
router = APIRouter(prefix=prefix, tags=["sync"])
|
|
17
|
+
|
|
18
|
+
@router.post("/mutations", response_model=list[MutationResult])
|
|
19
|
+
async def push(
|
|
20
|
+
mutations: list[Mutation], gateway: Annotated[RemoteStore, Depends(get_gateway)]
|
|
21
|
+
):
|
|
22
|
+
return await gateway.apply_mutations(mutations)
|
|
23
|
+
|
|
24
|
+
@router.get("/changes", response_model=ChangeBatch)
|
|
25
|
+
async def pull(
|
|
26
|
+
gateway: Annotated[RemoteStore, Depends(get_gateway)],
|
|
27
|
+
cursor: int = Query(0, ge=0),
|
|
28
|
+
limit: int = Query(100, ge=1, le=1000),
|
|
29
|
+
):
|
|
30
|
+
return await gateway.get_changes(cursor, limit)
|
|
31
|
+
|
|
32
|
+
return router
|
sync_sdk/models.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Wire contracts shared by the SDK and gateway."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Mutation(BaseModel):
|
|
11
|
+
mutation_id: UUID
|
|
12
|
+
client_id: str
|
|
13
|
+
entity: str
|
|
14
|
+
entity_id: str
|
|
15
|
+
operation: Literal["create", "update", "delete"]
|
|
16
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
17
|
+
base_version: int | None = None
|
|
18
|
+
created_at: datetime
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MutationResult(BaseModel):
|
|
22
|
+
mutation_id: UUID
|
|
23
|
+
status: Literal["applied", "conflict", "rejected"]
|
|
24
|
+
server_version: int | None = None
|
|
25
|
+
message: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Change(BaseModel):
|
|
29
|
+
sequence: int
|
|
30
|
+
entity: str
|
|
31
|
+
entity_id: str
|
|
32
|
+
operation: Literal["create", "update", "delete"]
|
|
33
|
+
payload: dict[str, Any]
|
|
34
|
+
version: int
|
|
35
|
+
created_at: datetime
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ChangeBatch(BaseModel):
|
|
39
|
+
cursor: int
|
|
40
|
+
has_more: bool
|
|
41
|
+
changes: list[Change]
|
sync_sdk/ports.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Database and framework independent contracts for sync integrations."""
|
|
2
|
+
|
|
3
|
+
from contextlib import AbstractAsyncContextManager
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
from uuid import UUID
|
|
6
|
+
|
|
7
|
+
from .models import ChangeBatch, Mutation, MutationResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MutationRejected(Exception):
|
|
11
|
+
"""Adapter validation failed BEFORE any writes for this mutation.
|
|
12
|
+
|
|
13
|
+
A binding may use this for invalid keys/payloads. It must never raise this
|
|
14
|
+
after changing data; operational failures must abort the transaction instead.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RetryableError(Exception):
|
|
19
|
+
"""A transport can be retried without changing mutation IDs."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
message: str,
|
|
24
|
+
*,
|
|
25
|
+
retry_after: float = 0,
|
|
26
|
+
reason: str = "transient",
|
|
27
|
+
):
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.retry_after = retry_after
|
|
30
|
+
self.reason = reason
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SyncTransport(Protocol):
|
|
34
|
+
async def push(self, mutations: list[Mutation]) -> list[MutationResult]: ...
|
|
35
|
+
async def pull(self, cursor: int, limit: int) -> ChangeBatch: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LocalStore(Protocol):
|
|
39
|
+
"""Single-worker durable store; all mutating methods commit before returning.
|
|
40
|
+
|
|
41
|
+
apply_page MUST atomically apply records/deletes and advance the cursor.
|
|
42
|
+
acknowledge MUST atomically persist the whole batch of results. Pending work
|
|
43
|
+
survives process interruption and retains immutable mutation IDs and bodies.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
client_id: str
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def cursor(self) -> int: ...
|
|
50
|
+
@property
|
|
51
|
+
def retry_state(self) -> tuple[int, float]: ...
|
|
52
|
+
def enqueue(self, mutation: Mutation) -> None: ...
|
|
53
|
+
def pending(self, limit: int) -> list[Mutation]: ...
|
|
54
|
+
def pending_count(self) -> int: ...
|
|
55
|
+
def acknowledge(self, results: list[MutationResult]) -> None: ...
|
|
56
|
+
def apply_page(self, page: ChangeBatch) -> None: ...
|
|
57
|
+
def schedule_retry(self, attempts: int, retry_at: float) -> None: ...
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class GatewayTransaction(Protocol):
|
|
61
|
+
"""One atomic, serialized gateway batch, including developer-owned data.
|
|
62
|
+
|
|
63
|
+
The adapter must prevent concurrent duplicate IDs/version races. Versions
|
|
64
|
+
survive deletion. Changes have strictly increasing, commit-ordered sequences:
|
|
65
|
+
a later cursor must never hide a change that commits subsequently. Aborting
|
|
66
|
+
or cancellation rolls back entity writes, versions, changes AND results.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
async def get_result(self, mutation_id: UUID) -> MutationResult | None: ...
|
|
70
|
+
async def get_version(self, entity: str, entity_id: str) -> int: ...
|
|
71
|
+
async def write(self, mutation: Mutation, version: int) -> None:
|
|
72
|
+
"""Write business data and its sync version in this transaction."""
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
async def append_change(self, mutation: Mutation, version: int) -> None: ...
|
|
76
|
+
async def save_result(self, mutation: Mutation, result: MutationResult) -> None: ...
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class GatewayStore(Protocol):
|
|
80
|
+
"""SQL/NoSQL adapter. No connection creation or schema creation in the engine."""
|
|
81
|
+
|
|
82
|
+
def transaction(self) -> AbstractAsyncContextManager[GatewayTransaction]: ...
|
|
83
|
+
async def get_changes(self, cursor: int, limit: int) -> ChangeBatch: ...
|
|
84
|
+
async def health_check(self) -> bool: ...
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class RemoteStore(Protocol):
|
|
88
|
+
"""Service boundary used by HTTP, direct in-process, or other integrations."""
|
|
89
|
+
|
|
90
|
+
async def apply_mutations(
|
|
91
|
+
self, mutations: list[Mutation]
|
|
92
|
+
) -> list[MutationResult]: ...
|
|
93
|
+
async def get_changes(self, cursor: int, limit: int = 100) -> ChangeBatch: ...
|
|
94
|
+
async def health_check(self) -> bool: ...
|
sync_sdk/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561.
|