halkyon/halkyon_learn_flutter/test/sync/support/sync_devices.dart
Tizian 「ティツィアーン」 a463f24444
refactor(auth)!: sign-in and the account come from halkyon_base
Sign-in (anonymous, GitHub, email codes), an account's ways in and its
deletion, the pruning and the instance's basics move into the
halkyon_base module (ADR 0014), which every Halkyon server includes.
Learn configures it in server.dart and deletes its own data with a
person through the module's hook, in the order it did. The identity
tables move unchanged: the migration records versions only. The app
calls the module's endpoints; instance.info() stays as it was.

BREAKING CHANGE: the sign-in endpoints moved into the module, so apps up
to build 20 can no longer sign in or start; minimumAppBuild is 21.
Deploy this only together with the release of build 21.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:06:09 +02:00

298 lines
11 KiB
Dart

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<AuthSuccess> 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<pg.Result> queryServer(
String sql,
Map<String, Object?> parameters,
) async {
final connection = await _openServerDatabase();
try {
return await connection.execute(pg.Sql.named(sql), parameters: parameters);
} finally {
await connection.close();
}
}
Future<pg.Connection> _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<SyncDevice> 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<void> 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?> 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?> 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?> 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<void> 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<AuthSuccess?> get() async => _session;
@override
Future<void> set(AuthSuccess? data) async {
_session = data;
}
}