import 'dart:io'; import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol; import 'package:halkyon_learn_flutter/src/core/asset/data/asset_file_cache.dart'; import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart'; import 'package:halkyon_learn_flutter/src/core/asset/data/serverpod_asset_server_gateway.dart'; import 'package:halkyon_learn_flutter/src/core/asset/data/sync_asset_gateway.dart'; import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_progress_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/sync_learning_path_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/sync_path_answer_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/note/data/sync_note_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/sync_pdf_source_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/settings/data/sync_settings_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/study/data/sync_review_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/sync_studyset_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart'; import 'package:halkyon_learn_flutter/src/feature/tag/data/sync_tag_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart'; import 'package:postgres/postgres.dart' as pg; import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'; import 'package:serverpod_offline_sync_client/offline_sync.dart'; import 'package:yaml/yaml.dart'; // What the sync tests (ADR 0001, rule 9) share: devices of people signed in // against the running development server, and a look at its database. const _serverUrl = 'http://localhost:8080/'; /// A new anonymous person, as the app signs one in on first launch. Future signInAnonymously() async { final client = protocol.Client(_serverUrl); try { return await client.modules.base.anonymousIdp.login(); } finally { client.close(); } } /// Runs [sql] on the running development server's database, as its operator /// could. /// /// `serverpod start` runs an embedded PostgreSQL. On Linux and macOS it /// listens on a Unix socket in the server's `.serverpod/development/run` and /// trusts local connections; elsewhere it listens on TCP as /// `config/development.yaml` says, with the password from /// `config/passwords.yaml`. Future queryServer( String sql, Map parameters, ) async { final connection = await _openServerDatabase(); try { return await connection.execute(pg.Sql.named(sql), parameters: parameters); } finally { await connection.close(); } } Future _openServerDatabase() async { // A worktree's tests run against the server `serverpod start` runs, which // may be another checkout's: SYNC_TEST_SERVER_DIR names its server folder. final server = Platform.environment['SYNC_TEST_SERVER_DIR'] ?? '../halkyon_learn_server'; const settings = pg.ConnectionSettings(sslMode: pg.SslMode.disable); final development = loadYaml( File('$server/config/development.yaml').readAsStringSync(), ) as YamlMap; final database = development['database'] as YamlMap; final name = database['name'] as String; final user = database['user'] as String; final socket = File( '$server/.serverpod/development/run/.s.PGSQL.5432', ).absolute.path; if (FileSystemEntity.typeSync(socket) == FileSystemEntityType.unixDomainSock) { // A socket's path may be about 100 bytes at most, fewer than the // project's depth needs; a link in /tmp leads there by a short path, and // an open connection no longer needs it. final shortcut = Directory('/tmp').createTempSync('halkyon-pg-'); try { final link = Link('${shortcut.path}/s')..createSync(socket); return await pg.Connection.open( pg.Endpoint( host: link.path, isUnixSocket: true, database: name, username: user, ), settings: settings, ); } finally { shortcut.deleteSync(recursive: true); } } final passwords = loadYaml( File('$server/config/passwords.yaml').readAsStringSync(), ) as YamlMap; return await pg.Connection.open( pg.Endpoint( host: database['host'] as String, port: database['port'] as int, database: name, username: user, password: (passwords['development'] as YamlMap)['database'] as String, ), settings: settings, ); } /// A device: its own client and session, its own local database, and the /// app's repositories over it. It syncs only when told to. final class SyncDevice( final protocol.Client _client, final OfflineSyncDatabaseSession _db, final SyncService _syncRequests, /// The app's studysets on this device's database. final StudysetRepository studysets, /// The app's cards on this device's database. final CardRepository cards, /// The app's notes on this device's database. final NoteRepository notes, /// The app's learning paths on this device's database. final LearningPathRepository paths, /// The app's answers to steps of paths on this device's database. final PathProgressRepository pathProgress, /// The app's reviews on this device's database. final ReviewRepository reviews, /// The app's tags on this device's database. final TagRepository tags, /// The app's settings on this device's database. final SettingsRepository settings, /// The app's files on this device's database and cache; uploads only when /// told to. final AssetRepository assets, /// The app's PDFs and their reading on this device's database. final PdfSourceRepository pdfs, ) { /// Opens a device signed in as [person], whose local database in /// [database] claims [space] as its personal space. static Future open( AuthSuccess person, File database, { required protocol.UuidValue space, }) async { final session = ClientAuthSessionManager(storage: _MemoryAuthStorage()); final client = protocol.Client(_serverUrl)..authSessionManager = session; await session.updateSignedInUser(person); final db = await client.createSyncSession( database.path, persistentUserId: space, ); // Syncs as the app's do, so what a sync merges reaches the repositories // (which keep what they read until then). The gateways ask for a sync // after every write; these tests sync explicitly instead ([sync]), so // a request would wait a day. final syncRequests = SyncService( ({required onMerge}) => client.offlineSync.syncOnce( db, onInboundMerge: (space) => onMerge(), ), debounce: const Duration(days: 1), ); final cards = CardRepository( SyncCardGateway(db, syncRequests, person.authUserId.uuid), ); final notes = NoteRepository( SyncNoteGateway(db, syncRequests, person.authUserId.uuid), ); final paths = LearningPathRepository( SyncLearningPathGateway(db, syncRequests, person.authUserId.uuid), ); final assets = AssetRepository( SyncAssetGateway(db, syncRequests), ServerpodAssetServerGateway(client), DirectoryAssetFileCache(Directory('${database.path}.files')), ); return SyncDevice( client, db, syncRequests, StudysetRepository(SyncStudysetGateway(db, syncRequests)), cards, notes, paths, PathProgressRepository(SyncPathAnswerGateway(db, syncRequests)), ReviewRepository(SyncReviewGateway(db, syncRequests)), TagRepository( SyncTagGateway(db, syncRequests), cardChanges: cards.changes, noteChanges: notes.changes, pathChanges: paths.changes, ), SettingsRepository(SyncSettingsGateway(db, syncRequests)), assets, PdfSourceRepository(SyncPdfSourceGateway(db, syncRequests), assets), ); } /// The device's client, signed in as its person: for what lives on the /// instance rather than in the synced data (profiles, publishing, /// Explore). protocol.Client get client => _client; /// Sends this device's changes and receives everyone else's, through /// its sync service, as the app syncs; throws when the sync failed. Future sync() async { final done = _syncRequests.statusChanges.firstWhere( (status) => !status.syncing, ); _syncRequests.syncNow(); if ((await done).failure != null) { throw StateError('The sync failed; the log says why.'); } } /// The studyset with [id] on this device, or null. Future studyset(String id) async { for (final studyset in await studysets.all()) { if (studyset.id == id) { return studyset; } } return null; } /// The card with [id] in [studysetId] on this device, or null. Future card(String studysetId, String id) async { for (final card in await cards.cardsOf(studysetId)) { if (card.id == id) { return card; } } return null; } /// The note with [id] in [studysetId] on this device, or null. Future note(String studysetId, String id) async { for (final note in await notes.notesOf(studysetId)) { if (note.id == id) { return note; } } return null; } /// Closes everything the device opened. Future close() async { await studysets.dispose(); await tags.dispose(); await settings.dispose(); await reviews.dispose(); await cards.dispose(); await notes.dispose(); await paths.dispose(); await pathProgress.dispose(); await pdfs.dispose(); await assets.dispose(); await _syncRequests.dispose(); await _db.close(); _client.close(); } } /// Keeps a device's session in memory, where the app uses secure storage. final class _MemoryAuthStorage() implements ClientAuthSuccessStorage { AuthSuccess? _session; @override Future get() async => _session; @override Future set(AuthSuccess? data) async { _session = data; } }