feat: notes in studysets, synced and merged as cards are, with tags

Server: three synced tables, only additions (migration
20261002191122122-notes): `note` (its studyset, a fractional position),
`note_revision` (append-only, as a card's: parents, author, report,
the payload {schemaVersion, title, body}) and `note_tag` (one row per
assignment, never updated, as `card_tag`). Each indexes `spaceId`, and
everything goes with its space, studyset, note or tag. A migration of
synced tables locks app builds from before it out of syncing (ADR 0002,
rule 3): cheap now, before the first release.

App, `feature/note`:
- `NoteContent` is a title and rich text, merged apart (ADR 0003): a
  title changed on one device and the body on another both survive, a
  body changed on two is a conflict.
- `NoteRepository` creates a note at the end of its studyset, saves an
  edit on the revisions it started from (`saveEdit`, as the card
  composer now does), says when a note was last edited (its newest
  head's UUID v7), counts notes per studyset, and deletes a note's
  revisions blanked first (ADR 0002, rule 2).
- Notes share the person's tags: `setTagsOfNote`, and folding tags of
  one name or undoing a tag's deletion covers notes too.
- The delayed deletion with its Undo moves to `core/async`
  (`DelayedDeletions`): `StudysetDeletions` builds on it and now takes
  a studyset's notes too; `NoteDeletions` deletes a single note.

Tests: content, merge, payload, repository, deletions, tags, the device
migration guard, and sync tests against the server (four).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-02 21:53:56 +02:00
commit f3b56f2c0c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,135 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/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);
}
@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> 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')}';
}