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
4 changed files with 638 additions and 2 deletions

View file

@ -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_ledger.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/video/media_players.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'),
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(
File(p.join(folder.path, 'sync_pending.json')),
);
@ -474,7 +483,11 @@ Future<PersonDependencies> _openPersonData(
db,
// Only when changes from other devices came in: a sync that only
// 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,
),

View file

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

View file

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

View file

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