accordsync-core 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.
@@ -0,0 +1,90 @@
1
+ """Accord's merge core: hybrid logical clocks, operations and the four merge strategies."""
2
+
3
+ from .canonical import canonical_json
4
+ from .errors import AccordError, ClockSkewError
5
+ from .hlc import MAX_COUNTER, MAX_SAFE_INTEGER, Hlc, assert_node, compare_hlc
6
+ from .op import (
7
+ AddOp,
8
+ AssignOp,
9
+ IncOp,
10
+ JsonValue,
11
+ Op,
12
+ OpId,
13
+ OpKind,
14
+ ParsedOpId,
15
+ RemoveOp,
16
+ SetElement,
17
+ compare_op_ids,
18
+ parse_op_id,
19
+ record_type,
20
+ )
21
+ from .replica import ApplyResult, FieldRef, RecordSnapshot, Replica
22
+ from .schema import (
23
+ KINDS,
24
+ RecordFields,
25
+ Schema,
26
+ Strategy,
27
+ StrategyName,
28
+ conflict,
29
+ counter,
30
+ define_schema,
31
+ fields_of,
32
+ lww,
33
+ set_,
34
+ strategy_for,
35
+ )
36
+ from .strategies import ABSENT, FieldSnapshot
37
+ from .wire import WireOp, decode_op, encode_op
38
+ from .writer import DEFAULT_MAX_SKEW_MS, LocalWriter
39
+
40
+ PROTOCOL_VERSION = 1
41
+ """The Accord sync protocol this package speaks (docs/protocol.md in the Accord repository).
42
+ A breaking change to the wire format bumps it."""
43
+
44
+ __all__ = [
45
+ "ABSENT",
46
+ "DEFAULT_MAX_SKEW_MS",
47
+ "KINDS",
48
+ "MAX_COUNTER",
49
+ "MAX_SAFE_INTEGER",
50
+ "PROTOCOL_VERSION",
51
+ "AccordError",
52
+ "AddOp",
53
+ "ApplyResult",
54
+ "AssignOp",
55
+ "ClockSkewError",
56
+ "FieldRef",
57
+ "FieldSnapshot",
58
+ "Hlc",
59
+ "IncOp",
60
+ "JsonValue",
61
+ "LocalWriter",
62
+ "Op",
63
+ "OpId",
64
+ "OpKind",
65
+ "ParsedOpId",
66
+ "RecordFields",
67
+ "RecordSnapshot",
68
+ "RemoveOp",
69
+ "Replica",
70
+ "Schema",
71
+ "SetElement",
72
+ "Strategy",
73
+ "StrategyName",
74
+ "WireOp",
75
+ "assert_node",
76
+ "canonical_json",
77
+ "compare_hlc",
78
+ "compare_op_ids",
79
+ "conflict",
80
+ "counter",
81
+ "decode_op",
82
+ "define_schema",
83
+ "encode_op",
84
+ "fields_of",
85
+ "lww",
86
+ "parse_op_id",
87
+ "record_type",
88
+ "set_",
89
+ "strategy_for",
90
+ ]
@@ -0,0 +1,15 @@
1
+ """String helpers that make Python strings behave like JavaScript's UTF-16 strings."""
2
+
3
+
4
+ def utf16_key(s: str) -> bytes:
5
+ """Sort key giving JavaScript's `a < b` order: by UTF-16 code unit, not by code point.
6
+
7
+ The two differ for characters above U+FFFF (surrogate pairs, 0xD800...) against U+E000-U+FFFF.
8
+ `surrogatepass` keeps lone surrogates, which JSON may carry and JavaScript strings may hold.
9
+ """
10
+ return s.encode("utf-16-be", "surrogatepass")
11
+
12
+
13
+ def utf16_len(s: str) -> int:
14
+ """`s.length` in JavaScript: the number of UTF-16 code units."""
15
+ return len(utf16_key(s)) // 2
@@ -0,0 +1,122 @@
1
+ """Canonical JSON, byte for byte what the TypeScript core's `canonicalJson` prints.
2
+
3
+ That is `JSON.stringify` of objects built from code-unit-sorted keys, so this reproduces
4
+ JavaScript rather than Python's `json` module:
5
+ - keys that are array indices ("0".."4294967294", no leading zeros) come first in numeric order,
6
+ because JavaScript objects always order them that way; the others follow by UTF-16 code unit;
7
+ - numbers print as JavaScript prints them: `1` not `1.0`, `0` for `-0.0`, `1e-7`, `1e+21`;
8
+ - strings escape exactly like `JSON.stringify` (lone surrogates as `\\udXXX`, the rest raw).
9
+ """
10
+
11
+ import math
12
+ import re
13
+ from collections.abc import Iterable, Mapping
14
+ from decimal import Decimal
15
+
16
+ from ._text import utf16_key
17
+
18
+ MAX_ARRAY_INDEX = 4294967294
19
+ _ARRAY_INDEX = re.compile(r"0|[1-9][0-9]*")
20
+ _SHORT_ESCAPES = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r"}
21
+ _SHORT_ESCAPES["\t"] = "\\t"
22
+
23
+
24
+ def canonical_json(value: object) -> str:
25
+ out: list[str] = []
26
+ _write(out, value)
27
+ return "".join(out)
28
+
29
+
30
+ def _write(out: list[str], value: object) -> None:
31
+ # `bool` first: it is a subclass of `int`.
32
+ if value is None:
33
+ out.append("null")
34
+ elif type(value) is bool:
35
+ out.append("true" if value else "false")
36
+ elif isinstance(value, int | float):
37
+ out.append(js_number(value))
38
+ elif isinstance(value, str):
39
+ out.append(js_string(value))
40
+ elif isinstance(value, list | tuple):
41
+ out.append("[")
42
+ for i, v in enumerate(value):
43
+ if i:
44
+ out.append(",")
45
+ _write(out, v)
46
+ out.append("]")
47
+ elif isinstance(value, Mapping):
48
+ out.append("{")
49
+ for i, k in enumerate(js_key_order(value.keys())):
50
+ if i:
51
+ out.append(",")
52
+ out.append(js_string(k))
53
+ out.append(":")
54
+ _write(out, value[k])
55
+ out.append("}")
56
+ else:
57
+ raise TypeError(f"canonical_json: not a JSON value: {type(value).__name__}")
58
+
59
+
60
+ def js_key_order(keys: Iterable[object]) -> list[str]:
61
+ """The order JavaScript gives the keys of an object built from code-unit-sorted entries."""
62
+ indices: list[str] = []
63
+ others: list[str] = []
64
+ for k in keys:
65
+ if not isinstance(k, str):
66
+ raise TypeError(f"canonical_json: object keys must be strings, got {k!r}")
67
+ if _ARRAY_INDEX.fullmatch(k) and int(k) <= MAX_ARRAY_INDEX:
68
+ indices.append(k)
69
+ else:
70
+ others.append(k)
71
+ indices.sort(key=int)
72
+ others.sort(key=utf16_key)
73
+ return indices + others
74
+
75
+
76
+ def js_number(n: int | float) -> str:
77
+ """A number as JavaScript's `String(n)` / `JSON.stringify` prints it."""
78
+ if isinstance(n, int):
79
+ # JavaScript numbers are doubles: an integer past 2^53 prints as the double it rounds to.
80
+ if abs(n) <= 2**53:
81
+ return str(n)
82
+ n = float(n)
83
+ if not math.isfinite(n):
84
+ return "null"
85
+ if n == 0:
86
+ return "0" # also -0.0
87
+ sign = "-" if n < 0 else ""
88
+ # repr gives the shortest digits that round-trip, as JavaScript does; only the layout differs.
89
+ # The value is 0.DIGITS x 10^point (the k digits and n of ECMAScript's Number::toString).
90
+ t = Decimal(repr(abs(n))).normalize().as_tuple()
91
+ digits = "".join(map(str, t.digits))
92
+ point = len(digits) + int(t.exponent)
93
+ k = len(digits)
94
+ if k <= point <= 21:
95
+ return sign + digits + "0" * (point - k)
96
+ if 0 < point <= 21:
97
+ return sign + digits[:point] + "." + digits[point:]
98
+ if -6 < point <= 0:
99
+ return sign + "0." + "0" * -point + digits
100
+ e = point - 1
101
+ e_str = f"e+{e}" if e >= 0 else f"e-{-e}"
102
+ if k == 1:
103
+ return sign + digits + e_str
104
+ return sign + digits[0] + "." + digits[1:] + e_str
105
+
106
+
107
+ def js_string(s: str) -> str:
108
+ """A string as `JSON.stringify` prints it."""
109
+ # Re-pair surrogates first: a high and a low surrogate side by side are one character to
110
+ # JavaScript, printed raw; only lone surrogates are escaped.
111
+ s = s.encode("utf-16-be", "surrogatepass").decode("utf-16-be", "surrogatepass")
112
+ out = ['"']
113
+ for ch in s:
114
+ c = ord(ch)
115
+ if ch in _SHORT_ESCAPES:
116
+ out.append(_SHORT_ESCAPES[ch])
117
+ elif c < 0x20 or 0xD800 <= c <= 0xDFFF:
118
+ out.append(f"\\u{c:04x}")
119
+ else:
120
+ out.append(ch)
121
+ out.append('"')
122
+ return "".join(out)
@@ -0,0 +1,9 @@
1
+ """Errors raised by the core."""
2
+
3
+
4
+ class AccordError(ValueError):
5
+ """Invalid input: a malformed op, a write that does not fit the schema, a bad snapshot."""
6
+
7
+
8
+ class ClockSkewError(AccordError):
9
+ """A remote clock too far ahead of this device's physical time."""
accordsync_core/hlc.py ADDED
@@ -0,0 +1,100 @@
1
+ """Hybrid logical clock: physical time + logical counter + node id.
2
+
3
+ Orders events consistently even when device clocks are wrong, and `compare_hlc` is a total order:
4
+ two distinct clocks never compare equal, because the node id breaks ties.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+
12
+ from ._text import utf16_key
13
+ from .errors import AccordError, ClockSkewError
14
+
15
+ MAX_COUNTER = 99_999
16
+ MAX_SAFE_INTEGER = 2**53 - 1
17
+ """JavaScript's `Number.MAX_SAFE_INTEGER`: integers on the wire stay within +/- this."""
18
+
19
+ # fullmatch and explicit [0-9]: Python's `$` accepts a trailing newline and `\d` any Unicode digit.
20
+ _NODE = re.compile(r"[A-Za-z0-9_-]{1,64}")
21
+ _ENCODED = re.compile(r"([0-9]{1,16}):([0-9]{5}):([A-Za-z0-9_-]{1,64})")
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class Hlc:
26
+ wall: int
27
+ """Milliseconds since the Unix epoch, as seen by the node (maybe pushed forward by others)."""
28
+ counter: int
29
+ """Disambiguates events within the same `wall` millisecond."""
30
+ node: str
31
+ """The device or server that produced the clock."""
32
+
33
+ @staticmethod
34
+ def initial(node: str) -> Hlc:
35
+ assert_node(node)
36
+ return Hlc(0, 0, node)
37
+
38
+ def encode(self) -> str:
39
+ """`wall:counter:node`, with the counter zero-padded to 5 digits."""
40
+ return f"{self.wall}:{self.counter:05d}:{self.node}"
41
+
42
+ @staticmethod
43
+ def decode(s: str) -> Hlc:
44
+ m = _ENCODED.fullmatch(s)
45
+ if not m:
46
+ raise AccordError(f'malformed hlc "{s}"')
47
+ wall = int(m[1])
48
+ if wall > MAX_SAFE_INTEGER:
49
+ raise AccordError(f'hlc wall out of range in "{s}"')
50
+ return Hlc(wall, int(m[2]), m[3])
51
+
52
+ def compare(self, other: Hlc) -> int:
53
+ return compare_hlc(self, other)
54
+
55
+ def tick(self, now: int) -> Hlc:
56
+ """The clock for a new local event at physical time `now`."""
57
+ if now > self.wall:
58
+ return Hlc(now, 0, self.node)
59
+ return _after(self.wall, self.counter, self.node)
60
+
61
+ def receive(self, remote: Hlc, now: int, max_skew_ms: int) -> Hlc:
62
+ """The clock after observing `remote` at physical time `now`.
63
+
64
+ Refuses a remote clock more than `max_skew_ms` ahead of `now`, so one phone with a wrong
65
+ date cannot win every merge forever.
66
+ """
67
+ if remote.wall - now > max_skew_ms:
68
+ raise ClockSkewError(
69
+ f"clock of {remote.node} is {remote.wall - now} ms ahead (limit {max_skew_ms} ms)"
70
+ )
71
+ wall = max(self.wall, remote.wall, now)
72
+ if wall == self.wall and wall == remote.wall:
73
+ return _after(wall, max(self.counter, remote.counter), self.node)
74
+ if wall == self.wall:
75
+ return _after(wall, self.counter, self.node)
76
+ if wall == remote.wall:
77
+ return _after(wall, remote.counter, self.node)
78
+ return Hlc(wall, 0, self.node)
79
+
80
+
81
+ def compare_hlc(a: Hlc, b: Hlc) -> int:
82
+ if a.wall != b.wall:
83
+ return -1 if a.wall < b.wall else 1
84
+ if a.counter != b.counter:
85
+ return -1 if a.counter < b.counter else 1
86
+ if a.node != b.node:
87
+ return -1 if utf16_key(a.node) < utf16_key(b.node) else 1
88
+ return 0
89
+
90
+
91
+ def assert_node(node: str) -> None:
92
+ if not isinstance(node, str) or not _NODE.fullmatch(node):
93
+ raise AccordError(f'node id must match [A-Za-z0-9_-]{{1,64}}, got "{node}"')
94
+
95
+
96
+ def _after(wall: int, counter: int, node: str) -> Hlc:
97
+ """The smallest clock after (wall, counter): a full counter rolls into the next millisecond."""
98
+ if counter >= MAX_COUNTER:
99
+ return Hlc(wall + 1, 0, node)
100
+ return Hlc(wall, counter + 1, node)
accordsync_core/op.py ADDED
@@ -0,0 +1,142 @@
1
+ """Operations: one change to one field of one record."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from dataclasses import dataclass
8
+ from typing import ClassVar, Literal, TypeAlias
9
+
10
+ from ._text import utf16_key, utf16_len
11
+ from .errors import AccordError
12
+ from .hlc import MAX_SAFE_INTEGER, Hlc
13
+
14
+ JsonValue: TypeAlias = "bool | int | float | str | list[JsonValue] | dict[str, JsonValue] | None"
15
+ """Any JSON value. Field values must survive a JSON round trip."""
16
+
17
+ SetElement: TypeAlias = str | int | float
18
+ """Set elements are compared by value, so they are limited to strings and finite numbers."""
19
+
20
+ OpId: TypeAlias = str
21
+ """`deviceId:sequence`. Unique per op, so applying an op twice is a no-op."""
22
+
23
+ OpKind: TypeAlias = Literal["assign", "inc", "add", "remove"]
24
+
25
+ _OP_ID = re.compile(r"([A-Za-z0-9_-]{1,64}):([1-9][0-9]{0,15})")
26
+ _RECORD_ID = re.compile(r"([A-Za-z][A-Za-z0-9_]{0,63}):(.+)", re.DOTALL)
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class AssignOp:
31
+ """Writes a value. For `lww` the highest clock wins. For `conflict`, `deps` lists the values the
32
+ writer could see; the assign supersedes exactly those, so resolving a conflict is an assign
33
+ whose deps name every conflicting value (ADR-0003)."""
34
+
35
+ kind: ClassVar[Literal["assign"]] = "assign"
36
+ op_id: OpId
37
+ record: str
38
+ field: str
39
+ hlc: Hlc
40
+ value: JsonValue
41
+ deps: tuple[OpId, ...] = ()
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class IncOp:
46
+ """Adds `by` (a positive or negative integer) to a counter."""
47
+
48
+ kind: ClassVar[Literal["inc"]] = "inc"
49
+ op_id: OpId
50
+ record: str
51
+ field: str
52
+ hlc: Hlc
53
+ by: int
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class AddOp:
58
+ """Adds an element to a set. The op id is the element's unique tag. `deps` lists the element's
59
+ tags the writer could see: the add replaces them, so re-adding keeps the set's state small,
60
+ while a concurrent remove (which only cites the tags it saw) still loses."""
61
+
62
+ kind: ClassVar[Literal["add"]] = "add"
63
+ op_id: OpId
64
+ record: str
65
+ field: str
66
+ hlc: Hlc
67
+ element: SetElement
68
+ deps: tuple[OpId, ...] = ()
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class RemoveOp:
73
+ """Removes the add-tags in `deps` (the ones the writer had seen); concurrent adds survive."""
74
+
75
+ kind: ClassVar[Literal["remove"]] = "remove"
76
+ op_id: OpId
77
+ record: str
78
+ field: str
79
+ hlc: Hlc
80
+ element: SetElement
81
+ deps: tuple[OpId, ...] = ()
82
+
83
+
84
+ Op: TypeAlias = AssignOp | IncOp | AddOp | RemoveOp
85
+
86
+
87
+ @dataclass(frozen=True, slots=True)
88
+ class ParsedOpId:
89
+ device: str
90
+ seq: int
91
+
92
+
93
+ def parse_op_id(op_id: str) -> ParsedOpId:
94
+ m = _OP_ID.fullmatch(op_id) if isinstance(op_id, str) else None
95
+ if not m:
96
+ raise AccordError(f'malformed op id "{op_id}" (expected device:sequence)')
97
+ return ParsedOpId(m[1], int(m[2]))
98
+
99
+
100
+ def record_type(record: str) -> str:
101
+ """The type of a `type:id` record id (the id: 1 to 256 UTF-16 code units, as in JavaScript)."""
102
+ m = _RECORD_ID.fullmatch(record) if isinstance(record, str) else None
103
+ if not m or utf16_len(m[2]) > 256:
104
+ raise AccordError(f'malformed record id "{record}" (expected type:id)')
105
+ return m[1]
106
+
107
+
108
+ def op_id_key(op_id: OpId) -> bytes:
109
+ """Sort key for op ids, used wherever output order must be deterministic (code-unit order)."""
110
+ return utf16_key(op_id)
111
+
112
+
113
+ def compare_op_ids(a: OpId, b: OpId) -> int:
114
+ ka, kb = op_id_key(a), op_id_key(b)
115
+ return -1 if ka < kb else 1 if ka > kb else 0
116
+
117
+
118
+ def is_number(x: object) -> bool:
119
+ """A JSON number: `int` or `float`, never `bool` (which is an `int` subclass in Python)."""
120
+ return type(x) is not bool and isinstance(x, int | float)
121
+
122
+
123
+ def as_set_element(e: object) -> SetElement:
124
+ """Checks a set element: a string or a finite number. Booleans are refused, so `True` can never
125
+ be confused with `1`. An integer past 2^53 becomes the double JavaScript would read."""
126
+ if isinstance(e, str):
127
+ return e
128
+ if type(e) is int:
129
+ return e if abs(e) <= MAX_SAFE_INTEGER else float(e)
130
+ if type(e) is float and math.isfinite(e):
131
+ return e
132
+ raise AccordError("set elements must be a string or finite number")
133
+
134
+
135
+ def as_increment(by: object) -> int:
136
+ """Checks a counter increment: a safe integer. A whole float (`3.0`, as some JSON decoders give)
137
+ is the same number to JavaScript and is accepted; `True` is not a number."""
138
+ if type(by) is float and math.isfinite(by) and by == int(by):
139
+ by = int(by)
140
+ if type(by) is not int or abs(by) > MAX_SAFE_INTEGER:
141
+ raise AccordError(f"counter increment must be a safe integer, got {by!r}")
142
+ return by
File without changes
@@ -0,0 +1,196 @@
1
+ """An op log and the state projected from it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any, Literal, TypeAlias
8
+
9
+ from ._text import utf16_key
10
+ from .canonical import canonical_json
11
+ from .errors import AccordError
12
+ from .op import Op, OpId, SetElement, op_id_key, parse_op_id
13
+ from .schema import KINDS, Schema, fields_of, strategy_for
14
+ from .strategies import (
15
+ ABSENT,
16
+ ConflictState,
17
+ FieldSnapshot,
18
+ FieldState,
19
+ apply_op,
20
+ empty_state,
21
+ observed_deps,
22
+ read_state,
23
+ snapshot_state,
24
+ state_from_snapshot,
25
+ )
26
+
27
+ ApplyResult: TypeAlias = Literal["applied", "duplicate"]
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class RecordSnapshot:
32
+ """A record's state with its history folded away (log compaction, ADR-0008)."""
33
+
34
+ record: str
35
+ fields: Mapping[str, FieldSnapshot]
36
+
37
+ def to_json(self) -> dict[str, Any]:
38
+ return {"record": self.record, "fields": dict(self.fields)}
39
+
40
+ @staticmethod
41
+ def from_json(data: Mapping[str, Any]) -> RecordSnapshot:
42
+ record, fields = data.get("record"), data.get("fields")
43
+ if not isinstance(record, str) or not isinstance(fields, Mapping):
44
+ raise AccordError("record snapshot needs a record and fields")
45
+ return RecordSnapshot(record, dict(fields))
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class FieldRef:
50
+ record: str
51
+ field: str
52
+
53
+
54
+ class Replica:
55
+ """An op log and the state projected from it. Pure: no I/O, no clock.
56
+
57
+ Two replicas holding the same set of ops always read the same state, whatever order the ops
58
+ arrived in.
59
+ """
60
+
61
+ def __init__(self, schema: Schema) -> None:
62
+ self._schema = schema
63
+ self._ops: dict[OpId, Op] = {}
64
+ self._records: dict[str, dict[str, FieldState]] = {}
65
+ # Snapshots this replica's state was started from, by record.
66
+ self._bases: dict[str, RecordSnapshot] = {}
67
+
68
+ @property
69
+ def schema(self) -> Schema:
70
+ return self._schema
71
+
72
+ def has(self, op_id: OpId) -> bool:
73
+ return op_id in self._ops
74
+
75
+ def __len__(self) -> int:
76
+ return len(self._ops)
77
+
78
+ @property
79
+ def size(self) -> int:
80
+ return len(self._ops)
81
+
82
+ def ops(self) -> list[Op]:
83
+ """All ops, in a deterministic order."""
84
+ return sorted(self._ops.values(), key=lambda o: op_id_key(o.op_id))
85
+
86
+ def validate(self, op: Op) -> None:
87
+ """Raises (without changing anything) if the op does not fit the schema."""
88
+ strategy = strategy_for(self._schema, op.record, op.field)
89
+ if op.kind not in KINDS[strategy]:
90
+ raise AccordError(
91
+ f'op kind "{op.kind}" does not apply to {op.field}, a {strategy} field'
92
+ )
93
+ if parse_op_id(op.op_id).device != op.hlc.node:
94
+ raise AccordError(f'op {op.op_id} carries a clock from "{op.hlc.node}"')
95
+
96
+ def apply(self, op: Op) -> ApplyResult:
97
+ if op.op_id in self._ops:
98
+ return "duplicate"
99
+ self.validate(op)
100
+ apply_op(self._state(op.record, op.field), op)
101
+ self._ops[op.op_id] = op
102
+ return "applied"
103
+
104
+ def read(self, record: str) -> dict[str, object] | None:
105
+ """The record's fields, or None if no op touched it. Fields never written are left out."""
106
+ states = self._records.get(record)
107
+ if states is None:
108
+ return None
109
+ out: dict[str, object] = {}
110
+ for name, s in fields_of(self._schema, record).items():
111
+ state = states.get(name)
112
+ value = read_state(state if state is not None else empty_state(s.strategy))
113
+ if value is not ABSENT:
114
+ out[name] = value
115
+ return out
116
+
117
+ def records(self) -> list[str]:
118
+ return sorted(self._records, key=utf16_key)
119
+
120
+ def conflicts(self) -> list[FieldRef]:
121
+ """Every `conflict()` field currently holding more than one value."""
122
+ out: list[FieldRef] = []
123
+ for record in self.records():
124
+ states = self._records[record]
125
+ for name in sorted(states, key=utf16_key):
126
+ state = states[name]
127
+ if isinstance(state, ConflictState) and len(state.live) > 1:
128
+ out.append(FieldRef(record, name))
129
+ return out
130
+
131
+ def observed_deps(
132
+ self, record: str, field: str, element: SetElement | None = None
133
+ ) -> list[OpId]:
134
+ """Op ids a new write to this field must cite (see `AssignOp` and `RemoveOp`)."""
135
+ strategy_for(self._schema, record, field)
136
+ state = self._records.get(record, {}).get(field)
137
+ return observed_deps(state, element) if state is not None else []
138
+
139
+ def snapshot(self) -> str:
140
+ """The whole state as canonical JSON: equal strings mean converged replicas."""
141
+ return canonical_json({r: self.read(r) for r in self.records()})
142
+
143
+ def snapshot_record(self, record: str) -> RecordSnapshot:
144
+ """The record's current state, with its history folded away."""
145
+ states = self._records.get(record, {})
146
+ return RecordSnapshot(record, {f: snapshot_state(s) for f, s in states.items()})
147
+
148
+ def load_snapshot(self, snap: RecordSnapshot, keep: Iterable[OpId] = ()) -> None:
149
+ """Replaces a record's state with a snapshot and forgets that record's ops, except `keep`
150
+ (local ops not yet on the server), which are applied again on top."""
151
+ keep = set(keep)
152
+ fields: dict[str, FieldState] = {}
153
+ for name, fs in snap.fields.items():
154
+ strategy_for(self._schema, snap.record, name)
155
+ fields[name] = state_from_snapshot(fs)
156
+ # Everything checked: now change state.
157
+ reapply = [o for o in self._ops.values() if o.record == snap.record and o.op_id in keep]
158
+ self._ops = {k: o for k, o in self._ops.items() if o.record != snap.record}
159
+ self._records[snap.record] = fields
160
+ self._bases[snap.record] = snap
161
+ for op in reapply:
162
+ self.apply(op)
163
+
164
+ def without(self, drop: Iterable[OpId]) -> Replica:
165
+ """A copy without the given ops (same snapshots, every other op): used to roll back."""
166
+ drop = set(drop)
167
+ nxt = Replica(self._schema)
168
+ for snap in self._bases.values():
169
+ nxt.load_snapshot(snap)
170
+ for op in self.ops():
171
+ if op.op_id not in drop:
172
+ nxt.apply(op)
173
+ return nxt
174
+
175
+ def forget(self, record: str, keep: Iterable[OpId] = ()) -> Replica:
176
+ """Forgets a record entirely (it left this device's scope), except ops in `keep`."""
177
+ keep = set(keep)
178
+ nxt = Replica(self._schema)
179
+ for r, snap in self._bases.items():
180
+ if r != record:
181
+ nxt.load_snapshot(snap)
182
+ for op in self.ops():
183
+ if op.record != record or op.op_id in keep:
184
+ nxt.apply(op)
185
+ return nxt
186
+
187
+ def bases(self) -> list[RecordSnapshot]:
188
+ """Snapshots this replica was started from (to persist them alongside the ops)."""
189
+ return list(self._bases.values())
190
+
191
+ def _state(self, record: str, field: str) -> FieldState:
192
+ fields = self._records.setdefault(record, {})
193
+ state = fields.get(field)
194
+ if state is None:
195
+ state = fields[field] = empty_state(strategy_for(self._schema, record, field))
196
+ return state