perf: what everyone follows is read once, and two server hot spots
Studysets, settings, tags and every studyset's progress are now a LatestValue (core/async/): a screen starting to follow gets the value last read, and only the first has it read. Before, every screen opening had the whole table read again and handed to every screen open. While nobody follows, nothing is kept or read, so the tags are no longer read after every card change with no tag screen open. StudysetRepository serialises its reads with SerialQueue instead of a kept future chain. The card browser, path runner and path list blocs no longer add events from stream callbacks once closing, which the later arrival of the tags made one test hit. Server: publishing gathers each item's tag names in one pass instead of scanning every assignment per item, and deleting a person deletes their stored files without reading their bytes back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
301751607d
commit
bbc357f3ef
16 changed files with 335 additions and 104 deletions
|
|
@ -144,7 +144,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.
|
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.
|
||||||
- **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows.
|
- **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows.
|
||||||
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
|
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
|
||||||
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). **What is read is kept until it changes** (decided 2026-10-17; on a copy of a real library, 8,000 cards and 21,000 reviews, every screen's counts took over a second after each change, now about 65 ms once): `CardRepository` keeps each studyset's cards and every card's stamp until a card changes, `ReviewRepository` the whole log once many cards' reviews are asked for, updated by this device's writes and read anew after a sync brings others', and `StudysetProgressRepository` works out every studyset's progress once for the library, every studyset open and its topics, handing the last to whoever follows it next. A read begun before a change never fills a cache (each counts the changes), and a cache keeps values, never futures. A test reading right after a change that reaches the repository another way (a fake remote change, a bloc's write still under way) lets the event queue run first. `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Since blanking can't be undone, an Undo comes before it: a moment's for cards, notes, paths and PDFs (`DelayedDeletions`), 14 days for a studyset (ADR 0007, decided 2026-10-12, as Vaia keeps them): `StudysetDeletions`, opened with the person's data, marks it deleted (`studyset.deletedAt`), and it waits in the library's Recently deleted with everything inside it, restorable, while every other list, count and session reads without it (`StudysetRepository.all` and `watchAll` leave out what is deleted and inside it, `notDeleted`; `withDeleted` reads everything); once the 14 days are over, whichever of the person's devices looks next (as their data opens, after every sync) deletes the whole subtree for good, what is inside first, each studyset's cards blanked before it, and Delete now does at once after asking. Deleting doesn't ask, offering Undo. Widgets delete, undo and delete for good through it directly (`deleteStudysetWithUndo`, `deleteStudysetForGood`), not through a bloc: deleting a studyset from its own screen closes that screen and its bloc, and the deletion and its Undo must outlive them. The server never deletes them (it can't blank a payload; ADR 0007), and its connector, publishing and learner count leave them out too (`notDeleted` in `lib/src/studyset/`). Tags differ, being values (a tag is its name whatever its case, which it keeps; ADR 0004 rule 2): one is blanked and deleted at once, and its Undo makes a tag of that name and colour anew, on the same cards. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
|
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). **What is read is kept until it changes** (decided 2026-10-17; on a copy of a real library, 8,000 cards and 21,000 reviews, every screen's counts took over a second after each change, now about 65 ms once): `CardRepository` keeps each studyset's cards and every card's stamp until a card changes, `ReviewRepository` the whole log once many cards' reviews are asked for, updated by this device's writes and read anew after a sync brings others', and `StudysetProgressRepository` works out every studyset's progress once for the library, every studyset open and its topics. What everyone follows (the studysets, the settings, the tags, the progress) is a `LatestValue` (`core/async/`): a screen starting to follow gets the value last read, and only the first has it read, rather than every screen opening having it read again for every screen open; while nobody follows, nothing is kept or read. A read begun before a change never fills a cache (each counts the changes), and a cache keeps values, never futures. A test reading right after a change that reaches the repository another way (a fake remote change, a bloc's write still under way) lets the event queue run first. `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Since blanking can't be undone, an Undo comes before it: a moment's for cards, notes, paths and PDFs (`DelayedDeletions`), 14 days for a studyset (ADR 0007, decided 2026-10-12, as Vaia keeps them): `StudysetDeletions`, opened with the person's data, marks it deleted (`studyset.deletedAt`), and it waits in the library's Recently deleted with everything inside it, restorable, while every other list, count and session reads without it (`StudysetRepository.all` and `watchAll` leave out what is deleted and inside it, `notDeleted`; `withDeleted` reads everything); once the 14 days are over, whichever of the person's devices looks next (as their data opens, after every sync) deletes the whole subtree for good, what is inside first, each studyset's cards blanked before it, and Delete now does at once after asking. Deleting doesn't ask, offering Undo. Widgets delete, undo and delete for good through it directly (`deleteStudysetWithUndo`, `deleteStudysetForGood`), not through a bloc: deleting a studyset from its own screen closes that screen and its bloc, and the deletion and its Undo must outlive them. The server never deletes them (it can't blank a payload; ADR 0007), and its connector, publishing and learner count leave them out too (`notDeleted` in `lib/src/studyset/`). Tags differ, being values (a tag is its name whatever its case, which it keeps; ADR 0004 rule 2): one is blanked and deleted at once, and its Undo makes a tag of that name and colour anew, on the same cards. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
|
||||||
- **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes.
|
- **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes.
|
||||||
- **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`); taking back a rating deletes its row. Besides ratings it holds a fresh start (`reset`), which never counts as studying; older app versions skip a mode they don't know. A studyset's Reset progress asks what of it starts afresh, naming only what has progress (decided 2026-10-12): its flashcards (a fresh start each), reading its PDFs (a `reset` in their reading log) and its learning paths (a `reset` answer per step), one Undo taking all of it back. A burial isn't in the log but on the card: `card.buriedAt` sets it aside until the next local day begins on the device looking, out of every queue and count (Unbury clears it), read with the cards every list reads anyway. A card's schedule is never stored: `scheduleOf` (`feature/study/model/card_schedule.dart`) replays its reviews in time order with FSRS-6 (`fsrs`, pinned exactly), and only the final due date is fuzzed, from the card's id and review count, so every device computes the same. Studying a studyset covers everything inside it but archived studysets (`studiedIn`), and the study queue (`study_queue.dart`) takes each studyset's cards as a group: due cards of all of them earliest first, then each studyset's new cards in tree order, within each studyset's daily limits of new cards and reviews (its study options, `StudysetStudyOptions` in its payload, schema 3; without, the person's new cards a day and no limit on reviews; cards in their learning steps come anyway), and the studied studyset's limits hold for everything inside it together, as Anki's do for a deck with its subdecks (decided 2026-10-17: a language's twenty topics started 20 new cards a day each), in the order the studied studyset's options ask for (reviews first, mixed, new first). **Exam plans** (`feature/studyset/model/exam_plan.dart`, decided 2026-10-14): a studyset plans for exams (`examPlans` in its payload, schema 7: each a name, the topics it takes whole by their ids, the tags whose cards it takes by their names), its author's published with it (the copier and the updater point the topics at the copy's studysets), and plans are offered from level tags too (`automaticExamPlans`: JLPT, HSK, KRDict's levels, each taking the levels below it, ids `auto:…`). The person's choice (`exam`: the plan's id and the exam's day, `YYYY-MM-DD`) is theirs alone, never published, like a PDF's deadline. `ExamCoverage` reads what a plan covers (its topics' studysets with their paths and PDFs, its tags' cards and learning paths anywhere in the studyset; a path's tags offer automatic plans as a card's do, decided 2026-10-17); the Study plan shows how far the person has come with it and the pace (`examPace`: what is new spread so that it is learnt a week before the exam, or halfway there when it is closer; new cards counted as the day began), and Study for the exam (`StudyFocus.exam`) studies what it covers alone, as one group whose new cards a day are that pace, reviews unlimited; once the exam's day is over, neither shows, and a session for it studies as usual. The library's and the topics' counts and badges add up the same way, each card on the schedule a session studies it on (`card_study_mode.dart`: written by hand while Write by hand is on, back first while Back first is; `ReviewRepository.schedulesAsStudied`); Home's due forecast still counts front to back, as it reads cards without their content. Deleting a card deletes its reviews.
|
- **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`); taking back a rating deletes its row. Besides ratings it holds a fresh start (`reset`), which never counts as studying; older app versions skip a mode they don't know. A studyset's Reset progress asks what of it starts afresh, naming only what has progress (decided 2026-10-12): its flashcards (a fresh start each), reading its PDFs (a `reset` in their reading log) and its learning paths (a `reset` answer per step), one Undo taking all of it back. A burial isn't in the log but on the card: `card.buriedAt` sets it aside until the next local day begins on the device looking, out of every queue and count (Unbury clears it), read with the cards every list reads anyway. A card's schedule is never stored: `scheduleOf` (`feature/study/model/card_schedule.dart`) replays its reviews in time order with FSRS-6 (`fsrs`, pinned exactly), and only the final due date is fuzzed, from the card's id and review count, so every device computes the same. Studying a studyset covers everything inside it but archived studysets (`studiedIn`), and the study queue (`study_queue.dart`) takes each studyset's cards as a group: due cards of all of them earliest first, then each studyset's new cards in tree order, within each studyset's daily limits of new cards and reviews (its study options, `StudysetStudyOptions` in its payload, schema 3; without, the person's new cards a day and no limit on reviews; cards in their learning steps come anyway), and the studied studyset's limits hold for everything inside it together, as Anki's do for a deck with its subdecks (decided 2026-10-17: a language's twenty topics started 20 new cards a day each), in the order the studied studyset's options ask for (reviews first, mixed, new first). **Exam plans** (`feature/studyset/model/exam_plan.dart`, decided 2026-10-14): a studyset plans for exams (`examPlans` in its payload, schema 7: each a name, the topics it takes whole by their ids, the tags whose cards it takes by their names), its author's published with it (the copier and the updater point the topics at the copy's studysets), and plans are offered from level tags too (`automaticExamPlans`: JLPT, HSK, KRDict's levels, each taking the levels below it, ids `auto:…`). The person's choice (`exam`: the plan's id and the exam's day, `YYYY-MM-DD`) is theirs alone, never published, like a PDF's deadline. `ExamCoverage` reads what a plan covers (its topics' studysets with their paths and PDFs, its tags' cards and learning paths anywhere in the studyset; a path's tags offer automatic plans as a card's do, decided 2026-10-17); the Study plan shows how far the person has come with it and the pace (`examPace`: what is new spread so that it is learnt a week before the exam, or halfway there when it is closer; new cards counted as the day began), and Study for the exam (`StudyFocus.exam`) studies what it covers alone, as one group whose new cards a day are that pace, reviews unlimited; once the exam's day is over, neither shows, and a session for it studies as usual. The library's and the topics' counts and badges add up the same way, each card on the schedule a session studies it on (`card_study_mode.dart`: written by hand while Write by hand is on, back first while Back first is; `ReviewRepository.schedulesAsStudied`); Home's due forecast still counts front to back, as it reads cards without their content. Deleting a card deletes its reviews.
|
||||||
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
|
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
|
||||||
|
|
|
||||||
65
halkyon_learn_flutter/lib/src/core/async/latest_value.dart
Normal file
65
halkyon_learn_flutter/lib/src/core/async/latest_value.dart
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// A value a repository reads again after every change, handed to everyone
|
||||||
|
/// following it (decided 2026-10-17): one starting to follow gets the value
|
||||||
|
/// last read at once, then every value read after it with the others. Only
|
||||||
|
/// while there is none, before the first read, after one failed or once
|
||||||
|
/// nobody followed it, does one starting to follow have it read; everyone
|
||||||
|
/// following gets what that read brings. Before, every screen opening had
|
||||||
|
/// everything read again and handed to every screen open.
|
||||||
|
///
|
||||||
|
/// While nobody follows it, nothing is kept, so a repository need not read
|
||||||
|
/// after a change then ([hasListener]).
|
||||||
|
class LatestValue<T>(
|
||||||
|
/// Has the value read and handed on ([add], [addError]).
|
||||||
|
final void Function() _read,
|
||||||
|
) {
|
||||||
|
late final _controller = StreamController<T>.broadcast(
|
||||||
|
onCancel: () => _last = null,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The value last read; null before the first read, after one failed and
|
||||||
|
/// while nobody follows it.
|
||||||
|
({T value})? _last;
|
||||||
|
|
||||||
|
/// The value last read, then every value read after it; a read that fails
|
||||||
|
/// arrives as an error.
|
||||||
|
Stream<T> get stream => Stream.multi((listener) {
|
||||||
|
final subscription = _controller.stream.listen(
|
||||||
|
listener.add,
|
||||||
|
onError: listener.addError,
|
||||||
|
);
|
||||||
|
listener.onCancel = subscription.cancel;
|
||||||
|
if (_last case (:final value)?) {
|
||||||
|
listener.add(value);
|
||||||
|
} else {
|
||||||
|
_read();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The value last read; null before the first read, after one failed and
|
||||||
|
/// while nobody follows it.
|
||||||
|
T? get value => _last?.value;
|
||||||
|
|
||||||
|
/// Whether anyone follows it: while nobody does, there is no need to read.
|
||||||
|
bool get hasListener => _controller.hasListener;
|
||||||
|
|
||||||
|
bool get isClosed => _controller.isClosed;
|
||||||
|
|
||||||
|
/// Hands [value], just read, to everyone following, and keeps it for
|
||||||
|
/// those starting to; with nobody following, it isn't kept, as changes
|
||||||
|
/// from now on may not be read.
|
||||||
|
void add(T value) {
|
||||||
|
_last = hasListener ? (value: value) : null;
|
||||||
|
_controller.add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hands a read that failed to everyone following; the next one starting
|
||||||
|
/// to follow has it read again.
|
||||||
|
void addError(Object error, StackTrace stackTrace) {
|
||||||
|
_last = null;
|
||||||
|
_controller.addError(error, stackTrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> close() => _controller.close();
|
||||||
|
}
|
||||||
|
|
@ -120,7 +120,14 @@ class CardBrowserBloc(
|
||||||
CardBrowserEventStarted event,
|
CardBrowserEventStarted event,
|
||||||
Emitter<CardBrowserState> emit,
|
Emitter<CardBrowserState> emit,
|
||||||
) async {
|
) async {
|
||||||
void refresh() => add(const CardBrowserEventRefreshed());
|
// A change can arrive while the browser closes, when no event may be
|
||||||
|
// added any more.
|
||||||
|
void refresh() {
|
||||||
|
if (!isClosed) {
|
||||||
|
add(const CardBrowserEventRefreshed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
for (final id in _studysetIds)
|
for (final id in _studysetIds)
|
||||||
emit.onEach(
|
emit.onEach(
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,10 @@ class PathListBloc(
|
||||||
_paths.watchPaths(_studysetId),
|
_paths.watchPaths(_studysetId),
|
||||||
onData: (paths) {
|
onData: (paths) {
|
||||||
_all = paths;
|
_all = paths;
|
||||||
add(const PathListEventCompletionRequested());
|
// Not while closing, when no event may be added any more.
|
||||||
|
if (!isClosed) {
|
||||||
|
add(const PathListEventCompletionRequested());
|
||||||
|
}
|
||||||
return state.copyWith(loaded: true, paths: _shown);
|
return state.copyWith(loaded: true, paths: _shown);
|
||||||
},
|
},
|
||||||
onError: (error, _) {
|
onError: (error, _) {
|
||||||
|
|
@ -166,7 +169,11 @@ class PathListBloc(
|
||||||
// Answers and changed steps change what is completed.
|
// Answers and changed steps change what is completed.
|
||||||
emit.onEach(
|
emit.onEach(
|
||||||
_progress.changes,
|
_progress.changes,
|
||||||
onData: (_) => add(const PathListEventCompletionRequested()),
|
onData: (_) {
|
||||||
|
if (!isClosed) {
|
||||||
|
add(const PathListEventCompletionRequested());
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
emit.onEach(
|
emit.onEach(
|
||||||
_deletions.watchPending(),
|
_deletions.watchPending(),
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,10 @@ class PathRunnerBloc(
|
||||||
emit.forEach(
|
emit.forEach(
|
||||||
_paths.watchSteps(_pathId),
|
_paths.watchSteps(_pathId),
|
||||||
onData: (steps) {
|
onData: (steps) {
|
||||||
add(const PathRunnerEventProgressRequested());
|
// Not while closing, when no event may be added any more.
|
||||||
|
if (!isClosed) {
|
||||||
|
add(const PathRunnerEventProgressRequested());
|
||||||
|
}
|
||||||
return state.copyWith(steps: steps);
|
return state.copyWith(steps: steps);
|
||||||
},
|
},
|
||||||
onError: (error, _) {
|
onError: (error, _) {
|
||||||
|
|
@ -64,7 +67,11 @@ class PathRunnerBloc(
|
||||||
),
|
),
|
||||||
emit.onEach(
|
emit.onEach(
|
||||||
_progress.changes,
|
_progress.changes,
|
||||||
onData: (_) => add(const PathRunnerEventProgressRequested()),
|
onData: (_) {
|
||||||
|
if (!isClosed) {
|
||||||
|
add(const PathRunnerEventProgressRequested());
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/async/latest_value.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
|
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_gateway.dart';
|
||||||
|
|
@ -22,7 +23,10 @@ class SettingsRepository(final SettingsGateway _gateway) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final _settings = StreamController<PersonSettings>.broadcast();
|
/// The settings as last read, and after every change.
|
||||||
|
late final _settings = LatestValue<PersonSettings>(
|
||||||
|
() => unawaited(_refresh()),
|
||||||
|
);
|
||||||
late final StreamSubscription<void> _remoteChanges;
|
late final StreamSubscription<void> _remoteChanges;
|
||||||
|
|
||||||
// Every read and write waits for the one before, so a fold never meets a
|
// Every read and write waits for the one before, so a fold never meets a
|
||||||
|
|
@ -31,14 +35,7 @@ class SettingsRepository(final SettingsGateway _gateway) {
|
||||||
|
|
||||||
/// The settings now and after every change, local or synced. A read that
|
/// The settings now and after every change, local or synced. A read that
|
||||||
/// fails arrives as an error; the next change reads again.
|
/// fails arrives as an error; the next change reads again.
|
||||||
Stream<PersonSettings> watch() => Stream.multi((listener) {
|
Stream<PersonSettings> watch() => _settings.stream;
|
||||||
final subscription = _settings.stream.listen(
|
|
||||||
listener.add,
|
|
||||||
onError: listener.addError,
|
|
||||||
);
|
|
||||||
listener.onCancel = subscription.cancel;
|
|
||||||
unawaited(_refresh());
|
|
||||||
});
|
|
||||||
|
|
||||||
/// The settings on this device right now, read once; [watch] follows every
|
/// The settings on this device right now, read once; [watch] follows every
|
||||||
/// change.
|
/// change.
|
||||||
|
|
@ -66,10 +63,17 @@ class SettingsRepository(final SettingsGateway _gateway) {
|
||||||
await _settings.close();
|
await _settings.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The refresh waiting in the queue, not started yet: several screens
|
||||||
|
/// opening at once wait for it rather than each reading again.
|
||||||
|
Future<void>? _waitingRefresh;
|
||||||
|
|
||||||
// A failed read goes to the listeners and never ends the queue: the next
|
// A failed read goes to the listeners and never ends the queue: the next
|
||||||
// change refreshes again.
|
// change refreshes again.
|
||||||
Future<void> _refresh() => _queue.run(() async {
|
Future<void> _refresh() => _waitingRefresh ??= _queue.run(() async {
|
||||||
if (_settings.isClosed) {
|
// Started: what changes from now on needs a refresh of its own.
|
||||||
|
_waitingRefresh = null;
|
||||||
|
// Nobody following: whoever starts to has them read.
|
||||||
|
if (_settings.isClosed || !_settings.hasListener) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/async/latest_value.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
|
||||||
|
|
@ -34,10 +35,9 @@ class StudysetProgressRepository({
|
||||||
required final ReviewRepository _reviews,
|
required final ReviewRepository _reviews,
|
||||||
required final SettingsRepository _settings,
|
required final SettingsRepository _settings,
|
||||||
}) {
|
}) {
|
||||||
final _progress = StreamController<Map<String, StudyProgress>>.broadcast();
|
/// The progress as last worked out, kept while anyone follows it;
|
||||||
|
/// following it starts following what it is worked out from.
|
||||||
/// The progress last worked out; null until then.
|
late final _progress = LatestValue<Map<String, StudyProgress>>(_follow);
|
||||||
Map<String, StudyProgress>? _last;
|
|
||||||
|
|
||||||
/// What it follows, once something follows it.
|
/// What it follows, once something follows it.
|
||||||
List<StreamSubscription<Object?>>? _following;
|
List<StreamSubscription<Object?>>? _following;
|
||||||
|
|
@ -56,14 +56,7 @@ class StudysetProgressRepository({
|
||||||
/// The progress of every studyset, by its id, now (once worked out) and
|
/// The progress of every studyset, by its id, now (once worked out) and
|
||||||
/// after every change. A studyset deleted, or waiting in Recently
|
/// after every change. A studyset deleted, or waiting in Recently
|
||||||
/// deleted, has none.
|
/// deleted, has none.
|
||||||
Stream<Map<String, StudyProgress>> watch() => Stream.multi((listener) {
|
Stream<Map<String, StudyProgress>> watch() => _progress.stream;
|
||||||
final subscription = _progress.stream.listen(listener.add);
|
|
||||||
listener.onCancel = subscription.cancel;
|
|
||||||
if (_last case final last?) {
|
|
||||||
listener.add(last);
|
|
||||||
}
|
|
||||||
_follow();
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Works the progress out again: after studying, say, or as the day
|
/// Works the progress out again: after studying, say, or as the day
|
||||||
/// moves on.
|
/// moves on.
|
||||||
|
|
@ -78,8 +71,12 @@ class StudysetProgressRepository({
|
||||||
await _progress.close();
|
await _progress.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Follows what the progress is worked out from, whose first values work
|
||||||
|
/// it out; already following, works it out again, as nothing is kept
|
||||||
|
/// while nobody follows it.
|
||||||
void _follow() {
|
void _follow() {
|
||||||
if (_following != null) {
|
if (_following != null) {
|
||||||
|
refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_following = [
|
_following = [
|
||||||
|
|
@ -109,12 +106,16 @@ class StudysetProgressRepository({
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Works the progress out, one at a time: changes while it runs (a sync
|
/// Works the progress out, one at a time: changes while it runs (a sync
|
||||||
/// brings many) make one more run after it, not one each.
|
/// brings many) make one more run after it, not one each. While nobody
|
||||||
|
/// follows it, nothing is worked out.
|
||||||
Future<void> _workOut() async {
|
Future<void> _workOut() async {
|
||||||
if (_working) {
|
if (_working) {
|
||||||
_again = true;
|
_again = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!_progress.hasListener) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_working = true;
|
_working = true;
|
||||||
try {
|
try {
|
||||||
do {
|
do {
|
||||||
|
|
@ -123,8 +124,7 @@ class StudysetProgressRepository({
|
||||||
if (_progress.isClosed) {
|
if (_progress.isClosed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!_sameProgress(progress, _last)) {
|
if (!_sameProgress(progress, _progress.value)) {
|
||||||
_last = progress;
|
|
||||||
_progress.add(progress);
|
_progress.add(progress);
|
||||||
}
|
}
|
||||||
} while (_again);
|
} while (_again);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import 'dart:developer';
|
||||||
|
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
import 'package:fractional_indexing_dart/fractional_indexing_dart.dart';
|
import 'package:fractional_indexing_dart/fractional_indexing_dart.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/async/latest_value.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
|
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_gateway.dart';
|
||||||
|
|
@ -23,30 +25,25 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every studyset, those deleted too, after every change.
|
/// Every studyset, those deleted too, as last read, and after every
|
||||||
final _studysets = StreamController<List<Studyset>>.broadcast();
|
/// change.
|
||||||
|
late final _studysets = LatestValue<List<Studyset>>(
|
||||||
|
() => unawaited(_refresh()),
|
||||||
|
);
|
||||||
late final StreamSubscription<void> _remoteChanges;
|
late final StreamSubscription<void> _remoteChanges;
|
||||||
Future<void> _lastRefresh = Future.value();
|
|
||||||
|
// Reads one after another, so an older list never overtakes a newer one.
|
||||||
|
final _queue = SerialQueue();
|
||||||
|
|
||||||
/// All studysets but those deleted and what is inside them ([all]), now
|
/// All studysets but those deleted and what is inside them ([all]), now
|
||||||
/// and after every change, local or synced. A read that fails arrives as
|
/// and after every change, local or synced. A read that fails arrives as
|
||||||
/// an error; the next change reads again.
|
/// an error; the next change reads again.
|
||||||
Stream<List<Studyset>> watchAll() => _watch(notDeleted);
|
Stream<List<Studyset>> watchAll() => _studysets.stream.map(notDeleted);
|
||||||
|
|
||||||
/// The studysets waiting in Recently deleted ([recentlyDeleted]), now and
|
/// The studysets waiting in Recently deleted ([recentlyDeleted]), now and
|
||||||
/// after every change.
|
/// after every change.
|
||||||
Stream<List<Studyset>> watchDeleted() => _watch(recentlyDeleted);
|
Stream<List<Studyset>> watchDeleted() =>
|
||||||
|
_studysets.stream.map(recentlyDeleted);
|
||||||
Stream<List<Studyset>> _watch(
|
|
||||||
List<Studyset> Function(List<Studyset> all) shown,
|
|
||||||
) => Stream.multi((listener) {
|
|
||||||
final subscription = _studysets.stream.listen(
|
|
||||||
(all) => listener.add(shown(all)),
|
|
||||||
onError: listener.addError,
|
|
||||||
);
|
|
||||||
listener.onCancel = subscription.cancel;
|
|
||||||
unawaited(_refresh());
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Reads the studysets again for those following them ([watchAll]), as
|
/// Reads the studysets again for those following them ([watchAll]), as
|
||||||
/// after a change: to try once more after a read failed.
|
/// after a change: to try once more after a read failed.
|
||||||
|
|
@ -187,11 +184,18 @@ class StudysetRepository(final StudysetGateway _gateway) {
|
||||||
await _studysets.close();
|
await _studysets.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refreshes one after another, so an older list never overtakes a newer one.
|
/// The refresh waiting in the queue, not started yet: changes meanwhile
|
||||||
// A failed read goes to the listeners and never ends the chain: the next
|
/// (a sync bringing many, several screens opening at once) wait for it
|
||||||
|
/// rather than each reading everything again.
|
||||||
|
Future<void>? _waitingRefresh;
|
||||||
|
|
||||||
|
// A failed read goes to the listeners and never ends the queue: the next
|
||||||
// change refreshes again.
|
// change refreshes again.
|
||||||
Future<void> _refresh() => _lastRefresh = _lastRefresh.then((_) async {
|
Future<void> _refresh() => _waitingRefresh ??= _queue.run(() async {
|
||||||
if (_studysets.isClosed) {
|
// Started: what changes from now on needs a refresh of its own.
|
||||||
|
_waitingRefresh = null;
|
||||||
|
// Nobody following: whoever starts to has them read.
|
||||||
|
if (_studysets.isClosed || !_studysets.hasListener) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/async/latest_value.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
|
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_gateway.dart';
|
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_gateway.dart';
|
||||||
|
|
@ -47,7 +48,8 @@ class TagRepository(
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
final _tagging = StreamController<Tagging>.broadcast();
|
/// The tagging as last read, and after every change.
|
||||||
|
late final _tagging = LatestValue<Tagging>(() => unawaited(_refresh()));
|
||||||
late final List<StreamSubscription<void>> _subscriptions;
|
late final List<StreamSubscription<void>> _subscriptions;
|
||||||
|
|
||||||
// Every read and write waits for the one before, so a fold never meets a
|
// Every read and write waits for the one before, so a fold never meets a
|
||||||
|
|
@ -56,14 +58,7 @@ class TagRepository(
|
||||||
|
|
||||||
/// The tagging now and after every change, local or synced. A read that
|
/// The tagging now and after every change, local or synced. A read that
|
||||||
/// fails arrives as an error; the next change reads again.
|
/// fails arrives as an error; the next change reads again.
|
||||||
Stream<Tagging> watch() => Stream.multi((listener) {
|
Stream<Tagging> watch() => _tagging.stream;
|
||||||
final subscription = _tagging.stream.listen(
|
|
||||||
listener.add,
|
|
||||||
onError: listener.addError,
|
|
||||||
);
|
|
||||||
listener.onCancel = subscription.cancel;
|
|
||||||
unawaited(_refresh());
|
|
||||||
});
|
|
||||||
|
|
||||||
/// The tagging on this device right now, read once; [watch] follows every
|
/// The tagging on this device right now, read once; [watch] follows every
|
||||||
/// change.
|
/// change.
|
||||||
|
|
@ -344,7 +339,8 @@ class TagRepository(
|
||||||
Future<void> _refresh() => _waitingRefresh ??= _serially(() async {
|
Future<void> _refresh() => _waitingRefresh ??= _serially(() async {
|
||||||
// Started: what changes from now on needs a refresh of its own.
|
// Started: what changes from now on needs a refresh of its own.
|
||||||
_waitingRefresh = null;
|
_waitingRefresh = null;
|
||||||
if (_tagging.isClosed) {
|
// Nobody following: whoever starts to has them read.
|
||||||
|
if (_tagging.isClosed || !_tagging.hasListener) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
85
halkyon_learn_flutter/test/core/async/latest_value_test.dart
Normal file
85
halkyon_learn_flutter/test/core/async/latest_value_test.dart
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/core/async/latest_value.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
var reads = 0;
|
||||||
|
late LatestValue<int> latest;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
reads = 0;
|
||||||
|
latest = LatestValue(() => reads++);
|
||||||
|
});
|
||||||
|
tearDown(() => latest.close());
|
||||||
|
|
||||||
|
/// Everything [latest] hands one following it, as it comes.
|
||||||
|
List<int> follow() {
|
||||||
|
final seen = <int>[];
|
||||||
|
final subscription = latest.stream.listen(seen.add, onError: (_) {});
|
||||||
|
addTearDown(subscription.cancel);
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('one starting to follow before anything was read has it read; those '
|
||||||
|
'after get the value last read at once, read no more', () async {
|
||||||
|
final first = follow();
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(reads, 1);
|
||||||
|
|
||||||
|
latest.add(1);
|
||||||
|
final second = follow();
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(first, [1]);
|
||||||
|
expect(second, [1]);
|
||||||
|
expect(reads, 1);
|
||||||
|
|
||||||
|
latest.add(2);
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(first, [1, 2]);
|
||||||
|
expect(second, [1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'after a read failed, one starting to follow has it read again',
|
||||||
|
() async {
|
||||||
|
final errors = <Object>[];
|
||||||
|
final subscription = latest.stream.listen(
|
||||||
|
(_) {},
|
||||||
|
onError: errors.add,
|
||||||
|
);
|
||||||
|
addTearDown(subscription.cancel);
|
||||||
|
latest
|
||||||
|
..add(1)
|
||||||
|
..addError(Exception('Disk unavailable'), StackTrace.current);
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(errors, hasLength(1));
|
||||||
|
expect(latest.value, isNull);
|
||||||
|
|
||||||
|
final seen = follow();
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(reads, 2);
|
||||||
|
expect(seen, isEmpty);
|
||||||
|
|
||||||
|
latest.add(3);
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(seen, [3]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('keeps nothing while nobody follows it, not even what a read brings '
|
||||||
|
'after everyone left: one starting to follow has it read anew', () async {
|
||||||
|
final first = latest.stream.listen((_) {});
|
||||||
|
await pumpEventQueue();
|
||||||
|
latest.add(1);
|
||||||
|
await first.cancel();
|
||||||
|
expect(latest.hasListener, isFalse);
|
||||||
|
expect(latest.value, isNull);
|
||||||
|
|
||||||
|
latest.add(2);
|
||||||
|
expect(latest.value, isNull);
|
||||||
|
|
||||||
|
final seen = follow();
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(reads, 2);
|
||||||
|
expect(seen, isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -129,4 +129,20 @@ void main() {
|
||||||
await pumpEventQueue();
|
await pumpEventQueue();
|
||||||
expect(seen.last[japanese]?.cards, 2);
|
expect(seen.last[japanese]?.cards, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('one following it again after nobody did gets it as things are '
|
||||||
|
'now', () async {
|
||||||
|
final japanese = await studysets.create(StudysetContent.titled('Japanese'));
|
||||||
|
final library = progress.watch().listen((_) {});
|
||||||
|
await pumpEventQueue();
|
||||||
|
await library.cancel();
|
||||||
|
|
||||||
|
await card(japanese);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
final seen = follow();
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(seen, hasLength(1));
|
||||||
|
expect(seen.single[japanese]?.cards, 1);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,27 @@ void main() {
|
||||||
await expectation;
|
await expectation;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('one more following them gets the studysets as last read at once, '
|
||||||
|
'read no more and handed to nobody else again', () async {
|
||||||
|
await repository.create(titled('Japanese'));
|
||||||
|
final first = <List<Studyset>>[];
|
||||||
|
final library = repository.watchAll().listen(first.add);
|
||||||
|
addTearDown(library.cancel);
|
||||||
|
await pumpEventQueue();
|
||||||
|
final reads = gateway.reads;
|
||||||
|
|
||||||
|
final second = <List<Studyset>>[];
|
||||||
|
final screen = repository.watchAll().listen(second.add);
|
||||||
|
addTearDown(screen.cancel);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(second.map(titles), [
|
||||||
|
['Japanese'],
|
||||||
|
]);
|
||||||
|
expect(first, hasLength(1));
|
||||||
|
expect(gateway.reads, reads);
|
||||||
|
});
|
||||||
|
|
||||||
test('reads the studysets on this device once', () async {
|
test('reads the studysets on this device once', () async {
|
||||||
await repository.create(titled('Japanese'));
|
await repository.create(titled('Japanese'));
|
||||||
await repository.create(titled('Kanji'));
|
await repository.create(titled('Kanji'));
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,9 @@ class FakeStudysetGateway() implements StudysetGateway {
|
||||||
/// When set, reading the studysets fails with it.
|
/// When set, reading the studysets fails with it.
|
||||||
Exception? failReads;
|
Exception? failReads;
|
||||||
|
|
||||||
|
/// How often the studysets were read.
|
||||||
|
int reads = 0;
|
||||||
|
|
||||||
/// Stores [payload] as if another device had written it and a sync had
|
/// Stores [payload] as if another device had written it and a sync had
|
||||||
/// brought it here, with the other columns given.
|
/// brought it here, with the other columns given.
|
||||||
void receiveRemoteChange(
|
void receiveRemoteChange(
|
||||||
|
|
@ -57,6 +60,7 @@ class FakeStudysetGateway() implements StudysetGateway {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<StudysetRow>> all() async {
|
Future<List<StudysetRow>> all() async {
|
||||||
|
reads++;
|
||||||
if (failReads case final error?) {
|
if (failReads case final error?) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,8 @@ Future<void> deletePerson(Session session, UuidValue authUserId) async {
|
||||||
);
|
);
|
||||||
// Their space's files are stored outside the space's rows (ADR 0004,
|
// Their space's files are stored outside the space's rows (ADR 0004,
|
||||||
// rule 5), in the database as Serverpod keeps private files; files
|
// rule 5), in the database as Serverpod keeps private files; files
|
||||||
// moved to other storage (S3) need deleting there instead.
|
// moved to other storage (S3) need deleting there instead. Not read
|
||||||
|
// back: the rows hold the files themselves, up to 500 MB of them.
|
||||||
// DELETE FROM "serverpod_cloud_storage"
|
// DELETE FROM "serverpod_cloud_storage"
|
||||||
// WHERE "storageId" = 'private' AND "path" LIKE 'spaces/<id>/%'
|
// WHERE "storageId" = 'private' AND "path" LIKE 'spaces/<id>/%'
|
||||||
await CloudStorageEntry.db.deleteWhere(
|
await CloudStorageEntry.db.deleteWhere(
|
||||||
|
|
@ -49,6 +50,7 @@ Future<void> deletePerson(Session session, UuidValue authUserId) async {
|
||||||
t.storageId.equals(assetStorage) &
|
t.storageId.equals(assetStorage) &
|
||||||
t.path.like('${assetPath(authUserId, '')}%'),
|
t.path.like('${assetPath(authUserId, '')}%'),
|
||||||
transaction: transaction,
|
transaction: transaction,
|
||||||
|
noReturn: true,
|
||||||
);
|
);
|
||||||
// DELETE FROM "public_profile" WHERE "authUserId" = $1, and its avatar
|
// DELETE FROM "public_profile" WHERE "authUserId" = $1, and its avatar
|
||||||
// unless another profile shows the same picture.
|
// unless another profile shows the same picture.
|
||||||
|
|
|
||||||
|
|
@ -137,11 +137,16 @@ Future<ReleaseFiles> readReleaseFiles(
|
||||||
session,
|
session,
|
||||||
where: (t) => t.spaceId.equals(space) & t.studysetId.inSet(ids),
|
where: (t) => t.spaceId.equals(space) & t.studysetId.inSet(ids),
|
||||||
);
|
);
|
||||||
final cardTags = await CardTag.db.find(
|
final cardTags = _namesByItem(
|
||||||
session,
|
await CardTag.db.find(
|
||||||
where: (t) =>
|
session,
|
||||||
t.spaceId.equals(space) &
|
where: (t) =>
|
||||||
t.cardId.inSet({for (final c in cards) c.id!}),
|
t.spaceId.equals(space) &
|
||||||
|
t.cardId.inSet({for (final c in cards) c.id!}),
|
||||||
|
),
|
||||||
|
(tag) => tag.cardId,
|
||||||
|
(tag) => tag.tagId,
|
||||||
|
names,
|
||||||
);
|
);
|
||||||
final cardHeads = _heads(
|
final cardHeads = _heads(
|
||||||
await CardRevision.db.find(
|
await CardRevision.db.find(
|
||||||
|
|
@ -175,13 +180,7 @@ Future<ReleaseFiles> readReleaseFiles(
|
||||||
{
|
{
|
||||||
'id': card.id!.uuid,
|
'id': card.id!.uuid,
|
||||||
'position': card.position,
|
'position': card.position,
|
||||||
'tags': _namesOf(
|
'tags': cardTags[card.id!] ?? const <String>[],
|
||||||
card.id!,
|
|
||||||
cardTags,
|
|
||||||
(tag) => tag.cardId,
|
|
||||||
(tag) => tag.tagId,
|
|
||||||
names,
|
|
||||||
),
|
|
||||||
'payload': payload,
|
'payload': payload,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -192,11 +191,16 @@ Future<ReleaseFiles> readReleaseFiles(
|
||||||
session,
|
session,
|
||||||
where: (t) => t.spaceId.equals(space) & t.studysetId.inSet(ids),
|
where: (t) => t.spaceId.equals(space) & t.studysetId.inSet(ids),
|
||||||
);
|
);
|
||||||
final noteTags = await NoteTag.db.find(
|
final noteTags = _namesByItem(
|
||||||
session,
|
await NoteTag.db.find(
|
||||||
where: (t) =>
|
session,
|
||||||
t.spaceId.equals(space) &
|
where: (t) =>
|
||||||
t.noteId.inSet({for (final n in notes) n.id!}),
|
t.spaceId.equals(space) &
|
||||||
|
t.noteId.inSet({for (final n in notes) n.id!}),
|
||||||
|
),
|
||||||
|
(tag) => tag.noteId,
|
||||||
|
(tag) => tag.tagId,
|
||||||
|
names,
|
||||||
);
|
);
|
||||||
final noteHeads = _heads(
|
final noteHeads = _heads(
|
||||||
await NoteRevision.db.find(
|
await NoteRevision.db.find(
|
||||||
|
|
@ -228,13 +232,7 @@ Future<ReleaseFiles> readReleaseFiles(
|
||||||
{
|
{
|
||||||
'id': note.id!.uuid,
|
'id': note.id!.uuid,
|
||||||
'position': note.position,
|
'position': note.position,
|
||||||
'tags': _namesOf(
|
'tags': noteTags[note.id!] ?? const <String>[],
|
||||||
note.id!,
|
|
||||||
noteTags,
|
|
||||||
(tag) => tag.noteId,
|
|
||||||
(tag) => tag.tagId,
|
|
||||||
names,
|
|
||||||
),
|
|
||||||
'payload': payload,
|
'payload': payload,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -246,9 +244,14 @@ Future<ReleaseFiles> readReleaseFiles(
|
||||||
where: (t) => t.spaceId.equals(space) & t.studysetId.inSet(ids),
|
where: (t) => t.spaceId.equals(space) & t.studysetId.inSet(ids),
|
||||||
);
|
);
|
||||||
final pathIds = {for (final path in paths) path.id!};
|
final pathIds = {for (final path in paths) path.id!};
|
||||||
final pathTags = await LearningPathTag.db.find(
|
final pathTags = _namesByItem(
|
||||||
session,
|
await LearningPathTag.db.find(
|
||||||
where: (t) => t.spaceId.equals(space) & t.pathId.inSet(pathIds),
|
session,
|
||||||
|
where: (t) => t.spaceId.equals(space) & t.pathId.inSet(pathIds),
|
||||||
|
),
|
||||||
|
(tag) => tag.pathId,
|
||||||
|
(tag) => tag.tagId,
|
||||||
|
names,
|
||||||
);
|
);
|
||||||
final steps = await LearningPathStep.db.find(
|
final steps = await LearningPathStep.db.find(
|
||||||
session,
|
session,
|
||||||
|
|
@ -279,13 +282,7 @@ Future<ReleaseFiles> readReleaseFiles(
|
||||||
files['${directory}path.json'] = _item({
|
files['${directory}path.json'] = _item({
|
||||||
'id': path.id!.uuid,
|
'id': path.id!.uuid,
|
||||||
'position': path.position,
|
'position': path.position,
|
||||||
'tags': _namesOf(
|
'tags': pathTags[path.id!] ?? const <String>[],
|
||||||
path.id!,
|
|
||||||
pathTags,
|
|
||||||
(tag) => tag.pathId,
|
|
||||||
(tag) => tag.tagId,
|
|
||||||
names,
|
|
||||||
),
|
|
||||||
'payload': payload,
|
'payload': payload,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -443,17 +440,27 @@ Future<Map<UuidValue, String>> _tagNames(Session session, int space) async => {
|
||||||
tag.id!: name.trim(),
|
tag.id!: name.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The names of the tags [rows] give [item], sorted.
|
/// The names of the tags [rows] give each item, by the item's id, sorted;
|
||||||
List<String> _namesOf<T>(
|
/// an item without any is left out. Gathered in one pass: a release of
|
||||||
UuidValue item,
|
/// thousands of cards would otherwise look through every assignment for
|
||||||
|
/// each card.
|
||||||
|
Map<UuidValue, List<String>> _namesByItem<T>(
|
||||||
List<T> rows,
|
List<T> rows,
|
||||||
UuidValue Function(T) itemOf,
|
UuidValue Function(T) itemOf,
|
||||||
UuidValue Function(T) tagOf,
|
UuidValue Function(T) tagOf,
|
||||||
Map<UuidValue, String> names,
|
Map<UuidValue, String> names,
|
||||||
) => {
|
) {
|
||||||
for (final row in rows)
|
final byItem = <UuidValue, Set<String>>{};
|
||||||
if (itemOf(row) == item) ?names[tagOf(row)],
|
for (final row in rows) {
|
||||||
}.toList()..sort();
|
if (names[tagOf(row)] case final name?) {
|
||||||
|
(byItem[itemOf(row)] ??= {}).add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
for (final MapEntry(:key, :value) in byItem.entries)
|
||||||
|
key: value.toList()..sort(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// A synced payload's JSON; null for a blank one, of a row being deleted.
|
/// A synced payload's JSON; null for a blank one, of a row being deleted.
|
||||||
Map<String, Object?>? _decoded(ByteData payload) => payload.lengthInBytes == 0
|
Map<String, Object?>? _decoded(ByteData payload) => payload.lengthInBytes == 0
|
||||||
|
|
|
||||||
|
|
@ -454,6 +454,12 @@ void main() {
|
||||||
'${content.kanji.uuid}/pdfs/${content.book.uuid}.json',
|
'${content.kanji.uuid}/pdfs/${content.book.uuid}.json',
|
||||||
);
|
);
|
||||||
expect((book['payload']! as Map).containsKey('deadline'), isFalse);
|
expect((book['payload']! as Map).containsKey('deadline'), isFalse);
|
||||||
|
final pictured = await fileAt(
|
||||||
|
published,
|
||||||
|
'v1',
|
||||||
|
'${content.kanji.uuid}/cards/${content.pictured.uuid}.json',
|
||||||
|
);
|
||||||
|
expect(pictured['tags'], isEmpty);
|
||||||
// The studysets' own study options are a suggestion to learners.
|
// The studysets' own study options are a suggestion to learners.
|
||||||
final root = await fileAt(published, 'v1', 'studyset.json');
|
final root = await fileAt(published, 'v1', 'studyset.json');
|
||||||
expect(root['payload'], containsPair('study', {'newPerDay': 10}));
|
expect(root['payload'], containsPair('study', {'newPerDay': 10}));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue