accordsync 0.3.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.
accordsync/__init__.py ADDED
@@ -0,0 +1,103 @@
1
+ """The Accord client for Python: local-first writes, background sync, conflicts and refusals."""
2
+
3
+ from accordsync_core import (
4
+ PROTOCOL_VERSION,
5
+ AccordError,
6
+ AddOp,
7
+ AssignOp,
8
+ IncOp,
9
+ JsonValue,
10
+ Op,
11
+ OpId,
12
+ RecordSnapshot,
13
+ RemoveOp,
14
+ Schema,
15
+ SetElement,
16
+ WireOp,
17
+ conflict,
18
+ counter,
19
+ define_schema,
20
+ lww,
21
+ set_,
22
+ )
23
+
24
+ from .client import (
25
+ EVENTS,
26
+ AccordClient,
27
+ ConflictInfo,
28
+ ConflictValue,
29
+ Event,
30
+ Listener,
31
+ Refusal,
32
+ SyncStatus,
33
+ random_device_id,
34
+ )
35
+ from .memory import MemoryStorage
36
+ from .sqlite import SqliteStorage
37
+ from .storage import StorageAdapter, StorageSnapshot, StorageTx, StoredMeta
38
+ from .transport import (
39
+ ExitItem,
40
+ HttpError,
41
+ HttpTransport,
42
+ OpItem,
43
+ PullItem,
44
+ PullPage,
45
+ PullResult,
46
+ PushResult,
47
+ RefusedOp,
48
+ ResyncRequired,
49
+ SnapshotItem,
50
+ Transport,
51
+ pull_item_from_json,
52
+ pull_result_from_json,
53
+ )
54
+
55
+ __all__ = [
56
+ "EVENTS",
57
+ "PROTOCOL_VERSION",
58
+ "AccordClient",
59
+ "AccordError",
60
+ "AddOp",
61
+ "AssignOp",
62
+ "ConflictInfo",
63
+ "ConflictValue",
64
+ "Event",
65
+ "ExitItem",
66
+ "HttpError",
67
+ "HttpTransport",
68
+ "IncOp",
69
+ "JsonValue",
70
+ "Listener",
71
+ "MemoryStorage",
72
+ "Op",
73
+ "OpId",
74
+ "OpItem",
75
+ "PullItem",
76
+ "PullPage",
77
+ "PullResult",
78
+ "PushResult",
79
+ "RecordSnapshot",
80
+ "Refusal",
81
+ "RefusedOp",
82
+ "RemoveOp",
83
+ "ResyncRequired",
84
+ "Schema",
85
+ "SetElement",
86
+ "SnapshotItem",
87
+ "SqliteStorage",
88
+ "StorageAdapter",
89
+ "StorageSnapshot",
90
+ "StorageTx",
91
+ "StoredMeta",
92
+ "SyncStatus",
93
+ "Transport",
94
+ "WireOp",
95
+ "conflict",
96
+ "counter",
97
+ "define_schema",
98
+ "lww",
99
+ "pull_item_from_json",
100
+ "pull_result_from_json",
101
+ "random_device_id",
102
+ "set_",
103
+ ]
accordsync/client.py ADDED
@@ -0,0 +1,572 @@
1
+ """The Accord client: a device that writes locally first and syncs with a server.
2
+
3
+ Port of `@accordsync/client` (client.ts), following the Dart port. Synchronous (ADR-Y01): writes
4
+ and `sync()` run on the caller's thread; `start()` syncs on a daemon thread. See ADR-Y05 for the
5
+ threading rules.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import logging
12
+ import random as _random
13
+ import secrets
14
+ import threading
15
+ import time
16
+ from collections.abc import Callable, Sequence
17
+ from concurrent.futures import Future
18
+ from dataclasses import dataclass
19
+ from typing import Any, Literal, TypeAlias, TypeVar
20
+
21
+ from accordsync_core import (
22
+ MAX_SAFE_INTEGER,
23
+ AddOp,
24
+ AssignOp,
25
+ Hlc,
26
+ IncOp,
27
+ JsonValue,
28
+ LocalWriter,
29
+ Op,
30
+ OpId,
31
+ RecordSnapshot,
32
+ RemoveOp,
33
+ Schema,
34
+ SetElement,
35
+ decode_op,
36
+ encode_op,
37
+ parse_op_id,
38
+ )
39
+
40
+ from .storage import StorageAdapter, StorageTx, StoredMeta
41
+ from .transport import ExitItem, OpItem, PullItem, PullPage, SnapshotItem, Transport
42
+
43
+ log = logging.getLogger("accordsync")
44
+
45
+ Event: TypeAlias = Literal["change", "refused", "synced", "resync", "error"]
46
+ EVENTS: tuple[Event, ...] = ("change", "refused", "synced", "resync", "error")
47
+ Listener: TypeAlias = Callable[[Any], None]
48
+
49
+ _O = TypeVar("_O", AssignOp, IncOp, AddOp, RemoveOp)
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class Refusal:
54
+ """A local write the server refused. It has already been rolled back on this device."""
55
+
56
+ op_id: OpId
57
+ record: str
58
+ field: str
59
+ reason: str
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class ConflictValue:
64
+ """One of the values a conflicted field holds, and the op that wrote it."""
65
+
66
+ value: JsonValue
67
+ op_id: OpId
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class ConflictInfo:
72
+ """A `conflict()` field holding more than one value. `values` are sorted by op id."""
73
+
74
+ record: str
75
+ field: str
76
+ values: tuple[ConflictValue, ...]
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class SyncStatus:
81
+ pending: int
82
+ cursor: int
83
+ last_sync_at: int | None
84
+ last_error: BaseException | None
85
+
86
+
87
+ def random_device_id() -> str:
88
+ """A random device id from a secure source. Device ids must never collide."""
89
+ return "d" + secrets.token_hex(16)
90
+
91
+
92
+ def _now_ms() -> int:
93
+ return time.time_ns() // 1_000_000
94
+
95
+
96
+ class AccordClient:
97
+ """An Accord device. Writes apply locally at once and are saved to storage; sync pushes them
98
+ and pulls everyone else's, in the background (`start()`) or on demand (`sync()`).
99
+
100
+ Events (`on(event, listener)`), with the listener's argument:
101
+
102
+ - ``"change"``: ``list[str]``, records whose local state changed;
103
+ - ``"refused"``: `Refusal`, a local write the server refused, already rolled back;
104
+ - ``"synced"``: ``int``, the cursor after a full round;
105
+ - ``"resync"``: ``None``, the server asked for a resync and local data was reloaded;
106
+ - ``"error"``: the exception of a failed background round (retried with backoff).
107
+
108
+ Listeners run on the thread that caused the event, without the client's lock held. An
109
+ exception in a listener is logged and ignored.
110
+ """
111
+
112
+ def __init__(
113
+ self,
114
+ *,
115
+ schema: Schema,
116
+ storage: StorageAdapter,
117
+ transport: Transport,
118
+ device_id: str,
119
+ meta: StoredMeta | None,
120
+ now: Callable[[], int],
121
+ random: Callable[[], float],
122
+ push_batch: int,
123
+ pull_limit: int,
124
+ sync_interval: float,
125
+ min_backoff: float,
126
+ max_backoff: float,
127
+ ) -> None:
128
+ """Use `AccordClient.open`."""
129
+ self.device_id = device_id
130
+ self._schema = schema
131
+ self._storage = storage
132
+ self._transport = transport
133
+ self._now = now
134
+ self._random = random
135
+ self._push_batch = push_batch
136
+ self._pull_limit = pull_limit
137
+ self._sync_interval = sync_interval
138
+ self._min_backoff = min_backoff
139
+ self._max_backoff = max_backoff
140
+
141
+ # All state below is guarded by `_lock`, never held during network I/O.
142
+ self._lock = threading.RLock()
143
+ self._cursor = meta.cursor if meta else 0
144
+ self._writer = self._new_writer((Hlc.decode(meta.hlc), meta.seq) if meta else None)
145
+ self._outbox: dict[OpId, Op] = {} # unacknowledged local ops, in write order
146
+ self._last_sync_at: int | None = None
147
+ self._last_error: BaseException | None = None
148
+ self._failures = 0
149
+
150
+ # One sync round at a time; concurrent callers share it.
151
+ self._round_guard = threading.Lock()
152
+ self._round: Future[None] | None = None
153
+ self._round_thread: int | None = None
154
+
155
+ # Background sync.
156
+ self._cond = threading.Condition()
157
+ self._running = False
158
+ self._generation = 0
159
+ self._next_at = 0.0
160
+ self._thread: threading.Thread | None = None
161
+
162
+ self._listeners: dict[str, list[Listener]] = {e: [] for e in EVENTS}
163
+ self._listeners_lock = threading.Lock()
164
+
165
+ @classmethod
166
+ def open(
167
+ cls,
168
+ *,
169
+ schema: Schema,
170
+ storage: StorageAdapter,
171
+ transport: Transport,
172
+ device_id: str | None = None,
173
+ now: Callable[[], int] | None = None,
174
+ random: Callable[[], float] | None = None,
175
+ push_batch: int = 200,
176
+ pull_limit: int = 500,
177
+ sync_interval: float = 30.0,
178
+ min_backoff: float = 1.0,
179
+ max_backoff: float = 60.0,
180
+ ) -> AccordClient:
181
+ """Opens the device: loads its stored ops, outbox and cursor.
182
+
183
+ `device_id` is used only the first time; afterwards the stored id is kept. Generated when
184
+ absent. `now` returns physical time in ms. `push_batch` is the ops per push request and
185
+ `pull_limit` the items per pull page. Background sync pauses `sync_interval` seconds
186
+ between successful rounds and backs off between `min_backoff` and `max_backoff` seconds on
187
+ errors (with jitter from `random`).
188
+ """
189
+ snap = storage.load()
190
+ meta = snap.meta
191
+ did = meta.device_id if meta else (device_id or random_device_id())
192
+ client = cls(
193
+ schema=schema,
194
+ storage=storage,
195
+ transport=transport,
196
+ device_id=did,
197
+ meta=meta,
198
+ now=now or _now_ms,
199
+ random=random or _random.random,
200
+ push_batch=push_batch,
201
+ pull_limit=pull_limit,
202
+ sync_interval=sync_interval,
203
+ min_backoff=min_backoff,
204
+ max_backoff=max_backoff,
205
+ )
206
+ for base in snap.snapshots:
207
+ client._writer.replica.load_snapshot(base)
208
+ by_id: dict[OpId, Op] = {}
209
+ for raw in snap.ops:
210
+ op = decode_op(raw)
211
+ by_id[op.op_id] = op
212
+ client._writer.receive(op)
213
+ for oid in sorted(snap.outbox, key=lambda i: parse_op_id(i).seq):
214
+ found = by_id.get(oid)
215
+ if found is not None:
216
+ client._outbox[oid] = found
217
+ if meta is None:
218
+ client._persist(StorageTx())
219
+ return client
220
+
221
+ # ── events ────────────────────────────────────────────────────────────
222
+
223
+ def on(self, event: Event, listener: Listener) -> Callable[[], None]:
224
+ """Subscribes to an event; returns a function that unsubscribes."""
225
+ if event not in self._listeners:
226
+ raise ValueError(f"unknown event {event!r}; expected one of {EVENTS}")
227
+ with self._listeners_lock:
228
+ self._listeners[event].append(listener)
229
+
230
+ def off() -> None:
231
+ with self._listeners_lock:
232
+ if listener in self._listeners[event]:
233
+ self._listeners[event].remove(listener)
234
+
235
+ return off
236
+
237
+ def _emit(self, event: Event, payload: Any) -> None:
238
+ with self._listeners_lock:
239
+ listeners = list(self._listeners[event])
240
+ for fn in listeners:
241
+ try:
242
+ fn(payload)
243
+ except Exception:
244
+ log.exception('accord: a "%s" listener raised', event)
245
+
246
+ # ── reading ───────────────────────────────────────────────────────────
247
+
248
+ def read(self, record: str) -> dict[str, object] | None:
249
+ """A record's fields, or None if this device has never seen it. Fields with no value are
250
+ left out."""
251
+ with self._lock:
252
+ return self._writer.replica.read(record)
253
+
254
+ def records(self, type: str | None = None) -> list[str]:
255
+ with self._lock:
256
+ everything = self._writer.replica.records()
257
+ return everything if type is None else [r for r in everything if r.startswith(f"{type}:")]
258
+
259
+ def conflicts(self) -> list[ConflictInfo]:
260
+ """Every `conflict()` field holding more than one value, with the values."""
261
+ with self._lock:
262
+ out: list[ConflictInfo] = []
263
+ for ref in self._writer.replica.conflicts():
264
+ fields = self._writer.replica.read(ref.record) or {}
265
+ shown: Any = fields[ref.field]
266
+ values = tuple(ConflictValue(v["value"], v["opId"]) for v in shown["conflicted"])
267
+ out.append(ConflictInfo(ref.record, ref.field, values))
268
+ return out
269
+
270
+ def status(self) -> SyncStatus:
271
+ with self._lock:
272
+ return SyncStatus(
273
+ pending=len(self._outbox),
274
+ cursor=self._cursor,
275
+ last_sync_at=self._last_sync_at,
276
+ last_error=self._last_error,
277
+ )
278
+
279
+ # ── writing (local-first) ─────────────────────────────────────────────
280
+
281
+ def assign(self, record: str, field: str, value: JsonValue) -> AssignOp:
282
+ """Sets a `lww` or `conflict` field. Returns once the write is saved on this device."""
283
+ return self._write(lambda w: w.assign(record, field, value))
284
+
285
+ def resolve(self, record: str, field: str, value: JsonValue) -> AssignOp:
286
+ """Resolves a conflicted field: writes `value`, superseding every value currently shown."""
287
+ return self.assign(record, field, value)
288
+
289
+ def inc(self, record: str, field: str, by: int) -> IncOp:
290
+ return self._write(lambda w: w.inc(record, field, by))
291
+
292
+ def add(self, record: str, field: str, element: SetElement) -> AddOp:
293
+ return self._write(lambda w: w.add(record, field, element))
294
+
295
+ def remove(self, record: str, field: str, element: SetElement) -> RemoveOp:
296
+ return self._write(lambda w: w.remove(record, field, element))
297
+
298
+ def _write(self, make: Callable[[LocalWriter], _O]) -> _O:
299
+ error: BaseException | None = None
300
+ with self._lock:
301
+ op = make(self._writer)
302
+ self._outbox[op.op_id] = op
303
+ try:
304
+ self._persist(StorageTx(put_ops=[encode_op(op)], outbox_add=[op.op_id]))
305
+ except BaseException as e: # reported after the change event, like client.ts
306
+ error = e
307
+ self._emit("change", [op.record])
308
+ if error is not None:
309
+ raise error
310
+ self._soon()
311
+ return op
312
+
313
+ # ── sync ──────────────────────────────────────────────────────────────
314
+
315
+ def sync(self) -> None:
316
+ """One full round: push the outbox, then pull every page. A call made while a round is
317
+ running waits for that round and shares its outcome."""
318
+ with self._round_guard:
319
+ running = self._round
320
+ if running is None:
321
+ mine: Future[None] = Future()
322
+ self._round = mine
323
+ self._round_thread = threading.get_ident()
324
+ if running is not None:
325
+ if self._round_thread == threading.get_ident():
326
+ raise RuntimeError("sync() called from inside its own round (from a listener?)")
327
+ running.result()
328
+ return
329
+ try:
330
+ self._run_round()
331
+ except BaseException as e:
332
+ self._finish_round(mine, e)
333
+ raise
334
+ self._finish_round(mine, None)
335
+
336
+ def _finish_round(self, fut: Future[None], error: BaseException | None) -> None:
337
+ with self._round_guard:
338
+ self._round = None
339
+ self._round_thread = None
340
+ if error is None:
341
+ fut.set_result(None)
342
+ else:
343
+ fut.set_exception(error)
344
+
345
+ def start(self) -> None:
346
+ """Syncs in the background, on a daemon thread: soon after each write, every
347
+ `sync_interval`, and with backoff on errors."""
348
+ with self._cond:
349
+ if self._running:
350
+ return
351
+ self._running = True
352
+ self._generation += 1
353
+ self._next_at = time.monotonic()
354
+ self._thread = threading.Thread(
355
+ target=self._loop, args=(self._generation,), name="accord-sync", daemon=True
356
+ )
357
+ self._thread.start()
358
+
359
+ def stop(self) -> None:
360
+ """Stops background sync. A round in flight finishes."""
361
+ with self._cond:
362
+ self._running = False
363
+ self._cond.notify_all()
364
+
365
+ def flush(self) -> None:
366
+ """Waits for a local save in progress. Saves are synchronous, so this returns at once
367
+ unless another thread is writing."""
368
+ with self._lock:
369
+ pass
370
+
371
+ def close(self) -> None:
372
+ """Stops background sync, waits for a round in flight, and closes the storage."""
373
+ self.stop()
374
+ with self._round_guard:
375
+ running, owner = self._round, self._round_thread
376
+ if running is not None and owner != threading.get_ident():
377
+ # The round's error, if any, was reported to its caller.
378
+ with contextlib.suppress(BaseException):
379
+ running.result()
380
+ thread = self._thread
381
+ if thread is not None and thread is not threading.current_thread():
382
+ thread.join()
383
+ self.flush()
384
+ self._storage.close()
385
+
386
+ def _run_round(self) -> None:
387
+ self._push_all()
388
+ while True:
389
+ with self._lock:
390
+ cursor = self._cursor
391
+ page = self._transport.pull(self.device_id, cursor, self._pull_limit)
392
+ if not isinstance(page, PullPage):
393
+ self._push_all()
394
+ self._resync()
395
+ continue
396
+ with self._lock:
397
+ # The server's count of this device's ops: never reuse an op id, even after lost
398
+ # storage.
399
+ if page.device_seq is not None:
400
+ self._writer.advance_seq(page.device_seq)
401
+ changed = self._apply_page(page.items, page.cursor)
402
+ if changed:
403
+ self._emit("change", changed)
404
+ if not page.has_more:
405
+ break
406
+ with self._lock:
407
+ self._last_sync_at = self._now()
408
+ self._last_error = None
409
+ self._failures = 0
410
+ cursor = self._cursor
411
+ self._emit("synced", cursor)
412
+
413
+ def _push_all(self) -> None:
414
+ while True:
415
+ with self._lock:
416
+ if not self._outbox:
417
+ return
418
+ batch = [encode_op(o) for o in list(self._outbox.values())[: self._push_batch]]
419
+ res = self._transport.push(self.device_id, batch)
420
+ if not res.acked and not res.refused:
421
+ raise RuntimeError("server neither acknowledged nor refused a non-empty push")
422
+ refusals: list[Refusal] = []
423
+ with self._lock:
424
+ for oid in res.acked:
425
+ self._outbox.pop(oid, None)
426
+ for r in res.refused:
427
+ op = self._outbox.pop(r.op_id, None)
428
+ if op is not None:
429
+ refusals.append(Refusal(op.op_id, op.record, op.field, r.reason))
430
+ if refusals:
431
+ self._writer.discard(r.op_id for r in refusals)
432
+ self._persist(
433
+ StorageTx(
434
+ outbox_delete=[*res.acked, *(r.op_id for r in res.refused)],
435
+ delete_ops=[r.op_id for r in refusals],
436
+ )
437
+ )
438
+ if refusals:
439
+ self._emit("change", list(dict.fromkeys(r.record for r in refusals)))
440
+ for refusal in refusals:
441
+ self._emit("refused", refusal)
442
+
443
+ def _apply_page(self, items: Sequence[PullItem], cursor: int) -> list[str]:
444
+ """Applies one pull page and saves it. Caller holds the lock. Returns changed records."""
445
+ put: dict[OpId, Op] = {}
446
+ forgotten: list[OpId] = []
447
+ snapshots: list[RecordSnapshot] = []
448
+ drop_snapshots: list[str] = []
449
+ changed: dict[str, None] = {}
450
+ pending = set(self._outbox)
451
+
452
+ def not_pending(record: str) -> list[OpId]:
453
+ return [
454
+ o.op_id
455
+ for o in self._writer.replica.ops()
456
+ if o.record == record and o.op_id not in pending
457
+ ]
458
+
459
+ for item in items:
460
+ if isinstance(item, OpItem):
461
+ op = decode_op(item.op)
462
+ if self._writer.receive(op) == "applied":
463
+ put[op.op_id] = op
464
+ changed[op.record] = None
465
+ elif isinstance(item, SnapshotItem):
466
+ # A compacted record: its snapshot replaces the ops it folded; our unpushed edits
467
+ # stay on top. The server sends a snapshot before any later op of that record.
468
+ snap = item.snapshot
469
+ for oid in not_pending(snap.record):
470
+ put.pop(oid, None)
471
+ forgotten.append(oid)
472
+ self._writer.replica.load_snapshot(snap, pending)
473
+ snapshots.append(snap)
474
+ changed[snap.record] = None
475
+ elif isinstance(item, ExitItem):
476
+ # The record left our scope: forget it, except our own unpushed edits, which will
477
+ # be pushed, refused and rolled back like any other refused write.
478
+ ids = not_pending(item.record)
479
+ for oid in ids:
480
+ put.pop(oid, None)
481
+ self._writer.forget(item.record, pending)
482
+ forgotten.extend(ids)
483
+ drop_snapshots.append(item.record)
484
+ changed[item.record] = None
485
+ self._cursor = max(self._cursor, cursor)
486
+ snapshotted = {s.record for s in snapshots}
487
+ self._persist(
488
+ StorageTx(
489
+ delete_ops=forgotten,
490
+ delete_snapshots=[r for r in drop_snapshots if r not in snapshotted],
491
+ put_snapshots=[s for s in snapshots if s.record not in drop_snapshots],
492
+ put_ops=[encode_op(o) for o in put.values()],
493
+ )
494
+ )
495
+ return list(changed)
496
+
497
+ def _resync(self) -> None:
498
+ """Read scopes changed: keep only unpushed local ops and pull everything again from 0."""
499
+ with self._lock:
500
+ keep = list(self._outbox.values())
501
+ before = self._writer.replica.records()
502
+ self._writer = self._new_writer((self._writer.clock, self._writer.seq))
503
+ for op in keep:
504
+ self._writer.receive(op)
505
+ self._cursor = 0
506
+ self._persist(StorageTx(clear_ops=True, put_ops=[encode_op(o) for o in keep]))
507
+ self._emit("resync", None)
508
+ self._emit("change", before)
509
+
510
+ # ── background sync ───────────────────────────────────────────────────
511
+
512
+ def _schedule(self, delay: float) -> None:
513
+ with self._cond:
514
+ self._next_at = time.monotonic() + delay
515
+ self._cond.notify_all()
516
+
517
+ def _soon(self) -> None:
518
+ """After a write, sync shortly (writes in a burst share one round)."""
519
+ with self._lock:
520
+ failures = self._failures
521
+ with self._cond:
522
+ running = self._running
523
+ if running and failures == 0:
524
+ self._schedule(0.05)
525
+
526
+ def _loop(self, generation: int) -> None:
527
+ while True:
528
+ with self._cond:
529
+ while True:
530
+ if not self._running or self._generation != generation:
531
+ return
532
+ wait = self._next_at - time.monotonic()
533
+ if wait <= 0:
534
+ break
535
+ self._cond.wait(wait)
536
+ self._next_at = float("inf")
537
+ try:
538
+ self.sync()
539
+ except Exception as error:
540
+ with self._lock:
541
+ self._failures += 1
542
+ self._last_error = error
543
+ failures = self._failures
544
+ self._emit("error", error)
545
+ base = min(self._max_backoff, self._min_backoff * 2 ** (failures - 1))
546
+ # Jitter: devices don't retry in lockstep.
547
+ self._schedule(base * (0.5 + self._random() / 2))
548
+ else:
549
+ self._schedule(self._sync_interval)
550
+
551
+ # ── internals ─────────────────────────────────────────────────────────
552
+
553
+ def _new_writer(self, resume: tuple[Hlc, int] | None) -> LocalWriter:
554
+ return LocalWriter(
555
+ self._schema,
556
+ self.device_id,
557
+ self._now,
558
+ # The server already refused ops with absurd clocks; a device with a wrong clock of
559
+ # its own must still accept everything the server sends.
560
+ max_skew_ms=MAX_SAFE_INTEGER,
561
+ resume=resume,
562
+ )
563
+
564
+ def _persist(self, tx: StorageTx) -> None:
565
+ """Commits `tx` with the current meta. Caller holds the lock (or is `open`)."""
566
+ meta = StoredMeta(
567
+ device_id=self.device_id,
568
+ cursor=self._cursor,
569
+ hlc=self._writer.clock.encode(),
570
+ seq=self._writer.seq,
571
+ )
572
+ self._storage.commit(tx.with_meta(meta))