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>
159 lines
4.3 KiB
Dart
159 lines
4.3 KiB
Dart
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/card/data/card_gateway.dart';
|
|
|
|
/// A [CardGateway] in memory. Ids sort by creation, as UUID v7 ids do: cards
|
|
/// are `card-0001`, `card-0002`, …, revisions `rev-0001`, …;
|
|
/// [receiveRemoteRevision] plays a sync that brought another device's edit.
|
|
class FakeCardGateway() implements CardGateway {
|
|
final _cards =
|
|
<String, ({String studysetId, String position, DateTime createdAt})>{};
|
|
final _revisions = <String, CardRevisionRow>{};
|
|
final _remoteChanges = StreamController<void>.broadcast();
|
|
var _nextId = 1;
|
|
|
|
/// Every revision written on this device, in order.
|
|
final written = <CardRevisionRow>[];
|
|
|
|
/// The deletion steps, in order: `blank <card id>`, `delete <card id>`.
|
|
final deletions = <String>[];
|
|
|
|
/// When set, storing a card 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 cardId,
|
|
required Set<String> parents,
|
|
required ByteData payload,
|
|
}) {
|
|
_revisions[id] = (
|
|
id: id,
|
|
cardId: cardId,
|
|
parents: parents,
|
|
payload: payload,
|
|
);
|
|
_remoteChanges.add(null);
|
|
}
|
|
|
|
@override
|
|
Future<List<CardRow>> cardsOf(String studysetId) async => [
|
|
for (final MapEntry(key: id, value: card) in _cards.entries)
|
|
if (card.studysetId == studysetId) (id: id, position: card.position),
|
|
];
|
|
|
|
@override
|
|
Future<List<CardStampRow>> allCards() async => [
|
|
for (final MapEntry(key: id, value: card) in _cards.entries)
|
|
(id: id, studysetId: card.studysetId, createdAt: card.createdAt),
|
|
];
|
|
|
|
@override
|
|
Future<List<CardRevisionRow>> revisionsOf(Set<String> cardIds) async => [
|
|
for (final revision in _revisions.values)
|
|
if (cardIds.contains(revision.cardId)) revision,
|
|
];
|
|
|
|
@override
|
|
Future<List<CardRevisionRow>> allRevisions() async => [..._revisions.values];
|
|
|
|
@override
|
|
Future<List<String>> insertCards(
|
|
String studysetId,
|
|
List<NewCardRow> cards,
|
|
) async => [
|
|
for (final card in cards)
|
|
await insertCard(
|
|
studysetId: studysetId,
|
|
position: card.position,
|
|
payload: card.payload,
|
|
),
|
|
];
|
|
|
|
@override
|
|
Future<String> insertCard({
|
|
required String studysetId,
|
|
required String position,
|
|
required ByteData payload,
|
|
}) async {
|
|
if (failWrites case final error?) {
|
|
throw error;
|
|
}
|
|
final id = _newId('card');
|
|
_cards[id] = (
|
|
studysetId: studysetId,
|
|
position: position,
|
|
createdAt: clock.now().toUtc(),
|
|
);
|
|
await insertRevision(cardId: id, parents: const {}, payload: payload);
|
|
return id;
|
|
}
|
|
|
|
@override
|
|
Future<String> insertRevision({
|
|
required String cardId,
|
|
required Set<String> parents,
|
|
required ByteData payload,
|
|
}) async {
|
|
if (failWrites case final error?) {
|
|
throw error;
|
|
}
|
|
final revision = (
|
|
id: _newId('rev'),
|
|
cardId: cardId,
|
|
parents: parents,
|
|
payload: payload,
|
|
);
|
|
_revisions[revision.id] = revision;
|
|
written.add(revision);
|
|
return revision.id;
|
|
}
|
|
|
|
@override
|
|
Future<void> updatePosition(String cardId, String position) async {
|
|
if (_cards[cardId] case final card?) {
|
|
_cards[cardId] = (
|
|
studysetId: card.studysetId,
|
|
position: position,
|
|
createdAt: card.createdAt,
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> blankRevisions(String cardId) async {
|
|
deletions.add('blank $cardId');
|
|
for (final revision in [..._revisions.values]) {
|
|
if (revision.cardId == cardId) {
|
|
_revisions[revision.id] = (
|
|
id: revision.id,
|
|
cardId: cardId,
|
|
parents: revision.parents,
|
|
payload: redactedPayload,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> deleteCard(String cardId) async {
|
|
deletions.add('delete $cardId');
|
|
_cards.remove(cardId);
|
|
}
|
|
|
|
@override
|
|
Stream<void> get remoteChanges => _remoteChanges.stream;
|
|
|
|
String _newId(String kind) =>
|
|
'$kind-${(_nextId++).toString().padLeft(4, '0')}';
|
|
}
|