feat: studysets record when they last changed; the library lists them so

Decided 2026-10-01: after the starred ones, the library lists the most
recently changed studysets first. That needs a change date, so studyset
gains updatedAt (ADR 0004): when the studyset itself last changed, its
content, place, star or archive date, written by the device making the
change in the same update as the change; the last writer wins. Studysets
from before have none and come last, the newest of them first.

The branch's migration is still unreleased (not merged, not deployed), so it
is recreated rather than followed by a second one:
20261001064926911-tree-tags-assets replaces 20260930213716558 and differs
only by one line on the server, ALTER TABLE "studyset" ADD COLUMN
"updatedAt" timestamp without time zone (nullable, so existing rows keep
null and no data changes), the same column in the table devices rebuild,
and its version. The model's comment on the payload no longer lists schema
1's fields alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-01 08:51:33 +02:00
commit 85c028fe3c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 349 additions and 74 deletions

View file

@ -0,0 +1,570 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "asset",
"dartName": "Asset",
"module": "halkyon_learn",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "spaceId",
"fieldName": "spaceId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "sha256",
"fieldName": "sha256",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "mimeType",
"fieldName": "mimeType",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "byteSize",
"fieldName": "byteSize",
"columnType": 6,
"isNullable": false,
"dartType": "int"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "keyId",
"fieldName": "keyId",
"columnType": 0,
"isNullable": true,
"dartType": "String?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "payload",
"fieldName": "payload",
"columnType": 5,
"isNullable": false,
"dartType": "dart:typed_data:ByteData"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "nonce",
"fieldName": "nonce",
"columnType": 5,
"isNullable": true,
"dartType": "dart:typed_data:ByteData?"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "asset_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "asset_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
},
{
"__className__": "serverpod.IndexDefinition",
"indexName": "asset_hash_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "sha256"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
}
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "card_tag",
"dartName": "CardTag",
"module": "halkyon_learn",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "spaceId",
"fieldName": "spaceId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "cardId",
"fieldName": "cardId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "tagId",
"fieldName": "tagId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "card_tag_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
},
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "card_tag_fk_1",
"columns": [
"cardId"
],
"referenceTable": "card",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4,
"deferrable": "initiallyDeferred"
},
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "card_tag_fk_2",
"columns": [
"tagId"
],
"referenceTable": "tag",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4,
"deferrable": "initiallyDeferred"
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "card_tag_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
},
{
"__className__": "serverpod.IndexDefinition",
"indexName": "card_tag_card_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "cardId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
},
{
"__className__": "serverpod.IndexDefinition",
"indexName": "card_tag_tag_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "tagId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
}
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "person_settings",
"dartName": "PersonSettings",
"module": "halkyon_learn",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "spaceId",
"fieldName": "spaceId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "keyId",
"fieldName": "keyId",
"columnType": 0,
"isNullable": true,
"dartType": "String?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "payload",
"fieldName": "payload",
"columnType": 5,
"isNullable": false,
"dartType": "dart:typed_data:ByteData"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "nonce",
"fieldName": "nonce",
"columnType": 5,
"isNullable": true,
"dartType": "dart:typed_data:ByteData?"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "person_settings_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "person_settings_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
}
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "alterTable",
"alterTable": {
"__className__": "serverpod.TableMigration",
"name": "review_log",
"schema": "public",
"addColumns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "mode",
"fieldName": "mode",
"columnType": 0,
"isNullable": false,
"columnDefault": "'review'",
"dartType": "String"
}
],
"deleteColumns": [],
"modifyColumns": [],
"addIndexes": [],
"deleteIndexes": [],
"addForeignKeys": [],
"deleteForeignKeys": [],
"warnings": []
}
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "alterTable",
"alterTable": {
"__className__": "serverpod.TableMigration",
"name": "studyset",
"schema": "public",
"addColumns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "parentId",
"fieldName": "parentId",
"columnType": 7,
"isNullable": true,
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "position",
"fieldName": "position",
"columnType": 0,
"isNullable": false,
"columnDefault": "'a0'",
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "archivedAt",
"fieldName": "archivedAt",
"columnType": 4,
"isNullable": true,
"dartType": "DateTime?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "starredAt",
"fieldName": "starredAt",
"columnType": 4,
"isNullable": true,
"dartType": "DateTime?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "updatedAt",
"fieldName": "updatedAt",
"columnType": 4,
"isNullable": true,
"dartType": "DateTime?"
}
],
"deleteColumns": [],
"modifyColumns": [],
"addIndexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "studyset_parent_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "parentId"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "position"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"deleteIndexes": [],
"addForeignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "studyset_fk_1",
"columns": [
"parentId"
],
"referenceTable": "studyset",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4,
"deferrable": "initiallyDeferred"
}
],
"deleteForeignKeys": [],
"warnings": []
}
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "tag",
"dartName": "Tag",
"module": "halkyon_learn",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "spaceId",
"fieldName": "spaceId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "keyId",
"fieldName": "keyId",
"columnType": 0,
"isNullable": true,
"dartType": "String?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "payload",
"fieldName": "payload",
"columnType": 5,
"isNullable": false,
"dartType": "dart:typed_data:ByteData"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "nonce",
"fieldName": "nonce",
"columnType": 5,
"isNullable": true,
"dartType": "dart:typed_data:ByteData?"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "tag_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "tag_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -7,7 +7,7 @@ import 'package:serverpod_database/serverpod_database.dart';
part '20260930094729362-studysets/migration.dart';
part '20260930122235111-cards/migration.dart';
part '20260930145136285-reviews/migration.dart';
part '20260930213716558-tree-tags-assets/migration.dart';
part '20261001064926911-tree-tags-assets/migration.dart';
/// Migration registry for the client-side database.
class MigrationRegistry {
@ -17,7 +17,7 @@ class MigrationRegistry {
_Migration20260930094729362_studysets(),
_Migration20260930122235111_cards(),
_Migration20260930145136285_reviews(),
_Migration20260930213716558_tree_tags_assets(),
_Migration20261001064926911_tree_tags_assets(),
];
/// List of all client-side database migration versions.

View file

@ -692,6 +692,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
isNullable: true,
dartType: 'DateTime?',
),
_isd.ColumnDefinition(
name: 'updatedAt',
columnType: _isd.ColumnType.timestampWithoutTimeZone,
isNullable: true,
dartType: 'DateTime?',
),
_isd.ColumnDefinition(
name: 'keyId',
columnType: _isd.ColumnType.text,

View file

@ -33,6 +33,7 @@ abstract class Studyset
String? position,
this.archivedAt,
this.starredAt,
this.updatedAt,
this.keyId,
required this.payload,
this.nonce,
@ -46,6 +47,7 @@ abstract class Studyset
String? position,
DateTime? archivedAt,
DateTime? starredAt,
DateTime? updatedAt,
String? keyId,
required _idt.ByteData payload,
_idt.ByteData? nonce,
@ -74,6 +76,9 @@ abstract class Studyset
starredAt: jsonSerialization['starredAt'] == null
? null
: _isc.DateTimeJsonExtension.fromJson(jsonSerialization['starredAt']),
updatedAt: jsonSerialization['updatedAt'] == null
? null
: _isc.DateTimeJsonExtension.fromJson(jsonSerialization['updatedAt']),
keyId: jsonSerialization['keyId'] as String?,
payload: _isc.ByteDataJsonExtension.fromJson(
jsonSerialization['payload'],
@ -117,11 +122,18 @@ abstract class Studyset
/// not starred. A column of its own, like archivedAt.
DateTime? starredAt;
/// When the studyset itself last changed (its content, place, star or archive
/// date), set by the device making the change, with it; the last writer wins.
/// Null for studysets from before this column. The library lists the most
/// recently changed first (decided 2026-10-01).
DateTime? updatedAt;
/// The encryption key's id; always null, since content isn't end-to-end encrypted
/// (ADR 0002). Kept so opt-in encryption of single studysets stays possible.
String? keyId;
/// The content as UTF-8 JSON: {"schemaVersion", "title", "description"}.
/// The content as UTF-8 JSON: {"schemaVersion", "title", "description", …}; the
/// app's studyset_payload.dart describes each schema.
_idt.ByteData payload;
/// The encryption nonce; always null, like keyId.
@ -141,6 +153,7 @@ abstract class Studyset
String? position,
DateTime? archivedAt,
DateTime? starredAt,
DateTime? updatedAt,
String? keyId,
_idt.ByteData? payload,
_idt.ByteData? nonce,
@ -156,6 +169,7 @@ abstract class Studyset
'position': position,
if (archivedAt != null) 'archivedAt': archivedAt?.toJson(),
if (starredAt != null) 'starredAt': starredAt?.toJson(),
if (updatedAt != null) 'updatedAt': updatedAt?.toJson(),
if (keyId != null) 'keyId': keyId,
'payload': payload.toJson(),
if (nonce != null) 'nonce': nonce?.toJson(),
@ -173,6 +187,7 @@ abstract class Studyset
'position': position,
if (archivedAt != null) 'archivedAt': archivedAt?.toJson(),
if (starredAt != null) 'starredAt': starredAt?.toJson(),
if (updatedAt != null) 'updatedAt': updatedAt?.toJson(),
if (keyId != null) 'keyId': keyId,
'payload': payload.toJson(),
if (nonce != null) 'nonce': nonce?.toJson(),
@ -218,6 +233,7 @@ class _StudysetImpl extends Studyset {
String? position,
DateTime? archivedAt,
DateTime? starredAt,
DateTime? updatedAt,
String? keyId,
required _idt.ByteData payload,
_idt.ByteData? nonce,
@ -229,6 +245,7 @@ class _StudysetImpl extends Studyset {
position: position,
archivedAt: archivedAt,
starredAt: starredAt,
updatedAt: updatedAt,
keyId: keyId,
payload: payload,
nonce: nonce,
@ -246,6 +263,7 @@ class _StudysetImpl extends Studyset {
String? position,
Object? archivedAt = _Undefined,
Object? starredAt = _Undefined,
Object? updatedAt = _Undefined,
Object? keyId = _Undefined,
_idt.ByteData? payload,
Object? nonce = _Undefined,
@ -258,6 +276,7 @@ class _StudysetImpl extends Studyset {
position: position ?? this.position,
archivedAt: archivedAt is DateTime? ? archivedAt : this.archivedAt,
starredAt: starredAt is DateTime? ? starredAt : this.starredAt,
updatedAt: updatedAt is DateTime? ? updatedAt : this.updatedAt,
keyId: keyId is String? ? keyId : this.keyId,
payload: payload ?? this.payload.clone(),
nonce: nonce is _idt.ByteData? ? nonce : this.nonce?.clone(),
@ -297,6 +316,12 @@ class StudysetUpdateTable extends _isd.UpdateTable<StudysetTable> {
value,
);
_isd.ColumnValue<DateTime, DateTime> updatedAt(DateTime? value) =>
_isd.ColumnValue(
table.updatedAt,
value,
);
_isd.ColumnValue<String, String> keyId(String? value) => _isd.ColumnValue(
table.keyId,
value,
@ -339,6 +364,10 @@ class StudysetTable extends _isd.Table<_isc.UuidValue?> {
'starredAt',
this,
);
updatedAt = _isd.ColumnDateTime(
'updatedAt',
this,
);
keyId = _isd.ColumnString(
'keyId',
this,
@ -381,11 +410,18 @@ class StudysetTable extends _isd.Table<_isc.UuidValue?> {
/// not starred. A column of its own, like archivedAt.
late final _isd.ColumnDateTime starredAt;
/// When the studyset itself last changed (its content, place, star or archive
/// date), set by the device making the change, with it; the last writer wins.
/// Null for studysets from before this column. The library lists the most
/// recently changed first (decided 2026-10-01).
late final _isd.ColumnDateTime updatedAt;
/// The encryption key's id; always null, since content isn't end-to-end encrypted
/// (ADR 0002). Kept so opt-in encryption of single studysets stays possible.
late final _isd.ColumnString keyId;
/// The content as UTF-8 JSON: {"schemaVersion", "title", "description"}.
/// The content as UTF-8 JSON: {"schemaVersion", "title", "description", …}; the
/// app's studyset_payload.dart describes each schema.
late final _isd.ColumnByteData payload;
/// The encryption nonce; always null, like keyId.
@ -412,6 +448,7 @@ class StudysetTable extends _isd.Table<_isc.UuidValue?> {
position,
archivedAt,
starredAt,
updatedAt,
keyId,
payload,
nonce,