utils/crdt_sync_dart/lib/src/firebase_client.dart
Krzysztof kuhy Rudnicki 027e67b197 crdt_sync: SYNC_NAMESPACE sandbox define
Compile-time dart-define that prefixes every RTDB path with sandbox/<slot>; invalid values throw at startup. Empty keeps prod paths unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 13:48:02 +01:00

223 lines
8.1 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';
import 'sync_namespace.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,
String namespace = syncNamespace,
}) : _databaseUrl = databaseUrl.endsWith('/')
? databaseUrl.substring(0, databaseUrl.length - 1)
: databaseUrl,
_prefix = _prefixFor(checkSyncNamespace(namespace)),
_http = httpClient ?? http.Client();
final String _databaseUrl;
/// `<namespace>/` or empty: put in front of every RTDB path. Every request
/// goes through [_uri], so this is the single choke point.
final String _prefix;
static String _prefixFor(String namespace) =>
namespace.isEmpty ? '' : '$namespace/';
/// 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);
// A bare namespace (the root probe) must not leave a trailing slash.
final full = encoded.isEmpty && _prefix.isNotEmpty
? _prefix.substring(0, _prefix.length - 1)
: '$_prefix$encoded';
return Uri.parse(
'$_databaseUrl/$full.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) {
// .message, never the exception: its toString prints the request URL,
// whose query is `?auth=<ID token>`. Same at every catch below.
throw FirebaseSyncError('network error reading $path: ${exc.message}');
}
}
@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.message}');
}
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.message}');
}
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.message}');
}
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();
}