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:
parent
11a16f5686
commit
4644125cc4
16 changed files with 26969 additions and 313 deletions
|
|
@ -4,6 +4,7 @@ import 'dart:typed_data';
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_learn_server/src/assistant/mcp_protocol.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_learn_server/src/studyset/deleted_studysets.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||
|
||||
|
|
@ -159,7 +160,8 @@ class _Content(final Session _session, final UuidValue _person) {
|
|||
where: (t) => t.uuidSpaceId.equals(_person),
|
||||
))?.id;
|
||||
|
||||
/// Their studysets that aren't being deleted, with their payloads.
|
||||
/// Their studysets that aren't deleted (nor inside a deleted one, nor
|
||||
/// being deleted for good), with their payloads.
|
||||
Future<List<({Studyset row, Map<String, Object?> payload})>> _all() async {
|
||||
if (_studysets case final known?) {
|
||||
return known;
|
||||
|
|
@ -173,7 +175,7 @@ class _Content(final Session _session, final UuidValue _person) {
|
|||
orderBy: (t) => t.position,
|
||||
);
|
||||
return _studysets = [
|
||||
for (final row in rows)
|
||||
for (final row in notDeleted(rows))
|
||||
if (_decoded(row.payload) case final payload?)
|
||||
(row: row, payload: payload),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:halkyon_learn_server/src/auth/scopes.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_learn_server/src/studyset/deleted_studysets.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||
|
|
@ -88,13 +89,13 @@ Future<void> countLearnersOf(Session session, {required DateTime now}) async {
|
|||
if (identifiedPeople.contains(person)) space,
|
||||
};
|
||||
|
||||
// Their studysets that are copies, and of which published studyset.
|
||||
// Their studysets that are copies, and of which published studyset; a
|
||||
// copy waiting in Recently deleted is no one's learning.
|
||||
// SELECT … FROM "studyset" WHERE "spaceId" = ANY($1)
|
||||
final publicationOf = <UuidValue, String>{};
|
||||
final spaceOf = <UuidValue, int>{};
|
||||
for (final studyset in await Studyset.db.find(
|
||||
session,
|
||||
where: (t) => t.spaceId.inSet(spaces),
|
||||
for (final studyset in notDeleted(
|
||||
await Studyset.db.find(session, where: (t) => t.spaceId.inSet(spaces)),
|
||||
)) {
|
||||
if (_json(studyset.payload)?['published']
|
||||
case {
|
||||
|
|
|
|||
|
|
@ -3111,6 +3111,12 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
isNullable: true,
|
||||
dartType: 'DateTime?',
|
||||
),
|
||||
_isp.ColumnDefinition(
|
||||
name: 'deletedAt',
|
||||
columnType: _isp.ColumnType.timestampWithoutTimeZone,
|
||||
isNullable: true,
|
||||
dartType: 'DateTime?',
|
||||
),
|
||||
_isp.ColumnDefinition(
|
||||
name: 'starredAt',
|
||||
columnType: _isp.ColumnType.timestampWithoutTimeZone,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ abstract class Studyset
|
|||
this.parent,
|
||||
String? position,
|
||||
this.archivedAt,
|
||||
this.deletedAt,
|
||||
this.starredAt,
|
||||
this.updatedAt,
|
||||
this.keyId,
|
||||
|
|
@ -45,6 +46,7 @@ abstract class Studyset
|
|||
_ie5k1jc2.Studyset? parent,
|
||||
String? position,
|
||||
DateTime? archivedAt,
|
||||
DateTime? deletedAt,
|
||||
DateTime? starredAt,
|
||||
DateTime? updatedAt,
|
||||
String? keyId,
|
||||
|
|
@ -70,6 +72,9 @@ abstract class Studyset
|
|||
archivedAt: jsonSerialization['archivedAt'] == null
|
||||
? null
|
||||
: _is.DateTimeJsonExtension.fromJson(jsonSerialization['archivedAt']),
|
||||
deletedAt: jsonSerialization['deletedAt'] == null
|
||||
? null
|
||||
: _is.DateTimeJsonExtension.fromJson(jsonSerialization['deletedAt']),
|
||||
starredAt: jsonSerialization['starredAt'] == null
|
||||
? null
|
||||
: _is.DateTimeJsonExtension.fromJson(jsonSerialization['starredAt']),
|
||||
|
|
@ -113,6 +118,15 @@ abstract class Studyset
|
|||
/// archiving on one device and editing on another don't overwrite each other.
|
||||
DateTime? archivedAt;
|
||||
|
||||
/// When the person deleted it (decided 2026-10-12); null while it isn't. A
|
||||
/// deleted studyset waits in the library's Recently deleted for 14 days,
|
||||
/// together with everything below it, out of every list and of studying, and
|
||||
/// can be restored until then; after that the person's devices delete it for
|
||||
/// good, its content blanked first (ADR 0002, rule 2). A column of its own,
|
||||
/// like archivedAt, so that deleting on one device and editing on another
|
||||
/// don't overwrite each other.
|
||||
DateTime? deletedAt;
|
||||
|
||||
/// When the person starred it, to keep it at the top of the library; null when
|
||||
/// not starred. A column of its own, like archivedAt.
|
||||
DateTime? starredAt;
|
||||
|
|
@ -147,6 +161,7 @@ abstract class Studyset
|
|||
_ie5k1jc2.Studyset? parent,
|
||||
String? position,
|
||||
DateTime? archivedAt,
|
||||
DateTime? deletedAt,
|
||||
DateTime? starredAt,
|
||||
DateTime? updatedAt,
|
||||
String? keyId,
|
||||
|
|
@ -163,6 +178,7 @@ abstract class Studyset
|
|||
if (parent != null) 'parent': parent?.toJson(),
|
||||
'position': position,
|
||||
if (archivedAt != null) 'archivedAt': archivedAt?.toJson(),
|
||||
if (deletedAt != null) 'deletedAt': deletedAt?.toJson(),
|
||||
if (starredAt != null) 'starredAt': starredAt?.toJson(),
|
||||
if (updatedAt != null) 'updatedAt': updatedAt?.toJson(),
|
||||
if (keyId != null) 'keyId': keyId,
|
||||
|
|
@ -181,6 +197,7 @@ abstract class Studyset
|
|||
if (parent != null) 'parent': parent?.toJsonForProtocol(),
|
||||
'position': position,
|
||||
if (archivedAt != null) 'archivedAt': archivedAt?.toJson(),
|
||||
if (deletedAt != null) 'deletedAt': deletedAt?.toJson(),
|
||||
if (starredAt != null) 'starredAt': starredAt?.toJson(),
|
||||
if (updatedAt != null) 'updatedAt': updatedAt?.toJson(),
|
||||
if (keyId != null) 'keyId': keyId,
|
||||
|
|
@ -227,6 +244,7 @@ class _StudysetImpl extends Studyset {
|
|||
_ie5k1jc2.Studyset? parent,
|
||||
String? position,
|
||||
DateTime? archivedAt,
|
||||
DateTime? deletedAt,
|
||||
DateTime? starredAt,
|
||||
DateTime? updatedAt,
|
||||
String? keyId,
|
||||
|
|
@ -239,6 +257,7 @@ class _StudysetImpl extends Studyset {
|
|||
parent: parent,
|
||||
position: position,
|
||||
archivedAt: archivedAt,
|
||||
deletedAt: deletedAt,
|
||||
starredAt: starredAt,
|
||||
updatedAt: updatedAt,
|
||||
keyId: keyId,
|
||||
|
|
@ -257,6 +276,7 @@ class _StudysetImpl extends Studyset {
|
|||
Object? parent = _Undefined,
|
||||
String? position,
|
||||
Object? archivedAt = _Undefined,
|
||||
Object? deletedAt = _Undefined,
|
||||
Object? starredAt = _Undefined,
|
||||
Object? updatedAt = _Undefined,
|
||||
Object? keyId = _Undefined,
|
||||
|
|
@ -270,6 +290,7 @@ class _StudysetImpl extends Studyset {
|
|||
parent: parent is _ie5k1jc2.Studyset? ? parent : this.parent?.copyWith(),
|
||||
position: position ?? this.position,
|
||||
archivedAt: archivedAt is DateTime? ? archivedAt : this.archivedAt,
|
||||
deletedAt: deletedAt is DateTime? ? deletedAt : this.deletedAt,
|
||||
starredAt: starredAt is DateTime? ? starredAt : this.starredAt,
|
||||
updatedAt: updatedAt is DateTime? ? updatedAt : this.updatedAt,
|
||||
keyId: keyId is String? ? keyId : this.keyId,
|
||||
|
|
@ -305,6 +326,12 @@ class StudysetUpdateTable extends _is.UpdateTable<StudysetTable> {
|
|||
value,
|
||||
);
|
||||
|
||||
_is.ColumnValue<DateTime, DateTime> deletedAt(DateTime? value) =>
|
||||
_is.ColumnValue(
|
||||
table.deletedAt,
|
||||
value,
|
||||
);
|
||||
|
||||
_is.ColumnValue<DateTime, DateTime> starredAt(DateTime? value) =>
|
||||
_is.ColumnValue(
|
||||
table.starredAt,
|
||||
|
|
@ -355,6 +382,10 @@ class StudysetTable extends _is.Table<_is.UuidValue?> {
|
|||
'archivedAt',
|
||||
this,
|
||||
);
|
||||
deletedAt = _is.ColumnDateTime(
|
||||
'deletedAt',
|
||||
this,
|
||||
);
|
||||
starredAt = _is.ColumnDateTime(
|
||||
'starredAt',
|
||||
this,
|
||||
|
|
@ -401,6 +432,15 @@ class StudysetTable extends _is.Table<_is.UuidValue?> {
|
|||
/// archiving on one device and editing on another don't overwrite each other.
|
||||
late final _is.ColumnDateTime archivedAt;
|
||||
|
||||
/// When the person deleted it (decided 2026-10-12); null while it isn't. A
|
||||
/// deleted studyset waits in the library's Recently deleted for 14 days,
|
||||
/// together with everything below it, out of every list and of studying, and
|
||||
/// can be restored until then; after that the person's devices delete it for
|
||||
/// good, its content blanked first (ADR 0002, rule 2). A column of its own,
|
||||
/// like archivedAt, so that deleting on one device and editing on another
|
||||
/// don't overwrite each other.
|
||||
late final _is.ColumnDateTime deletedAt;
|
||||
|
||||
/// When the person starred it, to keep it at the top of the library; null when
|
||||
/// not starred. A column of its own, like archivedAt.
|
||||
late final _is.ColumnDateTime starredAt;
|
||||
|
|
@ -442,6 +482,7 @@ class StudysetTable extends _is.Table<_is.UuidValue?> {
|
|||
parentId,
|
||||
position,
|
||||
archivedAt,
|
||||
deletedAt,
|
||||
starredAt,
|
||||
updatedAt,
|
||||
keyId,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'dart:typed_data';
|
|||
import 'package:halkyon_learn_server/src/asset/asset_endpoint.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_learn_server/src/publish/release_repository.dart';
|
||||
import 'package:halkyon_learn_server/src/studyset/deleted_studysets.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||
|
||||
|
|
@ -66,11 +67,11 @@ Future<ReleaseFiles> readReleaseFiles(
|
|||
if (space == null) {
|
||||
throw NotFoundException(what: 'studyset');
|
||||
}
|
||||
// Every studyset of the person's space; the sync engine hides deleted
|
||||
// ones.
|
||||
final all = await Studyset.db.find(
|
||||
session,
|
||||
where: (t) => t.spaceId.equals(space),
|
||||
// Every studyset of the person's space; the sync engine hides those
|
||||
// deleted for good, and those waiting in Recently deleted, with what is
|
||||
// inside them, are neither published nor part of a release.
|
||||
final all = notDeleted(
|
||||
await Studyset.db.find(session, where: (t) => t.spaceId.equals(space)),
|
||||
);
|
||||
final byId = {for (final studyset in all) studyset.id!: studyset};
|
||||
// A studyset whose payload is blank is being deleted, with everything
|
||||
|
|
|
|||
33
halkyon_learn_server/lib/src/studyset/deleted_studysets.dart
Normal file
33
halkyon_learn_server/lib/src/studyset/deleted_studysets.dart
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
/// The rows of [studysets] neither deleted nor inside a deleted one: what
|
||||
/// the person sees, while the deleted wait in the app's Recently deleted
|
||||
/// for their devices to delete them for good (decided 2026-10-12). Nothing
|
||||
/// on the server reads, publishes or counts those.
|
||||
List<Studyset> notDeleted(List<Studyset> studysets) {
|
||||
final childrenOf = <UuidValue, List<Studyset>>{};
|
||||
for (final studyset in studysets) {
|
||||
if (studyset.parentId case final parent?) {
|
||||
(childrenOf[parent] ??= []).add(studyset);
|
||||
}
|
||||
}
|
||||
final waiting = <UuidValue>{};
|
||||
void hide(Studyset studyset) {
|
||||
// A studyset caught in a cycle comes once.
|
||||
if (!waiting.add(studyset.id!)) {
|
||||
return;
|
||||
}
|
||||
(childrenOf[studyset.id] ?? const <Studyset>[]).forEach(hide);
|
||||
}
|
||||
|
||||
for (final studyset in studysets) {
|
||||
if (studyset.deletedAt != null) {
|
||||
hide(studyset);
|
||||
}
|
||||
}
|
||||
return [
|
||||
for (final studyset in studysets)
|
||||
if (!waiting.contains(studyset.id)) studyset,
|
||||
];
|
||||
}
|
||||
|
|
@ -22,6 +22,14 @@ fields:
|
|||
### everything below it. A column of its own, not part of the payload, so that
|
||||
### archiving on one device and editing on another don't overwrite each other.
|
||||
archivedAt: DateTime?
|
||||
### When the person deleted it (decided 2026-10-12); null while it isn't. A
|
||||
### deleted studyset waits in the library's Recently deleted for 14 days,
|
||||
### together with everything below it, out of every list and of studying, and
|
||||
### can be restored until then; after that the person's devices delete it for
|
||||
### good, its content blanked first (ADR 0002, rule 2). A column of its own,
|
||||
### like archivedAt, so that deleting on one device and editing on another
|
||||
### don't overwrite each other.
|
||||
deletedAt: DateTime?
|
||||
### When the person starred it, to keep it at the top of the library; null when
|
||||
### not starred. A column of its own, like archivedAt.
|
||||
starredAt: DateTime?
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"__className__": "serverpod.DatabaseMigration",
|
||||
"actions": [
|
||||
{
|
||||
"__className__": "serverpod.DatabaseMigrationAction",
|
||||
"type": "alterTable",
|
||||
"alterTable": {
|
||||
"__className__": "serverpod.TableMigration",
|
||||
"name": "studyset",
|
||||
"schema": "public",
|
||||
"addColumns": [
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "deletedAt",
|
||||
"fieldName": "deletedAt",
|
||||
"columnType": 4,
|
||||
"isNullable": true,
|
||||
"dartType": "DateTime?"
|
||||
}
|
||||
],
|
||||
"deleteColumns": [],
|
||||
"modifyColumns": [],
|
||||
"addIndexes": [],
|
||||
"deleteIndexes": [],
|
||||
"addForeignKeys": [],
|
||||
"deleteForeignKeys": [],
|
||||
"warnings": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"warnings": [],
|
||||
"migrationApiVersion": 1
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
BEGIN;
|
||||
|
||||
--
|
||||
-- ACTION ALTER TABLE
|
||||
--
|
||||
ALTER TABLE "studyset" ADD COLUMN "deletedAt" timestamp without time zone;
|
||||
|
||||
--
|
||||
-- MIGRATION VERSION FOR halkyon_learn
|
||||
--
|
||||
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||
VALUES ('halkyon_learn', '20261012162411835-studyset-deleted-at', now())
|
||||
ON CONFLICT ("module")
|
||||
DO UPDATE SET "version" = '20261012162411835-studyset-deleted-at', "timestamp" = now();
|
||||
|
||||
--
|
||||
-- MIGRATION VERSION FOR serverpod
|
||||
--
|
||||
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||
VALUES ('serverpod', '20260824182259319', now())
|
||||
ON CONFLICT ("module")
|
||||
DO UPDATE SET "version" = '20260824182259319', "timestamp" = now();
|
||||
|
||||
--
|
||||
-- MIGRATION VERSION FOR serverpod_auth_idp
|
||||
--
|
||||
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||
VALUES ('serverpod_auth_idp', '20260924105404509', now())
|
||||
ON CONFLICT ("module")
|
||||
DO UPDATE SET "version" = '20260924105404509', "timestamp" = now();
|
||||
|
||||
--
|
||||
-- MIGRATION VERSION FOR serverpod_offline_sync
|
||||
--
|
||||
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||
VALUES ('serverpod_offline_sync', '20260914143806119', now())
|
||||
ON CONFLICT ("module")
|
||||
DO UPDATE SET "version" = '20260914143806119', "timestamp" = now();
|
||||
|
||||
--
|
||||
-- MIGRATION VERSION FOR serverpod_auth_core
|
||||
--
|
||||
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||
VALUES ('serverpod_auth_core', '20260924105232991', now())
|
||||
ON CONFLICT ("module")
|
||||
DO UPDATE SET "version" = '20260924105232991', "timestamp" = now();
|
||||
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -25,3 +25,4 @@
|
|||
20261009220324372-based-on
|
||||
20261009224606274-feedback
|
||||
20261010095547719-assistant-connector
|
||||
20261012162411835-studyset-deleted-at
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue