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:
parent
074d05ab4e
commit
a1c5ac3f86
47 changed files with 35261 additions and 198 deletions
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue