utils/crdt_sync_dart/test/sync_revisions_test.dart
Krzysztof Rudnicki 02e2e13790 crdt_sync: add a Firebase Realtime Database transport behind a RemoteStore seam
Extracts the three-method remote surface GitHub was reached through into a
`RemoteStore` interface, then implements it against Firebase Realtime
Database over REST, so apps can move backend with a constructor change.

Why REST and not the SDKs: official FlutterFire has no Linux desktop
support, and `wake_alarm`'s PC side is a dependency-free systemd job. The
RTDB REST endpoints are plain HTTPS and behave identically on Linux,
Android and headless. Firebase is a transport, never the datastore -- each
app keeps its local log as the source of truth, so offline-first survives.

Why RTDB and not Firestore: on the Spark plan RTDB bills only storage and
bandwidth with no per-operation quota, so a runaway loop cannot exhaust a
daily budget and silently stop syncing mid-day.

Two savings make the no-cost tier a guarantee rather than a hope. Measured
against the GitHub-backed history this replaces: 4894 of 5542 commits
(88.3%) were byte-identical no-ops, and every tick re-downloaded every
peer's whole log. Both are now driven by one sha256 revision -- an
unchanged log is not re-pushed, and an unchanged peer is not re-fetched.
That turns egress from "ticks x log size" (~700 MB/month, and growing with
the log) into "edits" (~40 MB/month against a 10 GiB cap).

Revisions are one node per device, written only by its owner, rather than
one shared map -- a whole-map write would erase every peer's entry, after
which those peers would look permanently unchanged and never be fetched
again. Per-device keys make that impossible rather than merely avoided.

`.` is illegal in an RTDB key and the REST `.json` is a format suffix, so
paths get a reversible `~XX` escape (`log.json` -> `log~2Ejson`). Lossy
extension-stripping would break `todo`, whose listings return filenames.

`MirrorStore` / `MirrorSyncClient` dual-writes during the cutover. Reads
consult *both* backends: with primary-only reads a migrated device would
never see an un-migrated peer's writes while still mirroring its own back
-- silent one-directional convergence with no error raised. Writes are
asymmetric on purpose: a primary failure fails the tick, a mirror failure
is logged and survived.

Auth is Firebase email/password over identitytoolkit REST, with mandatory
refresh-token rotation. No service-account key anywhere: it would bypass
the security rules and is trivially extractable from an APK.

Verified live against a real database, not only mocks: sign-in and refresh,
key escaping round-tripping byte-for-byte, an unchanged tick writing
nothing, two devices converging, a revoked session throwing rather than
returning null, and a log written by the Python client being read by the
Dart one.

No app is migrated by this commit; every app still runs on GitHub.

Dart: 194 tests. Python: 203 tests at 100% branch coverage. No lint
suppressions added.
2026-08-03 18:14:25 +02:00

361 lines
12 KiB
Dart

/// Tests for the revision tracking that keeps sync inside the free tier.
///
/// Two savings, both measured against the GitHub-backed history this
/// replaces: 88.3% of pushes there were byte-identical no-ops, and every tick
/// re-downloaded every peer's whole log regardless of whether it had changed.
/// These tests assert the *request counts*, since that -- not the merge
/// result -- is what the free-tier headroom depends on.
library;
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:test/test.dart';
Log _log(String id, String value, {String nodeId = 'node-a'}) => {
id: Record(
id: id,
fields: {
'value': (value, Hlc(wallTimeMs: 1000, counter: 0, nodeId: nodeId)),
},
),
};
String _encode(Log log) =>
jsonEncode(log.map((id, record) => MapEntry(id, record.toJson())));
Log _decode(String text) => (jsonDecode(text) as Map<String, dynamic>).map(
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
);
/// An in-memory [RemoteStore] that also serves revision maps in one read,
/// standing in for [FirebaseRestClient]. Counts every call so tests can
/// assert on traffic rather than on results.
class _FakeRemote implements RemoteStore, BulkMapReader {
_FakeRemote(this.files);
final Map<String, String> files;
final List<String> reads = [];
final List<String> writes = [];
final List<String> mapReads = [];
@override
Future<List<String>> listDirectory(String path) async => files.keys
.where((key) => key.startsWith('$path/'))
.map((key) => key.substring(path.length + 1).split('/').first)
.toSet()
.toList();
@override
Future<String?> getFileText(String path) async {
reads.add(path);
return files[path];
}
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async {
writes.add(path);
files[path] = text;
}
@override
Future<Map<String, String>> getStringMap(String path) async {
mapReads.add(path);
return {
for (final entry in files.entries)
if (entry.key.startsWith('$path/'))
entry.key.substring(path.length + 1): entry.value,
};
}
@override
Future<void> deleteFile(String path, {String message = ''}) async =>
files.remove(path);
@override
Future<bool> canAccessRemote() async => true;
@override
void close() {}
}
/// A [RemoteStore] with no bulk-map capability, standing in for
/// [GitHubClient]: revision tracking must degrade, not break.
///
/// Delegates rather than extends, because a subclass of [_FakeRemote] would
/// inherit `BulkMapReader` -- the very capability this fake exists to lack.
class _FakeRemoteWithoutBulkRead implements RemoteStore {
_FakeRemoteWithoutBulkRead(Map<String, String> files)
: _inner = _FakeRemote(files);
final _FakeRemote _inner;
Map<String, String> get files => _inner.files;
List<String> get reads => _inner.reads;
List<String> get writes => _inner.writes;
@override
Future<List<String>> listDirectory(String path) =>
_inner.listDirectory(path);
@override
Future<String?> getFileText(String path) => _inner.getFileText(path);
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) => _inner.putFileText(path, text, message: message);
@override
Future<void> deleteFile(String path, {String message = ''}) =>
_inner.deleteFile(path, message: message);
@override
Future<bool> canAccessRemote() => _inner.canAccessRemote();
@override
void close() => _inner.close();
}
Future<Log> _tick(
RemoteStore remote,
SyncStateStore? store, {
Log local = const {},
String deviceId = 'pc',
}) => syncLog(
client: remote,
deviceId: deviceId,
pathPrefix: 'ns/devices',
localLog: local,
encode: _encode,
decode: _decode,
stateStore: store,
);
void main() {
group('revisionOf', () {
test('is stable for identical content', () {
expect(revisionOf('{"a":1}'), revisionOf('{"a":1}'));
});
test('differs for different content', () {
expect(revisionOf('{"a":1}'), isNot(revisionOf('{"a":2}')));
});
});
group('defaultRevsPath', () {
test('is a sibling of the device directory', () {
expect(defaultRevsPath('diet-guard-sync/devices'), 'diet-guard-sync/revs');
expect(defaultRevsPath('todo-sync/notes'), 'todo-sync/revs');
});
test('falls back to a child when the prefix has no parent', () {
expect(defaultRevsPath('ns'), 'ns/revs');
});
});
group('SyncState', () {
test('round-trips through JSON', () {
const state = SyncState(pushedRev: 'abc', peerRevs: {'phone': 'def'});
final restored = SyncState.fromJson(state.toJson());
expect(restored.pushedRev, 'abc');
expect(restored.peerRevs, {'phone': 'def'});
});
test('tolerates a missing or malformed peer map', () {
expect(SyncState.fromJson({}).peerRevs, isEmpty);
expect(
SyncState.fromJson({
'peer_revs': {'phone': 42},
}).peerRevs,
isEmpty,
);
});
});
group('no-op push suppression', () {
test('pushes on the first tick and publishes a revision', () async {
final remote = _FakeRemote({});
final store = InMemorySyncStateStore();
await _tick(remote, store, local: _log('a', '1'));
expect(remote.writes, [
'ns/devices/pc/log.json',
'ns/revs/pc',
]);
});
test('a second unchanged tick writes nothing at all', () async {
final remote = _FakeRemote({});
final store = InMemorySyncStateStore();
final local = _log('a', '1');
await _tick(remote, store, local: local);
remote.writes.clear();
await _tick(remote, store, local: local);
// This is the 88.3% of the old history that was pure waste.
expect(remote.writes, isEmpty);
});
test('a changed log pushes again', () async {
final remote = _FakeRemote({});
final store = InMemorySyncStateStore();
await _tick(remote, store, local: _log('a', '1'));
remote.writes.clear();
await _tick(remote, store, local: _log('a', '2'));
expect(remote.writes, contains('ns/devices/pc/log.json'));
});
test('without a state store every tick pushes, as it always did', () async {
final remote = _FakeRemote({});
final local = _log('a', '1');
await _tick(remote, null, local: local);
await _tick(remote, null, local: local);
expect(
remote.writes,
['ns/devices/pc/log.json', 'ns/devices/pc/log.json'],
);
// No state store means no revision publishing either.
expect(remote.writes.where((w) => w.startsWith('ns/revs')), isEmpty);
});
});
group('peer download suppression', () {
test('downloads a peer whose revision it has never seen', () async {
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
final remote = _FakeRemote({
'ns/devices/phone/log.json': peer,
'ns/revs/phone': revisionOf(peer),
});
final merged = await _tick(remote, InMemorySyncStateStore());
expect(remote.reads, contains('ns/devices/phone/log.json'));
expect(merged.keys, contains('b'));
});
test('skips the download when the peer revision is unchanged', () async {
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
final remote = _FakeRemote({
'ns/devices/phone/log.json': peer,
'ns/revs/phone': revisionOf(peer),
});
final store = InMemorySyncStateStore();
final first = await _tick(remote, store);
remote.reads.clear();
// The peer's records are already in the local log from tick one, so
// re-downloading them is pure waste -- this is the ~700 MB/month that
// the revision check removes.
await _tick(remote, store, local: first);
expect(remote.reads, isEmpty);
});
test('downloads again once the peer publishes a new revision', () async {
final peerV1 = _encode(_log('b', 'v1', nodeId: 'node-b'));
final remote = _FakeRemote({
'ns/devices/phone/log.json': peerV1,
'ns/revs/phone': revisionOf(peerV1),
});
final store = InMemorySyncStateStore();
final first = await _tick(remote, store);
remote.reads.clear();
final peerV2 = _encode({
..._decode(peerV1),
..._log('c', 'v2', nodeId: 'node-b'),
});
remote.files['ns/devices/phone/log.json'] = peerV2;
remote.files['ns/revs/phone'] = revisionOf(peerV2);
final second = await _tick(remote, store, local: first);
expect(remote.reads, contains('ns/devices/phone/log.json'));
expect(second.keys, containsAll(['b', 'c']));
});
test('re-downloads a peer whose push was corrupt', () async {
// A failed decode must not be remembered as seen, or the corruption
// would be permanent.
final remote = _FakeRemote({
'ns/devices/phone/log.json': 'not json at all',
'ns/revs/phone': revisionOf('not json at all'),
});
final store = InMemorySyncStateStore();
await _tick(remote, store);
remote.reads.clear();
await _tick(remote, store);
expect(remote.reads, contains('ns/devices/phone/log.json'));
});
test('downloads when the peer has published no revision yet', () async {
// A device still running the pre-migration code publishes a log but no
// revision; it must not be silently ignored.
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
final remote = _FakeRemote({'ns/devices/phone/log.json': peer});
final store = InMemorySyncStateStore();
final merged = await _tick(remote, store);
expect(merged.keys, contains('b'));
});
test('skips a peer that has nothing pushed yet', () async {
final remote = _FakeRemote({'ns/devices/phone/other.txt': 'x'});
final merged = await _tick(remote, InMemorySyncStateStore());
expect(merged, isEmpty);
});
test('never reads its own device back', () async {
final remote = _FakeRemote({
'ns/devices/pc/log.json': _encode(_log('a', '1')),
'ns/revs/pc': 'whatever',
});
await _tick(remote, InMemorySyncStateStore());
expect(remote.reads, isEmpty);
});
});
group('backends without bulk-map reads', () {
test('still sync correctly, just without the saving', () async {
// GitHubClient has no BulkMapReader, so revision lookup degrades to
// "fetch everything" -- correctness must not depend on the optimisation.
final peer = _encode(_log('b', 'from-phone', nodeId: 'node-b'));
final remote = _FakeRemoteWithoutBulkRead({
'ns/devices/phone/log.json': peer,
});
final store = InMemorySyncStateStore();
final first = await _tick(remote, store);
expect(first.keys, contains('b'));
remote.reads.clear();
await _tick(remote, store, local: first);
expect(remote.reads, contains('ns/devices/phone/log.json'));
});
});
group('revision publishing order', () {
test('publishes the log before its revision', () async {
// Reversed, a peer would cache "seen rev X" against a log it never
// received, and skip it forever.
final remote = _FakeRemote({});
await _tick(remote, InMemorySyncStateStore(), local: _log('a', '1'));
expect(
remote.writes.indexOf('ns/devices/pc/log.json'),
lessThan(remote.writes.indexOf('ns/revs/pc')),
);
});
test('each device writes only its own revision key', () async {
// Per-device keys rather than one shared map: a whole-map write would
// erase every other device's entry, after which those peers would look
// permanently unchanged and never be fetched again.
final remote = _FakeRemote({'ns/revs/phone': 'peer-rev'});
await _tick(remote, InMemorySyncStateStore(), local: _log('a', '1'));
expect(remote.files['ns/revs/phone'], 'peer-rev');
});
});
}