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
25 changed files with 349 additions and 74 deletions

View file

@ -19,7 +19,7 @@ have yet:
Every migration of a synced table locks older app versions out of syncing (ADR 0002, Every migration of a synced table locks older app versions out of syncing (ADR 0002,
rule 3). Before the first release that costs nothing; after it, every device has to rule 3). Before the first release that costs nothing; after it, every device has to
update before it syncs again. So all of this comes in one migration now, update before it syncs again. So all of this comes in one migration now,
`20260930213716558-tree-tags-assets`, and later content fields go into the versioned `20261001064926911-tree-tags-assets`, and later content fields go into the versioned
payloads instead. New card types (multiple choice, vocabulary, drag-and-drop, map payloads instead. New card types (multiple choice, vocabulary, drag-and-drop, map
questions) need no migration at all: they are the next schema version of the card questions) need no migration at all: they are the next schema version of the card
revision's payload, and the per-side merge of ADR 0003 becomes per field. revision's payload, and the per-side merge of ADR 0003 becomes per field.
@ -36,7 +36,12 @@ revision's payload, and the per-side merge of ADR 0003 becomes per field.
`studyset.archivedAt` and `studyset.starredAt` record when a studyset was archived or `studyset.archivedAt` and `studyset.starredAt` record when a studyset was archived or
starred (null when not). They are columns rather than payload fields for the same starred (null when not). They are columns rather than payload fields for the same
reason as the parent: starring on one device and renaming on another must not reason as the parent: starring on one device and renaming on another must not
overwrite each other. overwrite each other. `studyset.updatedAt` records when the studyset itself last
changed (its content, place, star or archive date), written by the device making the
change together with it; the last writer wins. The library lists the most recently
changed first, after the starred ones (added 2026-10-01: the library's order needs a
change date, and the column is free while the migration is unreleased). It is null
for studysets from before, which come last.
2. **Tags are rows, and so are their assignments.** `tag` holds a name in its payload 2. **Tags are rows, and so are their assignments.** `tag` holds a name in its payload
(content, like a studyset's title); `card_tag` links a card and a tag, one row per (content, like a studyset's title); `card_tag` links a card and a tag, one row per
assignment, never updated. Removing a tag deletes one row, and adding it on another assignment, never updated. Removing a tag deletes one row, and adding it on another

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 '20260930094729362-studysets/migration.dart';
part '20260930122235111-cards/migration.dart'; part '20260930122235111-cards/migration.dart';
part '20260930145136285-reviews/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. /// Migration registry for the client-side database.
class MigrationRegistry { class MigrationRegistry {
@ -17,7 +17,7 @@ class MigrationRegistry {
_Migration20260930094729362_studysets(), _Migration20260930094729362_studysets(),
_Migration20260930122235111_cards(), _Migration20260930122235111_cards(),
_Migration20260930145136285_reviews(), _Migration20260930145136285_reviews(),
_Migration20260930213716558_tree_tags_assets(), _Migration20261001064926911_tree_tags_assets(),
]; ];
/// List of all client-side database migration versions. /// List of all client-side database migration versions.

View file

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

View file

@ -33,6 +33,7 @@ abstract class Studyset
String? position, String? position,
this.archivedAt, this.archivedAt,
this.starredAt, this.starredAt,
this.updatedAt,
this.keyId, this.keyId,
required this.payload, required this.payload,
this.nonce, this.nonce,
@ -46,6 +47,7 @@ abstract class Studyset
String? position, String? position,
DateTime? archivedAt, DateTime? archivedAt,
DateTime? starredAt, DateTime? starredAt,
DateTime? updatedAt,
String? keyId, String? keyId,
required _idt.ByteData payload, required _idt.ByteData payload,
_idt.ByteData? nonce, _idt.ByteData? nonce,
@ -74,6 +76,9 @@ abstract class Studyset
starredAt: jsonSerialization['starredAt'] == null starredAt: jsonSerialization['starredAt'] == null
? null ? null
: _isc.DateTimeJsonExtension.fromJson(jsonSerialization['starredAt']), : _isc.DateTimeJsonExtension.fromJson(jsonSerialization['starredAt']),
updatedAt: jsonSerialization['updatedAt'] == null
? null
: _isc.DateTimeJsonExtension.fromJson(jsonSerialization['updatedAt']),
keyId: jsonSerialization['keyId'] as String?, keyId: jsonSerialization['keyId'] as String?,
payload: _isc.ByteDataJsonExtension.fromJson( payload: _isc.ByteDataJsonExtension.fromJson(
jsonSerialization['payload'], jsonSerialization['payload'],
@ -117,11 +122,18 @@ abstract class Studyset
/// not starred. A column of its own, like archivedAt. /// not starred. A column of its own, like archivedAt.
DateTime? starredAt; 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 /// 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. /// (ADR 0002). Kept so opt-in encryption of single studysets stays possible.
String? keyId; 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; _idt.ByteData payload;
/// The encryption nonce; always null, like keyId. /// The encryption nonce; always null, like keyId.
@ -141,6 +153,7 @@ abstract class Studyset
String? position, String? position,
DateTime? archivedAt, DateTime? archivedAt,
DateTime? starredAt, DateTime? starredAt,
DateTime? updatedAt,
String? keyId, String? keyId,
_idt.ByteData? payload, _idt.ByteData? payload,
_idt.ByteData? nonce, _idt.ByteData? nonce,
@ -156,6 +169,7 @@ abstract class Studyset
'position': position, 'position': position,
if (archivedAt != null) 'archivedAt': archivedAt?.toJson(), if (archivedAt != null) 'archivedAt': archivedAt?.toJson(),
if (starredAt != null) 'starredAt': starredAt?.toJson(), if (starredAt != null) 'starredAt': starredAt?.toJson(),
if (updatedAt != null) 'updatedAt': updatedAt?.toJson(),
if (keyId != null) 'keyId': keyId, if (keyId != null) 'keyId': keyId,
'payload': payload.toJson(), 'payload': payload.toJson(),
if (nonce != null) 'nonce': nonce?.toJson(), if (nonce != null) 'nonce': nonce?.toJson(),
@ -173,6 +187,7 @@ abstract class Studyset
'position': position, 'position': position,
if (archivedAt != null) 'archivedAt': archivedAt?.toJson(), if (archivedAt != null) 'archivedAt': archivedAt?.toJson(),
if (starredAt != null) 'starredAt': starredAt?.toJson(), if (starredAt != null) 'starredAt': starredAt?.toJson(),
if (updatedAt != null) 'updatedAt': updatedAt?.toJson(),
if (keyId != null) 'keyId': keyId, if (keyId != null) 'keyId': keyId,
'payload': payload.toJson(), 'payload': payload.toJson(),
if (nonce != null) 'nonce': nonce?.toJson(), if (nonce != null) 'nonce': nonce?.toJson(),
@ -218,6 +233,7 @@ class _StudysetImpl extends Studyset {
String? position, String? position,
DateTime? archivedAt, DateTime? archivedAt,
DateTime? starredAt, DateTime? starredAt,
DateTime? updatedAt,
String? keyId, String? keyId,
required _idt.ByteData payload, required _idt.ByteData payload,
_idt.ByteData? nonce, _idt.ByteData? nonce,
@ -229,6 +245,7 @@ class _StudysetImpl extends Studyset {
position: position, position: position,
archivedAt: archivedAt, archivedAt: archivedAt,
starredAt: starredAt, starredAt: starredAt,
updatedAt: updatedAt,
keyId: keyId, keyId: keyId,
payload: payload, payload: payload,
nonce: nonce, nonce: nonce,
@ -246,6 +263,7 @@ class _StudysetImpl extends Studyset {
String? position, String? position,
Object? archivedAt = _Undefined, Object? archivedAt = _Undefined,
Object? starredAt = _Undefined, Object? starredAt = _Undefined,
Object? updatedAt = _Undefined,
Object? keyId = _Undefined, Object? keyId = _Undefined,
_idt.ByteData? payload, _idt.ByteData? payload,
Object? nonce = _Undefined, Object? nonce = _Undefined,
@ -258,6 +276,7 @@ class _StudysetImpl extends Studyset {
position: position ?? this.position, position: position ?? this.position,
archivedAt: archivedAt is DateTime? ? archivedAt : this.archivedAt, archivedAt: archivedAt is DateTime? ? archivedAt : this.archivedAt,
starredAt: starredAt is DateTime? ? starredAt : this.starredAt, starredAt: starredAt is DateTime? ? starredAt : this.starredAt,
updatedAt: updatedAt is DateTime? ? updatedAt : this.updatedAt,
keyId: keyId is String? ? keyId : this.keyId, keyId: keyId is String? ? keyId : this.keyId,
payload: payload ?? this.payload.clone(), payload: payload ?? this.payload.clone(),
nonce: nonce is _idt.ByteData? ? nonce : this.nonce?.clone(), nonce: nonce is _idt.ByteData? ? nonce : this.nonce?.clone(),
@ -297,6 +316,12 @@ class StudysetUpdateTable extends _isd.UpdateTable<StudysetTable> {
value, value,
); );
_isd.ColumnValue<DateTime, DateTime> updatedAt(DateTime? value) =>
_isd.ColumnValue(
table.updatedAt,
value,
);
_isd.ColumnValue<String, String> keyId(String? value) => _isd.ColumnValue( _isd.ColumnValue<String, String> keyId(String? value) => _isd.ColumnValue(
table.keyId, table.keyId,
value, value,
@ -339,6 +364,10 @@ class StudysetTable extends _isd.Table<_isc.UuidValue?> {
'starredAt', 'starredAt',
this, this,
); );
updatedAt = _isd.ColumnDateTime(
'updatedAt',
this,
);
keyId = _isd.ColumnString( keyId = _isd.ColumnString(
'keyId', 'keyId',
this, this,
@ -381,11 +410,18 @@ class StudysetTable extends _isd.Table<_isc.UuidValue?> {
/// not starred. A column of its own, like archivedAt. /// not starred. A column of its own, like archivedAt.
late final _isd.ColumnDateTime starredAt; 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 /// 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. /// (ADR 0002). Kept so opt-in encryption of single studysets stays possible.
late final _isd.ColumnString keyId; 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; late final _isd.ColumnByteData payload;
/// The encryption nonce; always null, like keyId. /// The encryption nonce; always null, like keyId.
@ -412,6 +448,7 @@ class StudysetTable extends _isd.Table<_isc.UuidValue?> {
position, position,
archivedAt, archivedAt,
starredAt, starredAt,
updatedAt,
keyId, keyId,
payload, payload,
nonce, nonce,

View file

@ -97,8 +97,8 @@ final class const StudysetListStateFailed() extends StudysetListState {
List<Object?> get props => const []; List<Object?> get props => const [];
} }
/// The top-level studysets, starred ones first, each group oldest first, /// The top-level studysets, starred ones first, each group most recently
/// with their progress. /// changed first, with their progress.
final class const StudysetListStateLoaded( final class const StudysetListStateLoaded(
/// The studysets at the top level. /// The studysets at the top level.
final List<Studyset> studysets, { final List<Studyset> studysets, {
@ -215,12 +215,18 @@ class StudysetListBloc(
} }
} }
/// The top-level studysets, starred ones first; both groups keep their order /// The top-level studysets, starred ones first, each group most recently
/// (oldest first). /// changed first (decided 2026-10-01). Studysets from before changes were
List<Studyset> _inLibraryOrder(List<Studyset> all) { /// recorded come last, the newest of them first: ids are time-ordered.
final top = topLevel(all); List<Studyset> _inLibraryOrder(List<Studyset> all) =>
return [ [...topLevel(all)]..sort((a, b) {
...top.where((studyset) => studyset.isStarred), if (a.isStarred != b.isStarred) {
...top.where((studyset) => !studyset.isStarred), return a.isStarred ? -1 : 1;
];
} }
return switch ((a.updatedAt, b.updatedAt)) {
(final a?, final b?) when a != b => b.compareTo(a),
(_?, null) => -1,
(null, _?) => 1,
_ => b.id.compareTo(a.id),
};
});

View file

@ -9,29 +9,39 @@ typedef StudysetRow = ({
String position, String position,
DateTime? archivedAt, DateTime? archivedAt,
DateTime? starredAt, DateTime? starredAt,
DateTime? updatedAt,
}); });
/// The boundary to the studysets in this person's local, synced database. /// The boundary to the studysets in this person's local, synced database.
/// Tests replace it with a fake. /// Tests replace it with a fake.
/// ///
/// Every update writes only its own column, so a change of another column on /// Every update writes only its own column and `updatedAt`, which says when
/// another device survives the merge (ADR 0001, rule 1). /// the change was made, so a change of another column on another device
/// survives the merge (ADR 0001, rule 1).
abstract interface class StudysetGateway() { abstract interface class StudysetGateway() {
/// All studysets on this device, oldest first. /// All studysets on this device, oldest first.
Future<List<StudysetRow>> all(); Future<List<StudysetRow>> all();
/// Stores a new studyset at the top level and returns its id, a UUID v7 /// Stores a new studyset, made at [updatedAt], at the top level and returns
/// made on the device. /// its id, a UUID v7 made on the device.
Future<String> insert(ByteData payload); Future<String> insert(ByteData payload, DateTime updatedAt);
/// Replaces a studyset's payload. /// Replaces a studyset's payload, as of [updatedAt].
Future<void> updatePayload(String id, ByteData payload); Future<void> updatePayload(String id, ByteData payload, DateTime updatedAt);
/// Sets or clears when a studyset was starred. /// Sets or clears when a studyset was starred, as of [updatedAt].
Future<void> updateStarredAt(String id, DateTime? starredAt); Future<void> updateStarredAt(
String id,
DateTime? starredAt,
DateTime updatedAt,
);
/// Sets or clears when a studyset was archived. /// Sets or clears when a studyset was archived, as of [updatedAt].
Future<void> updateArchivedAt(String id, DateTime? archivedAt); Future<void> updateArchivedAt(
String id,
DateTime? archivedAt,
DateTime updatedAt,
);
/// Deletes a studyset's row. /// Deletes a studyset's row.
Future<void> delete(String id); Future<void> delete(String id);

View file

@ -40,45 +40,47 @@ class StudysetRepository(final StudysetGateway _gateway) {
/// Creates a studyset with [content] at the top level; returns its id. /// Creates a studyset with [content] at the top level; returns its id.
Future<String> create(StudysetContent content) async { Future<String> create(StudysetContent content) async {
final id = await _gateway.insert(encodeStudysetPayload(content)); final id = await _gateway.insert(encodeStudysetPayload(content), _now());
await _refresh(); await _refresh();
return id; return id;
} }
/// Stores the studyset [id]'s new [content]. /// Stores the studyset [id]'s new [content].
Future<void> updateContent(String id, StudysetContent content) async { Future<void> updateContent(String id, StudysetContent content) async {
await _gateway.updatePayload(id, encodeStudysetPayload(content)); await _gateway.updatePayload(id, encodeStudysetPayload(content), _now());
await _refresh(); await _refresh();
} }
/// Stars the studyset [id], keeping it at the top of the library. /// Stars the studyset [id], keeping it at the top of the library.
Future<void> star(String id) async { Future<void> star(String id) async {
await _gateway.updateStarredAt(id, clock.now().toUtc()); final now = _now();
await _gateway.updateStarredAt(id, now, now);
await _refresh(); await _refresh();
} }
/// Takes the studyset [id]'s star away. /// Takes the studyset [id]'s star away.
Future<void> unstar(String id) async { Future<void> unstar(String id) async {
await _gateway.updateStarredAt(id, null); await _gateway.updateStarredAt(id, null, _now());
await _refresh(); await _refresh();
} }
/// Archives the studyset [id], and so everything inside it. /// Archives the studyset [id], and so everything inside it.
Future<void> archive(String id) async { Future<void> archive(String id) async {
await _gateway.updateArchivedAt(id, clock.now().toUtc()); final now = _now();
await _gateway.updateArchivedAt(id, now, now);
await _refresh(); await _refresh();
} }
/// Takes the studyset [id] out of the archive. /// Takes the studyset [id] out of the archive.
Future<void> unarchive(String id) async { Future<void> unarchive(String id) async {
await _gateway.updateArchivedAt(id, null); await _gateway.updateArchivedAt(id, null, _now());
await _refresh(); await _refresh();
} }
/// Deletes a studyset. Its content is blanked first: the sync engine keeps /// Deletes a studyset. Its content is blanked first: the sync engine keeps
/// deleted rows until the whole space is deleted (ADR 0002, rule 2). /// deleted rows until the whole space is deleted (ADR 0002, rule 2).
Future<void> delete(String id) async { Future<void> delete(String id) async {
await _gateway.updatePayload(id, redactedPayload); await _gateway.updatePayload(id, redactedPayload, _now());
await _gateway.delete(id); await _gateway.delete(id);
await _refresh(); await _refresh();
} }
@ -120,10 +122,14 @@ class StudysetRepository(final StudysetGateway _gateway) {
position: row.position, position: row.position,
archivedAt: row.archivedAt, archivedAt: row.archivedAt,
starredAt: row.starredAt, starredAt: row.starredAt,
updatedAt: row.updatedAt,
); );
} on FormatException catch (error) { } on FormatException catch (error) {
log('Skipped studyset ${row.id}: ${error.message}', name: 'Studysets'); log('Skipped studyset ${row.id}: ${error.message}', name: 'Studysets');
return null; return null;
} }
} }
/// The time a change happens, in UTC, as it is stored.
DateTime _now() => clock.now().toUtc();
} }

View file

@ -26,50 +26,63 @@ class SyncStudysetGateway(
position: studyset.position, position: studyset.position,
archivedAt: studyset.archivedAt, archivedAt: studyset.archivedAt,
starredAt: studyset.starredAt, starredAt: studyset.starredAt,
updatedAt: studyset.updatedAt,
), ),
]; ];
@override @override
Future<String> insert(ByteData payload) async { Future<String> insert(ByteData payload, DateTime updatedAt) async {
final studyset = await protocol.Studyset.db.insertRow( final studyset = await protocol.Studyset.db.insertRow(
_db, _db,
protocol.Studyset(payload: payload), protocol.Studyset(payload: payload, updatedAt: updatedAt),
); );
_sync.requestSync(); _sync.requestSync();
return studyset.id!.uuid; return studyset.id!.uuid;
} }
@override @override
Future<void> updatePayload(String id, ByteData payload) async { Future<void> updatePayload(
String id,
ByteData payload,
DateTime updatedAt,
) async {
if (await _find(id) case final studyset?) { if (await _find(id) case final studyset?) {
await protocol.Studyset.db.updateRow( await protocol.Studyset.db.updateRow(
_db, _db,
studyset.copyWith(payload: payload), studyset.copyWith(payload: payload, updatedAt: updatedAt),
columns: (t) => [t.payload], columns: (t) => [t.payload, t.updatedAt],
); );
_sync.requestSync(); _sync.requestSync();
} }
} }
@override @override
Future<void> updateStarredAt(String id, DateTime? starredAt) async { Future<void> updateStarredAt(
String id,
DateTime? starredAt,
DateTime updatedAt,
) async {
if (await _find(id) case final studyset?) { if (await _find(id) case final studyset?) {
await protocol.Studyset.db.updateRow( await protocol.Studyset.db.updateRow(
_db, _db,
studyset.copyWith(starredAt: starredAt), studyset.copyWith(starredAt: starredAt, updatedAt: updatedAt),
columns: (t) => [t.starredAt], columns: (t) => [t.starredAt, t.updatedAt],
); );
_sync.requestSync(); _sync.requestSync();
} }
} }
@override @override
Future<void> updateArchivedAt(String id, DateTime? archivedAt) async { Future<void> updateArchivedAt(
String id,
DateTime? archivedAt,
DateTime updatedAt,
) async {
if (await _find(id) case final studyset?) { if (await _find(id) case final studyset?) {
await protocol.Studyset.db.updateRow( await protocol.Studyset.db.updateRow(
_db, _db,
studyset.copyWith(archivedAt: archivedAt), studyset.copyWith(archivedAt: archivedAt, updatedAt: updatedAt),
columns: (t) => [t.archivedAt], columns: (t) => [t.archivedAt, t.updatedAt],
); );
_sync.requestSync(); _sync.requestSync();
} }

View file

@ -20,6 +20,10 @@ final class const Studyset({
/// When the person starred it, in UTC; null when it isn't starred. /// When the person starred it, in UTC; null when it isn't starred.
final DateTime? starredAt, final DateTime? starredAt,
/// When it last changed itself (its content, place, star or archive), in
/// UTC; null for a studyset from before that was recorded.
final DateTime? updatedAt,
}) with Equatable { }) with Equatable {
/// Whether it was archived itself; a studyset inside an archived one rests /// Whether it was archived itself; a studyset inside an archived one rests
/// in the archive too. /// in the archive too.
@ -36,6 +40,7 @@ final class const Studyset({
position, position,
archivedAt, archivedAt,
starredAt, starredAt,
updatedAt,
]; ];
/// This studyset with [content] instead of its own. /// This studyset with [content] instead of its own.
@ -46,5 +51,6 @@ final class const Studyset({
position: position, position: position,
archivedAt: archivedAt, archivedAt: archivedAt,
starredAt: starredAt, starredAt: starredAt,
updatedAt: updatedAt,
); );
} }

View file

@ -63,7 +63,8 @@ void main() {
await cards.create(japanese, newCard()); await cards.create(japanese, newCard());
bloc.add(const StudysetListEventStarted()); bloc.add(const StudysetListEventStarted());
final state = await listing(['Japanese', 'German']); // German changed last: it was created after Japanese.
final state = await listing(['German', 'Japanese']);
expect(state.progress, { expect(state.progress, {
japanese: const StudyProgress(cards: 1, due: 1, reviewsDue: 0, mature: 0), japanese: const StudyProgress(cards: 1, due: 1, reviewsDue: 0, mature: 0),
@ -81,6 +82,38 @@ void main() {
await listing(['German', 'Japanese']); await listing(['German', 'Japanese']);
}); });
test(
'lists the most recently changed first, after the starred ones',
() async {
DateTime at(int hour) => DateTime.utc(2026, 10, 1, hour);
gateway
..receiveRemoteChange(
'old',
encodeStudysetPayload(StudysetContent.titled('Old')),
)
..receiveRemoteChange(
'earlier',
encodeStudysetPayload(StudysetContent.titled('Earlier')),
updatedAt: at(8),
)
..receiveRemoteChange(
'starred',
encodeStudysetPayload(StudysetContent.titled('Starred')),
starredAt: at(7),
updatedAt: at(7),
)
..receiveRemoteChange(
'later',
encodeStudysetPayload(StudysetContent.titled('Later')),
updatedAt: at(9),
);
bloc.add(const StudysetListEventStarted());
await listing(['Starred', 'Later', 'Earlier', 'Old']);
},
);
test('lists only the top level', () async { test('lists only the top level', () async {
await studysets.create(StudysetContent.titled('Japanese')); await studysets.create(StudysetContent.titled('Japanese'));
gateway.receiveRemoteChange( gateway.receiveRemoteChange(

View file

@ -60,14 +60,19 @@ void main() {
colorName: 'cobalt', colorName: 'cobalt',
); );
final id = await repository.create(content); final createdAt = DateTime.utc(2026, 10, 1, 8);
final id = await withClock(
Clock.fixed(createdAt),
() => repository.create(content),
);
expect(await repository.watchAll().first, [ expect(await repository.watchAll().first, [
Studyset(id: id, content: content), Studyset(id: id, content: content, updatedAt: createdAt),
]); ]);
}); });
test('stores changed content', () async { test('stores changed content', () async {
final changedAt = DateTime.utc(2026, 10, 1, 9);
final id = await repository.create(titled('Japanese')); final id = await repository.create(titled('Japanese'));
final changed = StudysetContent( final changed = StudysetContent(
title: '日本語', title: '日本語',
@ -78,10 +83,13 @@ void main() {
licenseYear: 2026, licenseYear: 2026,
); );
await repository.updateContent(id, changed); await withClock(
Clock.fixed(changedAt),
() => repository.updateContent(id, changed),
);
expect(await repository.watchAll().first, [ expect(await repository.watchAll().first, [
Studyset(id: id, content: changed), Studyset(id: id, content: changed, updatedAt: changedAt),
]); ]);
}); });
@ -110,6 +118,30 @@ void main() {
expect(gateway.writes, hasLength(1), reason: 'the payload only once'); expect(gateway.writes, hasLength(1), reason: 'the payload only once');
}); });
test('every change stamps when it happened', () async {
DateTime at(int hour) => DateTime.utc(2026, 10, 1, hour);
Future<DateTime?> updatedAt() async =>
(await repository.all()).single.updatedAt;
final id = await withClock(
Clock.fixed(at(8)),
() => repository.create(titled('Japanese')),
);
expect(await updatedAt(), at(8));
await withClock(
Clock.fixed(at(9)),
() => repository.updateContent(id, titled('日本語')),
);
expect(await updatedAt(), at(9));
await withClock(Clock.fixed(at(10)), () => repository.star(id));
expect(await updatedAt(), at(10));
await withClock(Clock.fixed(at(11)), () => repository.archive(id));
expect(await updatedAt(), at(11));
});
test('blanks the content before deleting', () async { test('blanks the content before deleting', () async {
final id = await repository.create(titled('Secret')); final id = await repository.create(titled('Secret'));

View file

@ -29,6 +29,7 @@ class FakeStudysetGateway() implements StudysetGateway {
String position = 'a0', String position = 'a0',
DateTime? archivedAt, DateTime? archivedAt,
DateTime? starredAt, DateTime? starredAt,
DateTime? updatedAt,
}) { }) {
_rows[id] = ( _rows[id] = (
id: id, id: id,
@ -37,6 +38,7 @@ class FakeStudysetGateway() implements StudysetGateway {
position: position, position: position,
archivedAt: archivedAt, archivedAt: archivedAt,
starredAt: starredAt, starredAt: starredAt,
updatedAt: updatedAt,
); );
_remoteChanges.add(null); _remoteChanges.add(null);
} }
@ -57,7 +59,7 @@ class FakeStudysetGateway() implements StudysetGateway {
} }
@override @override
Future<String> insert(ByteData payload) async { Future<String> insert(ByteData payload, DateTime updatedAt) async {
final id = 'studyset-${_nextId++}'; final id = 'studyset-${_nextId++}';
_rows[id] = ( _rows[id] = (
id: id, id: id,
@ -66,13 +68,18 @@ class FakeStudysetGateway() implements StudysetGateway {
position: 'a0', position: 'a0',
archivedAt: null, archivedAt: null,
starredAt: null, starredAt: null,
updatedAt: updatedAt,
); );
writes.add((id, payload)); writes.add((id, payload));
return id; return id;
} }
@override @override
Future<void> updatePayload(String id, ByteData payload) async { Future<void> updatePayload(
String id,
ByteData payload,
DateTime updatedAt,
) async {
if (_rows[id] case final row?) { if (_rows[id] case final row?) {
_rows[id] = ( _rows[id] = (
id: id, id: id,
@ -81,13 +88,18 @@ class FakeStudysetGateway() implements StudysetGateway {
position: row.position, position: row.position,
archivedAt: row.archivedAt, archivedAt: row.archivedAt,
starredAt: row.starredAt, starredAt: row.starredAt,
updatedAt: updatedAt,
); );
writes.add((id, payload)); writes.add((id, payload));
} }
} }
@override @override
Future<void> updateStarredAt(String id, DateTime? starredAt) async { Future<void> updateStarredAt(
String id,
DateTime? starredAt,
DateTime updatedAt,
) async {
if (_rows[id] case final row?) { if (_rows[id] case final row?) {
_rows[id] = ( _rows[id] = (
id: id, id: id,
@ -96,12 +108,17 @@ class FakeStudysetGateway() implements StudysetGateway {
position: row.position, position: row.position,
archivedAt: row.archivedAt, archivedAt: row.archivedAt,
starredAt: starredAt, starredAt: starredAt,
updatedAt: updatedAt,
); );
} }
} }
@override @override
Future<void> updateArchivedAt(String id, DateTime? archivedAt) async { Future<void> updateArchivedAt(
String id,
DateTime? archivedAt,
DateTime updatedAt,
) async {
if (_rows[id] case final row?) { if (_rows[id] case final row?) {
_rows[id] = ( _rows[id] = (
id: id, id: id,
@ -110,6 +127,7 @@ class FakeStudysetGateway() implements StudysetGateway {
position: row.position, position: row.position,
archivedAt: archivedAt, archivedAt: archivedAt,
starredAt: row.starredAt, starredAt: row.starredAt,
updatedAt: updatedAt,
); );
} }
} }

View file

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

View file

@ -32,6 +32,7 @@ abstract class Studyset
String? position, String? position,
this.archivedAt, this.archivedAt,
this.starredAt, this.starredAt,
this.updatedAt,
this.keyId, this.keyId,
required this.payload, required this.payload,
this.nonce, this.nonce,
@ -45,6 +46,7 @@ abstract class Studyset
String? position, String? position,
DateTime? archivedAt, DateTime? archivedAt,
DateTime? starredAt, DateTime? starredAt,
DateTime? updatedAt,
String? keyId, String? keyId,
required _idt.ByteData payload, required _idt.ByteData payload,
_idt.ByteData? nonce, _idt.ByteData? nonce,
@ -71,6 +73,9 @@ abstract class Studyset
starredAt: jsonSerialization['starredAt'] == null starredAt: jsonSerialization['starredAt'] == null
? null ? null
: _is.DateTimeJsonExtension.fromJson(jsonSerialization['starredAt']), : _is.DateTimeJsonExtension.fromJson(jsonSerialization['starredAt']),
updatedAt: jsonSerialization['updatedAt'] == null
? null
: _is.DateTimeJsonExtension.fromJson(jsonSerialization['updatedAt']),
keyId: jsonSerialization['keyId'] as String?, keyId: jsonSerialization['keyId'] as String?,
payload: _is.ByteDataJsonExtension.fromJson(jsonSerialization['payload']), payload: _is.ByteDataJsonExtension.fromJson(jsonSerialization['payload']),
nonce: jsonSerialization['nonce'] == null nonce: jsonSerialization['nonce'] == null
@ -112,11 +117,18 @@ abstract class Studyset
/// not starred. A column of its own, like archivedAt. /// not starred. A column of its own, like archivedAt.
DateTime? starredAt; 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 /// 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. /// (ADR 0002). Kept so opt-in encryption of single studysets stays possible.
String? keyId; 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; _idt.ByteData payload;
/// The encryption nonce; always null, like keyId. /// The encryption nonce; always null, like keyId.
@ -136,6 +148,7 @@ abstract class Studyset
String? position, String? position,
DateTime? archivedAt, DateTime? archivedAt,
DateTime? starredAt, DateTime? starredAt,
DateTime? updatedAt,
String? keyId, String? keyId,
_idt.ByteData? payload, _idt.ByteData? payload,
_idt.ByteData? nonce, _idt.ByteData? nonce,
@ -151,6 +164,7 @@ abstract class Studyset
'position': position, 'position': position,
if (archivedAt != null) 'archivedAt': archivedAt?.toJson(), if (archivedAt != null) 'archivedAt': archivedAt?.toJson(),
if (starredAt != null) 'starredAt': starredAt?.toJson(), if (starredAt != null) 'starredAt': starredAt?.toJson(),
if (updatedAt != null) 'updatedAt': updatedAt?.toJson(),
if (keyId != null) 'keyId': keyId, if (keyId != null) 'keyId': keyId,
'payload': payload.toJson(), 'payload': payload.toJson(),
if (nonce != null) 'nonce': nonce?.toJson(), if (nonce != null) 'nonce': nonce?.toJson(),
@ -168,6 +182,7 @@ abstract class Studyset
'position': position, 'position': position,
if (archivedAt != null) 'archivedAt': archivedAt?.toJson(), if (archivedAt != null) 'archivedAt': archivedAt?.toJson(),
if (starredAt != null) 'starredAt': starredAt?.toJson(), if (starredAt != null) 'starredAt': starredAt?.toJson(),
if (updatedAt != null) 'updatedAt': updatedAt?.toJson(),
if (keyId != null) 'keyId': keyId, if (keyId != null) 'keyId': keyId,
'payload': payload.toJson(), 'payload': payload.toJson(),
if (nonce != null) 'nonce': nonce?.toJson(), if (nonce != null) 'nonce': nonce?.toJson(),
@ -213,6 +228,7 @@ class _StudysetImpl extends Studyset {
String? position, String? position,
DateTime? archivedAt, DateTime? archivedAt,
DateTime? starredAt, DateTime? starredAt,
DateTime? updatedAt,
String? keyId, String? keyId,
required _idt.ByteData payload, required _idt.ByteData payload,
_idt.ByteData? nonce, _idt.ByteData? nonce,
@ -224,6 +240,7 @@ class _StudysetImpl extends Studyset {
position: position, position: position,
archivedAt: archivedAt, archivedAt: archivedAt,
starredAt: starredAt, starredAt: starredAt,
updatedAt: updatedAt,
keyId: keyId, keyId: keyId,
payload: payload, payload: payload,
nonce: nonce, nonce: nonce,
@ -241,6 +258,7 @@ class _StudysetImpl extends Studyset {
String? position, String? position,
Object? archivedAt = _Undefined, Object? archivedAt = _Undefined,
Object? starredAt = _Undefined, Object? starredAt = _Undefined,
Object? updatedAt = _Undefined,
Object? keyId = _Undefined, Object? keyId = _Undefined,
_idt.ByteData? payload, _idt.ByteData? payload,
Object? nonce = _Undefined, Object? nonce = _Undefined,
@ -253,6 +271,7 @@ class _StudysetImpl extends Studyset {
position: position ?? this.position, position: position ?? this.position,
archivedAt: archivedAt is DateTime? ? archivedAt : this.archivedAt, archivedAt: archivedAt is DateTime? ? archivedAt : this.archivedAt,
starredAt: starredAt is DateTime? ? starredAt : this.starredAt, starredAt: starredAt is DateTime? ? starredAt : this.starredAt,
updatedAt: updatedAt is DateTime? ? updatedAt : this.updatedAt,
keyId: keyId is String? ? keyId : this.keyId, keyId: keyId is String? ? keyId : this.keyId,
payload: payload ?? this.payload.clone(), payload: payload ?? this.payload.clone(),
nonce: nonce is _idt.ByteData? ? nonce : this.nonce?.clone(), nonce: nonce is _idt.ByteData? ? nonce : this.nonce?.clone(),
@ -292,6 +311,12 @@ class StudysetUpdateTable extends _is.UpdateTable<StudysetTable> {
value, value,
); );
_is.ColumnValue<DateTime, DateTime> updatedAt(DateTime? value) =>
_is.ColumnValue(
table.updatedAt,
value,
);
_is.ColumnValue<String, String> keyId(String? value) => _is.ColumnValue( _is.ColumnValue<String, String> keyId(String? value) => _is.ColumnValue(
table.keyId, table.keyId,
value, value,
@ -334,6 +359,10 @@ class StudysetTable extends _is.Table<_is.UuidValue?> {
'starredAt', 'starredAt',
this, this,
); );
updatedAt = _is.ColumnDateTime(
'updatedAt',
this,
);
keyId = _is.ColumnString( keyId = _is.ColumnString(
'keyId', 'keyId',
this, this,
@ -376,11 +405,18 @@ class StudysetTable extends _is.Table<_is.UuidValue?> {
/// not starred. A column of its own, like archivedAt. /// not starred. A column of its own, like archivedAt.
late final _is.ColumnDateTime starredAt; late final _is.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 _is.ColumnDateTime updatedAt;
/// The encryption key's id; always null, since content isn't end-to-end encrypted /// 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. /// (ADR 0002). Kept so opt-in encryption of single studysets stays possible.
late final _is.ColumnString keyId; late final _is.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 _is.ColumnByteData payload; late final _is.ColumnByteData payload;
/// The encryption nonce; always null, like keyId. /// The encryption nonce; always null, like keyId.
@ -407,6 +443,7 @@ class StudysetTable extends _is.Table<_is.UuidValue?> {
position, position,
archivedAt, archivedAt,
starredAt, starredAt,
updatedAt,
keyId, keyId,
payload, payload,
nonce, nonce,

View file

@ -25,10 +25,16 @@ fields:
### When the person starred it, to keep it at the top of the library; null when ### 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. ### not starred. A column of its own, like archivedAt.
starredAt: DateTime? starredAt: DateTime?
### 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).
updatedAt: DateTime?
### The encryption key's id; always null, since content isn't end-to-end encrypted ### 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. ### (ADR 0002). Kept so opt-in encryption of single studysets stays possible.
keyId: String? keyId: String?
### 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.
payload: ByteData payload: ByteData
### The encryption nonce; always null, like keyId. ### The encryption nonce; always null, like keyId.
nonce: ByteData? nonce: ByteData?

Some files were not shown because too many files have changed in this diff Show more