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,691 @@
1
+ """SQLAlchemy adapter for developer-owned tables and SDK-owned sync metadata.
2
+
3
+ All participating tables must use the same transactional database. A singleton
4
+ metadata row serializes gateway writes, including sequence allocation, so a pull
5
+ cursor can never skip a late-committing change. This prioritizes correctness over
6
+ write throughput. Schema initialization is an explicit application/setup action.
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import time
12
+ from collections.abc import Callable, Mapping
13
+ from contextlib import asynccontextmanager
14
+ from dataclasses import dataclass
15
+ from datetime import UTC, datetime
16
+ from typing import Any, Protocol
17
+ from uuid import UUID
18
+
19
+ from pydantic import TypeAdapter, ValidationError
20
+ from sqlalchemy import (
21
+ JSON,
22
+ Column,
23
+ DateTime,
24
+ Integer,
25
+ MetaData,
26
+ String,
27
+ Table,
28
+ and_,
29
+ delete,
30
+ insert,
31
+ select,
32
+ text,
33
+ update,
34
+ )
35
+ from sqlalchemy.exc import SQLAlchemyError
36
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
37
+
38
+ from ..models import Change, ChangeBatch, Mutation, MutationResult
39
+ from ..ports import MutationRejected
40
+ from ..telemetry import PipelineTelemetry, safe_telemetry
41
+
42
+ sync_metadata = MetaData()
43
+ clock = Table(
44
+ "sync_clock",
45
+ sync_metadata,
46
+ Column("id", Integer, primary_key=True),
47
+ Column("sequence", Integer, nullable=False),
48
+ )
49
+ versions = Table(
50
+ "sync_versions",
51
+ sync_metadata,
52
+ Column("entity", String(191), primary_key=True),
53
+ Column("entity_id", String(191), primary_key=True),
54
+ Column("version", Integer, nullable=False),
55
+ )
56
+ processed = Table(
57
+ "sync_processed_mutations",
58
+ sync_metadata,
59
+ Column("mutation_id", String(36), primary_key=True),
60
+ Column("client_id", String(191), nullable=False),
61
+ Column("result", JSON, nullable=False),
62
+ Column("processed_at", DateTime(timezone=True), nullable=False),
63
+ )
64
+ changes = Table(
65
+ "sync_change_log",
66
+ sync_metadata,
67
+ Column("sequence", Integer, primary_key=True, autoincrement=False),
68
+ Column("entity", String(191), nullable=False),
69
+ Column("entity_id", String(191), nullable=False),
70
+ Column("operation", String(16), nullable=False),
71
+ Column("payload", JSON, nullable=False),
72
+ Column("version", Integer, nullable=False),
73
+ Column("created_at", DateTime(timezone=True), nullable=False),
74
+ )
75
+ capture_checkpoints = Table(
76
+ "sync_capture_checkpoints",
77
+ sync_metadata,
78
+ Column("entity", String(191), primary_key=True),
79
+ Column("scanned_at", DateTime(timezone=True), nullable=False),
80
+ )
81
+ captured_rows = Table(
82
+ "sync_captured_rows",
83
+ sync_metadata,
84
+ Column("entity", String(191), primary_key=True),
85
+ Column("entity_id", String(191), primary_key=True),
86
+ Column("payload", JSON, nullable=False),
87
+ )
88
+
89
+
90
+ async def initialize_sync_metadata(engine: AsyncEngine) -> None:
91
+ """Create only SDK tables and the lock row. Run once before serving traffic.
92
+
93
+ Production applications may include sync_metadata in their own migrations.
94
+ This function never creates or alters business tables.
95
+ """
96
+ async with engine.begin() as connection:
97
+ await connection.run_sync(sync_metadata.create_all)
98
+ if (await connection.scalar(select(clock.c.id).where(clock.c.id == 1))) is None:
99
+ await connection.execute(insert(clock).values(id=1, sequence=0))
100
+
101
+
102
+ class SQLAlchemyBinding(Protocol):
103
+ """Custom entity mapping; MUST use this session and MUST NOT commit."""
104
+
105
+ async def write(
106
+ self, session: AsyncSession, mutation: Mutation, version: int
107
+ ) -> None: ...
108
+
109
+
110
+ class TableBinding:
111
+ """Map explicitly selected payload fields to application-owned columns.
112
+
113
+ Scalar keys use canonical strings. Composite keys use a canonical JSON object
114
+ of primary-key column names and string values, generated by encode_key().
115
+ Complete mapped payloads are required; nonmapped columns remain untouched.
116
+ """
117
+
118
+ def __init__(
119
+ self,
120
+ table: Table,
121
+ *,
122
+ fields: Mapping[str, str],
123
+ parse_key: Callable[[str], Any] | None = None,
124
+ key_parsers: Mapping[str, Callable[[str], Any]] | None = None,
125
+ value_decoders: Mapping[str, Callable[[Any], Any]] | None = None,
126
+ ):
127
+ self.keys = list(table.primary_key.columns)
128
+ if not self.keys:
129
+ raise ValueError("A synced table needs a primary key")
130
+ if parse_key is not None and (len(self.keys) != 1 or key_parsers is not None):
131
+ raise ValueError(
132
+ "parse_key is for scalar keys; use key_parsers for composite keys"
133
+ )
134
+ self.table = table
135
+ self.fields = dict(fields)
136
+ names = {column.name for column in self.keys}
137
+ if key_parsers is not None and set(key_parsers) != names:
138
+ raise ValueError("key_parsers must cover exactly the primary-key columns")
139
+ self.key_parsers = (
140
+ dict(key_parsers)
141
+ if key_parsers is not None
142
+ else {
143
+ column.name: parse_key or column.type.python_type
144
+ for column in self.keys
145
+ }
146
+ )
147
+ if len(set(self.fields.values())) != len(self.fields):
148
+ raise ValueError("Payload fields must map to distinct columns")
149
+ if any(
150
+ column not in table.c or column in names for column in self.fields.values()
151
+ ):
152
+ raise ValueError(
153
+ "Mapped columns must exist and cannot include the primary key"
154
+ )
155
+
156
+ overrides = dict(value_decoders or {})
157
+ if not set(overrides).issubset(self.fields):
158
+ raise ValueError("value_decoders must refer to registered payload fields")
159
+ self.value_decoders = {}
160
+ for field, column_name in self.fields.items():
161
+ column = table.c[column_name]
162
+ if field in overrides:
163
+ self.value_decoders[field] = overrides[field]
164
+ elif isinstance(column.type, JSON):
165
+ self.value_decoders[field] = lambda value: value
166
+ else:
167
+ self.value_decoders[field] = TypeAdapter(
168
+ column.type.python_type
169
+ ).validate_python
170
+
171
+ def encode_key(self, value: Any) -> str:
172
+ """Generate a stable wire ID from a scalar or a composite-key dictionary."""
173
+ if len(self.keys) == 1:
174
+ encoded = str(value)
175
+ else:
176
+ if not isinstance(value, Mapping) or set(value) != set(self.key_parsers):
177
+ raise ValueError("Supply exactly the composite-key columns")
178
+ encoded = json.dumps(
179
+ {name: str(value[name]) for name in self.key_parsers},
180
+ sort_keys=True,
181
+ separators=(",", ":"),
182
+ )
183
+ self._decode_key(encoded)
184
+ return encoded
185
+
186
+ def _decode_key(self, entity_id: str) -> dict[str, Any]:
187
+ try:
188
+ if len(self.keys) == 1:
189
+ raw = {self.keys[0].name: entity_id}
190
+ else:
191
+ raw = json.loads(entity_id)
192
+ if not isinstance(raw, dict) or set(raw) != set(self.key_parsers):
193
+ raise ValueError("Invalid composite key")
194
+ if json.dumps(raw, sort_keys=True, separators=(",", ":")) != entity_id:
195
+ raise ValueError("Noncanonical composite key")
196
+ values = {}
197
+ for name, parser in self.key_parsers.items():
198
+ if not isinstance(raw[name], str):
199
+ raise TypeError("Key parts must be strings")
200
+ value = parser(raw[name])
201
+ if str(value) != raw[name]:
202
+ raise ValueError("Noncanonical key part")
203
+ values[name] = value
204
+ if len(entity_id) > 191:
205
+ raise ValueError(
206
+ "Encoded key exceeds SQL metadata limit of 191 characters"
207
+ )
208
+ return values
209
+ except (ValueError, TypeError, OverflowError) as exc:
210
+ raise MutationRejected(
211
+ "Invalid entity ID; use the binding's encode_key() for canonical keys"
212
+ ) from exc
213
+
214
+ async def write(
215
+ self, session: AsyncSession, mutation: Mutation, version: int
216
+ ) -> None:
217
+ keys = self._decode_key(mutation.entity_id)
218
+ predicate = and_(*(self.table.c[name] == value for name, value in keys.items()))
219
+ if mutation.operation == "delete":
220
+ await session.execute(delete(self.table).where(predicate))
221
+ return
222
+ if set(mutation.payload) != set(self.fields):
223
+ raise MutationRejected(
224
+ "Payload must contain exactly the registered table fields; configure an Entity schema"
225
+ )
226
+ values: dict[str, Any] = {}
227
+ try:
228
+ for field, value in mutation.payload.items():
229
+ column = self.table.c[self.fields[field]]
230
+ if value is None:
231
+ if not column.nullable:
232
+ raise ValueError("Null is not allowed")
233
+ values[column.name] = None
234
+ else:
235
+ values[column.name] = self.value_decoders[field](value)
236
+ except (ValueError, TypeError, OverflowError, ValidationError) as exc:
237
+ raise MutationRejected(
238
+ "Payload value is incompatible with the registered column"
239
+ ) from exc
240
+ exists = await session.scalar(select(self.keys[0]).where(predicate))
241
+ if exists is None:
242
+ await session.execute(insert(self.table).values({**keys, **values}))
243
+ elif values:
244
+ await session.execute(update(self.table).where(predicate).values(values))
245
+
246
+ async def read_rows(
247
+ self,
248
+ session: AsyncSession,
249
+ normalize: Callable[[dict[str, Any]], dict[str, Any]],
250
+ ) -> dict[str, dict[str, Any]]:
251
+ """Read the sync-visible projection used by change capture."""
252
+ selected = [*self.keys]
253
+ selected.extend(self.table.c[name] for name in self.fields.values())
254
+ rows = (
255
+ (await session.execute(select(*selected).order_by(*self.keys)))
256
+ .mappings()
257
+ .all()
258
+ )
259
+ result: dict[str, dict[str, Any]] = {}
260
+ for row in rows:
261
+ if len(self.keys) == 1:
262
+ key: Any = row[self.keys[0].name]
263
+ else:
264
+ key = {column.name: row[column.name] for column in self.keys}
265
+ entity_id = self.encode_key(key)
266
+ payload = {
267
+ field: row[column_name] for field, column_name in self.fields.items()
268
+ }
269
+ result[entity_id] = normalize(payload)
270
+ return result
271
+
272
+
273
+ @dataclass(frozen=True)
274
+ class CaptureReport:
275
+ """Work completed by one database change-capture scan."""
276
+
277
+ scanned_rows: int = 0
278
+ captured_changes: int = 0
279
+ initialized_entities: int = 0
280
+
281
+
282
+ class SQLAlchemyChangeCapture:
283
+ """Poll registered SQL tables and append outside writes to the sync feed.
284
+
285
+ An entity's first scan stores a baseline without publishing its existing rows.
286
+ Later scans publish creates, updates, and deletes. The durable snapshot and feed
287
+ entries are committed together under the same clock lock as gateway writes.
288
+ """
289
+
290
+ def __init__(
291
+ self,
292
+ session_factory: Callable[[], AsyncSession],
293
+ bindings: Mapping[str, TableBinding],
294
+ normalizers: Mapping[str, Callable[[dict[str, Any]], dict[str, Any]]],
295
+ ):
296
+ self.session_factory = session_factory
297
+ self.bindings = dict(bindings)
298
+ self.normalizers = dict(normalizers)
299
+
300
+ async def capture_once(self) -> CaptureReport:
301
+ scanned_rows = captured_count = initialized_count = 0
302
+ async with self.session_factory() as session, session.begin():
303
+ sequence = await _lock_clock(session)
304
+ transaction = _Transaction(session, self.bindings, sequence)
305
+ for entity, binding in self.bindings.items():
306
+ current = await binding.read_rows(session, self.normalizers[entity])
307
+ scanned_rows += len(current)
308
+ initialized = await session.scalar(
309
+ select(capture_checkpoints.c.entity).where(
310
+ capture_checkpoints.c.entity == entity
311
+ )
312
+ )
313
+ if initialized is None:
314
+ await session.execute(
315
+ delete(captured_rows).where(captured_rows.c.entity == entity)
316
+ )
317
+ await self._save_rows(session, entity, current)
318
+ await session.execute(
319
+ insert(capture_checkpoints).values(
320
+ entity=entity, scanned_at=datetime.now(UTC)
321
+ )
322
+ )
323
+ initialized_count += 1
324
+ continue
325
+
326
+ previous_rows = (
327
+ (
328
+ await session.execute(
329
+ select(
330
+ captured_rows.c.entity_id,
331
+ captured_rows.c.payload,
332
+ ).where(captured_rows.c.entity == entity)
333
+ )
334
+ )
335
+ .mappings()
336
+ .all()
337
+ )
338
+ previous = {row["entity_id"]: row["payload"] for row in previous_rows}
339
+ for entity_id in sorted(current.keys() | previous.keys()):
340
+ payload = current.get(entity_id)
341
+ old_payload = previous.get(entity_id)
342
+ if payload is None:
343
+ await transaction.append_captured_change(
344
+ entity, entity_id, "delete", {}
345
+ )
346
+ await session.execute(
347
+ delete(captured_rows).where(
348
+ captured_rows.c.entity == entity,
349
+ captured_rows.c.entity_id == entity_id,
350
+ )
351
+ )
352
+ elif old_payload is None:
353
+ await transaction.append_captured_change(
354
+ entity, entity_id, "create", payload
355
+ )
356
+ await self._save_row(session, entity, entity_id, payload)
357
+ elif payload != old_payload:
358
+ await transaction.append_captured_change(
359
+ entity, entity_id, "update", payload
360
+ )
361
+ await session.execute(
362
+ update(captured_rows)
363
+ .where(
364
+ captured_rows.c.entity == entity,
365
+ captured_rows.c.entity_id == entity_id,
366
+ )
367
+ .values(payload=payload)
368
+ )
369
+ else:
370
+ continue
371
+ captured_count += 1
372
+ await session.execute(
373
+ update(capture_checkpoints)
374
+ .where(capture_checkpoints.c.entity == entity)
375
+ .values(scanned_at=datetime.now(UTC))
376
+ )
377
+ return CaptureReport(scanned_rows, captured_count, initialized_count)
378
+
379
+ async def run(self, *, interval: float = 1.0) -> None:
380
+ """Continuously capture changes until the task is cancelled."""
381
+ if interval <= 0:
382
+ raise ValueError("Capture interval must be greater than zero")
383
+ while True:
384
+ await self.capture_once()
385
+ await asyncio.sleep(interval)
386
+
387
+ @staticmethod
388
+ async def _save_row(
389
+ session: AsyncSession, entity: str, entity_id: str, payload: dict[str, Any]
390
+ ) -> None:
391
+ await session.execute(
392
+ insert(captured_rows).values(
393
+ entity=entity, entity_id=entity_id, payload=payload
394
+ )
395
+ )
396
+
397
+ @classmethod
398
+ async def _save_rows(
399
+ cls,
400
+ session: AsyncSession,
401
+ entity: str,
402
+ rows: Mapping[str, dict[str, Any]],
403
+ ) -> None:
404
+ for entity_id, payload in rows.items():
405
+ await cls._save_row(session, entity, entity_id, payload)
406
+
407
+
408
+ async def _lock_clock(session: AsyncSession) -> int:
409
+ backend = session.get_bind().dialect.name
410
+ if backend == "sqlite":
411
+ await session.execute(text("BEGIN IMMEDIATE"))
412
+ sequence = await session.scalar(
413
+ select(clock.c.sequence).where(clock.c.id == 1).with_for_update()
414
+ )
415
+ if sequence is None:
416
+ raise RuntimeError("Sync metadata is not initialized")
417
+ return sequence
418
+
419
+
420
+ class SQLAlchemyStore:
421
+ def __init__(
422
+ self,
423
+ session_factory: Callable[[], AsyncSession],
424
+ bindings: Mapping[str, SQLAlchemyBinding],
425
+ *,
426
+ telemetry: PipelineTelemetry | None = None,
427
+ ):
428
+ self.session_factory, self.bindings = session_factory, dict(bindings)
429
+ self.telemetry = safe_telemetry(telemetry)
430
+
431
+ @staticmethod
432
+ def _metric_attributes(
433
+ backend: str, *, operation: str, outcome: str
434
+ ) -> dict[str, str]:
435
+ return {
436
+ "db.system.name": backend,
437
+ "sync.storage.operation": operation,
438
+ "sync.outcome": outcome,
439
+ }
440
+
441
+ @asynccontextmanager
442
+ async def transaction(self):
443
+ started = time.perf_counter()
444
+ async with self.session_factory() as session:
445
+ backend = session.get_bind().dialect.name
446
+ span_attributes = {"db.system.name": backend}
447
+ outcome = "ok"
448
+ with self.telemetry.span(
449
+ "sync.storage.transaction", span_attributes
450
+ ) as span:
451
+ try:
452
+ async with session.begin():
453
+ sequence = await _lock_clock(session)
454
+ yield _Transaction(session, self.bindings, sequence)
455
+ except BaseException:
456
+ outcome = "error"
457
+ self.telemetry.count(
458
+ "sync.storage.failures",
459
+ attributes=self._metric_attributes(
460
+ backend, operation="transaction", outcome=outcome
461
+ ),
462
+ )
463
+ raise
464
+ finally:
465
+ attributes = self._metric_attributes(
466
+ backend, operation="transaction", outcome=outcome
467
+ )
468
+ self.telemetry.count(
469
+ "sync.storage.transactions", attributes=attributes
470
+ )
471
+ self.telemetry.record(
472
+ "sync.storage.transaction.duration",
473
+ (time.perf_counter() - started) * 1000,
474
+ attributes,
475
+ )
476
+ span.set_attribute("sync.outcome", outcome)
477
+
478
+ async def get_changes(self, cursor: int, limit: int) -> ChangeBatch:
479
+ started = time.perf_counter()
480
+ async with self.session_factory() as session:
481
+ backend = session.get_bind().dialect.name
482
+ outcome = "ok"
483
+ try:
484
+ rows = (
485
+ (
486
+ await session.execute(
487
+ select(changes)
488
+ .where(changes.c.sequence > cursor)
489
+ .order_by(changes.c.sequence)
490
+ .limit(limit + 1)
491
+ )
492
+ )
493
+ .mappings()
494
+ .all()
495
+ )
496
+ batch = [Change.model_validate(dict(row)) for row in rows[:limit]]
497
+ return ChangeBatch(
498
+ cursor=batch[-1].sequence if batch else cursor,
499
+ has_more=len(rows) > limit,
500
+ changes=batch,
501
+ )
502
+ except BaseException:
503
+ outcome = "error"
504
+ self.telemetry.count(
505
+ "sync.storage.failures",
506
+ attributes=self._metric_attributes(
507
+ backend, operation="pull", outcome=outcome
508
+ ),
509
+ )
510
+ raise
511
+ finally:
512
+ self.telemetry.record(
513
+ "sync.storage.pull.duration",
514
+ (time.perf_counter() - started) * 1000,
515
+ self._metric_attributes(backend, operation="pull", outcome=outcome),
516
+ )
517
+
518
+ async def health_check(self) -> bool:
519
+ started = time.perf_counter()
520
+ async with self.session_factory() as session:
521
+ backend = session.get_bind().dialect.name
522
+ outcome = "ok"
523
+ try:
524
+ healthy = (
525
+ await session.scalar(select(clock.c.id).where(clock.c.id == 1)) == 1
526
+ )
527
+ if not healthy:
528
+ outcome = "error"
529
+ return healthy
530
+ except (SQLAlchemyError, OSError):
531
+ outcome = "error"
532
+ return False
533
+ finally:
534
+ self.telemetry.record(
535
+ "sync.storage.health.duration",
536
+ (time.perf_counter() - started) * 1000,
537
+ self._metric_attributes(
538
+ backend, operation="health", outcome=outcome
539
+ ),
540
+ )
541
+
542
+
543
+ class _Transaction:
544
+ def __init__(
545
+ self,
546
+ session: AsyncSession,
547
+ bindings: Mapping[str, SQLAlchemyBinding],
548
+ sequence: int,
549
+ ):
550
+ self.session, self.bindings, self.sequence = session, bindings, sequence
551
+
552
+ async def get_result(self, mutation_id: UUID) -> MutationResult | None:
553
+ result = await self.session.scalar(
554
+ select(processed.c.result).where(
555
+ processed.c.mutation_id == str(mutation_id)
556
+ )
557
+ )
558
+ return MutationResult.model_validate(result) if result is not None else None
559
+
560
+ async def get_version(self, entity: str, entity_id: str) -> int:
561
+ return (
562
+ await self.session.scalar(
563
+ select(versions.c.version).where(
564
+ versions.c.entity == entity, versions.c.entity_id == entity_id
565
+ )
566
+ )
567
+ or 0
568
+ )
569
+
570
+ async def write(self, mutation: Mutation, version: int) -> None:
571
+ binding = self.bindings.get(mutation.entity)
572
+ if binding is None:
573
+ raise ValueError(
574
+ f"No storage binding for registered entity: {mutation.entity}"
575
+ )
576
+ await binding.write(self.session, mutation, version)
577
+ predicate = (versions.c.entity == mutation.entity) & (
578
+ versions.c.entity_id == mutation.entity_id
579
+ )
580
+ existing = await self.session.scalar(
581
+ select(versions.c.version).where(predicate)
582
+ )
583
+ if existing is None:
584
+ await self.session.execute(
585
+ insert(versions).values(
586
+ entity=mutation.entity,
587
+ entity_id=mutation.entity_id,
588
+ version=version,
589
+ )
590
+ )
591
+ else:
592
+ await self.session.execute(
593
+ update(versions).where(predicate).values(version=version)
594
+ )
595
+ if isinstance(binding, TableBinding):
596
+ await self._sync_capture_snapshot(mutation)
597
+
598
+ async def _sync_capture_snapshot(self, mutation: Mutation) -> None:
599
+ initialized = await self.session.scalar(
600
+ select(capture_checkpoints.c.entity).where(
601
+ capture_checkpoints.c.entity == mutation.entity
602
+ )
603
+ )
604
+ if initialized is None:
605
+ return
606
+ predicate = (captured_rows.c.entity == mutation.entity) & (
607
+ captured_rows.c.entity_id == mutation.entity_id
608
+ )
609
+ existing = await self.session.scalar(
610
+ select(captured_rows.c.entity_id).where(predicate)
611
+ )
612
+ if mutation.operation == "delete":
613
+ if existing is not None:
614
+ await self.session.execute(delete(captured_rows).where(predicate))
615
+ elif existing is None:
616
+ await self.session.execute(
617
+ insert(captured_rows).values(
618
+ entity=mutation.entity,
619
+ entity_id=mutation.entity_id,
620
+ payload=mutation.payload,
621
+ )
622
+ )
623
+ else:
624
+ await self.session.execute(
625
+ update(captured_rows).where(predicate).values(payload=mutation.payload)
626
+ )
627
+
628
+ async def append_change(self, mutation: Mutation, version: int) -> None:
629
+ self.sequence += 1
630
+ await self.session.execute(
631
+ insert(changes).values(
632
+ sequence=self.sequence,
633
+ entity=mutation.entity,
634
+ entity_id=mutation.entity_id,
635
+ operation=mutation.operation,
636
+ payload=mutation.payload,
637
+ version=version,
638
+ created_at=datetime.now(UTC),
639
+ )
640
+ )
641
+ await self.session.execute(
642
+ update(clock).where(clock.c.id == 1).values(sequence=self.sequence)
643
+ )
644
+
645
+ async def append_captured_change(
646
+ self,
647
+ entity: str,
648
+ entity_id: str,
649
+ operation: str,
650
+ payload: dict[str, Any],
651
+ ) -> None:
652
+ version = await self.get_version(entity, entity_id) + 1
653
+ predicate = (versions.c.entity == entity) & (versions.c.entity_id == entity_id)
654
+ existing = await self.session.scalar(
655
+ select(versions.c.version).where(predicate)
656
+ )
657
+ if existing is None:
658
+ await self.session.execute(
659
+ insert(versions).values(
660
+ entity=entity, entity_id=entity_id, version=version
661
+ )
662
+ )
663
+ else:
664
+ await self.session.execute(
665
+ update(versions).where(predicate).values(version=version)
666
+ )
667
+ self.sequence += 1
668
+ await self.session.execute(
669
+ insert(changes).values(
670
+ sequence=self.sequence,
671
+ entity=entity,
672
+ entity_id=entity_id,
673
+ operation=operation,
674
+ payload=payload,
675
+ version=version,
676
+ created_at=datetime.now(UTC),
677
+ )
678
+ )
679
+ await self.session.execute(
680
+ update(clock).where(clock.c.id == 1).values(sequence=self.sequence)
681
+ )
682
+
683
+ async def save_result(self, mutation: Mutation, result: MutationResult) -> None:
684
+ await self.session.execute(
685
+ insert(processed).values(
686
+ mutation_id=str(mutation.mutation_id),
687
+ client_id=mutation.client_id,
688
+ result=result.model_dump(mode="json"),
689
+ processed_at=datetime.now(UTC),
690
+ )
691
+ )