feat(privacy): the instance's side of private studysets (ADR 0019)

- `content_key`, a synced table in the person's space, keeps their data keys
  sealed to their account (a private studyset's, their personal one);
  `learning_path_answer` gains `keyId` and `nonce` for answers in private
  studysets. Additive only, on the server and the device.
- Publishing refuses a private studyset, and a tree holding a private row
  (`PublishRefusal.private`); a private studyset elsewhere no longer breaks
  publishing a readable one. Encrypted tags are named as the device names
  them (`tagNames`): a name for a tag that isn't the person's, or an
  encrypted tag of the release without one, refuses it
  (`tagNamesMismatch`); a readable tag keeps its own name.
- The connector leaves private studysets out and says how many there are.
- The learner count reads no payload any more: Count me is kept by the
  instance (`counted_learner`) and the device reports which publications
  its copies come from (`learning_copy`, accepted only while counted, each
  report replacing the last); only the reporter's own studysets count.
  Deleting a person deletes both.
- An upload says whether its file is encrypted: only a readable one over
  20 MB is checked to be a PDF, and an encrypted one may be 4 KiB larger.

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-21 20:52:35 +02:00
commit a1c5ac3f86
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
47 changed files with 35261 additions and 198 deletions

View file

@ -0,0 +1,144 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "content_key",
"dartName": "ContentKey",
"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": "dataKeyId",
"fieldName": "dataKeyId",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "kind",
"fieldName": "kind",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "studysetId",
"fieldName": "studysetId",
"columnType": 7,
"isNullable": true,
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "sealedKey",
"fieldName": "sealedKey",
"columnType": 5,
"isNullable": false,
"dartType": "dart:typed_data:ByteData"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "createdAt",
"fieldName": "createdAt",
"columnType": 4,
"isNullable": false,
"dartType": "DateTime"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "content_key_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "content_key_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": "learning_path_answer",
"schema": "public",
"addColumns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "keyId",
"fieldName": "keyId",
"columnType": 0,
"isNullable": true,
"dartType": "String?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "nonce",
"fieldName": "nonce",
"columnType": 5,
"isNullable": true,
"dartType": "dart:typed_data:ByteData?"
}
],
"deleteColumns": [],
"modifyColumns": [],
"addIndexes": [],
"deleteIndexes": [],
"addForeignKeys": [],
"deleteForeignKeys": [],
"warnings": []
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -27,6 +27,7 @@ part '20261020211224692-email-code-purpose/migration.dart';
part '20261021100422048-session-ended-warning/migration.dart';
part '20261021110159867-second-factor-paths/migration.dart';
part '20261021134803749-reset-hold-outs/migration.dart';
part '20261021184601197-private-studysets/migration.dart';
/// Migration registry for the client-side database.
class MigrationRegistry {
@ -56,6 +57,7 @@ class MigrationRegistry {
_Migration20261021100422048_session_ended_warning(),
_Migration20261021110159867_second_factor_paths(),
_Migration20261021134803749_reset_hold_outs(),
_Migration20261021184601197_private_studysets(),
];
/// List of all client-side database migration versions.

View file

@ -37,6 +37,8 @@ import 'package:halkyon_learn_client/src/protocol/discussion/discussion_target.d
as _iy0rtcfg;
import 'package:halkyon_learn_client/src/protocol/discussion/discussion_view.dart'
as _i31gsvwz;
import 'package:halkyon_learn_client/src/protocol/explore/copy_report.dart'
as _i3wii6lt;
import 'package:halkyon_learn_client/src/protocol/explore/explore_entry.dart'
as _iyigb6vc;
import 'package:halkyon_learn_client/src/protocol/explore/explore_query.dart'
@ -82,30 +84,41 @@ class EndpointAsset extends _isc.EndpointRef {
String get name => 'asset';
/// How to upload the caller's file with [sha256] (lowercase hex) of
/// [byteSize] bytes; null when the server has it already. Throws a
/// [FileLimitExceededException] when it doesn't fit (see [FileLimits]).
/// [byteSize] bytes, [encrypted] if it is a private studyset's (ADR
/// 0019), which may be larger by [encryptionOverhead]; null when the
/// server has it already. Throws a [FileLimitExceededException] when it
/// doesn't fit (see [FileLimits]).
_ida.Future<String?> startUpload(
String sha256,
int byteSize,
) => caller.callServerEndpoint<String?>(
int byteSize, {
bool? encrypted,
}) => caller.callServerEndpoint<String?>(
'asset',
'startUpload',
{
'sha256': sha256,
'byteSize': byteSize,
'encrypted': encrypted,
},
);
/// Completes the upload of the caller's file with [sha256]; whether it is
/// stored now. A file whose content has another hash is deleted, and so is
/// one over [maxOtherBytes] that isn't a PDF, and one that no longer fits,
/// with a [FileLimitExceededException].
_ida.Future<bool> finishUpload(String sha256) =>
caller.callServerEndpoint<bool>(
'asset',
'finishUpload',
{'sha256': sha256},
);
/// a readable one over [maxOtherBytes] that isn't a PDF, and one that no
/// longer fits, with a [FileLimitExceededException]. An [encrypted] file
/// (ADR 0019) can't be looked into: its size alone counts, as the quotas'
/// sizes do.
_ida.Future<bool> finishUpload(
String sha256, {
bool? encrypted,
}) => caller.callServerEndpoint<bool>(
'asset',
'finishUpload',
{
'sha256': sha256,
'encrypted': encrypted,
},
);
/// How much room the caller's files take, and may take.
_ida.Future<_iimptq0q.FileUsage> usage() =>
@ -646,6 +659,45 @@ class EndpointExplore extends _isc.EndpointRef {
);
}
/// Whether the caller is counted as learning what their copies come from
/// ("Learn this here", ADR 0005, rule 8), and which publications those are
/// (ADR 0019, D3): their settings and private copies are encrypted, so their
/// device says so here, and only while they chose to be counted. Only
/// people with an identity are counted.
/// {@category Endpoint}
class EndpointLearningCount extends _isc.EndpointRef {
EndpointLearningCount(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'learningCount';
/// Whether the caller chose to be counted.
_ida.Future<bool> counted() => caller.callServerEndpoint<bool>(
'learningCount',
'counted',
{},
);
/// Counts the caller from now on, or no longer ([counted] false), which
/// also forgets the copies their device reported.
_ida.Future<void> setCounted(bool counted) => caller.callServerEndpoint<void>(
'learningCount',
'setCounted',
{'counted': counted},
);
/// Replaces the copies the caller's device reported before with
/// [copies]; whether they were kept, which they are only while the
/// caller chose to be counted. Which of them are really the caller's,
/// and still there, the count works out from their own rows.
_ida.Future<bool> reportCopies(List<_i3wii6lt.CopyReport> copies) =>
caller.callServerEndpoint<bool>(
'learningCount',
'reportCopies',
{'copies': copies},
);
}
/// The reports and suggestions on a published studyset (ADR 0005, rule 6),
/// for everyone signed in to read: they are public, but for those its author
/// hid. Writing them is the sync engine's: each person writes in their own
@ -808,7 +860,9 @@ class EndpointPublish extends _isc.EndpointRef {
/// when given: a studyset may introduce itself to strangers otherwise
/// than in the library (decided 2026-10-14); without, the studyset's
/// own description. One that isn't rich text, or is larger than
/// [maxExploreDescription], counts as not given.
/// [maxExploreDescription], counts as not given. [tagNames] names the
/// tags its items carry (tag id to name), as the device reads them: the
/// person's tags are encrypted (ADR 0019, D2).
///
/// Throws [PublishRefusedException] when it can't be published (see
/// [PublishRefusal]), and [NotFoundException] for a studyset that isn't
@ -818,6 +872,7 @@ class EndpointPublish extends _isc.EndpointRef {
String slug,
String notes, {
String? description,
Map<String, String>? tagNames,
}) => caller.callServerEndpoint<_ij0cqo11.Publication>(
'publish',
'publish',
@ -826,6 +881,7 @@ class EndpointPublish extends _isc.EndpointRef {
'slug': slug,
'notes': notes,
'description': description,
'tagNames': tagNames,
},
);
@ -892,6 +948,7 @@ class Client extends _isc.ServerpodClientShared {
discussions = EndpointDiscussions(this);
discussion = EndpointDiscussion(this);
explore = EndpointExplore(this);
learningCount = EndpointLearningCount(this);
feedback = EndpointFeedback(this);
inbox = EndpointInbox(this);
instance = EndpointInstance(this);
@ -913,6 +970,8 @@ class Client extends _isc.ServerpodClientShared {
late final EndpointExplore explore;
late final EndpointLearningCount learningCount;
late final EndpointFeedback feedback;
late final EndpointInbox inbox;
@ -932,6 +991,7 @@ class Client extends _isc.ServerpodClientShared {
'discussions': discussions,
'discussion': discussion,
'explore': explore,
'learningCount': learningCount,
'feedback': feedback,
'inbox': inbox,
'instance': instance,

View file

@ -0,0 +1,98 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_learn_client/src/protocol/protocol.dart' as _itx5j3f5;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// A device's report of the studysets copied from one publication (ADR 0019,
/// D3): the copy's studysets, its topics with it.
abstract class CopyReport
implements _isc.SerializableModel, _isc.ProtocolSerialization {
CopyReport._({
required this.publicationId,
required this.studysetIds,
});
factory CopyReport({
required String publicationId,
required List<_isc.UuidValue> studysetIds,
}) = _CopyReportImpl;
factory CopyReport.fromJson(Map<String, dynamic> jsonSerialization) {
return CopyReport(
publicationId: jsonSerialization['publicationId'] as String,
studysetIds: _itx5j3f5.Protocol().deserialize<List<_isc.UuidValue>>(
jsonSerialization['studysetIds'],
),
);
}
/// The publication's public id.
String publicationId;
/// The person's studysets that are part of the copy.
List<_isc.UuidValue> studysetIds;
/// Returns a shallow copy of this [CopyReport]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
CopyReport copyWith({
String? publicationId,
List<_isc.UuidValue>? studysetIds,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'CopyReport',
'publicationId': publicationId,
'studysetIds': studysetIds.toJson(valueToJson: (v) => v.toJson()),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'CopyReport',
'publicationId': publicationId,
'studysetIds': studysetIds.toJson(valueToJson: (v) => v.toJson()),
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _CopyReportImpl extends CopyReport {
_CopyReportImpl({
required String publicationId,
required List<_isc.UuidValue> studysetIds,
}) : super._(
publicationId: publicationId,
studysetIds: studysetIds,
);
/// Returns a shallow copy of this [CopyReport]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
CopyReport copyWith({
String? publicationId,
List<_isc.UuidValue>? studysetIds,
}) {
return CopyReport(
publicationId: publicationId ?? this.publicationId,
studysetIds: studysetIds ?? this.studysetIds.map((e0) => e0).toList(),
);
}
}

View file

@ -11,6 +11,7 @@
// ignore_for_file: dead_code, unnecessary_null_comparison
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:halkyon_learn_client/src/protocol/protocol.dart' as _itx5j3f5;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
import 'package:serverpod_database/serverpod_database.dart' as _isd;
@ -30,6 +31,8 @@ abstract class LearningPathAnswer
required this.answeredAt,
required this.outcome,
this.answer,
this.keyId,
this.nonce,
});
factory LearningPathAnswer({
@ -40,6 +43,8 @@ abstract class LearningPathAnswer
required DateTime answeredAt,
required String outcome,
String? answer,
String? keyId,
_idt.ByteData? nonce,
}) = _LearningPathAnswerImpl;
factory LearningPathAnswer.fromJson(Map<String, dynamic> jsonSerialization) {
@ -59,6 +64,10 @@ abstract class LearningPathAnswer
),
outcome: jsonSerialization['outcome'] as String,
answer: jsonSerialization['answer'] as String?,
keyId: jsonSerialization['keyId'] as String?,
nonce: jsonSerialization['nonce'] == null
? null
: _isc.ByteDataJsonExtension.fromJson(jsonSerialization['nonce']),
);
}
@ -87,9 +96,17 @@ abstract class LearningPathAnswer
String outcome;
/// What was answered, as JSON in the step's exercise schema (the choices made,
/// the pairing, the words placed); null for 'continued' and 'reset'.
/// the pairing, the words placed); null for 'continued' and 'reset'. In a
/// private studyset (ADR 0019), its sealed form in base64: the signature,
/// then the ciphertext.
String? answer;
/// The key the answer is encrypted with (ADR 0019); null when readable.
String? keyId;
/// The encryption nonce; null when readable, like keyId.
_idt.ByteData? nonce;
@override
_isd.Table<_isc.UuidValue?> get table => t;
@ -104,6 +121,8 @@ abstract class LearningPathAnswer
DateTime? answeredAt,
String? outcome,
String? answer,
String? keyId,
_idt.ByteData? nonce,
});
@override
Map<String, dynamic> toJson() {
@ -116,6 +135,8 @@ abstract class LearningPathAnswer
'answeredAt': answeredAt.toJson(),
'outcome': outcome,
if (answer != null) 'answer': answer,
if (keyId != null) 'keyId': keyId,
if (nonce != null) 'nonce': nonce?.toJson(),
};
}
@ -130,6 +151,8 @@ abstract class LearningPathAnswer
'answeredAt': answeredAt.toJson(),
'outcome': outcome,
if (answer != null) 'answer': answer,
if (keyId != null) 'keyId': keyId,
if (nonce != null) 'nonce': nonce?.toJson(),
};
}
@ -174,6 +197,8 @@ class _LearningPathAnswerImpl extends LearningPathAnswer {
required DateTime answeredAt,
required String outcome,
String? answer,
String? keyId,
_idt.ByteData? nonce,
}) : super._(
id: id,
spaceId: spaceId,
@ -182,6 +207,8 @@ class _LearningPathAnswerImpl extends LearningPathAnswer {
answeredAt: answeredAt,
outcome: outcome,
answer: answer,
keyId: keyId,
nonce: nonce,
);
/// Returns a shallow copy of this [LearningPathAnswer]
@ -196,6 +223,8 @@ class _LearningPathAnswerImpl extends LearningPathAnswer {
DateTime? answeredAt,
String? outcome,
Object? answer = _Undefined,
Object? keyId = _Undefined,
Object? nonce = _Undefined,
}) {
return LearningPathAnswer(
id: id is _isc.UuidValue? ? id : this.id,
@ -205,6 +234,8 @@ class _LearningPathAnswerImpl extends LearningPathAnswer {
answeredAt: answeredAt ?? this.answeredAt,
outcome: outcome ?? this.outcome,
answer: answer is String? ? answer : this.answer,
keyId: keyId is String? ? keyId : this.keyId,
nonce: nonce is _idt.ByteData? ? nonce : this.nonce?.clone(),
);
}
}
@ -240,6 +271,17 @@ class LearningPathAnswerUpdateTable
table.answer,
value,
);
_isd.ColumnValue<String, String> keyId(String? value) => _isd.ColumnValue(
table.keyId,
value,
);
_isd.ColumnValue<_idt.ByteData, _idt.ByteData> nonce(_idt.ByteData? value) =>
_isd.ColumnValue(
table.nonce,
value,
);
}
class LearningPathAnswerTable extends _isd.Table<_isc.UuidValue?> {
@ -266,6 +308,14 @@ class LearningPathAnswerTable extends _isd.Table<_isc.UuidValue?> {
'answer',
this,
);
keyId = _isd.ColumnString(
'keyId',
this,
);
nonce = _isd.ColumnByteData(
'nonce',
this,
);
}
late final LearningPathAnswerUpdateTable updateTable;
@ -288,9 +338,17 @@ class LearningPathAnswerTable extends _isd.Table<_isc.UuidValue?> {
late final _isd.ColumnString outcome;
/// What was answered, as JSON in the step's exercise schema (the choices made,
/// the pairing, the words placed); null for 'continued' and 'reset'.
/// the pairing, the words placed); null for 'continued' and 'reset'. In a
/// private studyset (ADR 0019), its sealed form in base64: the signature,
/// then the ciphertext.
late final _isd.ColumnString answer;
/// The key the answer is encrypted with (ADR 0019); null when readable.
late final _isd.ColumnString keyId;
/// The encryption nonce; null when readable, like keyId.
late final _isd.ColumnByteData nonce;
_i0qbf905.LearningPathStepTable get step {
if (_step != null) return _step!;
_step = _isd.createRelationTable(
@ -312,6 +370,8 @@ class LearningPathAnswerTable extends _isd.Table<_isc.UuidValue?> {
answeredAt,
outcome,
answer,
keyId,
nonce,
];
@override

View file

@ -0,0 +1,736 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
import 'package:serverpod_database/serverpod_database.dart' as _isd;
/// A data key of the person's (ADR 0019): a private studyset's, which encrypts
/// everything in its tree, or their personal key, which encrypts their tags
/// and settings. Sealed on a device to the account's X25519 key and signed by
/// it (halkyon_keys' `sealDataKey`), so only the person's devices open it;
/// the instance keeps it as it is. Synced, so keys arrive with the data, work
/// offline and go with the space. Never updated: a studyset made private again
/// gets a new key.
abstract class ContentKey
implements _isd.TableRow<_isc.UuidValue?>, _isc.ProtocolSerialization {
ContentKey._({
this.id,
this.spaceId,
required this.dataKeyId,
required this.kind,
this.studysetId,
required this.sealedKey,
required this.createdAt,
});
factory ContentKey({
_isc.UuidValue? id,
int? spaceId,
required String dataKeyId,
required String kind,
_isc.UuidValue? studysetId,
required _idt.ByteData sealedKey,
required DateTime createdAt,
}) = _ContentKeyImpl;
factory ContentKey.fromJson(Map<String, dynamic> jsonSerialization) {
return ContentKey(
id: jsonSerialization['id'] == null
? null
: _isc.UuidValueJsonExtension.fromJson(jsonSerialization['id']),
spaceId: jsonSerialization['spaceId'] as int?,
dataKeyId: jsonSerialization['dataKeyId'] as String,
kind: jsonSerialization['kind'] as String,
studysetId: jsonSerialization['studysetId'] == null
? null
: _isc.UuidValueJsonExtension.fromJson(
jsonSerialization['studysetId'],
),
sealedKey: _isc.ByteDataJsonExtension.fromJson(
jsonSerialization['sealedKey'],
),
createdAt: _isc.DateTimeJsonExtension.fromJson(
jsonSerialization['createdAt'],
),
);
}
static final t = ContentKeyTable();
static const db = ContentKeyRepository._();
@override
_isc.UuidValue? id;
/// The space owning this row. Maintained by the sync engine.
int? spaceId;
/// The key's ID, which the rows it encrypts name in their `keyId`.
String dataKeyId;
/// 'studyset' for a private studyset's key, 'personal' for the person's.
String kind;
/// The top-level studyset a studyset key is of; null for the personal key.
/// A plain id: the key outlives nothing it encrypts, and arrives in any
/// order.
_isc.UuidValue? studysetId;
/// The sealed key: the sealer's Ed25519 signature, then the sealed box.
_idt.ByteData sealedKey;
/// When it was made, in UTC.
DateTime createdAt;
@override
_isd.Table<_isc.UuidValue?> get table => t;
/// Returns a shallow copy of this [ContentKey]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
ContentKey copyWith({
_isc.UuidValue? id,
int? spaceId,
String? dataKeyId,
String? kind,
_isc.UuidValue? studysetId,
_idt.ByteData? sealedKey,
DateTime? createdAt,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'ContentKey',
if (id != null) 'id': id?.toJson(),
if (spaceId != null) 'spaceId': spaceId,
'dataKeyId': dataKeyId,
'kind': kind,
if (studysetId != null) 'studysetId': studysetId?.toJson(),
'sealedKey': sealedKey.toJson(),
'createdAt': createdAt.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'ContentKey',
if (id != null) 'id': id?.toJson(),
if (spaceId != null) 'spaceId': spaceId,
'dataKeyId': dataKeyId,
'kind': kind,
if (studysetId != null) 'studysetId': studysetId?.toJson(),
'sealedKey': sealedKey.toJson(),
'createdAt': createdAt.toJson(),
};
}
static ContentKeyInclude include() {
return ContentKeyInclude._();
}
static ContentKeyIncludeList includeList({
_isd.WhereExpressionBuilder<ContentKeyTable>? where,
int? limit,
int? offset,
_isd.OrderByBuilder<ContentKeyTable>? orderBy,
_isd.OrderByListBuilder<ContentKeyTable>? orderByList,
ContentKeyInclude? include,
}) {
return ContentKeyIncludeList._(
where: where,
limit: limit,
offset: offset,
orderBy: orderBy?.call(ContentKey.t),
orderByList: orderByList?.call(ContentKey.t),
include: include,
);
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _ContentKeyImpl extends ContentKey {
_ContentKeyImpl({
_isc.UuidValue? id,
int? spaceId,
required String dataKeyId,
required String kind,
_isc.UuidValue? studysetId,
required _idt.ByteData sealedKey,
required DateTime createdAt,
}) : super._(
id: id,
spaceId: spaceId,
dataKeyId: dataKeyId,
kind: kind,
studysetId: studysetId,
sealedKey: sealedKey,
createdAt: createdAt,
);
/// Returns a shallow copy of this [ContentKey]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
ContentKey copyWith({
Object? id = _Undefined,
Object? spaceId = _Undefined,
String? dataKeyId,
String? kind,
Object? studysetId = _Undefined,
_idt.ByteData? sealedKey,
DateTime? createdAt,
}) {
return ContentKey(
id: id is _isc.UuidValue? ? id : this.id,
spaceId: spaceId is int? ? spaceId : this.spaceId,
dataKeyId: dataKeyId ?? this.dataKeyId,
kind: kind ?? this.kind,
studysetId: studysetId is _isc.UuidValue? ? studysetId : this.studysetId,
sealedKey: sealedKey ?? this.sealedKey.clone(),
createdAt: createdAt ?? this.createdAt,
);
}
}
class ContentKeyUpdateTable extends _isd.UpdateTable<ContentKeyTable> {
ContentKeyUpdateTable(super.table);
_isd.ColumnValue<int, int> spaceId(int? value) => _isd.ColumnValue(
table.spaceId,
value,
);
_isd.ColumnValue<String, String> dataKeyId(String value) => _isd.ColumnValue(
table.dataKeyId,
value,
);
_isd.ColumnValue<String, String> kind(String value) => _isd.ColumnValue(
table.kind,
value,
);
_isd.ColumnValue<_isc.UuidValue, _isc.UuidValue> studysetId(
_isc.UuidValue? value,
) => _isd.ColumnValue(
table.studysetId,
value,
);
_isd.ColumnValue<_idt.ByteData, _idt.ByteData> sealedKey(
_idt.ByteData value,
) => _isd.ColumnValue(
table.sealedKey,
value,
);
_isd.ColumnValue<DateTime, DateTime> createdAt(DateTime value) =>
_isd.ColumnValue(
table.createdAt,
value,
);
}
class ContentKeyTable extends _isd.Table<_isc.UuidValue?> {
ContentKeyTable({super.tableRelation}) : super(tableName: 'content_key') {
updateTable = ContentKeyUpdateTable(this);
spaceId = _isd.ColumnInt(
'spaceId',
this,
);
dataKeyId = _isd.ColumnString(
'dataKeyId',
this,
);
kind = _isd.ColumnString(
'kind',
this,
);
studysetId = _isd.ColumnUuid(
'studysetId',
this,
);
sealedKey = _isd.ColumnByteData(
'sealedKey',
this,
);
createdAt = _isd.ColumnDateTime(
'createdAt',
this,
);
}
late final ContentKeyUpdateTable updateTable;
/// The space owning this row. Maintained by the sync engine.
late final _isd.ColumnInt spaceId;
/// The key's ID, which the rows it encrypts name in their `keyId`.
late final _isd.ColumnString dataKeyId;
/// 'studyset' for a private studyset's key, 'personal' for the person's.
late final _isd.ColumnString kind;
/// The top-level studyset a studyset key is of; null for the personal key.
/// A plain id: the key outlives nothing it encrypts, and arrives in any
/// order.
late final _isd.ColumnUuid studysetId;
/// The sealed key: the sealer's Ed25519 signature, then the sealed box.
late final _isd.ColumnByteData sealedKey;
/// When it was made, in UTC.
late final _isd.ColumnDateTime createdAt;
@override
List<_isd.Column> get columns => [
id,
spaceId,
dataKeyId,
kind,
studysetId,
sealedKey,
createdAt,
];
}
class ContentKeyInclude extends _isd.IncludeObject {
ContentKeyInclude._();
@override
Map<String, _isd.Include?> get includes => {};
@override
_isd.Table<_isc.UuidValue?> get table => ContentKey.t;
}
class ContentKeyIncludeList extends _isd.IncludeList {
ContentKeyIncludeList._({
_isd.WhereExpressionBuilder<ContentKeyTable>? where,
super.limit,
super.offset,
super.orderBy,
super.orderByList,
super.include,
}) {
super.where = where?.call(ContentKey.t);
}
@override
Map<String, _isd.Include?> get includes => include?.includes ?? {};
@override
_isd.Table<_isc.UuidValue?> get table => ContentKey.t;
}
class ContentKeyRepository {
const ContentKeyRepository._();
/// Returns a list of [ContentKey]s matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order of the items use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// The maximum number of items can be set by [limit]. If no limit is set,
/// all items matching the query will be returned.
///
/// [offset] defines how many items to skip, after which [limit] (or all)
/// items are read from the database.
///
/// ```dart
/// var persons = await Persons.db.find(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.firstName,
/// limit: 100,
/// );
/// ```
Future<List<ContentKey>> find(
_isd.DatabaseSession session, {
_isd.WhereExpressionBuilder<ContentKeyTable>? where,
int? limit,
int? offset,
_isd.OrderByBuilder<ContentKeyTable>? orderBy,
_isd.OrderByListBuilder<ContentKeyTable>? orderByList,
_isd.Transaction? transaction,
_isd.LockMode? lockMode,
_isd.LockBehavior? lockBehavior,
}) async {
return session.db.find<ContentKey>(
where: where?.call(ContentKey.t),
orderBy: orderBy?.call(ContentKey.t),
orderByList: orderByList?.call(ContentKey.t),
limit: limit,
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Returns the first matching [ContentKey] matching the given query parameters.
///
/// Use [where] to specify which items to include in the return value.
/// If none is specified, all items will be returned.
///
/// To specify the order use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// [offset] defines how many items to skip, after which the next one will be picked.
///
/// ```dart
/// var youngestPerson = await Persons.db.findFirstRow(
/// session,
/// where: (t) => t.lastName.equals('Jones'),
/// orderBy: (t) => t.age,
/// );
/// ```
Future<ContentKey?> findFirstRow(
_isd.DatabaseSession session, {
_isd.WhereExpressionBuilder<ContentKeyTable>? where,
int? offset,
_isd.OrderByBuilder<ContentKeyTable>? orderBy,
_isd.OrderByListBuilder<ContentKeyTable>? orderByList,
_isd.Transaction? transaction,
_isd.LockMode? lockMode,
_isd.LockBehavior? lockBehavior,
}) async {
return session.db.findFirstRow<ContentKey>(
where: where?.call(ContentKey.t),
orderBy: orderBy?.call(ContentKey.t),
orderByList: orderByList?.call(ContentKey.t),
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Finds a single [ContentKey] by its [id] or null if no such row exists.
Future<ContentKey?> findById(
_isd.DatabaseSession session,
_isc.UuidValue id, {
_isd.Transaction? transaction,
_isd.LockMode? lockMode,
_isd.LockBehavior? lockBehavior,
}) async {
return session.db.findById<ContentKey>(
id,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Inserts all [ContentKey]s in the list and returns the inserted rows.
///
/// The returned [ContentKey]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails to
/// insert, none of the rows will be inserted.
///
/// If [ignoreConflicts] is set to `true`, rows that conflict with existing
/// rows are silently skipped, and only the successfully inserted rows are
/// returned.
///
/// If [noReturn] is set to `true`, the inserted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<ContentKey>> insert(
_isd.DatabaseSession session,
List<ContentKey> rows, {
_isd.Transaction? transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
return session.db.insert<ContentKey>(
rows,
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
}
/// Inserts a single [ContentKey] and returns the inserted row.
///
/// The returned [ContentKey] will have its `id` field set.
Future<ContentKey> insertRow(
_isd.DatabaseSession session,
ContentKey row, {
_isd.Transaction? transaction,
}) async {
return session.db.insertRow<ContentKey>(
row,
transaction: transaction,
);
}
/// Upserts all [ContentKey]s in the list and returns the resulting rows.
///
/// If a row conflicts on the given [conflictColumns], the existing row is
/// updated with the new values. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies to rows matching the
/// given expression. Conflicting rows that don't match are skipped and not
/// returned, so the resulting list may be shorter than [rows].
///
/// The returned [ContentKey]s will have their `id` fields set.
///
/// This is an atomic operation, meaning that if one of the rows fails,
/// none of the rows will be affected.
///
/// If [noReturn] is set to `true`, the resulting rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<ContentKey>> upsert(
_isd.DatabaseSession session,
List<ContentKey> rows, {
required _isd.ColumnSelections<ContentKeyTable> conflictColumns,
_isd.ColumnSelections<ContentKeyTable>? updateColumns,
_isd.WhereExpressionBuilder<ContentKeyTable>? updateWhere,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.upsert<ContentKey>(
rows,
conflictColumns: conflictColumns(ContentKey.t),
updateColumns: updateColumns?.call(ContentKey.t),
updateWhere: updateWhere?.call(ContentKey.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Upserts a single [ContentKey] and returns the resulting row.
///
/// If the row conflicts on the given [conflictColumns], the existing row is
/// updated. Otherwise, a new row is inserted.
///
/// If [updateColumns] is provided, only those columns will be updated on
/// conflict. If null, all non-conflict, non-id columns are updated.
///
/// If [updateWhere] is provided, the update only applies when the existing
/// row matches the expression. Returns `null` if no row was affected — for
/// example when [updateWhere] does not match the conflicting row.
///
/// The returned [ContentKey] will have its `id` field set.
Future<ContentKey?> upsertRow(
_isd.DatabaseSession session,
ContentKey row, {
required _isd.ColumnSelections<ContentKeyTable> conflictColumns,
_isd.ColumnSelections<ContentKeyTable>? updateColumns,
_isd.WhereExpressionBuilder<ContentKeyTable>? updateWhere,
_isd.Transaction? transaction,
}) async {
return session.db.upsertRow<ContentKey>(
row,
conflictColumns: conflictColumns(ContentKey.t),
updateColumns: updateColumns?.call(ContentKey.t),
updateWhere: updateWhere?.call(ContentKey.t),
transaction: transaction,
);
}
/// Updates all [ContentKey]s in the list and returns the updated rows. If
/// [columns] is provided, only those columns will be updated. Defaults to
/// all columns.
/// This is an atomic operation, meaning that if one of the rows fails to
/// update, none of the rows will be updated.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<ContentKey>> update(
_isd.DatabaseSession session,
List<ContentKey> rows, {
_isd.ColumnSelections<ContentKeyTable>? columns,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.update<ContentKey>(
rows,
columns: columns?.call(ContentKey.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Updates a single [ContentKey]. The row needs to have its id set.
/// Optionally, a list of [columns] can be provided to only update those
/// columns. Defaults to all columns.
Future<ContentKey> updateRow(
_isd.DatabaseSession session,
ContentKey row, {
_isd.ColumnSelections<ContentKeyTable>? columns,
_isd.Transaction? transaction,
}) async {
return session.db.updateRow<ContentKey>(
row,
columns: columns?.call(ContentKey.t),
transaction: transaction,
);
}
/// Updates a single [ContentKey] by its [id] with the specified [columnValues].
/// Returns the updated row or null if no row with the given id exists.
Future<ContentKey?> updateById(
_isd.DatabaseSession session,
_isc.UuidValue id, {
required _isd.ColumnValueListBuilder<ContentKeyUpdateTable> columnValues,
_isd.Transaction? transaction,
}) async {
return session.db.updateById<ContentKey>(
id,
columnValues: columnValues(ContentKey.t.updateTable),
transaction: transaction,
);
}
/// Updates all [ContentKey]s matching the [where] expression with the specified [columnValues].
/// Returns the list of updated rows.
///
/// If [noReturn] is set to `true`, the updated rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<ContentKey>> updateWhere(
_isd.DatabaseSession session, {
required _isd.ColumnValueListBuilder<ContentKeyUpdateTable> columnValues,
required _isd.WhereExpressionBuilder<ContentKeyTable> where,
int? limit,
int? offset,
_isd.OrderByBuilder<ContentKeyTable>? orderBy,
_isd.OrderByListBuilder<ContentKeyTable>? orderByList,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.updateWhere<ContentKey>(
columnValues: columnValues(ContentKey.t.updateTable),
where: where(ContentKey.t),
limit: limit,
offset: offset,
orderBy: orderBy?.call(ContentKey.t),
orderByList: orderByList?.call(ContentKey.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes all [ContentKey]s in the list and returns the deleted rows.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// This is an atomic operation, meaning that if one of the rows fail to
/// be deleted, none of the rows will be deleted.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<ContentKey>> delete(
_isd.DatabaseSession session,
List<ContentKey> rows, {
_isd.OrderByBuilder<ContentKeyTable>? orderBy,
_isd.OrderByListBuilder<ContentKeyTable>? orderByList,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.delete<ContentKey>(
rows,
orderBy: orderBy?.call(ContentKey.t),
orderByList: orderByList?.call(ContentKey.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes a single [ContentKey].
Future<ContentKey> deleteRow(
_isd.DatabaseSession session,
ContentKey row, {
_isd.Transaction? transaction,
}) async {
return session.db.deleteRow<ContentKey>(
row,
transaction: transaction,
);
}
/// Deletes all rows matching the [where] expression.
///
/// To specify the order of the returned rows use [orderBy] or [orderByList]
/// when sorting by multiple columns.
///
/// If [noReturn] is set to `true`, the deleted rows are not read back from
/// the database and an empty list is returned. This avoids the overhead of
/// transferring and deserializing the rows when the result is not needed.
Future<List<ContentKey>> deleteWhere(
_isd.DatabaseSession session, {
required _isd.WhereExpressionBuilder<ContentKeyTable> where,
_isd.OrderByBuilder<ContentKeyTable>? orderBy,
_isd.OrderByListBuilder<ContentKeyTable>? orderByList,
_isd.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.deleteWhere<ContentKey>(
where: where(ContentKey.t),
orderBy: orderBy?.call(ContentKey.t),
orderByList: orderByList?.call(ContentKey.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Counts the number of rows matching the [where] expression. If omitted,
/// will return the count of all rows in the table.
Future<int> count(
_isd.DatabaseSession session, {
_isd.WhereExpressionBuilder<ContentKeyTable>? where,
int? limit,
_isd.Transaction? transaction,
}) async {
return session.db.count<ContentKey>(
where: where?.call(ContentKey.t),
limit: limit,
transaction: transaction,
);
}
/// Acquires row-level locks on [ContentKey] rows matching the [where] expression.
Future<void> lockRows(
_isd.DatabaseSession session, {
required _isd.WhereExpressionBuilder<ContentKeyTable> where,
required _isd.LockMode lockMode,
required _isd.Transaction transaction,
_isd.LockBehavior lockBehavior = _isd.LockBehavior.wait,
}) async {
return session.db.lockRows<ContentKey>(
where: where(ContentKey.t),
lockMode: lockMode,
lockBehavior: lockBehavior,
transaction: transaction,
);
}
}

View file

@ -26,6 +26,8 @@ import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_source.d
as _i8vj64sp;
import 'package:halkyon_learn_client/src/protocol/discussion/discussion_summary.dart'
as _i7lxdcg2;
import 'package:halkyon_learn_client/src/protocol/explore/copy_report.dart'
as _i3wii6lt;
import 'package:halkyon_learn_client/src/protocol/explore/explore_entry.dart'
as _iyigb6vc;
import 'package:halkyon_learn_client/src/protocol/feedback/feedback_view.dart'
@ -62,6 +64,7 @@ import 'discussion/discussion_refused_exception.dart' as _i1nsp6ib;
import 'discussion/discussion_summary.dart' as _i5mmlb7v;
import 'discussion/discussion_target.dart' as _ijf8cxgd;
import 'discussion/discussion_view.dart' as _ixby8mme;
import 'explore/copy_report.dart' as _i1e7dzqy;
import 'explore/explore_entry.dart' as _iaxdli27;
import 'explore/explore_query.dart' as _ibl9h9o9;
import 'explore/release_content.dart' as _ievpeb4f;
@ -82,6 +85,7 @@ import 'note/note.dart' as _i9njjhyv;
import 'note/note_revision.dart' as _i6j5dng4;
import 'pdf_source/pdf_reading.dart' as _i4l20c7i;
import 'pdf_source/pdf_source.dart' as _iz838ro9;
import 'privacy/content_key.dart' as _imed3x5d;
import 'publish/publication.dart' as _i9o1kyhh;
import 'publish/publish_refusal.dart' as _iofgvwaa;
import 'publish/publish_refused_exception.dart' as _itdn8966;
@ -117,6 +121,7 @@ export 'discussion/discussion_refused_exception.dart';
export 'discussion/discussion_summary.dart';
export 'discussion/discussion_target.dart';
export 'discussion/discussion_view.dart';
export 'explore/copy_report.dart';
export 'explore/explore_entry.dart';
export 'explore/explore_query.dart';
export 'explore/release_content.dart';
@ -137,6 +142,7 @@ export 'note/note.dart';
export 'note/note_revision.dart';
export 'pdf_source/pdf_reading.dart';
export 'pdf_source/pdf_source.dart';
export 'privacy/content_key.dart';
export 'publish/publication.dart';
export 'publish/publish_refusal.dart';
export 'publish/publish_refused_exception.dart';
@ -580,6 +586,85 @@ class Protocol extends _isd.DatabaseSerializationManager {
],
managed: true,
),
_isd.TableDefinition(
name: 'content_key',
dartName: 'ContentKey',
schema: 'public',
module: 'halkyon_learn',
columns: [
_isd.ColumnDefinition(
name: 'id',
columnType: _isd.ColumnType.uuid,
isNullable: false,
dartType: 'UuidValue?',
columnDefault: 'random_v7',
),
_isd.ColumnDefinition(
name: 'spaceId',
columnType: _isd.ColumnType.bigint,
isNullable: true,
dartType: 'int?',
),
_isd.ColumnDefinition(
name: 'dataKeyId',
columnType: _isd.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isd.ColumnDefinition(
name: 'kind',
columnType: _isd.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isd.ColumnDefinition(
name: 'studysetId',
columnType: _isd.ColumnType.uuid,
isNullable: true,
dartType: 'UuidValue?',
),
_isd.ColumnDefinition(
name: 'sealedKey',
columnType: _isd.ColumnType.bytea,
isNullable: false,
dartType: 'dart:typed_data:ByteData',
),
_isd.ColumnDefinition(
name: 'createdAt',
columnType: _isd.ColumnType.timestampWithoutTimeZone,
isNullable: false,
dartType: 'DateTime',
),
],
foreignKeys: [
_isd.ForeignKeyDefinition(
constraintName: 'content_key_fk_0',
columns: ['spaceId'],
referenceTable: 'offline_sync_spaces',
referenceTableSchema: 'public',
referenceColumns: ['id'],
onUpdate: _isd.ForeignKeyAction.noAction,
onDelete: _isd.ForeignKeyAction.cascade,
matchType: null,
),
],
indexes: [
_isd.IndexDefinition(
indexName: 'content_key_space_idx',
tableSpace: null,
elements: [
_isd.IndexElementDefinition(
type: _isd.IndexElementDefinitionType.column,
definition: 'spaceId',
),
],
type: 'btree',
isUnique: false,
isPrimary: false,
),
],
managed: true,
),
_isd.TableDefinition(
name: 'feedback_decision',
dartName: 'FeedbackDecision',
@ -982,6 +1067,18 @@ class Protocol extends _isd.DatabaseSerializationManager {
isNullable: true,
dartType: 'String?',
),
_isd.ColumnDefinition(
name: 'keyId',
columnType: _isd.ColumnType.text,
isNullable: true,
dartType: 'String?',
),
_isd.ColumnDefinition(
name: 'nonce',
columnType: _isd.ColumnType.bytea,
isNullable: true,
dartType: 'dart:typed_data:ByteData?',
),
],
foreignKeys: [
_isd.ForeignKeyDefinition(
@ -2372,6 +2469,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _ixby8mme.DiscussionView) {
return _ixby8mme.DiscussionView.fromJson(data) as T;
}
if (t == _i1e7dzqy.CopyReport) {
return _i1e7dzqy.CopyReport.fromJson(data) as T;
}
if (t == _iaxdli27.ExploreEntry) {
return _iaxdli27.ExploreEntry.fromJson(data) as T;
}
@ -2432,6 +2532,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _iz838ro9.PdfSource) {
return _iz838ro9.PdfSource.fromJson(data) as T;
}
if (t == _imed3x5d.ContentKey) {
return _imed3x5d.ContentKey.fromJson(data) as T;
}
if (t == _i9o1kyhh.Publication) {
return _i9o1kyhh.Publication.fromJson(data) as T;
}
@ -2566,6 +2669,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
return (data != null ? _ixby8mme.DiscussionView.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_i1e7dzqy.CopyReport?>()) {
return (data != null ? _i1e7dzqy.CopyReport.fromJson(data) : null) as T;
}
if (t == _isc.getType<_iaxdli27.ExploreEntry?>()) {
return (data != null ? _iaxdli27.ExploreEntry.fromJson(data) : null) as T;
}
@ -2639,6 +2745,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _isc.getType<_iz838ro9.PdfSource?>()) {
return (data != null ? _iz838ro9.PdfSource.fromJson(data) : null) as T;
}
if (t == _isc.getType<_imed3x5d.ContentKey?>()) {
return (data != null ? _imed3x5d.ContentKey.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i9o1kyhh.Publication?>()) {
return (data != null ? _i9o1kyhh.Publication.fromJson(data) : null) as T;
}
@ -2776,12 +2885,27 @@ class Protocol extends _isd.DatabaseSerializationManager {
return (data as List).map((e) => deserialize<_isc.UuidValue>(e)).toList()
as T;
}
if (t == List<_i3wii6lt.CopyReport>) {
return (data as List)
.map((e) => deserialize<_i3wii6lt.CopyReport>(e))
.toList()
as T;
}
if (t == List<_idpwh21d.FeedbackView>) {
return (data as List)
.map((e) => deserialize<_idpwh21d.FeedbackView>(e))
.toList()
as T;
}
if (t == _isc.getType<Map<String, String>?>()) {
return (data != null
? (data as Map).map(
(k, v) =>
MapEntry(deserialize<String>(k), deserialize<String>(v)),
)
: null)
as T;
}
try {
return _ihk4p5t7.Protocol().deserialize<T>(data, t);
} on _isc.DeserializationTypeNotFoundException catch (_) {}
@ -2823,6 +2947,7 @@ class Protocol extends _isd.DatabaseSerializationManager {
_i5mmlb7v.DiscussionSummary => 'DiscussionSummary',
_ijf8cxgd.DiscussionTarget => 'DiscussionTarget',
_ixby8mme.DiscussionView => 'DiscussionView',
_i1e7dzqy.CopyReport => 'CopyReport',
_iaxdli27.ExploreEntry => 'ExploreEntry',
_ibl9h9o9.ExploreQuery => 'ExploreQuery',
_ievpeb4f.ReleaseContent => 'ReleaseContent',
@ -2843,6 +2968,7 @@ class Protocol extends _isd.DatabaseSerializationManager {
_i6j5dng4.NoteRevision => 'NoteRevision',
_i4l20c7i.PdfReading => 'PdfReading',
_iz838ro9.PdfSource => 'PdfSource',
_imed3x5d.ContentKey => 'ContentKey',
_i9o1kyhh.Publication => 'Publication',
_iofgvwaa.PublishRefusal => 'PublishRefusal',
_itdn8966.PublishRefusedException => 'PublishRefusedException',
@ -2919,6 +3045,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'DiscussionTarget';
case _ixby8mme.DiscussionView():
return 'DiscussionView';
case _i1e7dzqy.CopyReport():
return 'CopyReport';
case _iaxdli27.ExploreEntry():
return 'ExploreEntry';
case _ibl9h9o9.ExploreQuery():
@ -2959,6 +3087,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'PdfReading';
case _iz838ro9.PdfSource():
return 'PdfSource';
case _imed3x5d.ContentKey():
return 'ContentKey';
case _i9o1kyhh.Publication():
return 'Publication';
case _iofgvwaa.PublishRefusal():
@ -3085,6 +3215,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'DiscussionView') {
return deserialize<_ixby8mme.DiscussionView>(data['data']);
}
if (dataClassName == 'CopyReport') {
return deserialize<_i1e7dzqy.CopyReport>(data['data']);
}
if (dataClassName == 'ExploreEntry') {
return deserialize<_iaxdli27.ExploreEntry>(data['data']);
}
@ -3145,6 +3278,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'PdfSource') {
return deserialize<_iz838ro9.PdfSource>(data['data']);
}
if (dataClassName == 'ContentKey') {
return deserialize<_imed3x5d.ContentKey>(data['data']);
}
if (dataClassName == 'Publication') {
return deserialize<_i9o1kyhh.Publication>(data['data']);
}
@ -3271,6 +3407,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
return _i4l20c7i.PdfReading.t;
case _iz838ro9.PdfSource:
return _iz838ro9.PdfSource.t;
case _imed3x5d.ContentKey:
return _imed3x5d.ContentKey.t;
case _irzjbdqz.ReviewLog:
return _irzjbdqz.ReviewLog.t;
case _irnhx5e5.PersonSettings:

View file

@ -55,7 +55,16 @@ enum PublishRefusal implements _isc.SerializableModel {
/// It is a copy of a published studyset under a licence that asks
/// adaptations to stay under it (ShareAlike): `license` says which.
licenseMustStay;
licenseMustStay,
/// The studyset is private (ADR 0019): its content is encrypted, so the
/// instance can't read a release from it. Making it non-private opens it.
private,
/// The tag names the device sent don't name exactly the tags the release's
/// items carry, or a tag the release needs has no name the instance can
/// read: tags are encrypted with the person's key (ADR 0019, D2).
tagNamesMismatch;
static PublishRefusal fromJson(String name) {
switch (name) {
@ -83,6 +92,10 @@ enum PublishRefusal implements _isc.SerializableModel {
return PublishRefusal.forkForbidden;
case 'licenseMustStay':
return PublishRefusal.licenseMustStay;
case 'private':
return PublishRefusal.private;
case 'tagNamesMismatch':
return PublishRefusal.tagNamesMismatch;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "PublishRefusal"',

View file

@ -25,6 +25,7 @@ import 'note/note.dart' as _i9njjhyv;
import 'note/note_revision.dart' as _i6j5dng4;
import 'pdf_source/pdf_reading.dart' as _i4l20c7i;
import 'pdf_source/pdf_source.dart' as _iz838ro9;
import 'privacy/content_key.dart' as _imed3x5d;
import 'review/review_log.dart' as _irzjbdqz;
import 'settings/person_settings.dart' as _irnhx5e5;
import 'studyset/studyset.dart' as _ikbclmae;
@ -41,6 +42,7 @@ final List<_isd.Table> syncTables = [
_ipiw1cmx.Card.t,
_ipbyuef1.CardRevision.t,
_i6jz6nyq.CardTag.t,
_imed3x5d.ContentKey.t,
_idsmz0xa.FeedbackDecision.t,
_iqqrr7at.FeedbackMessage.t,
_ihfcaz5v.FeedbackThread.t,

View file

@ -159,6 +159,11 @@ Map<String, int> _fillSyncedTables(Database db) {
"VALUES (?, 1, ?, 'closed')",
[_id(), _id()],
),
'content_key': (
'INSERT INTO content_key (id, spaceId, dataKeyId, kind, studysetId, '
"sealedKey, createdAt) VALUES (?, 1, 'key', 'studyset', ?, x'00', 0)",
[_id(), studyset],
),
};
final synced = {

View file

@ -30,16 +30,24 @@ class AssetEndpoint extends Endpoint {
/// is checked to be a PDF once stored, and deleted otherwise.
static const maxOtherBytes = 20 * 1024 * 1024;
/// What encrypting adds to a file at most (ADR 0019): its key's ID, the
/// nonce, the signature and the cipher's tag, with room to spare.
static const encryptionOverhead = 4 * 1024;
/// How to upload the caller's file with [sha256] (lowercase hex) of
/// [byteSize] bytes; null when the server has it already. Throws a
/// [FileLimitExceededException] when it doesn't fit (see [FileLimits]).
/// [byteSize] bytes, [encrypted] if it is a private studyset's (ADR
/// 0019), which may be larger by [encryptionOverhead]; null when the
/// server has it already. Throws a [FileLimitExceededException] when it
/// doesn't fit (see [FileLimits]).
Future<String?> startUpload(
Session session,
String sha256,
int byteSize,
) async {
int byteSize, {
bool? encrypted,
}) async {
_checkHash(sha256);
if (byteSize <= 0 || byteSize > maxBytes) {
final most = maxBytes + (encrypted ?? false ? encryptionOverhead : 0);
if (byteSize <= 0 || byteSize > most) {
throw ArgumentError.value(byteSize, 'byteSize', 'is out of range');
}
final space = _caller(session);
@ -60,15 +68,21 @@ class AssetEndpoint extends Endpoint {
return await session.storage.createUploadDescription(
storageId: assetStorage,
path: path,
options: UploadOptions(contentLength: byteSize, maxFileSize: maxBytes),
options: UploadOptions(contentLength: byteSize, maxFileSize: most),
);
}
/// Completes the upload of the caller's file with [sha256]; whether it is
/// stored now. A file whose content has another hash is deleted, and so is
/// one over [maxOtherBytes] that isn't a PDF, and one that no longer fits,
/// with a [FileLimitExceededException].
Future<bool> finishUpload(Session session, String sha256) async {
/// a readable one over [maxOtherBytes] that isn't a PDF, and one that no
/// longer fits, with a [FileLimitExceededException]. An [encrypted] file
/// (ADR 0019) can't be looked into: its size alone counts, as the quotas'
/// sizes do.
Future<bool> finishUpload(
Session session,
String sha256, {
bool? encrypted,
}) async {
_checkHash(sha256);
final path = assetPath(_caller(session), sha256);
if (!await session.storage.verifyUpload(
@ -82,7 +96,10 @@ class AssetEndpoint extends Endpoint {
path: path,
);
if (_hashOf(bytes) != sha256 ||
(bytes.lengthInBytes > maxOtherBytes && !_isPdf(bytes))) {
(!(encrypted ?? false) &&
bytes.lengthInBytes > maxOtherBytes &&
!_isPdf(bytes)) ||
bytes.lengthInBytes > maxBytes + encryptionOverhead) {
await session.storage.deleteFile(storageId: assetStorage, path: path);
return false;
}

View file

@ -21,7 +21,8 @@ List<McpTool> connectorTools(Session session, UuidValue person) {
"Lists the person's studysets in Halkyon Learn, a flashcard and "
'learning app: each with its id, title, where it sits (the '
'studysets above it), whether it is archived, and how many cards '
'and notes it holds.',
'and notes it holds; and how many are private, end-to-end '
'encrypted, which nothing here can read.',
inputSchema: const {'type': 'object', 'properties': <String, Object?>{}},
call: (_) async => _json(await content.studysets()),
),
@ -174,14 +175,33 @@ class _Content(final Session _session, final UuidValue _person) {
where: (t) => t.spaceId.equals(space),
orderBy: (t) => t.position,
);
final alive = notDeleted(rows);
// Private studysets (ADR 0019) are encrypted on the person's devices:
// nothing here reads them, and only their number is told.
final private = {
for (final row in alive)
if (row.keyId != null) row.id!,
};
_private = {
for (final row in alive)
if (row.keyId != null && !private.contains(row.parentId)) row.id!,
};
_privateTrees = private;
return _studysets = [
for (final row in notDeleted(rows))
if (_decoded(row.payload) case final payload?)
(row: row, payload: payload),
for (final row in alive)
if (row.keyId == null)
if (_decoded(row.payload) case final payload?)
(row: row, payload: payload),
];
}
Future<List<Map<String, Object?>>> studysets() async {
/// The top-level private studysets [_all] left out.
Set<UuidValue> _private = const {};
/// Every studyset of a private tree, the top-level ones and those inside.
Set<UuidValue> _privateTrees = const {};
Future<Map<String, Object?>> studysets() async {
final all = await _all();
final space = await _spaceId();
final cards = await _countBy(
@ -196,21 +216,27 @@ class _Content(final Session _session, final UuidValue _person) {
(note) => note.studysetId,
all,
);
return [
for (final (:row, :payload) in all)
{
'id': row.id!.uuid,
'title': _title(payload),
if (_text(payload['secondaryTitle']) case final subtitle
when subtitle.isNotEmpty)
'subtitle': subtitle,
if (_path(row, all) case final path when path.isNotEmpty)
'inside': path,
if (row.archivedAt != null) 'archived': true,
'cards': cards[row.id] ?? 0,
'notes': notes[row.id] ?? 0,
},
];
return {
'studysets': [
for (final (:row, :payload) in all)
{
'id': row.id!.uuid,
'title': _title(payload),
if (_text(payload['secondaryTitle']) case final subtitle
when subtitle.isNotEmpty)
'subtitle': subtitle,
if (_path(row, all) case final path when path.isNotEmpty)
'inside': path,
if (row.archivedAt != null) 'archived': true,
'cards': cards[row.id] ?? 0,
'notes': notes[row.id] ?? 0,
},
],
if (_private.isNotEmpty) ...{
'private_studysets': _private.length,
'private_note': _privateNote,
},
};
}
Future<Map<String, Object?>> studyset(
@ -221,7 +247,11 @@ class _Content(final Session _session, final UuidValue _person) {
final all = await _all();
final found = all.where((each) => each.row.id == id).firstOrNull;
if (found == null) {
throw const McpToolError('No studyset of the person has this id.');
throw McpToolError(
_privateTrees.contains(id)
? _privateNote
: 'No studyset of the person has this id.',
);
}
final (:row, :payload) = found;
final cards = await _cards({id});
@ -592,6 +622,12 @@ String _richText(Object? editor) {
return lines.join('\n');
}
/// What the tools say of private studysets (ADR 0019).
const _privateNote =
"Private studysets are end-to-end encrypted on the person's devices: "
"the connector can't read them. The person can make one non-private "
'in the app to let it be read here.';
/// A synced payload's JSON; null for a blank one, of a row being deleted.
Map<String, Object?>? _decoded(ByteData payload) {
if (payload.lengthInBytes == 0) {

View file

@ -94,6 +94,20 @@ Future<AfterDeletion?> deleteLearnData(
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
// Whether they chose to be counted, and the copies their device reported
// (ADR 0019, D3).
// DELETE FROM "counted_learner" WHERE "authUserId" = $1
await CountedLearner.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
// DELETE FROM "learning_copy" WHERE "authUserId" = $1
await LearningCopy.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(authUserId),
transaction: transaction,
);
if (published.isEmpty) {
return null;
}

View file

@ -0,0 +1,8 @@
### A device's report of the studysets copied from one publication (ADR 0019,
### D3): the copy's studysets, its topics with it.
class: CopyReport
fields:
### The publication's public id.
publicationId: String
### The person's studysets that are part of the copy.
studysetIds: List<UuidValue>

View file

@ -0,0 +1,15 @@
### A person who chose to be counted as learning the published studysets
### their copies come from (ADR 0005, rule 8; ADR 0019): kept by the instance,
### as their settings are encrypted. Never sent.
class: CountedLearner
table: counted_learner
serverOnly: true
fields:
### The person.
authUserId: UuidValue
### When they chose it, in UTC.
since: DateTime
indexes:
counted_learner_person_idx:
fields: authUserId
unique: true

View file

@ -1,6 +1,3 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:halkyon_learn_server/src/studyset/deleted_studysets.dart';
@ -33,12 +30,14 @@ int? learnersShown(int learners) {
/// Counts, for every published studyset, the people who learn it on this
/// instance (ADR 0005, rule 4; decided 2026-10-09): those with an identity
/// who chose to be counted (`countLearning` in their settings) and studied
/// a copy of it on [learningDays] or more days of the 30 before [now].
/// Learning activity is shown, never ranked, and only a number the instance
/// computes itself counts: another instance could claim any. Nothing about
/// a person leaves the instance, and a payload it can't read counts for
/// nothing.
/// who chose to be counted ([CountedLearner]) and studied a copy of it on
/// [learningDays] or more days of the 30 before [now]. Which studysets are
/// copies of what, their devices report ([LearningCopy], ADR 0019, D3): a
/// private copy's origin is encrypted. Only a reported studyset that is the
/// person's own and not in Recently deleted counts. Learning activity is
/// shown, never ranked, and only a number the instance computes itself
/// counts: another instance could claim any. Nothing about a person leaves
/// the instance.
Future<void> countLearnersOf(Session session, {required DateTime now}) async {
// SELECT … FROM "published_studyset"
final published = await PublishedStudyset.db.find(session);
@ -49,61 +48,63 @@ Future<void> countLearnersOf(Session session, {required DateTime now}) async {
for (final studyset in published) studyset.publicId.uuid,
};
// The people who chose to be counted, by their space: their settings row
// of the smallest id, as their devices fold several.
// SELECT … FROM "person_settings" ORDER BY "id"
final counted = <int>{};
final seen = <int>{};
for (final settings in await PersonSettings.db.find(
session,
orderBy: (t) => t.id,
)) {
if (seen.add(settings.spaceId!) &&
_json(settings.payload)?['countLearning'] == true) {
counted.add(settings.spaceId!);
}
}
if (counted.isEmpty) {
// The people who chose to be counted, of them those with an identity.
// SELECT "authUserId" FROM "counted_learner"
final counted = {
for (final learner in await CountedLearner.db.find(session))
learner.authUserId,
};
// SELECT … FROM "serverpod_auth_core_user" WHERE "id" = ANY($1)
final people = counted.isEmpty
? const <UuidValue>{}
: {
for (final person in await AuthUser.db.find(
session,
where: (t) => t.id.inSet(counted),
))
if (person.scopeNames.contains(identified.name)) person.id!,
};
if (people.isEmpty) {
await _store(session, published, const {});
return;
}
// Of them, those with an identity: a space is its person's.
// SELECT … FROM "offline_sync_spaces" WHERE "id" = ANY($1)
final people = {
// Their spaces: a space is its person's.
// SELECT … FROM "offline_sync_spaces" WHERE "uuidSpaceId" = ANY($1)
final spaceOfPerson = {
for (final space in await OfflineSyncSpace.db.find(
session,
where: (t) => t.id.inSet(counted),
where: (t) => t.uuidSpaceId.inSet(people),
))
space.id!: space.uuidSpaceId,
space.uuidSpaceId: space.id!,
};
// SELECT … FROM "serverpod_auth_core_user" WHERE "id" = ANY($1)
final identifiedPeople = {
for (final person in await AuthUser.db.find(
session,
where: (t) => t.id.inSet(people.values.toSet()),
// Their studysets not waiting in Recently deleted, by space; read for
// their tree alone, never their content.
// SELECT … FROM "studyset" WHERE "spaceId" = ANY($1)
final alive = {
for (final studyset in notDeleted(
await Studyset.db.find(
session,
where: (t) => t.spaceId.inSet(spaceOfPerson.values.toSet()),
),
))
if (person.scopeNames.contains(identified.name)) person.id,
};
final spaces = {
for (final MapEntry(key: space, value: person) in people.entries)
if (identifiedPeople.contains(person)) space,
studyset.id!: studyset.spaceId!,
};
// 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)
// The copies their devices reported, of a published studyset, each a
// studyset of the reporter's own.
// SELECT … FROM "learning_copy" WHERE "authUserId" = ANY($1)
final publicationOf = <UuidValue, String>{};
final spaceOf = <UuidValue, int>{};
for (final studyset in notDeleted(
await Studyset.db.find(session, where: (t) => t.spaceId.inSet(spaces)),
for (final copy in await LearningCopy.db.find(
session,
where: (t) => t.authUserId.inSet(people),
)) {
if (_json(studyset.payload)?['published']
case {
'publication': final String publication,
}
when publications.contains(publication)) {
publicationOf[studyset.id!] = publication;
spaceOf[studyset.id!] = studyset.spaceId!;
final space = spaceOfPerson[copy.authUserId];
if (space != null &&
alive[copy.studysetId] == space &&
publications.contains(copy.publicationId)) {
publicationOf[copy.studysetId] = copy.publicationId;
spaceOf[copy.studysetId] = space;
}
}
@ -160,18 +161,3 @@ Future<void> _store(
columns: (t) => [t.learners],
);
}
/// A synced payload's JSON; null for a blank one or one that isn't JSON.
Map<String, Object?>? _json(ByteData payload) {
if (payload.lengthInBytes == 0) {
return null;
}
try {
return switch (jsonDecode(utf8.decode(Uint8List.sublistView(payload)))) {
final Map<String, Object?> json => json,
_ => null,
};
} on FormatException {
return null;
}
}

View file

@ -0,0 +1,17 @@
### A studyset of a counted person's that is a copy of a published one, as
### their device reports it (ADR 0019, D3): the instance can't read a private
### copy's origin. Replaced with each report; gone once they stop being
### counted. Never sent.
class: LearningCopy
table: learning_copy
serverOnly: true
fields:
### Whose copy it is.
authUserId: UuidValue
### The publication's public id.
publicationId: String
### The person's studyset that is part of the copy.
studysetId: UuidValue
indexes:
learning_copy_person_idx:
fields: authUserId

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