feat(learn): Learn's agent, an MCP server suggesting in shared studysets
Some checks failed
CI / check (push) Failing after 1m56s

Halkyon's ADR 0044, step 6c (D7, D49 to D52). apps/learn/halkyon_learn_agent
is one executable, `halkyon-agent --instance URL sign-in | sync | mcp`:

- sign-in keeps the token read from stdin, signs in with it, makes the
  agent's key pair on its first sign-in, keeps it before setting the
  public halves on the register (agents.setKeys), and syncs;
- it opens its data as the app's _openPersonData does, on
  halkyon_learn_core and halkyon_content: its own SQLite database, the
  sync's space router, its keys and those of the spaces shared with it,
  and the repositories of studysets, cards, notes, paths, files and
  feedback;
- mcp serves ten tools over stdio (dart_mcp): list, read and search the
  studysets shared with it, suggest a change or a new card or note,
  report, list a studyset's suggestions and reply. Each syncs first and
  a suggestion again at once; a refusal is the tool's error;
- its files live in $XDG_CONFIG_HOME/halkyon-agent/<host>, 0700 and
  0600, refused when others may read them (D50);
- tool/install.sh builds it with `dart build cli` (libsodium and SQLite
  bundled) and copies it to its user on the host, the build before kept
  (D52).

The synced gateways of learning paths and files, and the fakes of the
core's gateways, move into halkyon_learn_core (its testing.dart), which
the app's tests and the agent's share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 22:06:29 +01:00
commit 54e212a51c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
11 changed files with 2925 additions and 314 deletions

View file

@ -0,0 +1,133 @@
import 'dart:typed_data';
import 'package:halkyon_content/halkyon_content.dart';
import 'package:halkyon_core/halkyon_core.dart';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_core/core/asset/data/asset_gateway.dart';
import 'package:halkyon_learn_core/core/content/payload.dart';
import 'package:halkyon_learn_core/core/privacy/data/privacy.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// [AssetGateway] on the person's local database, which the sync engine keeps
/// in step with their other devices. A file of a private studyset has its
/// row's payload sealed with its tree's key ([Privacy], ADR 0019).
class SyncAssetGateway(
final OfflineSyncDatabaseSession _db,
final SyncRequests _sync,
final Privacy _privacy,
) implements AssetGateway {
@override
Future<AssetRow?> byId(String id) async {
final protocol.UuidValue uuid;
try {
uuid = protocol.UuidValue.withValidation(id);
} on FormatException {
// Content can name anything; a name that is no id names no row.
return null;
}
await _privacy.keys.refresh();
return _row(await protocol.Asset.db.findById(_db, uuid));
}
@override
Future<AssetRow?> bySha256(String sha256) async {
await _privacy.keys.refresh();
return _row(
await protocol.Asset.db.findFirstRow(
_db,
where: (t) => t.sha256.equals(sha256),
),
);
}
@override
Future<List<AssetRow>> all() async {
await _privacy.keys.refresh();
return [
for (final asset in await protocol.Asset.db.find(_db)) ?_row(asset),
];
}
@override
String newId() => Privacy.newId().uuid;
@override
Future<String> insert({
required String id,
required String sha256,
required String mimeType,
required int byteSize,
required ByteData payload,
String? keyId,
}) async {
final sealed = _privacy.seal(
payload,
keyId: keyId,
table: 'asset',
bound: '',
id: id,
item: 'asset:$id',
);
final asset = await protocol.Asset.db.insertRow(
_db,
protocol.Asset(
id: protocol.UuidValue.withValidation(id),
sha256: sha256,
mimeType: mimeType,
byteSize: byteSize,
keyId: sealed.keyId,
nonce: sealed.nonce,
payload: sealed.payload,
),
);
_sync.requestSync();
return asset.id!.uuid;
}
@override
Future<void> delete(String id) async {
final uuid = protocol.UuidValue.withValidation(id);
if (await protocol.Asset.db.findById(_db, uuid) case final asset?) {
// The name it came with is content (ADR 0002, rule 2).
await protocol.Asset.db.updateRow(
_db,
asset.copyWith(payload: redactedPayload),
columns: (t) => [t.payload],
);
await protocol.Asset.db.deleteWhere(
_db,
where: (t) => t.id.equals(uuid),
);
_sync.requestSync();
}
}
AssetRow? _row(protocol.Asset? asset) {
if (asset == null) {
return null;
}
// A file this device saw sealed never counts as readable again (the
// reading of 22 Oct): a readable row in its place nobody signed is
// locked.
final opened = _privacy.openItem(
(keyId: asset.keyId, nonce: asset.nonce, payload: asset.payload),
table: 'asset',
bound: '',
id: asset.id!.uuid,
item: 'asset:${asset.id!.uuid}',
treeKey: null,
);
return (
id: asset.id!.uuid,
sha256: asset.sha256,
mimeType: asset.mimeType,
byteSize: asset.byteSize,
payload: switch (opened) {
Readable(:final payload) => payload,
Locked() => redactedPayload,
},
keyId: asset.keyId,
locked: opened is Locked,
);
}
}

View file

@ -0,0 +1,387 @@
import 'dart:typed_data';
import 'package:halkyon_content/halkyon_content.dart';
import 'package:halkyon_core/halkyon_core.dart';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_core/core/content/payload.dart';
import 'package:halkyon_learn_core/core/privacy/data/privacy.dart';
import 'package:halkyon_learn_core/feature/learning_path/data/learning_path_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// [LearningPathGateway] on the person's local database, which the sync
/// engine keeps in step with their other devices. Step revisions record
/// [_authorId] as their author. In a private studyset, paths and step
/// revisions are sealed with their tree's key ([Privacy], ADR 0019); one
/// that doesn't open here reads as blank.
class SyncLearningPathGateway(
final OfflineSyncDatabaseSession _db,
final SyncRequests _sync,
final String _authorId,
final Privacy _privacy,
) implements LearningPathGateway {
@override
Future<List<PathRow>> paths({String? studysetId}) async {
await _privacy.keys.refresh();
final treeKey = await _privacy.treeKeys();
return [
for (final path in await protocol.LearningPath.db.find(
_db,
where: studysetId == null
? null
: (t) => t.studysetId.equals(_uuid(studysetId)),
))
(
id: path.id!.uuid,
studysetId: path.studysetId.uuid,
position: path.position,
payload: _opened(
'learning_path',
path.id!.uuid,
(keyId: path.keyId, nonce: path.nonce, payload: path.payload),
bound: 'studyset:${path.studysetId.uuid}',
item: 'path:${path.id!.uuid}',
treeKey: treeKey(path.studysetId.uuid),
),
),
];
}
/// [envelope]'s payload opened as [item]'s, [bound] to the row it
/// belongs to, in a tree with [treeKey]; blank where it doesn't open here,
/// or isn't trusted there.
ByteData _opened(
String table,
String id,
Envelope envelope, {
required String bound,
required String item,
required String? treeKey,
}) => switch (_privacy.openItem(
envelope,
table: table,
id: id,
bound: bound,
item: item,
treeKey: treeKey,
)) {
Readable(:final payload) => payload,
Locked() => redactedPayload,
};
@override
Future<String> insertPath({
required String studysetId,
required String position,
required ByteData payload,
}) async {
final id = Privacy.newId();
final sealed = _privacy.seal(
payload,
keyId: await _privacy.keyToWriteIn(studysetId),
table: 'learning_path',
id: id.uuid,
bound: 'studyset:$studysetId',
item: 'path:${id.uuid}',
);
final path = await protocol.LearningPath.db.insertRow(
_db,
protocol.LearningPath(
id: id,
studysetId: _uuid(studysetId),
position: position,
keyId: sealed.keyId,
nonce: sealed.nonce,
payload: sealed.payload,
),
);
_sync.requestSync();
return path.id!.uuid;
}
@override
Future<void> updatePathPayload(String id, ByteData payload) async {
if (await protocol.LearningPath.db.findById(_db, _uuid(id))
case final path?) {
final sealed = _privacy.seal(
payload,
keyId: await _privacy.keyOfPath(id),
table: 'learning_path',
id: id,
bound: 'studyset:${path.studysetId.uuid}',
item: 'path:$id',
);
// Only the named columns change (ADR 0001, rule 1).
await protocol.LearningPath.db.updateRow(
_db,
path.copyWith(
keyId: sealed.keyId,
nonce: sealed.nonce,
payload: sealed.payload,
),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
_sync.requestSync();
}
}
@override
Future<void> updatePathPosition(String id, String position) async {
if (await protocol.LearningPath.db.findById(_db, _uuid(id))
case final path?) {
await protocol.LearningPath.db.updateRow(
_db,
path.copyWith(position: position),
columns: (t) => [t.position],
);
_sync.requestSync();
}
}
@override
Future<void> deletePath(String id) async {
await protocol.LearningPathTag.db.deleteWhere(
_db,
where: (t) => t.pathId.equals(_uuid(id)),
);
await protocol.LearningPath.db.deleteWhere(
_db,
where: (t) => t.id.equals(_uuid(id)),
);
_sync.requestSync();
}
@override
Future<List<StepRow>> steps(Set<String> pathIds) async {
if (pathIds.isEmpty) {
return const [];
}
return [
for (final step in await protocol.LearningPathStep.db.find(
_db,
where: (t) => t.pathId.inSet({for (final id in pathIds) _uuid(id)}),
))
(
id: step.id!.uuid,
pathId: step.pathId.uuid,
position: step.position,
kind: step.kind,
),
];
}
@override
Future<List<StepRevisionRow>> revisions(Set<String> stepIds) async {
if (stepIds.isEmpty) {
return const [];
}
await _privacy.keys.refresh();
final ids = {for (final id in stepIds) _uuid(id)};
final treeKey = await _privacy.treeKeys();
final pathOf = {
for (final step in await protocol.LearningPathStep.db.find(
_db,
where: (t) => t.id.inSet(ids),
))
step.id!.uuid: step.pathId,
};
final studysetOf = pathOf.isEmpty
? const <String, String>{}
: {
for (final path in await protocol.LearningPath.db.find(
_db,
where: (t) => t.id.inSet(<protocol.UuidValue>{...pathOf.values}),
))
path.id!.uuid: path.studysetId.uuid,
};
String? treeKeyOf(String stepId) =>
switch (studysetOf[pathOf[stepId]?.uuid]) {
final studyset? => treeKey(studyset),
null => null,
};
return [
for (final revision in await protocol.LearningPathStepRevision.db.find(
_db,
where: (t) => t.stepId.inSet(ids),
))
(
id: revision.id!.uuid,
stepId: revision.stepId.uuid,
parents: {for (final parent in revision.parents) parent.uuid},
payload: _opened(
'learning_path_step_revision',
revision.id!.uuid,
(
keyId: revision.keyId,
nonce: revision.nonce,
payload: revision.payload,
),
bound: 'step:${revision.stepId.uuid}',
item: 'step:${revision.stepId.uuid}',
treeKey: treeKeyOf(revision.stepId.uuid),
),
),
];
}
@override
Future<({String stepId, String revisionId})> insertStep({
required String pathId,
required String position,
required String kind,
required ByteData payload,
}) async {
// Refused before anything is written where this device may write
// nothing (the final reading of 22 Oct).
await _privacy.keyOfPath(pathId);
final step = await protocol.LearningPathStep.db.insertRow(
_db,
protocol.LearningPathStep(
pathId: _uuid(pathId),
position: position,
kind: kind,
),
);
final revisionId = await insertRevision(
stepId: step.id!.uuid,
parents: const {},
payload: payload,
);
return (stepId: step.id!.uuid, revisionId: revisionId);
}
@override
Future<List<String>> insertSteps(
String pathId,
List<NewStepRow> steps,
) async {
if (steps.isEmpty) {
return const [];
}
final keyId = await _privacy.keyOfPath(pathId);
final rows = await protocol.LearningPathStep.db.insert(_db, [
for (final step in steps)
protocol.LearningPathStep(
pathId: _uuid(pathId),
position: step.position,
kind: step.kind,
),
]);
// The batch's positions are distinct, so they tell the stored rows apart
// whatever order they come back in.
final idOf = {for (final row in rows) row.position: row.id!};
await protocol.LearningPathStepRevision.db.insert(_db, [
for (final step in steps)
_newRevision(idOf[step.position]!, const [], step.payload, keyId),
]);
_sync.requestSync();
return [for (final step in steps) idOf[step.position]!.uuid];
}
@override
Future<String> insertRevision({
required String stepId,
required Set<String> parents,
required ByteData payload,
}) async {
final revision = await protocol.LearningPathStepRevision.db.insertRow(
_db,
_newRevision(
_uuid(stepId),
parents,
payload,
await _privacy.keyOfStep(stepId),
),
);
_sync.requestSync();
return revision.id!.uuid;
}
/// A new revision of [stepId] with [payload], sealed with [keyId].
protocol.LearningPathStepRevision _newRevision(
protocol.UuidValue stepId,
Iterable<String> parents,
ByteData payload,
String? keyId,
) {
final id = Privacy.newId();
final sealed = _privacy.seal(
payload,
keyId: keyId,
table: 'learning_path_step_revision',
id: id.uuid,
bound: 'step:${stepId.uuid}',
item: 'step:${stepId.uuid}',
);
return protocol.LearningPathStepRevision(
id: id,
stepId: stepId,
parents: [for (final parent in parents) _uuid(parent)],
authorId: _uuid(_authorId),
keyId: sealed.keyId,
nonce: sealed.nonce,
payload: sealed.payload,
);
}
@override
Future<void> updateStepPosition(String id, String position) async {
if (await protocol.LearningPathStep.db.findById(_db, _uuid(id))
case final step?) {
await protocol.LearningPathStep.db.updateRow(
_db,
step.copyWith(position: position),
columns: (t) => [t.position],
);
_sync.requestSync();
}
}
@override
Future<void> blank({String? stepId, String? pathId}) async {
if (stepId != null) {
final revisions = await protocol.LearningPathStepRevision.db.find(
_db,
where: (t) => t.stepId.equals(_uuid(stepId)),
);
// Column-scoped updates, which sync like any other edit (ADR 0002,
// rule 2).
await protocol.LearningPathStepRevision.db.update(
_db,
[
for (final revision in revisions)
revision.copyWith(payload: redactedPayload),
],
columns: (t) => [t.payload],
);
}
if (pathId != null) {
await updatePathPayload(pathId, redactedPayload);
}
_sync.requestSync();
}
@override
Future<void> deleteStep(String id) async {
final step = _uuid(id);
// Its answers go too: no content, but personal study data that would
// otherwise stay on the server as hidden rows.
await protocol.LearningPathAnswer.db.deleteWhere(
_db,
where: (t) => t.stepId.equals(step),
);
await protocol.LearningPathStepRevision.db.deleteWhere(
_db,
where: (t) => t.stepId.equals(step),
);
await protocol.LearningPathStep.db.deleteWhere(
_db,
where: (t) => t.id.equals(step),
);
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
protocol.UuidValue _uuid(String id) => protocol.UuidValue.withValidation(id);
}

View file

@ -0,0 +1,13 @@
/// Fakes of halkyon_learn_core's gateways, in memory, for the tests of
/// Learn's app and of its agent: nothing here reaches a server, a database
/// or the file system.
library;
export 'testing/fake_asset_gateway.dart';
export 'testing/fake_asset_sealing.dart';
export 'testing/fake_card_gateway.dart';
export 'testing/fake_feedback_gateways.dart';
export 'testing/fake_learning_path_gateway.dart';
export 'testing/fake_note_gateway.dart';
export 'testing/fake_studyset_gateway.dart';
export 'testing/fake_tag_gateway.dart';

View file

@ -0,0 +1,82 @@
import 'dart:typed_data';
import 'package:halkyon_learn_core/core/asset/data/asset_gateway.dart';
/// An asset row as tests write one: readable, unless
/// `FakeAssetGateway.receiveRemote` is told its key.
typedef FakeAssetRow = ({
String id,
String sha256,
String mimeType,
int byteSize,
ByteData payload,
});
/// An [AssetGateway] in memory: ids are `asset-001`, `asset-002`, … in the
/// order of creation. Payloads are kept as given: sealing them is the
/// envelope's, which its own tests cover.
class FakeAssetGateway() implements AssetGateway {
final _rows = <String, AssetRow>{};
var _next = 1;
/// The rows, in the order they were stored.
List<AssetRow> get rows => [..._rows.values];
/// The rows deleted, in order.
final deleted = <String>[];
/// Stores [row] as if another device had written it and a sync had
/// brought it here, sealed with [keyId] if given, [locked] where that key
/// doesn't open here.
void receiveRemote(FakeAssetRow row, {String? keyId, bool locked = false}) =>
_rows[row.id] = (
id: row.id,
sha256: row.sha256,
mimeType: row.mimeType,
byteSize: row.byteSize,
payload: row.payload,
keyId: keyId,
locked: locked,
);
@override
Future<AssetRow?> byId(String id) async => _rows[id];
@override
Future<AssetRow?> bySha256(String sha256) async =>
_rows.values.where((row) => row.sha256 == sha256).firstOrNull;
@override
Future<List<AssetRow>> all() async => [..._rows.values];
@override
String newId() => 'asset-${'${_next++}'.padLeft(3, '0')}';
@override
Future<String> insert({
required String id,
required String sha256,
required String mimeType,
required int byteSize,
required ByteData payload,
String? keyId,
}) async {
_rows[id] = (
id: id,
sha256: sha256,
mimeType: mimeType,
byteSize: byteSize,
payload: payload,
keyId: keyId,
locked: false,
);
return id;
}
@override
Future<void> delete(String id) async {
if (_rows.remove(id) != null) {
deleted.add(id);
}
}
}

View file

@ -0,0 +1,87 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_content/halkyon_content.dart';
import 'package:halkyon_content/testing.dart';
import 'package:halkyon_learn_core/core/asset/data/asset_sealing.dart';
/// [AssetSealing] for tests: the studysets in [keys] are private, sealed
/// with the key named; the others readable. Files are sealed with
/// [FakeContentCipher], bound to their row as the real ones are, behind a
/// header naming their key.
class FakeAssetSealing({
/// Each private studyset's key, by its id.
final Map<String, String> keys = const {},
/// Each private item's key, by its id.
final Map<String, String> itemKeys = const {},
}) implements AssetSealing {
/// The cipher the keys are open in; [keys]' keys are opened on first use.
final cipher = FakeContentCipher();
var _unlocked = false;
void _unlock() {
if (_unlocked) {
return;
}
_unlocked = true;
unawaited(
cipher.unlock(
UnlockedAccountKeys(
encryptionPublicKey: Uint8List.fromList(List.filled(32, 1)),
encryptionSecretKey: Uint8List.fromList(List.filled(32, 2)),
signingPublicKey: Uint8List.fromList(List.filled(32, 3)),
signingSecretKey: Uint8List.fromList(List.filled(64, 4)),
),
),
);
<String>{...keys.values, ...itemKeys.values}.forEach(cipher.newKey);
}
@override
Future<String?> keyOfStudyset(String studysetId) async => keys[studysetId];
@override
Future<String?> keyToWriteIn(String studysetId) async => keys[studysetId];
@override
Future<String?> keyOfItem(ItemKind kind, String id) async => itemKeys[id];
@override
Uint8List sealFile(
Uint8List plain, {
required String keyId,
required String assetId,
}) {
_unlock();
final sealed = cipher.seal(
plain,
keyId: keyId,
context: utf8.encode('asset|$assetId|file'),
);
final id = utf8.encode(keyId);
return Uint8List.fromList([id.length, ...id, ...sealed.payload]);
}
@override
Uint8List? openFile(Uint8List sealed, {required String assetId}) {
_unlock();
if (sealed.isEmpty || sealed.length < 1 + sealed[0]) {
return null;
}
final keyId = utf8.decode(
Uint8List.sublistView(sealed, 1, 1 + sealed[0]),
allowMalformed: true,
);
return cipher.unseal(
SealedPayload(
keyId: keyId,
nonce: Uint8List(24),
payload: Uint8List.sublistView(sealed, 1 + sealed[0]),
),
context: utf8.encode('asset|$assetId|file'),
);
}
}

View file

@ -0,0 +1,230 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_learn_core/core/content/payload.dart';
import 'package:halkyon_learn_core/feature/card/data/card_gateway.dart';
/// A [CardGateway] in memory. Ids sort by creation, as UUID v7 ids do: cards
/// are `card-0001`, `card-0002`, …, revisions `rev-0001`, …;
/// [receiveRemoteRevision] plays a sync that brought another device's edit.
class FakeCardGateway() implements CardGateway {
final _cards =
<
String,
({
String studysetId,
String position,
DateTime? buriedAt,
DateTime createdAt,
})
>{};
final _revisions = <String, CardRevisionRow>{};
final _remoteChanges = StreamController<void>.broadcast();
var _nextId = 1;
/// Every revision written on this device, in order.
final written = <CardRevisionRow>[];
/// The deletion steps, in order: `blank <card id>`, `delete <card id>`.
final deletions = <String>[];
/// When set, storing a card or a revision fails with it.
Exception? failWrites;
/// When set, deleting a card fails with it, before anything is blanked.
Exception? failDeletes;
/// When set, deleting a card waits until it completes.
Completer<void>? holdDeletes;
/// When set, storing cards in a batch waits until it completes.
Completer<void>? holdWrites;
/// The payload each revision holds now, by revision id.
Map<String, ByteData> get payloads => {
for (final revision in _revisions.values) revision.id: revision.payload,
};
/// Stores a revision as if another device had written it and a sync had
/// brought it here.
void receiveRemoteRevision({
required String id,
required String cardId,
required Set<String> parents,
required ByteData payload,
}) {
_revisions[id] = (
id: id,
cardId: cardId,
parents: parents,
payload: payload,
);
_remoteChanges.add(null);
}
@override
Future<List<CardRow>> cardsOf(String studysetId) async => [
for (final MapEntry(key: id, value: card) in _cards.entries)
if (card.studysetId == studysetId)
(id: id, position: card.position, buriedAt: card.buriedAt),
];
@override
Future<List<CardStampRow>> allCards() async => [
for (final MapEntry(key: id, value: card) in _cards.entries)
(id: id, studysetId: card.studysetId, createdAt: card.createdAt),
];
@override
Future<List<CardRevisionRow>> revisionsOf(Set<String> cardIds) async => [
for (final revision in _revisions.values)
if (cardIds.contains(revision.cardId)) revision,
];
@override
Future<List<CardRevisionRow>> allRevisions() async => [..._revisions.values];
@override
Future<List<String>> insertCards(
String studysetId,
List<NewCardRow> cards,
) async {
if (holdWrites case final hold?) {
await hold.future;
}
return [
for (final card in cards)
await insertCard(
studysetId: studysetId,
position: card.position,
payload: card.payload,
),
];
}
@override
Future<String> insertCard({
required String studysetId,
required String position,
required ByteData payload,
}) async {
if (failWrites case final error?) {
throw error;
}
final id = _newId('card');
_cards[id] = (
studysetId: studysetId,
position: position,
buriedAt: null,
createdAt: clock.now().toUtc(),
);
await insertRevision(cardId: id, parents: const {}, payload: payload);
return id;
}
@override
Future<String> insertRevision({
required String cardId,
required Set<String> parents,
required ByteData payload,
}) async {
if (failWrites case final error?) {
throw error;
}
final revision = (
id: _newId('rev'),
cardId: cardId,
parents: parents,
payload: payload,
);
_revisions[revision.id] = revision;
written.add(revision);
return revision.id;
}
/// When set, moving cards fails with it, none moved.
Exception? failMoves;
/// While set, moving cards waits for it to complete.
Completer<void>? holdMoves;
/// How many times cards were moved, however many at once.
int moves = 0;
@override
Future<void> updateStudysets(
String studysetId,
Map<String, String> positions,
) async {
await holdMoves?.future;
if (failMoves case final error?) {
throw error;
}
moves++;
for (final MapEntry(key: id, value: position) in positions.entries) {
_place(id, studysetId, position);
}
}
@override
Future<void> updateStudyset(
String cardId,
String studysetId,
String position,
) async => _place(cardId, studysetId, position);
void _place(String cardId, String studysetId, String position) {
if (_cards[cardId] case final card?) {
_cards[cardId] = (
studysetId: studysetId,
position: position,
buriedAt: card.buriedAt,
createdAt: card.createdAt,
);
}
}
@override
Future<void> updateBuriedAt(String cardId, DateTime? buriedAt) async {
if (_cards[cardId] case final card?) {
_cards[cardId] = (
studysetId: card.studysetId,
position: card.position,
buriedAt: buriedAt?.toUtc(),
createdAt: card.createdAt,
);
}
}
@override
Future<void> blankRevisions(String cardId) async {
await holdDeletes?.future;
if (failDeletes case final error?) {
throw error;
}
deletions.add('blank $cardId');
for (final revision in [..._revisions.values]) {
if (revision.cardId == cardId) {
_revisions[revision.id] = (
id: revision.id,
cardId: cardId,
parents: revision.parents,
payload: redactedPayload,
);
}
}
}
@override
Future<void> deleteCard(String cardId) async {
deletions.add('delete $cardId');
_cards.remove(cardId);
}
@override
Stream<void> get remoteChanges => _remoteChanges.stream;
String _newId(String kind) =>
'$kind-${(_nextId++).toString().padLeft(4, '0')}';
}

View file

@ -0,0 +1,227 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_content/halkyon_content.dart';
import 'package:halkyon_learn_core/feature/feedback/data/feedback_envelopes.dart';
import 'package:halkyon_learn_core/feature/feedback/data/feedback_gateway.dart';
import 'package:halkyon_learn_core/feature/feedback/model/feedback.dart';
/// A [FeedbackWriteGateway] in memory: what was written, in order.
class FakeFeedbackWriteGateway() implements FeedbackWriteGateway {
/// The threads written: their published studyset, kind and payload JSON
/// (opened, where [FakeFeedbackEnvelopes] sealed it), and within a shared
/// space, the space, its row id and its key.
final threads =
<
({
String publicationId,
FeedbackKind kind,
Map<String, Object?> json,
String? space,
String? id,
String? keyId,
})
>[];
/// The replies written, by thread, with their key.
final messages =
<({String threadId, Map<String, Object?> json, String? keyId})>[];
/// The decisions written, by thread, and the shared space they went in.
final decisions =
<({String threadId, FeedbackDecision decision, String? space})>[];
/// When set, writing a decision fails with it.
Exception? failDecisions;
/// While set, writing a decision waits for it to complete.
Completer<void>? holdDecisions;
@override
Future<String> insertThread({
required String publicationId,
required FeedbackKind kind,
required ByteData payload,
String? space,
String? id,
String? keyId,
ByteData? nonce,
}) async {
threads.add((
publicationId: publicationId,
kind: kind,
json: _json(payload),
space: space,
id: id,
keyId: keyId,
));
return id ?? 'thread-${threads.length}';
}
@override
Future<void> insertMessage(
String threadId,
ByteData payload, {
String? id,
String? keyId,
ByteData? nonce,
}) async =>
messages.add((threadId: threadId, json: _json(payload), keyId: keyId));
@override
Future<void> insertDecision(
String threadId,
FeedbackDecision decision, {
String? space,
}) async {
await holdDecisions?.future;
if (failDecisions case final error?) {
throw error;
}
decisions.add((threadId: threadId, decision: decision, space: space));
}
static Map<String, Object?> _json(ByteData payload) =>
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
as Map<String, Object?>;
}
/// A [FeedbackServerGateway] in memory: [threads] as the instance gathers
/// them (all public, for the inbox too), [files] by SHA-256.
class FakeFeedbackServerGateway({
final List<FeedbackThread> threads = const [],
final Map<String, Uint8List> files = const {},
}) implements FeedbackServerGateway {
late var _threads = [...threads];
/// The threads read, and the people blocked.
final seen = <String>[];
final blocked = <String>[];
/// When set, every request fails with it.
Exception? fail;
/// While set, marking a thread read waits for it to complete.
Completer<void>? holdSeen;
/// Replaces what the instance gathers.
set gathered(List<FeedbackThread> threads) => _threads = [...threads];
Future<void> _maybeFail() async {
if (fail case final error?) {
throw error;
}
}
@override
Future<List<FeedbackThread>> threadsOf(String publicId) async {
await _maybeFail();
return [
for (final thread in _threads)
if (thread.publicId == publicId) thread,
];
}
@override
Future<List<FeedbackThread>> threadsInSpace(String space) async {
await _maybeFail();
return [
for (final thread in _threads)
if (thread.space == space) thread,
];
}
@override
Future<List<FeedbackThread>> inbox() async {
await _maybeFail();
return [
for (final thread in _threads)
if (thread.mine || thread.forMe) thread,
];
}
@override
Future<int> unseen() async =>
(await inbox()).where((thread) => thread.unseen).length;
@override
Future<void> markSeen(String threadId) async {
await holdSeen?.future;
await _maybeFail();
seen.add(threadId);
}
@override
Future<void> block(String handle) async => blocked.add(handle);
@override
Future<void> unblock(String handle) async => blocked.remove(handle);
@override
Future<List<String>> blockedPeople() async => [...blocked];
@override
Future<Uint8List> file(String threadId, String sha256) async {
await _maybeFail();
return files[sha256]!;
}
}
/// [FeedbackEnvelopes] in memory: the studysets [spaces] holds are shared
/// in those spaces, those [keys] holds private under those keys; sealing
/// marks a payload with its key and leaves it readable, so tests read what
/// was written, and opening refuses a key not in [keys]'s values or a row
/// sealed for another.
class FakeFeedbackEnvelopes({
final Map<String, String> spaces = const {},
final Map<String, String> keys = const {},
final Map<String, String> titles = const {},
}) implements FeedbackEnvelopes {
var _ids = 0;
@override
Future<String?> spaceOf(String studysetId) async => spaces[studysetId];
@override
Future<String?> keyToWriteIn(String studysetId) async => keys[studysetId];
@override
String newId() => 'row-${++_ids}';
@override
Envelope seal(
ByteData plain, {
required String? keyId,
required String table,
required String id,
required String bound,
}) => (
keyId: keyId,
nonce: keyId == null
? null
: ByteData.sublistView(utf8.encode('$table|$id|$bound')),
payload: plain,
);
@override
ByteData? open(
Envelope envelope, {
required String table,
required String id,
required String bound,
}) {
if (envelope.keyId case final keyId?) {
final nonce = envelope.nonce;
if (!keys.containsValue(keyId) ||
nonce == null ||
utf8.decode(Uint8List.sublistView(nonce)) != '$table|$id|$bound') {
return null;
}
}
return envelope.payload;
}
@override
Future<String> titleOf(String studysetId) async => titles[studysetId] ?? '';
}

View file

@ -0,0 +1,211 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:halkyon_learn_core/core/content/payload.dart';
import 'package:halkyon_learn_core/feature/learning_path/data/learning_path_gateway.dart';
/// A [LearningPathGateway] in memory. Ids sort by creation, as UUID v7 ids
/// do: paths are `path-0001`, …, steps `step-0002`, …, revisions
/// `srev-0003`, …; [receiveRemoteRevision] plays a sync that brought another
/// device's edit.
class FakeLearningPathGateway() implements LearningPathGateway {
final _paths = <String, PathRow>{};
final _steps = <String, StepRow>{};
final _revisions = <String, StepRevisionRow>{};
final _remoteChanges = StreamController<void>.broadcast();
var _nextId = 1;
/// Every step revision written on this device, in order.
final written = <StepRevisionRow>[];
/// While set, a new step waits for it to complete before it is stored, as
/// on a slow device: what shows while steps come in can be seen.
Completer<void>? holdSteps;
/// The deletion steps, in order: `blank step <id>`, `blank path <id>`,
/// `delete step <id>`, `delete path <id>`.
final deletions = <String>[];
/// The payload each step revision holds now, by revision id.
Map<String, ByteData> get revisionPayloads => {
for (final revision in _revisions.values) revision.id: revision.payload,
};
/// The payload the path [id] holds now; null once deleted.
ByteData? pathPayload(String id) => _paths[id]?.payload;
/// Stores a step revision as if another device had written it and a sync
/// had brought it here.
void receiveRemoteRevision({
required String id,
required String stepId,
required Set<String> parents,
required ByteData payload,
}) {
_revisions[id] = (
id: id,
stepId: stepId,
parents: parents,
payload: payload,
);
_remoteChanges.add(null);
}
@override
Future<List<PathRow>> paths({String? studysetId}) async => [
for (final path in _paths.values)
if (studysetId == null || path.studysetId == studysetId) path,
];
@override
Future<String> insertPath({
required String studysetId,
required String position,
required ByteData payload,
}) async {
final id = _newId('path');
_paths[id] = (
id: id,
studysetId: studysetId,
position: position,
payload: payload,
);
return id;
}
@override
Future<void> updatePathPayload(String id, ByteData payload) async {
if (_paths[id] case final path?) {
_paths[id] = (
id: id,
studysetId: path.studysetId,
position: path.position,
payload: payload,
);
}
}
@override
Future<void> updatePathPosition(String id, String position) async {
if (_paths[id] case final path?) {
_paths[id] = (
id: id,
studysetId: path.studysetId,
position: position,
payload: path.payload,
);
}
}
@override
Future<void> deletePath(String id) async {
deletions.add('delete path $id');
_paths.remove(id);
}
@override
Future<List<StepRow>> steps(Set<String> pathIds) async => [
for (final step in _steps.values)
if (pathIds.contains(step.pathId)) step,
];
@override
Future<List<StepRevisionRow>> revisions(Set<String> stepIds) async => [
for (final revision in _revisions.values)
if (stepIds.contains(revision.stepId)) revision,
];
@override
Future<({String stepId, String revisionId})> insertStep({
required String pathId,
required String position,
required String kind,
required ByteData payload,
}) async {
await holdSteps?.future;
final id = _newId('step');
_steps[id] = (id: id, pathId: pathId, position: position, kind: kind);
final revisionId = await insertRevision(
stepId: id,
parents: const {},
payload: payload,
);
return (stepId: id, revisionId: revisionId);
}
@override
Future<List<String>> insertSteps(
String pathId,
List<NewStepRow> steps,
) async => [
for (final step in steps)
(await insertStep(
pathId: pathId,
position: step.position,
kind: step.kind,
payload: step.payload,
)).stepId,
];
@override
Future<String> insertRevision({
required String stepId,
required Set<String> parents,
required ByteData payload,
}) async {
final revision = (
id: _newId('srev'),
stepId: stepId,
parents: parents,
payload: payload,
);
_revisions[revision.id] = revision;
written.add(revision);
return revision.id;
}
@override
Future<void> updateStepPosition(String id, String position) async {
if (_steps[id] case final step?) {
_steps[id] = (
id: id,
pathId: step.pathId,
position: position,
kind: step.kind,
);
}
}
@override
Future<void> blank({String? stepId, String? pathId}) async {
if (stepId != null) {
deletions.add('blank step $stepId');
for (final revision in [..._revisions.values]) {
if (revision.stepId == stepId) {
_revisions[revision.id] = (
id: revision.id,
stepId: stepId,
parents: revision.parents,
payload: redactedPayload,
);
}
}
}
if (pathId != null) {
deletions.add('blank path $pathId');
await updatePathPayload(pathId, redactedPayload);
}
}
@override
Future<void> deleteStep(String id) async {
deletions.add('delete step $id');
_steps.remove(id);
}
@override
Stream<void> get remoteChanges => _remoteChanges.stream;
String _newId(String kind) =>
'$kind-${(_nextId++).toString().padLeft(4, '0')}';
}

View file

@ -0,0 +1,149 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_learn_core/core/content/payload.dart';
import 'package:halkyon_learn_core/feature/note/data/note_gateway.dart';
/// A [NoteGateway] in memory. Ids sort by creation, as UUID v7 ids do: notes
/// are `note-0001`, `note-0002`, …, revisions `nrev-0001`, …, each written
/// at the clock's time; [receiveRemoteRevision] plays a sync that brought
/// another device's edit.
class FakeNoteGateway() implements NoteGateway {
final _notes = <String, ({String studysetId, String position})>{};
final _revisions = <String, NoteRevisionRow>{};
final _remoteChanges = StreamController<void>.broadcast();
var _nextId = 1;
/// Every revision written on this device, in order.
final written = <NoteRevisionRow>[];
/// The deletion steps, in order: `blank <note id>`, `delete <note id>`.
final deletions = <String>[];
/// When set, storing a note or a revision fails with it.
Exception? failWrites;
/// The payload each revision holds now, by revision id.
Map<String, ByteData> get payloads => {
for (final revision in _revisions.values) revision.id: revision.payload,
};
/// Stores a revision as if another device had written it and a sync had
/// brought it here.
void receiveRemoteRevision({
required String id,
required String noteId,
required Set<String> parents,
required ByteData payload,
}) {
_revisions[id] = (
id: id,
noteId: noteId,
parents: parents,
payload: payload,
writtenAt: clock.now().toUtc(),
);
_remoteChanges.add(null);
}
/// Deletes the note [noteId] as if another device had done it and a sync
/// had brought it here.
void receiveRemoteDeletion(String noteId) {
_notes.remove(noteId);
_remoteChanges.add(null);
}
@override
Future<List<NoteRow>> notesOf(String studysetId) async => [
for (final MapEntry(key: id, value: note) in _notes.entries)
if (note.studysetId == studysetId) (id: id, position: note.position),
];
@override
Future<List<NoteStampRow>> allNotes() async => [
for (final MapEntry(key: id, value: note) in _notes.entries)
(id: id, studysetId: note.studysetId),
];
@override
Future<List<NoteRevisionRow>> revisionsOf(Set<String> noteIds) async => [
for (final revision in _revisions.values)
if (noteIds.contains(revision.noteId)) revision,
];
@override
Future<({String noteId, String revisionId})> insertNote({
required String studysetId,
required String position,
required ByteData payload,
}) async {
if (failWrites case final error?) {
throw error;
}
final id = _newId('note');
_notes[id] = (studysetId: studysetId, position: position);
final revisionId = await insertRevision(
noteId: id,
parents: const {},
payload: payload,
);
return (noteId: id, revisionId: revisionId);
}
@override
Future<String> insertRevision({
required String noteId,
required Set<String> parents,
required ByteData payload,
}) async {
if (failWrites case final error?) {
throw error;
}
final revision = (
id: _newId('nrev'),
noteId: noteId,
parents: parents,
payload: payload,
writtenAt: clock.now().toUtc(),
);
_revisions[revision.id] = revision;
written.add(revision);
return revision.id;
}
@override
Future<void> updatePosition(String noteId, String position) async {
if (_notes[noteId] case final note?) {
_notes[noteId] = (studysetId: note.studysetId, position: position);
}
}
@override
Future<void> blankRevisions(String noteId) async {
deletions.add('blank $noteId');
for (final revision in [..._revisions.values]) {
if (revision.noteId == noteId) {
_revisions[revision.id] = (
id: revision.id,
noteId: noteId,
parents: revision.parents,
payload: redactedPayload,
writtenAt: revision.writtenAt,
);
}
}
}
@override
Future<void> deleteNote(String noteId) async {
deletions.add('delete $noteId');
_notes.remove(noteId);
}
@override
Stream<void> get remoteChanges => _remoteChanges.stream;
String _newId(String kind) =>
'$kind-${(_nextId++).toString().padLeft(4, '0')}';
}

View file

@ -0,0 +1,347 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:halkyon_learn_core/core/privacy/model/unsigned_edit.dart';
import 'package:halkyon_learn_core/feature/studyset/data/studyset_gateway.dart';
/// A [StudysetGateway] in memory: ids are `studyset-1`, `studyset-2`, …, and
/// [receiveRemoteChange] and [receiveRemoteDeletion] play a sync that brought
/// changes.
class FakeStudysetGateway() implements StudysetGateway {
final _rows = <String, StudysetRow>{};
final _remoteChanges = StreamController<void>.broadcast();
var _nextId = 1;
/// Every payload written, in order, as (id, payload).
final writes = <(String, ByteData)>[];
/// The ids of the deleted rows, in order.
final deleted = <String>[];
/// Every move, in order, as (id, parent, position).
final moves = <(String, String?, String)>[];
/// When set, reading the studysets fails with it.
Exception? failReads;
/// When set, reading the studysets waits until it completes.
Completer<void>? holdReads;
/// How often the studysets were read.
int reads = 0;
/// Whether a new top-level studyset is private, as on a device holding
/// the account's keys (ADR 0019).
bool privateByDefault = false;
/// Stores [payload] as if another device had written it and a sync had
/// brought it here, with the other columns given.
void receiveRemoteChange(
String id,
ByteData payload, {
String? parentId,
String position = 'a0',
DateTime? archivedAt,
DateTime? deletedAt,
DateTime? starredAt,
DateTime? updatedAt,
bool isPrivate = false,
bool locked = false,
}) {
_rows[id] = (
id: id,
payload: payload,
parentId: parentId,
position: position,
archivedAt: archivedAt,
deletedAt: deletedAt,
starredAt: starredAt,
updatedAt: updatedAt,
isPrivate: isPrivate,
locked: locked,
fullyPrivate: true,
keysHere: true,
);
_remoteChanges.add(null);
}
/// Removes the studyset [id] as if another device had deleted it and a
/// sync had brought that here.
void receiveRemoteDeletion(String id) {
_rows.remove(id);
_remoteChanges.add(null);
}
/// Every change of privacy, in order, as (id, private).
final privacyChanges = <(String, bool)>[];
/// While set, making a tree private or readable waits for it, as a
/// rewrite of thousands of rows does.
Completer<void>? holdPrivacy;
@override
bool canMakePrivate = false;
@override
bool keysMissing = false;
/// The unsigned edits each studyset holds.
final unsigned = <String, List<UnsignedEdit>>{};
/// The studysets whose unsigned edits were taken in.
final adopted = <String>[];
@override
Future<List<UnsignedEdit>> unsignedEditsOf(String id) async =>
unsigned[id] ?? const [];
@override
Future<int> adoptUnsignedEdits(String id, List<UnsignedEdit> listed) async {
adopted.add(id);
unsigned.remove(id);
return 0;
}
@override
Future<int> setPrivate(String id, {required bool private}) async {
if (holdPrivacy case final hold?) {
await hold.future;
}
final tree = {id};
var grew = true;
while (grew) {
grew = false;
for (final row in _rows.values) {
if (tree.contains(row.parentId) && tree.add(row.id)) {
grew = true;
}
}
}
for (final member in tree) {
final row = _rows[member]!;
_rows[member] = (
id: row.id,
payload: row.payload,
parentId: row.parentId,
position: row.position,
archivedAt: row.archivedAt,
deletedAt: row.deletedAt,
starredAt: row.starredAt,
updatedAt: row.updatedAt,
isPrivate: private,
locked: row.locked,
fullyPrivate: true,
keysHere: true,
);
}
privacyChanges.add((id, private));
return 0;
}
/// The studysets in shared spaces, as [sharing] gives them.
final shared = <String, StudysetSharing>{};
@override
Future<Map<String, StudysetSharing>> sharing() async => {...shared};
@override
Future<List<StudysetRow>> all() async {
reads++;
if (holdReads case final hold?) {
await hold.future;
}
if (failReads case final error?) {
throw error;
}
return [..._rows.values];
}
@override
Future<String> insert(
ByteData payload,
DateTime updatedAt, {
String? parentId,
String position = 'a0',
String? id,
}) async {
id ??= 'studyset-${_nextId++}';
_rows[id] = (
id: id,
payload: payload,
parentId: parentId,
position: position,
archivedAt: null,
deletedAt: null,
starredAt: null,
updatedAt: updatedAt,
// New studysets are private where the device can encrypt (ADR 0019):
// the parent's tree's privacy, or [privateByDefault] at the top.
isPrivate: switch (parentId) {
final parent? => _rows[parent]?.isPrivate ?? false,
null => privateByDefault,
},
locked: false,
fullyPrivate: true,
keysHere: true,
);
writes.add((id, payload));
return id;
}
@override
Future<void> updatePayload(
String id,
ByteData payload,
DateTime updatedAt,
) async {
if (_rows[id] case final row?) {
_rows[id] = (
id: id,
payload: payload,
parentId: row.parentId,
position: row.position,
archivedAt: row.archivedAt,
deletedAt: row.deletedAt,
starredAt: row.starredAt,
updatedAt: updatedAt,
isPrivate: row.isPrivate,
locked: row.locked,
fullyPrivate: true,
keysHere: true,
);
writes.add((id, payload));
}
}
@override
Future<void> updateStarredAt(
String id,
DateTime? starredAt,
DateTime updatedAt,
) async {
if (_rows[id] case final row?) {
_rows[id] = (
id: id,
payload: row.payload,
parentId: row.parentId,
position: row.position,
archivedAt: row.archivedAt,
deletedAt: row.deletedAt,
starredAt: starredAt,
updatedAt: updatedAt,
isPrivate: row.isPrivate,
locked: row.locked,
fullyPrivate: true,
keysHere: true,
);
}
}
@override
Future<void> updateParent(
String id,
String? parentId,
String position,
DateTime updatedAt,
) async {
if (_rows[id] case final row?) {
_rows[id] = (
id: id,
payload: row.payload,
parentId: parentId,
position: position,
archivedAt: row.archivedAt,
deletedAt: row.deletedAt,
starredAt: row.starredAt,
updatedAt: updatedAt,
isPrivate: row.isPrivate,
locked: row.locked,
fullyPrivate: true,
keysHere: true,
);
moves.add((id, parentId, position));
}
}
@override
Future<void> updatePosition(
String id,
String position,
DateTime updatedAt,
) async {
if (_rows[id] case final row?) {
_rows[id] = (
id: id,
payload: row.payload,
parentId: row.parentId,
position: position,
archivedAt: row.archivedAt,
deletedAt: row.deletedAt,
starredAt: row.starredAt,
updatedAt: updatedAt,
isPrivate: row.isPrivate,
locked: row.locked,
fullyPrivate: true,
keysHere: true,
);
moves.add((id, row.parentId, position));
}
}
@override
Future<void> updateArchivedAt(
String id,
DateTime? archivedAt,
DateTime updatedAt,
) async {
if (_rows[id] case final row?) {
_rows[id] = (
id: id,
payload: row.payload,
parentId: row.parentId,
position: row.position,
archivedAt: archivedAt,
deletedAt: row.deletedAt,
starredAt: row.starredAt,
updatedAt: updatedAt,
isPrivate: row.isPrivate,
locked: row.locked,
fullyPrivate: true,
keysHere: true,
);
}
}
@override
Future<void> updateDeletedAt(
String id,
DateTime? deletedAt,
DateTime updatedAt,
) async {
if (_rows[id] case final row?) {
_rows[id] = (
id: id,
payload: row.payload,
parentId: row.parentId,
position: row.position,
archivedAt: row.archivedAt,
deletedAt: deletedAt,
starredAt: row.starredAt,
updatedAt: updatedAt,
isPrivate: row.isPrivate,
locked: row.locked,
fullyPrivate: true,
keysHere: true,
);
}
}
@override
Future<void> delete(String id) async {
_rows.remove(id);
deleted.add(id);
}
@override
Stream<void> get remoteChanges => _remoteChanges.stream;
}

View file

@ -0,0 +1,162 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:halkyon_learn_core/feature/tag/data/tag_gateway.dart';
/// A [TagGateway] in memory: ids are `tag-001`, `tag-002`, … and
/// `card-tag-001`, … and `note-tag-001`, …, in the order of creation as UUID
/// v7s are, and the
/// `receiveRemote…` methods play a sync that brought changes.
class FakeTagGateway() implements TagGateway {
final _tags = <String, ByteData>{};
final _cardTags = <String, CardTagRow>{};
final _noteTags = <String, NoteTagRow>{};
final _pathTags = <String, PathTagRow>{};
final _remoteChanges = StreamController<void>.broadcast();
var _nextTag = 1;
var _nextCardTag = 1;
var _nextNoteTag = 1;
var _nextPathTag = 1;
/// Every tag payload written, in order, as (id, payload).
final writes = <(String, ByteData)>[];
/// The ids of the deleted tags, in order.
final deleted = <String>[];
/// When set, reading the tags fails with it.
Exception? failReads;
/// How often the cards' tags were read: every card's, so reading them is
/// what costs.
int cardTagReads = 0;
/// How many tags cards have, in all.
int get cardTagCount => _cardTags.length;
/// Stores the tag [id] as if another device had written it and a sync had
/// brought it here.
void receiveRemoteTag(String id, ByteData payload) {
_tags[id] = payload;
_remoteChanges.add(null);
}
/// Gives the card [cardId] the tag [tagId] as if another device had done
/// it and a sync had brought it here.
void receiveRemoteCardTag(String id, String cardId, String tagId) {
_cardTags[id] = (id: id, cardId: cardId, tagId: tagId);
_remoteChanges.add(null);
}
/// Gives the note [noteId] the tag [tagId] as if another device had done
/// it and a sync had brought it here.
void receiveRemoteNoteTag(String id, String noteId, String tagId) {
_noteTags[id] = (id: id, noteId: noteId, tagId: tagId);
_remoteChanges.add(null);
}
/// Gives the learning path [pathId] the tag [tagId] as if another device
/// had done it and a sync had brought it here.
void receiveRemotePathTag(String id, String pathId, String tagId) {
_pathTags[id] = (id: id, pathId: pathId, tagId: tagId);
_remoteChanges.add(null);
}
@override
Future<List<TagRow>> tags() async {
if (failReads case final error?) {
throw error;
}
return [
for (final MapEntry(key: id, value: payload) in _tags.entries)
(id: id, payload: payload),
];
}
@override
Future<List<CardTagRow>> cardTags() async {
cardTagReads++;
return [..._cardTags.values];
}
@override
Future<List<NoteTagRow>> noteTags() async => [..._noteTags.values];
@override
Future<List<PathTagRow>> pathTags() async => [..._pathTags.values];
@override
Future<String> insertTag(ByteData payload) async {
final id = 'tag-${_padded(_nextTag++)}';
_tags[id] = payload;
writes.add((id, payload));
return id;
}
@override
Future<void> updateTagPayload(String id, ByteData payload) async {
if (_tags.containsKey(id)) {
_tags[id] = payload;
writes.add((id, payload));
}
}
@override
Future<void> deleteTag(String id) async {
_cardTags.removeWhere((_, row) => row.tagId == id);
_noteTags.removeWhere((_, row) => row.tagId == id);
_pathTags.removeWhere((_, row) => row.tagId == id);
_tags.remove(id);
deleted.add(id);
}
@override
Future<void> insertCardTags(
Iterable<({String cardId, String tagId})> assignments,
) async {
for (final (:cardId, :tagId) in assignments) {
final id = 'card-tag-${_padded(_nextCardTag++)}';
_cardTags[id] = (id: id, cardId: cardId, tagId: tagId);
}
}
@override
Future<void> deleteCardTags(Set<String> ids) async {
_cardTags.removeWhere((id, _) => ids.contains(id));
}
@override
Future<void> insertNoteTags(
Iterable<({String noteId, String tagId})> assignments,
) async {
for (final (:noteId, :tagId) in assignments) {
final id = 'note-tag-${_padded(_nextNoteTag++)}';
_noteTags[id] = (id: id, noteId: noteId, tagId: tagId);
}
}
@override
Future<void> deleteNoteTags(Set<String> ids) async {
_noteTags.removeWhere((id, _) => ids.contains(id));
}
@override
Future<void> insertPathTags(
Iterable<({String pathId, String tagId})> assignments,
) async {
for (final (:pathId, :tagId) in assignments) {
final id = 'path-tag-${_padded(_nextPathTag++)}';
_pathTags[id] = (id: id, pathId: pathId, tagId: tagId);
}
}
@override
Future<void> deletePathTags(Set<String> ids) async {
_pathTags.removeWhere((id, _) => ids.contains(id));
}
@override
Stream<void> get remoteChanges => _remoteChanges.stream;
String _padded(int number) => '$number'.padLeft(3, '0');
}