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