utils/crdt-sync/crdt_sync/_record.py
Krzysztof Rudnicki 1c49abe8c2 Initial implementation: unified CRDT merge scheme + GitHub sync transport
Extracted and generalized from diet_guard's existing tombstone-merge scheme
and GitHub Contents API client, extended with per-field HLC LWW so the same
library can also cover todo's mutable notes and wake_alarm's single-value
sync (Workstream D scoping decision, 2026-07-04). 100% branch coverage,
clean ruff/mypy/pylint/bandit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017McuA1nAi6jBT1EaS5BrVf
2026-07-05 20:40:09 +02:00

129 lines
4.3 KiB
Python

"""Record: a per-field LWW map with a sticky (monotonic) delete flag.
This is the unit CRDT merges operate on. It's deliberately domain-agnostic --
translating an app's own model (a note, a food-log entry, a workout entry,
a wake-time value) to and from ``Record`` is each app's job, not this
library's.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from crdt_sync._hlc import Hlc
Field = tuple[Any, Hlc]
@dataclass(frozen=True)
class Record:
"""One CRDT record: an id, per-field LWW values, and a sticky tombstone.
Attributes:
id: Stable identifier, unique within one ``Log``.
fields: Field name -> ``(value, Hlc)``. Concurrent edits to the same
field converge to whichever side has the greater ``Hlc``.
deleted: Whether this record is tombstoned. Deletion is monotonic:
once true on either side of a merge, it stays true.
deleted_hlc: The clock value of the delete, if any -- kept for
informational/ordering purposes only; it does not participate in
the merge decision (that's a plain boolean OR, see
:func:`merge_record`).
"""
id: str
fields: dict[str, Field]
deleted: bool = False
deleted_hlc: Hlc | None = None
def to_dict(self) -> dict[str, Any]:
"""Serialize to a JSON-compatible dict."""
return {
"id": self.id,
"fields": {
name: [value, hlc.to_str()]
for name, (value, hlc) in self.fields.items()
},
"deleted": self.deleted,
"deleted_hlc": self.deleted_hlc.to_str()
if self.deleted_hlc is not None
else None,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Record:
"""Parse the format produced by :meth:`to_dict`."""
fields = {
name: (value, Hlc.from_str(hlc_str))
for name, (value, hlc_str) in data["fields"].items()
}
deleted_hlc_str = data.get("deleted_hlc")
deleted_hlc = (
Hlc.from_str(deleted_hlc_str) if deleted_hlc_str is not None else None
)
return cls(
id=data["id"],
fields=fields,
deleted=bool(data["deleted"]),
deleted_hlc=deleted_hlc,
)
def merge_field(a: Field, b: Field) -> Field:
"""Return whichever of ``a``, ``b`` has the greater ``Hlc``.
Ties (equal ``Hlc``) keep ``a``; two ticks never compare equal across
different nodes (see :class:`crdt_sync._hlc.Hlc`), so a tie only happens
when both sides are literally the same tick re-merged, in which case the
value is expected to be identical anyway.
"""
return a if a[1] >= b[1] else b
def _merge_deleted_hlc(a: Record, b: Record) -> Hlc | None:
"""Return the delete-clock to keep, informational only (see class docs)."""
if a.deleted and b.deleted:
if a.deleted_hlc is None:
return b.deleted_hlc
if b.deleted_hlc is None:
return a.deleted_hlc
return max(a.deleted_hlc, b.deleted_hlc)
if a.deleted:
return a.deleted_hlc
if b.deleted:
return b.deleted_hlc
return None
def merge_record(a: Record, b: Record) -> Record:
"""Merge two versions of the same record.
Per-field last-writer-wins over the union of field names, plus a sticky
delete: ``deleted = a.deleted or b.deleted``, never the other way around.
A tombstone can never be resurrected by merging in an older, non-deleted
copy pulled from a device that hasn't seen the delete yet.
Commutative and idempotent: ``merge_record(a, b) == merge_record(b, a)``
and ``merge_record(a, a) == a``.
Raises:
ValueError: If ``a.id != b.id`` -- merging two different records is
a caller bug, not a case to silently paper over.
"""
if a.id != b.id:
msg = f"cannot merge records with different ids: {a.id!r} != {b.id!r}"
raise ValueError(msg)
merged_fields = dict(a.fields)
for name, b_field in b.fields.items():
a_field = merged_fields.get(name)
merged_fields[name] = (
b_field if a_field is None else merge_field(a_field, b_field)
)
return Record(
id=a.id,
fields=merged_fields,
deleted=a.deleted or b.deleted,
deleted_hlc=_merge_deleted_hlc(a, b),
)