diet-guard/app/lib/services/sync_merge_banks.dart
Krzysztof kuhy Rudnicki fef140d970 Split wrapper_server and sync_merge into siblings
Two Dart files whose contents were already cleanly separable.

wrapper_server.dart (254 -> 228): contentTypeFor is a pure function of the
path with no server state, so it moves to content_types.dart as a plain
library. WrapperServer.contentTypeFor stays as a static forwarder because the
test reaches for it by that name.

sync_merge.dart (475 -> 190): three near-independent blocks -- daylog, banks,
budget -- split along the seams the tests already use. The banks and budget
adapters move to siblings and are re-exported, so sync_service.dart and both
test files import exactly what they did before and needed no edits.

Flutter 555 tests unchanged; analyze clean; gate 52 -> 49.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MK3HCZrM75sRHL1gL8usve
2026-08-15 16:42:27 +02:00

152 lines
5.1 KiB
Dart

/// Food-bank <-> crdt_sync.Record adapters, for both halves of the bank.
///
/// Split out of `sync_merge.dart` for file size; that file re-exports these
/// so existing importers keep working.
///
/// The derived bank's merge clock is the record's own `count`, not a wall
/// time: last-writer-wins therefore means *max-count-wins*, which is the
/// correct merge for a derived counter and is idempotent. The curated
/// (manual) bank is one record per normalized name, LWW by `editedAt`.
library;
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:diet_guard_app/models/food_bank_record.dart';
import 'package:diet_guard_app/services/sync_device_id.dart';
/// Converts the log-derived food bank into a crdt_sync [Log].
///
/// The bank is *derived* -- both devices replay the same synced log and
/// compute the same records -- so this exists to make them agree
/// **immediately** rather than only after each has replayed.
///
/// The clock is the record's own `count`, not a wall time, so
/// last-writer-wins means *max-count-wins*: the device that has seen more of
/// the log has the higher count, and re-merging is idempotent because the
/// count does not move unless the log did. Mirrors
/// `_sync_merge.food_bank_to_log`.
Log foodBankToLog(Map<String, FoodBankRecord> bank) {
final log = <String, Record>{};
for (final mapEntry in bank.entries) {
log[mapEntry.key] = Record(
id: mapEntry.key,
fields: {
'body': (
mapEntry.value.toJson(),
Hlc.newTick(
currentSyncDeviceId,
wallTimeMsOverride: mapEntry.value.count.toInt(),
),
),
},
);
}
return log;
}
/// Converts a merged food-bank [Log] back into bank shape.
Map<String, FoodBankRecord> logToFoodBank(Log log) {
final bank = <String, FoodBankRecord>{};
for (final mapEntry in log.entries) {
if (mapEntry.value.deleted) continue;
final body = mapEntry.value.fields['body']?.$1;
if (body is! Map) continue;
bank[mapEntry.key] = FoodBankRecord.fromJson(
Map<String, dynamic>.from(body.cast<String, dynamic>()),
);
}
return bank;
}
/// Parses one device's pushed `food_bank.json` into a crdt_sync [Log].
Log parseRemoteFoodBank(String text) {
final raw = jsonDecode(text);
if (raw is! Map) {
throw const FormatException(
'top-level food-bank payload is not a JSON object',
);
}
return raw.cast<String, dynamic>().map(
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
);
}
/// Serializes a merged food-bank [Log] for push.
String encodeFoodBankForPush(Log log) => jsonEncode({
for (final entry in log.entries) entry.key: entry.value.toJson(),
});
/// Converts the hand-curated food bank into a crdt_sync [Log].
///
/// One record per normalized food name, carrying the whole bank record as an
/// opaque `body` -- the same shape food-log entries use. Unlike an entry a
/// curated food is editable, so its [Hlc] comes from the record's own
/// `editedAt` stamp rather than a fixed birth time. Mirrors
/// `_sync_merge.manual_bank_to_log`.
Log manualBankToLog(Map<String, FoodBankRecord> bank) {
final log = <String, Record>{};
for (final mapEntry in bank.entries) {
final wallTimeMs =
DateTime.tryParse(
mapEntry.value.editedAt ?? '',
)?.toUtc().millisecondsSinceEpoch ??
0;
final body = Map<String, dynamic>.from(mapEntry.value.toJson())
..remove('t');
log[mapEntry.key] = Record(
id: mapEntry.key,
fields: {
'body': (
body,
Hlc.newTick(currentSyncDeviceId, wallTimeMsOverride: wallTimeMs),
),
},
);
}
return log;
}
/// Converts a merged curated-bank [Log] back into bank shape.
///
/// Each record's `editedAt` is reconstructed from its field [Hlc], so the
/// stored stamp and the clock the merge compared can never drift apart.
/// Mirrors `_sync_merge.log_to_manual_bank`.
Map<String, FoodBankRecord> logToManualBank(Log log) {
final bank = <String, FoodBankRecord>{};
for (final mapEntry in log.entries) {
if (mapEntry.value.deleted) continue;
final field = mapEntry.value.fields['body'];
final body = field?.$1;
if (body is! Map) continue;
final json = Map<String, dynamic>.from(body.cast<String, dynamic>());
final hlc = field?.$2;
if (hlc != null) {
json['t'] = DateTime.fromMillisecondsSinceEpoch(
hlc.wallTimeMs,
isUtc: true,
).toLocal().toIso8601String();
}
bank[mapEntry.key] = FoodBankRecord.fromJson(json);
}
return bank;
}
/// Parses one device's pushed curated-bank text into a crdt_sync [Log].
Log parseRemoteManualBank(String text) {
final raw = jsonDecode(text);
if (raw is! Map) {
throw const FormatException(
'top-level curated-bank payload is not a JSON object',
);
}
return raw.cast<String, dynamic>().map(
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
);
}
/// Serializes a merged curated-bank [Log] for push.
String encodeManualBankForPush(Log log) => jsonEncode({
for (final entry in log.entries) entry.key: entry.value.toJson(),
});