fix(app): a card's edit builds on what it started from

The composer saved a stored card with the card's current heads as its
parents, so whatever had arrived meanwhile was replaced, not merged:

- another device's change to the side the person left alone was
  overwritten silently;
- an edit made while saving, saved before the card was read back,
  stood beside the first save: a conflict with itself;
- a new card left again before it was read back wasn't written at
  all, while it showed as saved.

A draft now keeps the revisions its content came from (`base`): taken
with the stored content while the person hasn't changed it, and its own
revision after each save. A save replaces exactly those
(`CardRepository.saveEdit`), so what another device wrote meanwhile
stays a head of its own and merges per side (ADR 0003): a side only it
changed comes through, a side both changed is a conflict to settle. A
changed draft takes new conflicts only once it is saved.

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 20:57:11 +02:00
commit 3b10922888
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 192 additions and 15 deletions

View file

@ -102,8 +102,11 @@ class CardComposerBloc(
/// The state with the stored [cards]: drafts of cards not yet in it are
/// added, those gone (deleted, here or elsewhere) dropped, and the others
/// take the stored version as their baseline, and as their content unless
/// the person changed them.
/// take the stored version as their baseline. Those the person hasn't
/// changed take it whole: content, conflicts, and the revisions it came
/// from. A changed one keeps the revisions it started from until it is
/// saved, so its save stands beside what another device wrote meanwhile
/// and merges with it per side, rather than replacing it unseen.
CardComposerState _withStored(List<Card> cards) {
final byId = {for (final draft in state.items) draft.cardId: draft};
final saved = {
@ -120,14 +123,19 @@ class CardComposerBloc(
cardId: card.id,
content: card.content,
saved: card.content,
base: card.heads,
tagIds: tagIds,
savedTagIds: tagIds,
conflicts: card.conflicts,
);
}
if (draft.isDirty) {
return draft.copyWith(saved: card.content);
}
return draft.copyWith(
saved: card.content,
content: draft.isDirty ? null : card.content,
content: card.content,
base: card.heads,
conflicts: card.conflicts,
);
}
@ -263,15 +271,17 @@ class CardComposerBloc(
_update(emit, draft.id, (d) => d.copyWith(status: CardDraftStatus.saving));
try {
final String cardId;
var base = draft.base;
if (draft.cardId case final id?) {
cardId = id;
final card = _stored[id];
if (card != null &&
(content != draft.saved.normalized || card.hasConflict)) {
await _cards.save(card, content);
// On top of the revisions it started from: all heads once the
// person chose a version of every field in conflict.
if (content != draft.saved.normalized || draft.conflicts.isNotEmpty) {
base = {await _cards.saveEdit(id, content, basedOn: draft.base)};
}
} else {
cardId = await _cards.create(_studysetId, content);
base = await _cards.headsOf(cardId);
}
if (!_sameSet(tagIds, draft.savedTagIds)) {
await _tags.setTagsOf(cardId, tagIds);
@ -282,6 +292,8 @@ class CardComposerBloc(
(d) => d.copyWith(
cardId: cardId,
saved: content,
// What a change made while saving builds on.
base: base,
savedTagIds: tagIds,
resolved: const {},
savedHere: true,
@ -406,6 +418,10 @@ final class const CardDraft({
/// The content as stored; empty for a new card.
required final CardContent saved,
/// The revisions the person's content started from, which its next save
/// replaces; empty for a new card.
final Set<String> base = const {},
/// The ids of the tags the person gave it.
required final Set<String> tagIds,
@ -453,6 +469,7 @@ final class const CardDraft({
CardDraft copyWith({
CardContent? content,
CardContent? saved,
Set<String>? base,
Set<String>? tagIds,
Set<String>? savedTagIds,
String? cardId,
@ -464,6 +481,7 @@ final class const CardDraft({
id: id,
content: content ?? this.content,
saved: saved ?? this.saved,
base: base ?? this.base,
tagIds: tagIds ?? this.tagIds,
savedTagIds: savedTagIds ?? this.savedTagIds,
cardId: cardId ?? this.cardId,
@ -478,6 +496,7 @@ final class const CardDraft({
id,
content,
saved,
base,
tagIds,
savedTagIds,
cardId,

View file

@ -44,8 +44,9 @@ abstract interface class CardGateway() {
/// once; returns their ids in the same order. Their positions differ.
Future<List<String>> insertCards(String studysetId, List<NewCardRow> cards);
/// Appends a revision to the card [cardId] that replaces [parents].
Future<void> insertRevision({
/// Appends a revision to the card [cardId] that replaces [parents];
/// returns its id.
Future<String> insertRevision({
required String cardId,
required Set<String> parents,
required ByteData payload,

View file

@ -184,12 +184,35 @@ class CardRepository(final CardGateway _gateway) {
if (content == card.content && !card.hasConflict) {
return;
}
await _gateway.insertRevision(
cardId: card.id,
parents: card.heads,
await saveEdit(card.id, content, basedOn: card.heads);
}
/// Stores [content] as the next revision of the card [cardId], replacing
/// [basedOn]: the revisions the edit started from. What another device
/// wrote meanwhile stays a head of its own and merges with this one per
/// side (ADR 0003), so it is never replaced unseen. Returns the new
/// revision's id, for the next edit to build on.
Future<String> saveEdit(
String cardId,
CardContent content, {
required Set<String> basedOn,
}) async {
final id = await _gateway.insertRevision(
cardId: cardId,
parents: basedOn,
payload: encodeCardPayload(content),
);
_changes.add(null);
return id;
}
/// The current heads of the card [cardId]: what an edit of it as it is
/// now replaces. Empty for a card not on this device.
Future<Set<String>> headsOf(String cardId) async {
final revisions = [
for (final row in await _gateway.revisionsOf({cardId})) ?_decode(row),
];
return mergeRevisions(revisions)?.heads ?? const {};
}
/// Moves [card] to [toIndex] among the cards of [studysetId].

View file

@ -117,12 +117,12 @@ class SyncCardGateway(
}
@override
Future<void> insertRevision({
Future<String> insertRevision({
required String cardId,
required Set<String> parents,
required ByteData payload,
}) async {
await protocol.CardRevision.db.insertRow(
final revision = await protocol.CardRevision.db.insertRow(
_db,
protocol.CardRevision(
cardId: protocol.UuidValue.withValidation(cardId),
@ -135,6 +135,7 @@ class SyncCardGateway(
),
);
_sync.requestSync();
return revision.id!.uuid;
}
@override

View file

@ -317,6 +317,116 @@ void main() {
);
expect(state.byId(neko)!.status, CardDraftStatus.clean);
});
test('keeps what another device changed meanwhile in a side the person '
'left alone', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
final stored = (await cards.cardsOf('japanese')).single;
bloc.add(CardComposerEventChanged(neko, card('ねこ', 'cat')));
await until((s) => s.byId(neko)?.status == CardDraftStatus.dirty);
gateway.receiveRemoteRevision(
id: 'rev-9000',
cardId: neko,
parents: stored.heads,
payload: encodeCardPayload(card('猫', 'kitty')),
);
await until((s) => s.byId(neko)?.saved == card('猫', 'kitty'));
bloc.add(CardComposerEventLeft(neko));
await until((s) => s.byId(neko)?.content == card('ねこ', 'kitty'));
final merged = (await cards.cardsOf('japanese')).single;
expect(merged.content, card('ねこ', 'kitty'));
expect(merged.hasConflict, isFalse);
});
test('a side both changed meanwhile becomes a conflict, never '
'overwritten', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
final stored = (await cards.cardsOf('japanese')).single;
bloc.add(CardComposerEventChanged(neko, card('猫', 'a cat')));
await until((s) => s.byId(neko)?.status == CardDraftStatus.dirty);
gateway.receiveRemoteRevision(
id: 'rev-9000',
cardId: neko,
parents: stored.heads,
payload: encodeCardPayload(card('猫', 'kitty')),
);
await until((s) => s.byId(neko)?.saved == card('猫', 'kitty'));
bloc.add(CardComposerEventLeft(neko));
final conflicted = await until(
(s) => s.byId(neko)?.conflicts.isNotEmpty ?? false,
);
expect(conflicted.byId(neko)!.conflicts, {
CardField.back: [text('a cat'), text('kitty')],
});
expect(
(await cards.cardsOf('japanese')).single.conflicts[CardField.back],
[text('a cat'), text('kitty')],
);
});
test('builds an edit made while saving on that save, not beside '
'it', () async {
final neko = await cards.create('japanese', card('猫', 'cat'));
start();
await loaded();
bloc
..add(CardComposerEventChanged(neko, card('猫', 'a cat')))
..add(CardComposerEventLeft(neko))
..add(CardComposerEventChanged(neko, card('猫', 'the cat')));
await until(
(s) =>
s.byId(neko)?.savedHere == true &&
s.byId(neko)?.status == CardDraftStatus.dirty,
);
bloc.add(CardComposerEventLeft(neko));
await until(
(s) =>
s.byId(neko)?.status == CardDraftStatus.saved &&
s.byId(neko)?.saved == card('猫', 'the cat'),
);
final stored = (await cards.cardsOf('japanese')).single;
expect(stored.hasConflict, isFalse);
expect(stored.heads, hasLength(1));
expect(stored.content, card('猫', 'the cat'));
});
test('builds an edit made while a new card is created on that '
'card', () async {
start();
await loaded();
bloc
..add(const CardComposerEventDraftAdded('new'))
..add(CardComposerEventChanged('new', card('犬', 'dog')))
..add(const CardComposerEventLeft('new'))
..add(CardComposerEventChanged('new', card('犬', 'a dog')));
await until(
(s) =>
s.byId('new')?.savedHere == true &&
s.byId('new')?.status == CardDraftStatus.dirty,
);
bloc.add(const CardComposerEventLeft('new'));
await until(
(s) =>
s.byId('new')?.status == CardDraftStatus.saved &&
s.byId('new')?.saved == card('犬', 'a dog'),
);
final stored = (await cards.cardsOf('japanese')).single;
expect(stored.hasConflict, isFalse);
expect(stored.heads, hasLength(1));
expect(stored.content, card('犬', 'a dog'));
});
});
test('a duplicate copies content and tags as a new card', () async {

View file

@ -150,6 +150,28 @@ void main() {
expect(gateway.written.last.parents, conflicted.heads);
expect(conflicted.heads, hasLength(2));
});
test('an edit saved on what it started from stands beside what came '
'meanwhile, and merges with it', () async {
remoteEdit(card('猫', 'a cat'));
final id = await repository.saveEdit(
created.id,
card('ねこ', 'cat'),
basedOn: created.heads,
);
expect(gateway.written.last.id, id);
expect(gateway.written.last.parents, created.heads);
final merged = (await repository.cardsOf('japanese')).single;
expect(merged.content, card('ねこ', 'a cat'));
expect(merged.heads, {id, 'rev-9000'});
expect(await repository.headsOf(created.id), {id, 'rev-9000'});
});
});
test('a card not on this device has no heads', () async {
expect(await repository.headsOf('card-9999'), isEmpty);
});
test(

View file

@ -100,7 +100,7 @@ class FakeCardGateway() implements CardGateway {
}
@override
Future<void> insertRevision({
Future<String> insertRevision({
required String cardId,
required Set<String> parents,
required ByteData payload,
@ -116,6 +116,7 @@ class FakeCardGateway() implements CardGateway {
);
_revisions[revision.id] = revision;
written.add(revision);
return revision.id;
}
@override