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))}),