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
7 changed files with 26969 additions and 313 deletions

View file

@ -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
}

View file

@ -14,6 +14,7 @@ part '20261003110855698-pdf-sources/migration.dart';
part '20261007205440868-card-buried-at/migration.dart';
part '20261008165051407-pdf-position/migration.dart';
part '20261009224606274-feedback/migration.dart';
part '20261012162411835-studyset-deleted-at/migration.dart';
/// Migration registry for the client-side database.
class MigrationRegistry {
@ -30,6 +31,7 @@ class MigrationRegistry {
_Migration20261007205440868_card_buried_at(),
_Migration20261008165051407_pdf_position(),
_Migration20261009224606274_feedback(),
_Migration20261012162411835_studyset_deleted_at(),
];
/// List of all client-side database migration versions.

View file

@ -2075,6 +2075,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
isNullable: true,
dartType: 'DateTime?',
),
_isd.ColumnDefinition(
name: 'deletedAt',
columnType: _isd.ColumnType.timestampWithoutTimeZone,
isNullable: true,
dartType: 'DateTime?',
),
_isd.ColumnDefinition(
name: 'starredAt',
columnType: _isd.ColumnType.timestampWithoutTimeZone,

View file

@ -32,6 +32,7 @@ abstract class Studyset
this.parent,
String? position,
this.archivedAt,
this.deletedAt,
this.starredAt,
this.updatedAt,
this.keyId,
@ -46,6 +47,7 @@ abstract class Studyset
_ie5k1jc2.Studyset? parent,
String? position,
DateTime? archivedAt,
DateTime? deletedAt,
DateTime? starredAt,
DateTime? updatedAt,
String? keyId,
@ -73,6 +75,9 @@ abstract class Studyset
: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['archivedAt'],
),
deletedAt: jsonSerialization['deletedAt'] == null
? null
: _isc.DateTimeJsonExtension.fromJson(jsonSerialization['deletedAt']),
starredAt: jsonSerialization['starredAt'] == null
? null
: _isc.DateTimeJsonExtension.fromJson(jsonSerialization['starredAt']),
@ -118,6 +123,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;
@ -152,6 +166,7 @@ abstract class Studyset
_ie5k1jc2.Studyset? parent,
String? position,
DateTime? archivedAt,
DateTime? deletedAt,
DateTime? starredAt,
DateTime? updatedAt,
String? keyId,
@ -168,6 +183,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,
@ -186,6 +202,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,
@ -232,6 +249,7 @@ class _StudysetImpl extends Studyset {
_ie5k1jc2.Studyset? parent,
String? position,
DateTime? archivedAt,
DateTime? deletedAt,
DateTime? starredAt,
DateTime? updatedAt,
String? keyId,
@ -244,6 +262,7 @@ class _StudysetImpl extends Studyset {
parent: parent,
position: position,
archivedAt: archivedAt,
deletedAt: deletedAt,
starredAt: starredAt,
updatedAt: updatedAt,
keyId: keyId,
@ -262,6 +281,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,
@ -275,6 +295,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,
@ -310,6 +331,12 @@ class StudysetUpdateTable extends _isd.UpdateTable<StudysetTable> {
value,
);
_isd.ColumnValue<DateTime, DateTime> deletedAt(DateTime? value) =>
_isd.ColumnValue(
table.deletedAt,
value,
);
_isd.ColumnValue<DateTime, DateTime> starredAt(DateTime? value) =>
_isd.ColumnValue(
table.starredAt,
@ -360,6 +387,10 @@ class StudysetTable extends _isd.Table<_isc.UuidValue?> {
'archivedAt',
this,
);
deletedAt = _isd.ColumnDateTime(
'deletedAt',
this,
);
starredAt = _isd.ColumnDateTime(
'starredAt',
this,
@ -406,6 +437,15 @@ class StudysetTable extends _isd.Table<_isc.UuidValue?> {
/// archiving on one device and editing on another don't overwrite each other.
late final _isd.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 _isd.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 _isd.ColumnDateTime starredAt;
@ -447,6 +487,7 @@ class StudysetTable extends _isd.Table<_isc.UuidValue?> {
parentId,
position,
archivedAt,
deletedAt,
starredAt,
updatedAt,
keyId,