From 6b14e6d36d137d90851f00d1e282a2069c13d3a6 Mon Sep 17 00:00:00 2001 From: Krzysztof Rudnicki Date: Sun, 5 Jul 2026 20:55:22 +0200 Subject: [PATCH] Fix wire-format key mismatch with Python (deletedHlc -> deleted_hlc) Record.toJson() was writing the delete-clock field as deletedHlc while the Python package's Record.to_dict() writes deleted_hlc -- an undetected cross-language wire divergence: a tombstoned record synced Python<->Dart would silently lose its delete clock on the receiving side. Also widen syncLog's decode error handling from FormatException alone to also catch TypeError, matching the Python side's (ValueError, KeyError, TypeError) so a valid-JSON-but-wrong-shape push from an incompatible writer is skipped per-device instead of aborting the whole sync. Adds a fixture test shared verbatim with the Python package's test suite so this class of divergence fails loudly going forward. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017McuA1nAi6jBT1EaS5BrVf --- crdt_sync_dart/lib/src/record.dart | 6 ++++-- crdt_sync_dart/lib/src/sync.dart | 11 +++++++--- crdt_sync_dart/test/record_test.dart | 31 ++++++++++++++++++++++++++++ crdt_sync_dart/test/sync_test.dart | 25 ++++++++++++++++++++++ 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/crdt_sync_dart/lib/src/record.dart b/crdt_sync_dart/lib/src/record.dart index 5b31756..acc1719 100644 --- a/crdt_sync_dart/lib/src/record.dart +++ b/crdt_sync_dart/lib/src/record.dart @@ -38,7 +38,9 @@ class Record { (name, field) => MapEntry(name, [field.$1, field.$2.toStr()]), ), 'deleted': deleted, - 'deletedHlc': deletedHlc?.toStr(), + // Wire key is snake_case to match crdt_sync's Python `Record.to_dict` -- + // this is a cross-language wire format, not a Dart-idiomatic API. + 'deleted_hlc': deletedHlc?.toStr(), }; factory Record.fromJson(Map json) { @@ -47,7 +49,7 @@ class Record { final pair = value as List; return MapEntry(name, (pair[0], Hlc.fromStr(pair[1] as String))); }); - final deletedHlcStr = json['deletedHlc'] as String?; + final deletedHlcStr = json['deleted_hlc'] as String?; return Record( id: json['id'] as String, fields: fields, diff --git a/crdt_sync_dart/lib/src/sync.dart b/crdt_sync_dart/lib/src/sync.dart index 946729b..ab4cb5c 100644 --- a/crdt_sync_dart/lib/src/sync.dart +++ b/crdt_sync_dart/lib/src/sync.dart @@ -12,9 +12,12 @@ const _defaultFilename = 'log.json'; /// to `//`. /// /// [encode] serializes a merged log for pushing. [decode] parses a remote -/// device's pushed text back into a log; throwing [FormatException] is -/// treated as a corrupt/unparsable push, and that device is skipped for -/// this tick rather than aborting the whole sync. +/// device's pushed text back into a log; throwing [FormatException] (bad +/// JSON syntax) or [TypeError] (valid JSON, wrong shape -- e.g. a missing +/// or mistyped field during [Record.fromJson]) is treated as a +/// corrupt/unparsable push, and that device is skipped for this tick +/// rather than aborting the whole sync. Mirrors the Python `sync_log`'s +/// `(ValueError, KeyError, TypeError)` catch for the same reason. Future syncLog({ required GitHubClient client, required String deviceId, @@ -36,6 +39,8 @@ Future syncLog({ mergedLog = mergeLogs(mergedLog, decode(text)); } on FormatException { continue; + } on TypeError { + continue; } } diff --git a/crdt_sync_dart/test/record_test.dart b/crdt_sync_dart/test/record_test.dart index 24bebaa..cfc45a7 100644 --- a/crdt_sync_dart/test/record_test.dart +++ b/crdt_sync_dart/test/record_test.dart @@ -138,4 +138,35 @@ void main() { expect(Record.fromJson(record.toJson()), equals(record)); }); }); + + group('cross-language wire format', () { + // Pins the exact wire shape shared with crdt-sync's Python Record. + // These values are duplicated verbatim in the Python package's + // `test_record.py` (`TestCrossLanguageWireFormat`). If this test and + // that one both pass, the two languages agree on the wire format; if + // only one changes, the two suites diverge and at least one of them + // fails -- catching exactly the kind of key-naming mismatch + // (`deleted_hlc` vs `deletedHlc`) that neither language's own + // round-trip test can see on its own. + test('matches the fixture shared with the Python package', () { + final record = Record( + id: 'abc123', + fields: { + 'text': ('hello', Hlc(wallTimeMs: 100, counter: 0, nodeId: 'pc')), + }, + deleted: true, + deletedHlc: Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'node-a'), + ); + final expected = { + 'id': 'abc123', + 'fields': { + 'text': ['hello', '1970-01-01T00:00:00.100Z-0000-pc'], + }, + 'deleted': true, + 'deleted_hlc': '1970-01-01T00:00:01.000Z-0000-node-a', + }; + expect(record.toJson(), equals(expected)); + expect(Record.fromJson(expected), equals(record)); + }); + }); } diff --git a/crdt_sync_dart/test/sync_test.dart b/crdt_sync_dart/test/sync_test.dart index c906d80..84204d1 100644 --- a/crdt_sync_dart/test/sync_test.dart +++ b/crdt_sync_dart/test/sync_test.dart @@ -146,6 +146,31 @@ void main() { expect(putCalls, hasLength(1)); }); + test('skips a device whose pushed JSON has the wrong shape', () async { + // Valid JSON that isn't a record map (e.g. from an incompatible + // writer) must be skipped like corrupt JSON, not crash the whole + // sync -- this is what the `on TypeError` catch in `syncLog` is + // for, not just JSON syntax errors caught by `FormatException`. + final (:client, :putCalls) = _client( + contentResponses: { + 'devices': _directoryOf(['phone']), + 'devices/phone/log.json': _fileContaining('{"a": 5}'), + }, + ); + + final merged = await syncLog( + client: client, + deviceId: 'pc', + pathPrefix: 'devices', + localLog: {}, + encode: _encode, + decode: _decode, + ); + + expect(merged, isEmpty); + expect(putCalls, hasLength(1)); + }); + test('merges in a remote device\'s entries', () async { final remoteLog = { 'b': Record(id: 'b', fields: {'text': ('from phone', _make(100))}),