chore: Halkyon Learn moves into apps/learn
Its server, client, app and curator, and the tools that write its assets (maps, Anki fixtures, icons), as they are; paths are fixed in the commits that follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
11abd9afce
commit
649499be23
1 changed files with 0 additions and 0 deletions
|
|
@ -0,0 +1,298 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
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/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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue