feat(studyset): deleted studysets wait 14 days in Recently deleted

Deleting a studyset blanked it with everything inside it once a
four-second Undo was over. It now only marks it (`studyset.deletedAt`, a
column as `archivedAt`): it leaves every list, count and session with
what is inside it and waits in the library's Recently deleted for 14
days, saying how many are left, with Restore and Delete now. Deleting no
longer asks first and offers Undo; Delete now asks. Once the 14 days are
over, whichever of the person's devices looks next (as their data opens,
after every sync) deletes it for good, blanking first as before; the
server can't blank a payload and never writes a person's space (ADR
0007). The connector, publishing and the learner count leave deleted
studysets out too.

The migration adds one nullable column: app versions before it stop
syncing until they update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-12 18:50:10 +02:00
commit 4644125cc4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 26969 additions and 313 deletions

View file

@ -601,17 +601,19 @@ void main() {
/// Gives [person] a studyset [title] holding cards with [fronts],
/// each card's back the front's English, a note, and a review of
/// the first card; its id.
/// the first card, deleted at [deletedAt] if given; its id.
Future<UuidValue> studyset(
UuidValue person,
String title, {
List<(String, String)> cards = const [],
UuidValue? parent,
DateTime? deletedAt,
}) => asPerson(person, (transaction) async {
final id = (await Studyset.db.insertRow(
session,
Studyset(
parentId: parent,
deletedAt: deletedAt,
payload: _json({
'schemaVersion': 4,
'title': title,
@ -737,6 +739,27 @@ void main() {
}
});
test('leaves out a studyset waiting in Recently deleted, and what '
'is inside it', () async {
final japanese = await studyset(
alice,
'Japanese',
cards: [('火', 'fire')],
deletedAt: DateTime.utc(2026, 10, 12),
);
await studyset(alice, 'Kanji', parent: japanese);
await studyset(alice, 'Greek');
final token = (await tokensOf(alice))['access_token'];
final listed =
jsonDecode(await call(token, 'list_studysets')) as List;
expect([for (final s in listed) (s as Map)['title']], ['Greek']);
final found = jsonDecode(
await call(token, 'search_cards', {'query': 'fire'}),
) as List;
expect(found, isEmpty);
});
test("reads the person's studysets, cards, notes and progress, and "
"none of anyone else's", () async {
final japanese = await studyset(

View file

@ -77,12 +77,14 @@ void main() {
/// A person (with an identity unless [anonymous]), counted if
/// [counted], who studied a copy of Japanese on [daysAgo] (a review
/// in [mode] each).
/// in [mode] each), and since put it in Recently deleted if
/// [deleted].
Future<void> learner({
bool counted = true,
bool anonymous = false,
List<int> daysAgo = const [1, 2, 3],
String mode = 'review',
bool deleted = false,
}) async {
final person = (await AuthServices.instance.authUsers.create(
session,
@ -104,6 +106,7 @@ void main() {
final studyset = (await Studyset.db.insertRow(
session,
Studyset(
deletedAt: deleted ? now : null,
payload: json({
'schemaVersion': 5,
'title': 'Japanese',
@ -153,13 +156,14 @@ void main() {
expect(await counted(), 2);
});
test('leaves out who chose not to be counted, anonymous people, and '
'too little or too long ago', () async {
test('leaves out who chose not to be counted, anonymous people, too '
'little or too long ago, and a copy deleted', () async {
await learner(counted: false);
await learner(anonymous: true);
await learner(daysAgo: [1, 2]);
await learner(daysAgo: [31, 32, 40]);
await learner(mode: 'reset');
await learner(deleted: true);
expect(await counted(), 0);
});

View file

@ -605,6 +605,32 @@ void main() {
expect(published.releases.single.cardCount, 1);
});
test('a studyset waiting in Recently deleted is left out with '
'everything inside it, and not published itself', () async {
await profile(alice, 'alice');
final content = await japanese(alice);
// Set as a device's deletion sets it; in SQL, as the test above
// writes.
Future<void> delete(UuidValue studyset) => session.db.unsafeExecute(
'UPDATE "studyset" SET "deletedAt" = now() WHERE "id" = @id',
parameters: QueryParameters.named({'id': studyset.uuid}),
);
await delete(content.kanji);
final published = await publish(alice, content.root);
expect(
await git(published.publicId, ['ls-tree', '--name-only', 'v1']),
isNot(contains(content.kanji.uuid)),
);
expect(published.releases.single.cardCount, 1);
await delete(content.root);
await expectLater(
publish(alice, content.root),
throwsA(isA<NotFoundException>()),
);
});
test('its name is lower-case letters, digits and hyphens, one per '
'studyset of its person, and fixed from the first publish', () async {
await profile(alice, 'alice');