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:
parent
9639ce8569
commit
f3b56f2c0c
1 changed files with 25469 additions and 131 deletions
135
halkyon_learn_flutter/test/support/fake_note_gateway.dart
Normal file
135
halkyon_learn_flutter/test/support/fake_note_gateway.dart
Normal 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')}';
|
||||
}
|
||||
Loading…
Reference in a new issue