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:
Tizian 「ティツィアーン」 2026-11-11 13:00:38 +01:00
commit 92b87e218d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 638 additions and 2 deletions

View file

@ -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);
});
}

View file

@ -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);
});
}

View file

@ -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,
);
});
}