feat(learn): Learn's agent, an MCP server suggesting in shared studysets
Some checks failed
CI / check (push) Failing after 1m56s
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:
parent
72cac92365
commit
54e212a51c
12 changed files with 2925 additions and 314 deletions
|
|
@ -8,13 +8,14 @@ both read and write, so the privacy rules and the payloads live in one place.
|
|||
`SyncSpaceLookup`, positions), people's files (`asset/`), `LatestValue` and `watchReads`
|
||||
(`async/`), citations and public people.
|
||||
- `feature/`: the models, payload codecs, gateways (the synced ones included) and repositories
|
||||
of studysets, cards, notes, tags and feedback, and what they take of learning paths and study
|
||||
of studysets, cards, notes, tags, feedback and learning paths, and what they take of study
|
||||
options.
|
||||
- `testing.dart`: the fakes of its gateways, for the app's tests and the agent's.
|
||||
|
||||
The synced gateways take halkyon_core's `SyncRequests`, which halkyon_app's `SyncService` and
|
||||
the agent's own sync implement. What only the app shows (progress, exam coverage, deletions'
|
||||
waits, the card browser, a subject's icon and name) stays in the app, `halkyon_learn_flutter`.
|
||||
Paths' sync, PDFs, reviews and settings move here when the agent needs them.
|
||||
PDFs, reviews and settings move here when the agent needs them.
|
||||
|
||||
Its tests are still the app's (`halkyon_learn_flutter/test/`), on the fakes there. Licensed under
|
||||
Its tests are still the app's (`halkyon_learn_flutter/test/`), on the fakes of `testing.dart`. Licensed under
|
||||
the AGPL-3.0.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
13
apps/learn/halkyon_learn_core/lib/testing.dart
Normal file
13
apps/learn/halkyon_learn_core/lib/testing.dart
Normal 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';
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
230
apps/learn/halkyon_learn_core/lib/testing/fake_card_gateway.dart
Normal file
230
apps/learn/halkyon_learn_core/lib/testing/fake_card_gateway.dart
Normal 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')}';
|
||||
}
|
||||
|
|
@ -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] ?? '';
|
||||
}
|
||||
|
|
@ -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')}';
|
||||
}
|
||||
149
apps/learn/halkyon_learn_core/lib/testing/fake_note_gateway.dart
Normal file
149
apps/learn/halkyon_learn_core/lib/testing/fake_note_gateway.dart
Normal 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')}';
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
162
apps/learn/halkyon_learn_core/lib/testing/fake_tag_gateway.dart
Normal file
162
apps/learn/halkyon_learn_core/lib/testing/fake_tag_gateway.dart
Normal 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');
|
||||
}
|
||||
Loading…
Reference in a new issue