feat(app): flashcards on append-only revisions
A studyset's screen lists its cards (reorderable; a marker on cards changed on two devices) and opens a card's screen with an editor for each side. Saving appends a revision whose parents are all current heads. mergeRevisions merges several heads per side against their newest common ancestor, as decided: different sides merge automatically, the same side changed differently is a conflict. The card screen then shows every version of that side with "Keep this", and saving is possible once each conflicted side is settled, which resolves the conflict for every device. Nothing is merged silently inside a side. CardRepository watches a studyset's cards, creates at the end and moves by fractional index (fractional_indexing_dart, checked against the reference algorithm's test vectors), and deletes by blanking every revision first (ADR 0002, rule 2). Deleting a studyset now deletes its cards the same way before the studyset goes. Every person gets a CardRepository whose revisions name them as author. RichContent gains plainText for the list's previews. Tests: the merge (chains, different and same sides, conflicts, resolving, three heads, missing parents, arrival order), the payload, the repository against a fake gateway, both blocs, and the widgets, including that cancelling a delete keeps the card. The sync tests, which need the running server, cover merging and conflicts across two devices and blank revisions on the server; their device helper is now shared in test/sync/support. AGENTS.md describes the revision pattern, the fractional order, and two tooling traps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
a98fabd0e6
commit
66886ef383
1 changed files with 2392 additions and 142 deletions
146
halkyon_learn_flutter/test/sync/support/sync_devices.dart
Normal file
146
halkyon_learn_flutter/test/sync/support/sync_devices.dart
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
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/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: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.anonymousIdp.login();
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs [sql] on the running development server's database, as its operator
|
||||
/// could: the connection from `config/development.yaml`, the password from
|
||||
/// `config/passwords.yaml`.
|
||||
Future<pg.Result> queryServer(
|
||||
String sql,
|
||||
Map<String, Object?> parameters,
|
||||
) async {
|
||||
const config = '../halkyon_learn_server/config';
|
||||
final development =
|
||||
loadYaml(File('$config/development.yaml').readAsStringSync()) as YamlMap;
|
||||
final database = development['database'] as YamlMap;
|
||||
final passwords =
|
||||
loadYaml(File('$config/passwords.yaml').readAsStringSync()) as YamlMap;
|
||||
final connection = await pg.Connection.open(
|
||||
pg.Endpoint(
|
||||
host: database['host'] as String,
|
||||
port: database['port'] as int,
|
||||
database: database['name'] as String,
|
||||
username: database['user'] as String,
|
||||
password: (passwords['development'] as YamlMap)['database'] as String,
|
||||
),
|
||||
settings: const pg.ConnectionSettings(sslMode: pg.SslMode.disable),
|
||||
);
|
||||
try {
|
||||
return await connection.execute(pg.Sql.named(sql), parameters: parameters);
|
||||
} finally {
|
||||
await connection.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
) {
|
||||
/// 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,
|
||||
);
|
||||
// The gateways ask for a sync after every write; these tests sync
|
||||
// explicitly instead, so the requests go nowhere.
|
||||
final syncRequests = SyncService(({required onMerge}) async {});
|
||||
return SyncDevice(
|
||||
client,
|
||||
db,
|
||||
syncRequests,
|
||||
StudysetRepository(SyncStudysetGateway(db, syncRequests)),
|
||||
CardRepository(
|
||||
SyncCardGateway(db, syncRequests, person.authUserId.uuid),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Sends this device's changes and receives everyone else's.
|
||||
Future<void> sync() => _client.offlineSync.syncOnce(_db);
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// Closes everything the device opened.
|
||||
Future<void> close() async {
|
||||
await studysets.dispose();
|
||||
await cards.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