feat(learn): new rows of a shared studyset go in its space
ADR 0044, D24, step 4's routing. `SpaceRouter` answers the sync engine's `newRowSpace`: a studyset's cards, notes, paths, PDFs and their revisions go in the space the studyset is in, a topic in its parent's, a studyset placed for a share (`placeIn`) in its space, and a file in the space its key is for; everything else, reviews and tags among them (D2, D25, D26), stays the person's own. Which studysets are shared is read from the engine's grants and kept until a sync brings changes. A shared space's key has an ID of its own, `SpaceKeyId` (`h1.<space>.<ms>.<nonce>`), which `ContentKeyId` reads as none of its own, so no `content_key` row passes for one. No studyset is shared yet: until one is, every row goes in the person's space as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
4befd98d2f
commit
92b87e218d
8 changed files with 638 additions and 2 deletions
File diff suppressed because one or more lines are too long
|
|
@ -17,6 +17,8 @@ import 'package:halkyon_learn_flutter/src/core/privacy/data/content_keys.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy_ledger.dart';
|
import 'package:halkyon_learn_flutter/src/core/privacy/data/privacy_ledger.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/privacy/data/sync_content_key_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/core/privacy/data/sync_content_key_gateway.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/space_router.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/sync_space_lookup.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
|
import 'package:halkyon_learn_flutter/src/core/version/app_version.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
|
import 'package:halkyon_learn_flutter/src/core/video/media_players.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
|
import 'package:halkyon_learn_flutter/src/core/video/youtube_videos.dart';
|
||||||
|
|
@ -465,6 +467,13 @@ Future<PersonDependencies> _openPersonData(
|
||||||
p.join(folder.path, 'sync.sqlite'),
|
p.join(folder.path, 'sync.sqlite'),
|
||||||
persistentUserId: UuidValue.withValidation(person.authUserId),
|
persistentUserId: UuidValue.withValidation(person.authUserId),
|
||||||
);
|
);
|
||||||
|
// A new row of a studyset shared goes in its space, the person's own rows
|
||||||
|
// in theirs (Halkyon's ADR 0044, D24).
|
||||||
|
final spaces = SpaceRouter(SyncSpaceLookup(db, person.authUserId));
|
||||||
|
db.db.newRowSpace = (row) async => switch (await spaces.spaceOf(row)) {
|
||||||
|
final space? => UuidValue.withValidation(space),
|
||||||
|
null => null,
|
||||||
|
};
|
||||||
final kept = FilePendingChangesStore(
|
final kept = FilePendingChangesStore(
|
||||||
File(p.join(folder.path, 'sync_pending.json')),
|
File(p.join(folder.path, 'sync_pending.json')),
|
||||||
);
|
);
|
||||||
|
|
@ -474,7 +483,11 @@ Future<PersonDependencies> _openPersonData(
|
||||||
db,
|
db,
|
||||||
// Only when changes from other devices came in: a sync that only
|
// Only when changes from other devices came in: a sync that only
|
||||||
// sent this device's changes has nothing new to read.
|
// sent this device's changes has nothing new to read.
|
||||||
onInboundMerge: (space) => onMerge(),
|
onInboundMerge: (space) {
|
||||||
|
// A sync may have brought or taken shared studysets.
|
||||||
|
spaces.forget();
|
||||||
|
onMerge();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
compatibility,
|
compatibility,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// A shared space's data key's ID (Halkyon's ADR 0044, D21, D22): the space
|
||||||
|
/// it is for and when it was made. The ID is sealed with the key to each
|
||||||
|
/// member (halkyon_keys' `sealDataKey` opens a key only under the ID it was
|
||||||
|
/// sealed with), so the instance can't hand one space's key out as
|
||||||
|
/// another's. Never a `content_key` row's: those are the person's own
|
||||||
|
/// (`ContentKeyId` reads this form as none of its own).
|
||||||
|
final class const SpaceKeyId({
|
||||||
|
/// The shared space the key is for.
|
||||||
|
required final String space,
|
||||||
|
|
||||||
|
/// When it was made, in milliseconds since the epoch (UTC), on the device
|
||||||
|
/// that made it: the latest is the one new writes take (D22).
|
||||||
|
required final int madeAt,
|
||||||
|
|
||||||
|
/// The ID as it is stored.
|
||||||
|
required final String value,
|
||||||
|
}) with Equatable {
|
||||||
|
/// A new ID for [space]'s key, made now, later than [after] (milliseconds,
|
||||||
|
/// the latest key known of the space) whatever this device's clock says.
|
||||||
|
factory next(String space, {int? after}) {
|
||||||
|
final random = Random.secure();
|
||||||
|
final nonce = base64Url
|
||||||
|
.encode([for (var i = 0; i < 12; i++) random.nextInt(256)])
|
||||||
|
.replaceAll('=', '');
|
||||||
|
final now = clock.now().toUtc().millisecondsSinceEpoch;
|
||||||
|
final ms = after != null && after >= now ? after + 1 : now;
|
||||||
|
return SpaceKeyId(space: space, madeAt: ms, value: 'h1.$space.$ms.$nonce');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [value] read as a space key's ID; null for any other form.
|
||||||
|
static SpaceKeyId? parse(String? value) {
|
||||||
|
if (value case final value?) {
|
||||||
|
if (value.split('.') case ['h1', final space, final ms, final nonce]
|
||||||
|
when space.isNotEmpty && nonce.isNotEmpty) {
|
||||||
|
if (int.tryParse(ms) case final madeAt?) {
|
||||||
|
return SpaceKeyId(space: space, madeAt: madeAt, value: value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [space, madeAt, value];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => value;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/privacy/data/space_key_id.dart';
|
||||||
|
|
||||||
|
/// What [SpaceRouter] asks of the person's database.
|
||||||
|
abstract interface class SpaceLookup() {
|
||||||
|
/// The studysets in each shared space the person is a member of, by
|
||||||
|
/// space; their personal space left out.
|
||||||
|
Future<Map<String, Set<String>>> sharedStudysets();
|
||||||
|
|
||||||
|
/// The studyset of the card [id]; null for none here.
|
||||||
|
Future<String?> studysetOfCard(String id);
|
||||||
|
|
||||||
|
/// The studyset of the note [id]; null for none here.
|
||||||
|
Future<String?> studysetOfNote(String id);
|
||||||
|
|
||||||
|
/// The studyset of the learning path [id]; null for none here.
|
||||||
|
Future<String?> studysetOfPath(String id);
|
||||||
|
|
||||||
|
/// The learning path of the step [id]; null for none here.
|
||||||
|
Future<String?> pathOfStep(String id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which space a new synced row goes in (Halkyon's ADR 0044, D24), as the
|
||||||
|
/// sync engine asks (`OfflineSyncDatabase.newRowSpace`): what belongs to a
|
||||||
|
/// studyset goes in that studyset's space, a file in the space its key is
|
||||||
|
/// for, a studyset placed with [placeIn] in that space; everything else,
|
||||||
|
/// and everything of a studyset in no shared space, in the person's own
|
||||||
|
/// (null). Reviews, tags, settings and the like are the person's always
|
||||||
|
/// (D2, D25, D26).
|
||||||
|
class SpaceRouter(final SpaceLookup _lookup) {
|
||||||
|
/// The shared space of each studyset in one, read once until [forget].
|
||||||
|
Map<String, String>? _spaces;
|
||||||
|
|
||||||
|
/// Studysets placed before they are written, by their ids.
|
||||||
|
final _placed = <String, String>{};
|
||||||
|
|
||||||
|
/// Places the studyset [studysetId], about to be written without a
|
||||||
|
/// parent, in the shared [space]: the top of a studyset shared (D14).
|
||||||
|
void placeIn(String studysetId, String space) => _placed[studysetId] = space;
|
||||||
|
|
||||||
|
/// Forgets which studysets are in which space, as a sync may have
|
||||||
|
/// brought or taken some.
|
||||||
|
void forget() => _spaces = null;
|
||||||
|
|
||||||
|
/// The shared space [row] goes in; null for the person's own.
|
||||||
|
Future<String?> spaceOf(Object row) => switch (row) {
|
||||||
|
protocol.Studyset(:final id?, parentId: null) => _placedStudyset(id.uuid),
|
||||||
|
protocol.Studyset(:final id, :final parentId?) => _placedChild(
|
||||||
|
id?.uuid,
|
||||||
|
parentId.uuid,
|
||||||
|
),
|
||||||
|
protocol.Card(:final studysetId) ||
|
||||||
|
protocol.Note(:final studysetId) ||
|
||||||
|
protocol.LearningPath(:final studysetId) ||
|
||||||
|
protocol.PdfSource(:final studysetId) => _ofStudyset(studysetId.uuid),
|
||||||
|
protocol.CardRevision(:final cardId) =>
|
||||||
|
_lookup.studysetOfCard(cardId.uuid).then(_ofStudyset),
|
||||||
|
protocol.NoteRevision(:final noteId) =>
|
||||||
|
_lookup.studysetOfNote(noteId.uuid).then(_ofStudyset),
|
||||||
|
protocol.LearningPathStep(:final pathId) =>
|
||||||
|
_lookup.studysetOfPath(pathId.uuid).then(_ofStudyset),
|
||||||
|
protocol.LearningPathStepRevision(:final stepId) => _ofStep(stepId.uuid),
|
||||||
|
// A file goes where its key is for: a shared space's key, or none.
|
||||||
|
protocol.Asset(:final keyId) => Future.value(
|
||||||
|
SpaceKeyId.parse(keyId)?.space,
|
||||||
|
),
|
||||||
|
_ => Future.value(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<String?> _placedStudyset(String id) async {
|
||||||
|
final space = _placed.remove(id);
|
||||||
|
if (space != null) {
|
||||||
|
(await _studysetSpaces())[id] = space;
|
||||||
|
}
|
||||||
|
return space;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A studyset inside another goes in its parent's space, and is known to
|
||||||
|
/// be there from now on.
|
||||||
|
Future<String?> _placedChild(String? id, String parentId) async {
|
||||||
|
final space = await _ofStudyset(parentId);
|
||||||
|
if (space != null && id != null) {
|
||||||
|
(await _studysetSpaces())[id] = space;
|
||||||
|
}
|
||||||
|
return space;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _ofStep(String stepId) async {
|
||||||
|
final pathId = await _lookup.pathOfStep(stepId);
|
||||||
|
return pathId == null
|
||||||
|
? null
|
||||||
|
: await _ofStudyset(await _lookup.studysetOfPath(pathId));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _ofStudyset(String? studysetId) async =>
|
||||||
|
studysetId == null ? null : (await _studysetSpaces())[studysetId];
|
||||||
|
|
||||||
|
Future<Map<String, String>> _studysetSpaces() async => _spaces ??= {
|
||||||
|
for (final MapEntry(key: space, value: studysets)
|
||||||
|
in (await _lookup.sharedStudysets()).entries)
|
||||||
|
for (final studyset in studysets) studyset: space,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/space_router.dart';
|
||||||
|
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||||
|
|
||||||
|
/// [SpaceLookup] on the person's database: the spaces the sync engine says
|
||||||
|
/// they are a member of, and the rows it holds.
|
||||||
|
class SyncSpaceLookup(
|
||||||
|
final OfflineSyncDatabaseSession _db,
|
||||||
|
|
||||||
|
/// The person, whose personal space has their account's ID.
|
||||||
|
final String _person,
|
||||||
|
) implements SpaceLookup {
|
||||||
|
@override
|
||||||
|
Future<Map<String, Set<String>>> sharedStudysets() async {
|
||||||
|
final person = protocol.UuidValue.withValidation(_person);
|
||||||
|
return {
|
||||||
|
for (final grant in await OfflineSyncSpaceMembership.memberGrants(
|
||||||
|
_db,
|
||||||
|
person,
|
||||||
|
))
|
||||||
|
if (grant.uuidSpaceId != person)
|
||||||
|
grant.uuidSpaceId.uuid: {
|
||||||
|
for (final studyset in await protocol.Studyset.db.find(
|
||||||
|
_db,
|
||||||
|
where: (t) => t.spaceEquals(grant.uuidSpaceId),
|
||||||
|
))
|
||||||
|
studyset.id!.uuid,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> studysetOfCard(String id) async =>
|
||||||
|
(await protocol.Card.db.findById(_db, _uuid(id)))?.studysetId.uuid;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> studysetOfNote(String id) async =>
|
||||||
|
(await protocol.Note.db.findById(_db, _uuid(id)))?.studysetId.uuid;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> studysetOfPath(String id) async =>
|
||||||
|
(await protocol.LearningPath.db.findById(
|
||||||
|
_db,
|
||||||
|
_uuid(id),
|
||||||
|
))?.studysetId.uuid;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> pathOfStep(String id) async =>
|
||||||
|
(await protocol.LearningPathStep.db.findById(
|
||||||
|
_db,
|
||||||
|
_uuid(id),
|
||||||
|
))?.pathId.uuid;
|
||||||
|
|
||||||
|
protocol.UuidValue _uuid(String id) => protocol.UuidValue.withValidation(id);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/privacy/data/content_key_id.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/privacy/data/space_key_id.dart';
|
||||||
|
|
||||||
|
/// A shared space's key's ID (Halkyon's ADR 0044, D21, D22).
|
||||||
|
void main() {
|
||||||
|
const space = '01a1c000-0000-7000-8000-000000000001';
|
||||||
|
|
||||||
|
test('it names its space and when it was made, and reads back', () {
|
||||||
|
final id = withClock(
|
||||||
|
Clock.fixed(DateTime.utc(2026, 11, 11)),
|
||||||
|
() => SpaceKeyId.next(space),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(id.space, space);
|
||||||
|
expect(id.madeAt, DateTime.utc(2026, 11, 11).millisecondsSinceEpoch);
|
||||||
|
expect(SpaceKeyId.parse(id.value), id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a key made after another is later, whatever the clock says', () {
|
||||||
|
final later = DateTime.utc(2030).millisecondsSinceEpoch;
|
||||||
|
final id = withClock(
|
||||||
|
Clock.fixed(DateTime.utc(2026, 11, 11)),
|
||||||
|
() => SpaceKeyId.next(space, after: later),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(id.madeAt, greaterThan(later));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("other forms aren't a space key's, and a space key's ID is no "
|
||||||
|
"studyset key's", () {
|
||||||
|
for (final other in [
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
's1.$space.1.nonce',
|
||||||
|
'p1.1.nonce',
|
||||||
|
'h1..1.nonce',
|
||||||
|
'h1.$space.soon.nonce',
|
||||||
|
'h1.$space.1.',
|
||||||
|
'h1.$space.1',
|
||||||
|
]) {
|
||||||
|
expect(SpaceKeyId.parse(other), isNull, reason: other);
|
||||||
|
}
|
||||||
|
expect(ContentKeyId.parse(SpaceKeyId.next(space).value), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/privacy/data/space_key_id.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/space_router.dart';
|
||||||
|
|
||||||
|
class _Lookup() implements SpaceLookup {
|
||||||
|
Map<String, Set<String>> shared = {};
|
||||||
|
final cards = <String, String>{};
|
||||||
|
final notes = <String, String>{};
|
||||||
|
final paths = <String, String>{};
|
||||||
|
final steps = <String, String>{};
|
||||||
|
int reads = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Map<String, Set<String>>> sharedStudysets() async {
|
||||||
|
reads++;
|
||||||
|
return shared;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> studysetOfCard(String id) async => cards[id];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> studysetOfNote(String id) async => notes[id];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> studysetOfPath(String id) async => paths[id];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> pathOfStep(String id) async => steps[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol.UuidValue _id(String id) => protocol.UuidValue.withValidation(id);
|
||||||
|
|
||||||
|
/// Which space a new row goes in (Halkyon's ADR 0044, D24): a shared
|
||||||
|
/// studyset's content in its space, the person's own rows in theirs.
|
||||||
|
void main() {
|
||||||
|
const space = '01a1c000-0000-7000-8000-000000000001';
|
||||||
|
const shared = '01a1c000-0000-7000-8000-0000000000a1';
|
||||||
|
const topic = '01a1c000-0000-7000-8000-0000000000a2';
|
||||||
|
const own = '01a1c000-0000-7000-8000-0000000000b1';
|
||||||
|
const card = '01a1c000-0000-7000-8000-0000000000c1';
|
||||||
|
const ownCard = '01a1c000-0000-7000-8000-0000000000c2';
|
||||||
|
const note = '01a1c000-0000-7000-8000-0000000000d1';
|
||||||
|
const path = '01a1c000-0000-7000-8000-0000000000e1';
|
||||||
|
const step = '01a1c000-0000-7000-8000-0000000000f1';
|
||||||
|
final payload = ByteData(1);
|
||||||
|
final now = DateTime.utc(2026, 11, 11);
|
||||||
|
|
||||||
|
late _Lookup lookup;
|
||||||
|
late SpaceRouter router;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
lookup = _Lookup()
|
||||||
|
..shared = {
|
||||||
|
space: {shared},
|
||||||
|
};
|
||||||
|
lookup.cards.addAll({card: shared, ownCard: own});
|
||||||
|
lookup.notes[note] = shared;
|
||||||
|
lookup.paths[path] = shared;
|
||||||
|
lookup.steps[step] = path;
|
||||||
|
router = SpaceRouter(lookup);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a shared studyset's content and revisions go in its space", () async {
|
||||||
|
final rows = <Object>[
|
||||||
|
protocol.Card(studysetId: _id(shared), position: 'a0'),
|
||||||
|
protocol.Note(studysetId: _id(shared), position: 'a0'),
|
||||||
|
protocol.LearningPath(
|
||||||
|
studysetId: _id(shared),
|
||||||
|
position: 'a0',
|
||||||
|
payload: payload,
|
||||||
|
),
|
||||||
|
protocol.PdfSource(studysetId: _id(shared), payload: payload),
|
||||||
|
protocol.CardRevision(
|
||||||
|
cardId: _id(card),
|
||||||
|
authorId: _id(own),
|
||||||
|
parents: const [],
|
||||||
|
payload: payload,
|
||||||
|
),
|
||||||
|
protocol.NoteRevision(
|
||||||
|
noteId: _id(note),
|
||||||
|
authorId: _id(own),
|
||||||
|
parents: const [],
|
||||||
|
payload: payload,
|
||||||
|
),
|
||||||
|
protocol.LearningPathStep(
|
||||||
|
pathId: _id(path),
|
||||||
|
position: 'a0',
|
||||||
|
kind: 'text',
|
||||||
|
),
|
||||||
|
protocol.LearningPathStepRevision(
|
||||||
|
stepId: _id(step),
|
||||||
|
authorId: _id(own),
|
||||||
|
parents: const [],
|
||||||
|
payload: payload,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (final row in rows) {
|
||||||
|
expect(await router.spaceOf(row), space, reason: '$row');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the person's own studysets' content and their own rows stay "
|
||||||
|
'theirs', () async {
|
||||||
|
final rows = <Object>[
|
||||||
|
protocol.Studyset(payload: payload),
|
||||||
|
protocol.Card(studysetId: _id(own), position: 'a0'),
|
||||||
|
protocol.CardRevision(
|
||||||
|
cardId: _id(ownCard),
|
||||||
|
authorId: _id(own),
|
||||||
|
parents: const [],
|
||||||
|
payload: payload,
|
||||||
|
),
|
||||||
|
// Reviews and tags of shared cards are the person's (D2, D25, D26).
|
||||||
|
protocol.ReviewLog(
|
||||||
|
cardId: _id(card),
|
||||||
|
rating: 3,
|
||||||
|
reviewedAt: now,
|
||||||
|
durationMs: 1,
|
||||||
|
),
|
||||||
|
protocol.CardTag(cardId: _id(card), tagId: _id(own)),
|
||||||
|
protocol.LearningPathAnswer(
|
||||||
|
stepId: _id(step),
|
||||||
|
answeredAt: now,
|
||||||
|
outcome: 'right',
|
||||||
|
),
|
||||||
|
// A revision of something this device doesn't hold.
|
||||||
|
protocol.CardRevision(
|
||||||
|
cardId: _id(own),
|
||||||
|
authorId: _id(own),
|
||||||
|
parents: const [],
|
||||||
|
payload: payload,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (final row in rows) {
|
||||||
|
expect(await router.spaceOf(row), isNull, reason: '$row');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a topic goes in its parent's space, and its own content follows "
|
||||||
|
'it there', () async {
|
||||||
|
expect(
|
||||||
|
await router.spaceOf(
|
||||||
|
protocol.Studyset(
|
||||||
|
id: _id(topic),
|
||||||
|
parentId: _id(shared),
|
||||||
|
payload: payload,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
space,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await router.spaceOf(
|
||||||
|
protocol.Card(studysetId: _id(topic), position: 'a0'),
|
||||||
|
),
|
||||||
|
space,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a studyset placed in a space goes there once; one not placed stays '
|
||||||
|
"the person's", () async {
|
||||||
|
const copy = '01a1c000-0000-7000-8000-0000000000a9';
|
||||||
|
router.placeIn(copy, space);
|
||||||
|
final top = protocol.Studyset(id: _id(copy), payload: payload);
|
||||||
|
|
||||||
|
expect(await router.spaceOf(top), space);
|
||||||
|
expect(
|
||||||
|
await router.spaceOf(
|
||||||
|
protocol.Card(studysetId: _id(copy), position: 'a0'),
|
||||||
|
),
|
||||||
|
space,
|
||||||
|
);
|
||||||
|
expect(await router.spaceOf(top), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a file goes in the space its key is for, else the person's", () async {
|
||||||
|
final key = SpaceKeyId.next(space).value;
|
||||||
|
protocol.Asset asset(String? keyId) => protocol.Asset(
|
||||||
|
sha256: 'a' * 64,
|
||||||
|
mimeType: 'image/png',
|
||||||
|
byteSize: 1,
|
||||||
|
keyId: keyId,
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await router.spaceOf(asset(key)), space);
|
||||||
|
expect(await router.spaceOf(asset(null)), isNull);
|
||||||
|
expect(await router.spaceOf(asset('s1.$shared.1.nonce')), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('which studysets are shared is read once, and again after '
|
||||||
|
'forgetting', () async {
|
||||||
|
final row = protocol.Card(studysetId: _id(shared), position: 'a0');
|
||||||
|
await router.spaceOf(row);
|
||||||
|
await router.spaceOf(row);
|
||||||
|
expect(lookup.reads, 1);
|
||||||
|
|
||||||
|
lookup.shared = {};
|
||||||
|
router.forget();
|
||||||
|
expect(await router.spaceOf(row), isNull);
|
||||||
|
expect(lookup.reads, 2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/space_router.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/sync/sync_space_lookup.dart';
|
||||||
|
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||||
|
|
||||||
|
/// The router on a device's own database (Halkyon's ADR 0044, D24): new
|
||||||
|
/// rows of a shared studyset go in its space, its rows change there, and
|
||||||
|
/// the person's own rows stay theirs. Over a local database without a
|
||||||
|
/// server, the shared space written into it as a sync would bring it.
|
||||||
|
void main() {
|
||||||
|
final person = const protocol.Uuid().v7obj();
|
||||||
|
final space = const protocol.Uuid().v7obj();
|
||||||
|
final payload = ByteData(1);
|
||||||
|
late OfflineSyncDatabaseSession db;
|
||||||
|
late SpaceRouter router;
|
||||||
|
late protocol.Studyset shared;
|
||||||
|
late protocol.Studyset own;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
final folder = await Directory.systemTemp.createTemp('space_lookup');
|
||||||
|
addTearDown(() => folder.delete(recursive: true));
|
||||||
|
db = await protocol.Client(
|
||||||
|
'http://127.0.0.1:9/',
|
||||||
|
).createSyncSession('${folder.path}/sync.sqlite', persistentUserId: person);
|
||||||
|
router = SpaceRouter(SyncSpaceLookup(db, person.uuid));
|
||||||
|
db.db.newRowSpace = (row) async => switch (await router.spaceOf(row)) {
|
||||||
|
final space? => protocol.UuidValue.withValidation(space),
|
||||||
|
null => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// The space as a sync brings it: its studyset, and the person a member
|
||||||
|
// who writes.
|
||||||
|
shared = await db.db.transactionForUser(
|
||||||
|
space,
|
||||||
|
(t) => protocol.Studyset.db.insertRow(
|
||||||
|
db,
|
||||||
|
protocol.Studyset(payload: payload),
|
||||||
|
transaction: t,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final row = await OfflineSyncSpace.db.findFirstRow(
|
||||||
|
db,
|
||||||
|
where: (t) => t.uuidSpaceId.equals(space),
|
||||||
|
);
|
||||||
|
await OfflineSyncSpaceMember.db.insertRow(
|
||||||
|
db,
|
||||||
|
OfflineSyncSpaceMember(
|
||||||
|
spaceId: row!.id!,
|
||||||
|
userUuid: person,
|
||||||
|
role: OfflineSyncSpaceRole.readWrite,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
own = await protocol.Studyset.db.insertRow(
|
||||||
|
db,
|
||||||
|
protocol.Studyset(payload: payload),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it finds the shared studysets and what belongs to them', () async {
|
||||||
|
final lookup = SyncSpaceLookup(db, person.uuid);
|
||||||
|
expect(await lookup.sharedStudysets(), {
|
||||||
|
space.uuid: {shared.id!.uuid},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a shared studyset's new card and its revision go in its space, the "
|
||||||
|
"person's review of it in theirs", () async {
|
||||||
|
final card = await protocol.Card.db.insertRow(
|
||||||
|
db,
|
||||||
|
protocol.Card(studysetId: shared.id!, position: 'a0'),
|
||||||
|
);
|
||||||
|
final revision = await protocol.CardRevision.db.insertRow(
|
||||||
|
db,
|
||||||
|
protocol.CardRevision(
|
||||||
|
cardId: card.id!,
|
||||||
|
authorId: person,
|
||||||
|
parents: const [],
|
||||||
|
payload: payload,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final review = await protocol.ReviewLog.db.insertRow(
|
||||||
|
db,
|
||||||
|
protocol.ReviewLog(
|
||||||
|
cardId: card.id!,
|
||||||
|
rating: 3,
|
||||||
|
reviewedAt: DateTime.utc(2026, 11, 11),
|
||||||
|
durationMs: 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await protocol.Card.db.count(
|
||||||
|
db,
|
||||||
|
where: (t) => t.id.equals(card.id) & t.spaceEquals(space),
|
||||||
|
) ==
|
||||||
|
1,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await protocol.CardRevision.db.count(
|
||||||
|
db,
|
||||||
|
where: (t) => t.id.equals(revision.id) & t.spaceEquals(space),
|
||||||
|
) ==
|
||||||
|
1,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await protocol.ReviewLog.db.count(
|
||||||
|
db,
|
||||||
|
where: (t) => t.id.equals(review.id) & t.spaceEquals(space),
|
||||||
|
) ==
|
||||||
|
1,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await protocol.ReviewLog.db.findById(db, review.id!))?.cardId,
|
||||||
|
card.id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the shared studyset changes in its space, and the own one stays '
|
||||||
|
"the person's", () async {
|
||||||
|
await protocol.Studyset.db.updateRow(
|
||||||
|
db,
|
||||||
|
shared.copyWith(position: 'b0'),
|
||||||
|
columns: (t) => [t.position],
|
||||||
|
);
|
||||||
|
final card = await protocol.Card.db.insertRow(
|
||||||
|
db,
|
||||||
|
protocol.Card(studysetId: own.id!, position: 'a0'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await protocol.Studyset.db.findById(db, shared.id!))?.position,
|
||||||
|
'b0',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await protocol.Studyset.db.count(
|
||||||
|
db,
|
||||||
|
where: (t) => t.id.equals(shared.id) & t.spaceEquals(space),
|
||||||
|
) ==
|
||||||
|
1,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await protocol.Card.db.count(
|
||||||
|
db,
|
||||||
|
where: (t) => t.id.equals(card.id) & t.spaceEquals(space),
|
||||||
|
) ==
|
||||||
|
1,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue