utils/crdt_sync_dart/lib/src/firebase_client.dart
Krzysztof kuhy Rudnicki ea0dc9348c crdt_sync_dart: split four library files — package now under the cap
firebase_auth_rest.dart 333 -> 238 + firebase_credentials 71
                                     + firebase_auth_error 31
  firebase_client.dart    283 -> 207 + firebase_keys 60 + firebase_errors 19
  sync.dart               259 -> 141 + sync_state 121

Mirrors the split just made on the Python side, module for module, so the two
implementations stay legible against each other: credentials with their store,
key escaping with its escape table, the error type with the response parser
that builds its message.

Every new file is added to lib/crdt_sync.dart. A src/ file that is not in the
barrel compiles and tests green while silently dropping the symbol from every
consumer's import, so the export list is part of the split, not an afterthought.

280 tests pass and `dart analyze` is clean — including the four consumers
(firebase_config, secure_credential_store) whose imports had to follow the
moved types.

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

207 lines
7.3 KiB
Dart

/// Firebase Realtime Database as dumb keyed storage, over its REST API.
///
/// The RTDB REST endpoints are plain HTTPS, so this works identically on
/// Linux desktop, Android and headless systemd -- unlike `firebase_database`,
/// which has no Linux desktop support at all.
///
/// Why RTDB rather than Firestore: on the Spark (free) plan RTDB bills only
/// storage and bandwidth, with **no per-operation quota**, so a misbehaving
/// sync loop can never exhaust a daily budget and silently stop working
/// mid-day. Its path model is also a direct match for the existing
/// `<pathPrefix>/<deviceId>/<filename>` layout.
library;
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'firebase_auth_rest.dart';
import 'firebase_errors.dart';
import 'firebase_keys.dart';
import 'remote_store.dart';
const _httpUnauthorized = 401;
const _httpForbidden = 403;
/// Realtime Database seen through the same three-method contract as GitHub.
///
/// Reads and writes UTF-8 text blobs stored as JSON string leaves, so the
/// on-the-wire payload is byte-identical to what the GitHub backend stored
/// and the two can be mirrored against each other during migration.
class FirebaseRestClient implements RemoteStore, BulkMapReader {
FirebaseRestClient({
required String databaseUrl,
required this.auth,
http.Client? httpClient,
}) : _databaseUrl = databaseUrl.endsWith('/')
? databaseUrl.substring(0, databaseUrl.length - 1)
: databaseUrl,
_http = httpClient ?? http.Client();
final String _databaseUrl;
/// Mints the ID token every request carries. Public so a settings screen
/// can drive `signIn` / `signOut` through the same instance the client uses.
final FirebaseTokenProvider auth;
final http.Client _http;
Future<Uri> _uri(String path, {Map<String, String> query = const {}}) async {
final encoded = encodePath(path);
return Uri.parse(
'$_databaseUrl/$encoded.json',
).replace(queryParameters: {'auth': await auth.idToken(), ...query});
}
/// Turns a non-2xx response into the right error type.
///
/// 401/403 mean the rules rejected this uid or the token is bad -- the
/// database-level failure a caller may want to single out. Everything else
/// (including a Spark quota exhaustion, which answers with an error rather
/// than billing you) is a plain [FirebaseSyncError]. Nothing here is ever
/// swallowed: a quota-exhausted database that silently returned "no data"
/// would look exactly like "nothing to sync".
Never _raise(String what, http.Response res) {
final detail = res.body.isEmpty ? '' : ' ${res.body.trim()}';
if (res.statusCode == _httpUnauthorized ||
res.statusCode == _httpForbidden) {
throw DatabaseNotFoundError(
'$what rejected: HTTP ${res.statusCode}$detail -- the database URL '
'or the security rules do not allow this account',
);
}
throw FirebaseSyncError('$what failed: HTTP ${res.statusCode}$detail');
}
Future<http.Response> _get(
String path, {
Map<String, String> query = const {},
}) async {
try {
return await _http.get(await _uri(path, query: query));
} on http.ClientException catch (exc) {
throw FirebaseSyncError('network error reading $path: $exc');
}
}
@override
Future<List<String>> listDirectory(String path) async {
// shallow=true returns `{key: true}` without any values, so listing a
// directory costs bytes rather than the whole subtree. This is the
// difference between a sync tick costing hundreds of bytes and costing
// hundreds of kilobytes.
final res = await _get(path, query: {'shallow': 'true'});
if (res.statusCode < 200 || res.statusCode >= 300) {
_raise('listing $path', res);
}
final decoded = jsonDecode(res.body);
if (decoded is! Map<String, dynamic>) return [];
return decoded.keys.map(decodeKey).toList();
}
@override
Future<String?> getFileText(String path) async {
final res = await _get(path);
if (res.statusCode < 200 || res.statusCode >= 300) {
_raise('reading $path', res);
}
final decoded = jsonDecode(res.body);
if (decoded == null) return null;
if (decoded is! String) {
throw FirebaseSyncError(
'value at $path is ${decoded.runtimeType}, not the expected text blob',
);
}
return decoded;
}
/// Writes [text] at [path]. [message] is ignored -- Realtime Database has
/// no commit log to attach a reason to.
@override
Future<void> putFileText(
String path,
String text, {
required String message,
}) async {
late final http.Response res;
try {
res = await _http.put(await _uri(path), body: jsonEncode(text));
} on http.ClientException catch (exc) {
throw FirebaseSyncError('network error writing $path: $exc');
}
if (res.statusCode < 200 || res.statusCode >= 300) {
_raise('writing $path', res);
}
}
/// Merges [values] into the map at [path], leaving sibling keys alone.
///
/// Used for the shared `revs` map, where a plain PUT would replace the
/// whole map and wipe every *other* device's entry -- after which those
/// devices would look permanently unchanged and never be fetched again.
Future<void> patchValues(String path, Map<String, Object?> values) async {
late final http.Response res;
try {
res = await _http.patch(await _uri(path), body: jsonEncode(values));
} on http.ClientException catch (exc) {
throw FirebaseSyncError('network error patching $path: $exc');
}
if (res.statusCode < 200 || res.statusCode >= 300) {
_raise('patching $path', res);
}
}
/// Reads the map at [path] as `key -> string`, or an empty map if absent.
///
/// Tolerates a non-map or malformed value rather than throwing: this backs
/// the revision cache, which is an *optimisation*. A corrupt revs node must
/// degrade into "fetch everything", never into a failed sync.
@override
Future<Map<String, String>> getStringMap(String path) async {
final res = await _get(path);
if (res.statusCode < 200 || res.statusCode >= 300) {
_raise('reading $path', res);
}
final decoded = jsonDecode(res.body);
if (decoded is! Map<String, dynamic>) return {};
return {
for (final entry in decoded.entries)
if (entry.value is String) decodeKey(entry.key): entry.value as String,
};
}
@override
Future<void> deleteFile(
String path, {
String message = 'crdt_sync: delete',
}) async {
late final http.Response res;
try {
res = await _http.delete(await _uri(path));
} on http.ClientException catch (exc) {
throw FirebaseSyncError('network error deleting $path: $exc');
}
if (res.statusCode < 200 || res.statusCode >= 300) {
_raise('deleting $path', res);
}
}
/// Probes the database root. Never throws -- a bad URL, a rejected token or
/// a network failure all report false.
@override
Future<bool> canAccessRemote() async {
try {
final res = await _http.get(await _uri('', query: {'shallow': 'true'}));
return res.statusCode >= 200 && res.statusCode < 300;
} on http.ClientException {
return false;
} on RemoteSyncError {
// Includes FirebaseAuthError from the token refresh: "cannot get a
// token" is exactly "cannot access the remote".
return false;
}
}
@override
void close() => _http.close();
}