refactor(base,learn): people's files kept by halkyon_base

ADR 0043, D2, D4, D6 and D7, the first step towards Todo's attachments:
Learn's file store moves into the module, for every server that keeps
files, and Learn becomes its first host.

halkyon_base gets the store as Learn had it: uploads into the caller's
own space by SHA-256, downloads by a row the caller may read, each
person's room (FILE_LIMIT_IDENTIFIED_MB, FILE_LIMIT_INSTANCE_MB, the
operator's file_allowance, which moves into the module unchanged) and
the pruning of files no live row names. It stays free of the sync
engine: a host hands it a FileStore saying which of its rows name a file
and what it takes (HalkyonBase.files); without one the files endpoint
refuses. Its tests, with refusals, run on a stand-in host.

Learn hands it learnFiles: its asset rows, read by their space's members,
a readable file over 20 MB only as a PDF, up to 100 MB. Its app calls the
module; its own asset endpoint stays for released apps alone, forwarding
to the module in Learn's own models, whose names are on the wire. Its
file tests still pass through it, end to end.

Migrations: the module's creates file_allowance. Learn's server migration
holds version rows alone, as tables are matched by name (ADR 0014); its
device migration creates the module's empty table, as every module's.
Todo's, Train's and Meditate's create the empty table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 00:11:36 +01:00
commit b7f0aaaec7
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 112084 additions and 348 deletions

View file

@ -32,6 +32,8 @@ import 'package:halkyon_base_client/src/protocol/auth/email_code_purpose.dart'
as _i6rtanuw;
import 'package:halkyon_base_client/src/protocol/auth/sign_in_ways.dart'
as _ifld8s4r;
import 'package:halkyon_base_client/src/protocol/files/file_usage.dart'
as _iertpojl;
import 'package:halkyon_base_client/src/protocol/instance/instance_basics.dart'
as _isnyur2i;
import 'package:halkyon_base_client/src/protocol/keys/device_keys.dart'
@ -372,6 +374,66 @@ class EndpointEmailCode extends _isc.EndpointRef {
);
}
/// People's files (Halkyon's ADR 0043): uploaded into the caller's own
/// space by SHA-256, downloaded by the host's row naming them, the room
/// they take; see `stored_files.dart`. Only on a host that keeps files
/// (`HalkyonBase.files`).
/// {@category Endpoint}
class EndpointFiles extends _isc.EndpointRef {
EndpointFiles(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'halkyon_base.files';
/// How to upload the caller's file with [sha256] of [byteSize] bytes,
/// [encrypted] where sealed; null when the server has it already. Throws
/// a [FileLimitExceededException] when it doesn't fit.
_ida.Future<String?> startUpload(
String sha256,
int byteSize, {
bool? encrypted,
}) => caller.callServerEndpoint<String?>(
'halkyon_base.files',
'startUpload',
{
'sha256': sha256,
'byteSize': byteSize,
'encrypted': encrypted,
},
);
/// Completes the upload of the caller's file with [sha256]; whether it is
/// stored now.
_ida.Future<bool> finishUpload(
String sha256, {
bool? encrypted,
}) => caller.callServerEndpoint<bool>(
'halkyon_base.files',
'finishUpload',
{
'sha256': sha256,
'encrypted': encrypted,
},
);
/// How much room the caller's files take, and may take.
_ida.Future<_iertpojl.FileUsage> usage() =>
caller.callServerEndpoint<_iertpojl.FileUsage>(
'halkyon_base.files',
'usage',
{},
);
/// The file of the host's row [rowId]. Throws a
/// [StoredFileNotFoundException] when it can't be had.
_ida.Future<_idt.ByteData> download(_isc.UuidValue rowId) =>
caller.callServerEndpoint<_idt.ByteData>(
'halkyon_base.files',
'download',
{'rowId': rowId},
);
}
/// What every Halkyon instance tells its apps. Public: an app asks before
/// anyone is signed in.
/// {@category Endpoint}
@ -1391,6 +1453,7 @@ class Caller extends _isc.ModuleEndpointCaller {
assistantOperator = EndpointAssistantOperator(this);
account = EndpointAccount(this);
emailCode = EndpointEmailCode(this);
files = EndpointFiles(this);
instance = EndpointInstance(this);
keys = EndpointKeys(this);
instanceOperator = EndpointInstanceOperator(this);
@ -1413,6 +1476,8 @@ class Caller extends _isc.ModuleEndpointCaller {
late final EndpointEmailCode emailCode;
late final EndpointFiles files;
late final EndpointInstance instance;
late final EndpointKeys keys;
@ -1440,6 +1505,7 @@ class Caller extends _isc.ModuleEndpointCaller {
'halkyon_base.assistantOperator': assistantOperator,
'halkyon_base.account': account,
'halkyon_base.emailCode': emailCode,
'halkyon_base.files': files,
'halkyon_base.instance': instance,
'halkyon_base.keys': keys,
'halkyon_base.instanceOperator': instanceOperator,

View file

@ -0,0 +1,128 @@
/* 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:serverpod_client/serverpod_client.dart' as _isc;
/// More room for one person's files than the instance's default (decided
/// 2026-10-02): the operator grants it on request, by adding a row. Only the
/// server reads it; deleting the person deletes it.
abstract class FileAllowance
implements _isc.SerializableModel, _isc.ProtocolSerialization {
FileAllowance._({
this.id,
required this.authUserId,
required this.maxBytes,
this.note,
});
factory FileAllowance({
int? id,
required _isc.UuidValue authUserId,
required int maxBytes,
String? note,
}) = _FileAllowanceImpl;
factory FileAllowance.fromJson(Map<String, dynamic> jsonSerialization) {
return FileAllowance(
id: jsonSerialization['id'] as int?,
authUserId: _isc.UuidValueJsonExtension.fromJson(
jsonSerialization['authUserId'],
),
maxBytes: jsonSerialization['maxBytes'] as int,
note: jsonSerialization['note'] as String?,
);
}
/// The database id, set if the object has been inserted into the
/// database or if it has been fetched from the database. Otherwise,
/// the id will be null.
int? id;
/// The person: the AuthUser's id.
_isc.UuidValue authUserId;
/// How many bytes of files they may keep.
int maxBytes;
/// Why, for the operator (e.g. "asked by mail, 2026-10-02").
String? note;
/// Returns a shallow copy of this [FileAllowance]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
FileAllowance copyWith({
int? id,
_isc.UuidValue? authUserId,
int? maxBytes,
String? note,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.FileAllowance',
if (id != null) 'id': id,
'authUserId': authUserId.toJson(),
'maxBytes': maxBytes,
if (note != null) 'note': note,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_base.FileAllowance',
if (id != null) 'id': id,
'authUserId': authUserId.toJson(),
'maxBytes': maxBytes,
if (note != null) 'note': note,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _Undefined {}
class _FileAllowanceImpl extends FileAllowance {
_FileAllowanceImpl({
int? id,
required _isc.UuidValue authUserId,
required int maxBytes,
String? note,
}) : super._(
id: id,
authUserId: authUserId,
maxBytes: maxBytes,
note: note,
);
/// Returns a shallow copy of this [FileAllowance]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
FileAllowance copyWith({
Object? id = _Undefined,
_isc.UuidValue? authUserId,
int? maxBytes,
Object? note = _Undefined,
}) {
return FileAllowance(
id: id is int? ? id : this.id,
authUserId: authUserId ?? this.authUserId,
maxBytes: maxBytes ?? this.maxBytes,
note: note is String? ? note : this.note,
);
}
}

View file

@ -0,0 +1,115 @@
/* 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:serverpod_client/serverpod_client.dart' as _isc;
/// A file doesn't fit: it would take the caller past what they may keep, or the
/// instance past what it keeps in all. Nothing was stored.
abstract class FileLimitExceededException
implements
_isc.SerializableException,
_isc.SerializableModel,
_isc.ProtocolSerialization {
FileLimitExceededException._({
required this.instanceFull,
required this.limitBytes,
required this.usedBytes,
});
factory FileLimitExceededException({
required bool instanceFull,
required int limitBytes,
required int usedBytes,
}) = _FileLimitExceededExceptionImpl;
factory FileLimitExceededException.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return FileLimitExceededException(
instanceFull: _isc.BoolJsonExtension.fromJson(
jsonSerialization['instanceFull'],
),
limitBytes: jsonSerialization['limitBytes'] as int,
usedBytes: jsonSerialization['usedBytes'] as int,
);
}
/// Whether the instance is full, rather than the caller's own room.
bool instanceFull;
/// What the caller may keep, in bytes.
int limitBytes;
/// What the caller keeps now, in bytes.
int usedBytes;
/// Returns a shallow copy of this [FileLimitExceededException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
FileLimitExceededException copyWith({
bool? instanceFull,
int? limitBytes,
int? usedBytes,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.FileLimitExceededException',
'instanceFull': instanceFull,
'limitBytes': limitBytes,
'usedBytes': usedBytes,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_base.FileLimitExceededException',
'instanceFull': instanceFull,
'limitBytes': limitBytes,
'usedBytes': usedBytes,
};
}
@override
String toString() {
return 'FileLimitExceededException(instanceFull: $instanceFull, limitBytes: $limitBytes, usedBytes: $usedBytes)';
}
}
class _FileLimitExceededExceptionImpl extends FileLimitExceededException {
_FileLimitExceededExceptionImpl({
required bool instanceFull,
required int limitBytes,
required int usedBytes,
}) : super._(
instanceFull: instanceFull,
limitBytes: limitBytes,
usedBytes: usedBytes,
);
/// Returns a shallow copy of this [FileLimitExceededException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
FileLimitExceededException copyWith({
bool? instanceFull,
int? limitBytes,
int? usedBytes,
}) {
return FileLimitExceededException(
instanceFull: instanceFull ?? this.instanceFull,
limitBytes: limitBytes ?? this.limitBytes,
usedBytes: usedBytes ?? this.usedBytes,
);
}
}

View file

@ -0,0 +1,95 @@
/* 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:serverpod_client/serverpod_client.dart' as _isc;
/// How much room the caller's files take, and how much they may take.
abstract class FileUsage
implements _isc.SerializableModel, _isc.ProtocolSerialization {
FileUsage._({
required this.usedBytes,
required this.limitBytes,
});
factory FileUsage({
required int usedBytes,
required int limitBytes,
}) = _FileUsageImpl;
factory FileUsage.fromJson(Map<String, dynamic> jsonSerialization) {
return FileUsage(
usedBytes: jsonSerialization['usedBytes'] as int,
limitBytes: jsonSerialization['limitBytes'] as int,
);
}
/// What their files take now, in bytes.
int usedBytes;
/// What they may keep, in bytes: their own limit, or less when the instance
/// has less room left.
int limitBytes;
/// Returns a shallow copy of this [FileUsage]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
FileUsage copyWith({
int? usedBytes,
int? limitBytes,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.FileUsage',
'usedBytes': usedBytes,
'limitBytes': limitBytes,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_base.FileUsage',
'usedBytes': usedBytes,
'limitBytes': limitBytes,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _FileUsageImpl extends FileUsage {
_FileUsageImpl({
required int usedBytes,
required int limitBytes,
}) : super._(
usedBytes: usedBytes,
limitBytes: limitBytes,
);
/// Returns a shallow copy of this [FileUsage]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
FileUsage copyWith({
int? usedBytes,
int? limitBytes,
}) {
return FileUsage(
usedBytes: usedBytes ?? this.usedBytes,
limitBytes: limitBytes ?? this.limitBytes,
);
}
}

View file

@ -0,0 +1,76 @@
/* 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:serverpod_client/serverpod_client.dart' as _isc;
/// The file asked for can't be had: no row of that ID the caller may read, a
/// deleted row, or a file not uploaded yet. Said alike for each, so a caller
/// learns nothing of rows they can't read.
abstract class StoredFileNotFoundException
implements
_isc.SerializableException,
_isc.SerializableModel,
_isc.ProtocolSerialization {
StoredFileNotFoundException._({required this.what});
factory StoredFileNotFoundException({required String what}) =
_StoredFileNotFoundExceptionImpl;
factory StoredFileNotFoundException.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return StoredFileNotFoundException(
what: jsonSerialization['what'] as String,
);
}
/// Always 'file'.
String what;
/// Returns a shallow copy of this [StoredFileNotFoundException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
StoredFileNotFoundException copyWith({String? what});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'halkyon_base.StoredFileNotFoundException',
'what': what,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'halkyon_base.StoredFileNotFoundException',
'what': what,
};
}
@override
String toString() {
return 'StoredFileNotFoundException(what: $what)';
}
}
class _StoredFileNotFoundExceptionImpl extends StoredFileNotFoundException {
_StoredFileNotFoundExceptionImpl({required String what})
: super._(what: what);
/// Returns a shallow copy of this [StoredFileNotFoundException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
StoredFileNotFoundException copyWith({String? what}) {
return StoredFileNotFoundException(what: what ?? this.what);
}
}

View file

@ -54,6 +54,10 @@ import 'auth/email_code_purpose.dart' as _ifpka2on;
import 'auth/email_sign_in_refusal.dart' as _ieyvcag1;
import 'auth/email_sign_in_refused_exception.dart' as _iztfgag5;
import 'auth/sign_in_ways.dart' as _ie6tbh85;
import 'files/file_allowance.dart' as _ieulzhzs;
import 'files/file_limit_exceeded_exception.dart' as _iaz1i85g;
import 'files/file_usage.dart' as _i45yjkiy;
import 'files/stored_file_not_found_exception.dart' as _i8o9pcsj;
import 'instance/instance_basics.dart' as _i8jixqx6;
import 'keys/device_keys.dart' as _iz50cwqw;
import 'keys/keys_challenge.dart' as _itebbsfc;
@ -118,6 +122,10 @@ export 'auth/email_code_purpose.dart';
export 'auth/email_sign_in_refusal.dart';
export 'auth/email_sign_in_refused_exception.dart';
export 'auth/sign_in_ways.dart';
export 'files/file_allowance.dart';
export 'files/file_limit_exceeded_exception.dart';
export 'files/file_usage.dart';
export 'files/stored_file_not_found_exception.dart';
export 'instance/instance_basics.dart';
export 'keys/device_keys.dart';
export 'keys/keys_challenge.dart';
@ -287,6 +295,18 @@ class Protocol extends _isc.SerializationManager {
if (t == _ie6tbh85.SignInWays) {
return _ie6tbh85.SignInWays.fromJson(data) as T;
}
if (t == _ieulzhzs.FileAllowance) {
return _ieulzhzs.FileAllowance.fromJson(data) as T;
}
if (t == _iaz1i85g.FileLimitExceededException) {
return _iaz1i85g.FileLimitExceededException.fromJson(data) as T;
}
if (t == _i45yjkiy.FileUsage) {
return _i45yjkiy.FileUsage.fromJson(data) as T;
}
if (t == _i8o9pcsj.StoredFileNotFoundException) {
return _i8o9pcsj.StoredFileNotFoundException.fromJson(data) as T;
}
if (t == _i8jixqx6.InstanceBasics) {
return _i8jixqx6.InstanceBasics.fromJson(data) as T;
}
@ -514,6 +534,25 @@ class Protocol extends _isc.SerializationManager {
if (t == _isc.getType<_ie6tbh85.SignInWays?>()) {
return (data != null ? _ie6tbh85.SignInWays.fromJson(data) : null) as T;
}
if (t == _isc.getType<_ieulzhzs.FileAllowance?>()) {
return (data != null ? _ieulzhzs.FileAllowance.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_iaz1i85g.FileLimitExceededException?>()) {
return (data != null
? _iaz1i85g.FileLimitExceededException.fromJson(data)
: null)
as T;
}
if (t == _isc.getType<_i45yjkiy.FileUsage?>()) {
return (data != null ? _i45yjkiy.FileUsage.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i8o9pcsj.StoredFileNotFoundException?>()) {
return (data != null
? _i8o9pcsj.StoredFileNotFoundException.fromJson(data)
: null)
as T;
}
if (t == _isc.getType<_i8jixqx6.InstanceBasics?>()) {
return (data != null ? _i8jixqx6.InstanceBasics.fromJson(data) : null)
as T;
@ -787,6 +826,10 @@ class Protocol extends _isc.SerializationManager {
_ieyvcag1.EmailSignInRefusal => 'EmailSignInRefusal',
_iztfgag5.EmailSignInRefusedException => 'EmailSignInRefusedException',
_ie6tbh85.SignInWays => 'SignInWays',
_ieulzhzs.FileAllowance => 'FileAllowance',
_iaz1i85g.FileLimitExceededException => 'FileLimitExceededException',
_i45yjkiy.FileUsage => 'FileUsage',
_i8o9pcsj.StoredFileNotFoundException => 'StoredFileNotFoundException',
_i8jixqx6.InstanceBasics => 'InstanceBasics',
_iz50cwqw.DeviceKeys => 'DeviceKeys',
_itebbsfc.KeysChallenge => 'KeysChallenge',
@ -897,6 +940,14 @@ class Protocol extends _isc.SerializationManager {
return 'EmailSignInRefusedException';
case _ie6tbh85.SignInWays():
return 'SignInWays';
case _ieulzhzs.FileAllowance():
return 'FileAllowance';
case _iaz1i85g.FileLimitExceededException():
return 'FileLimitExceededException';
case _i45yjkiy.FileUsage():
return 'FileUsage';
case _i8o9pcsj.StoredFileNotFoundException():
return 'StoredFileNotFoundException';
case _i8jixqx6.InstanceBasics():
return 'InstanceBasics';
case _iz50cwqw.DeviceKeys():
@ -1064,6 +1115,18 @@ class Protocol extends _isc.SerializationManager {
if (dataClassName == 'SignInWays') {
return deserialize<_ie6tbh85.SignInWays>(data['data']);
}
if (dataClassName == 'FileAllowance') {
return deserialize<_ieulzhzs.FileAllowance>(data['data']);
}
if (dataClassName == 'FileLimitExceededException') {
return deserialize<_iaz1i85g.FileLimitExceededException>(data['data']);
}
if (dataClassName == 'FileUsage') {
return deserialize<_i45yjkiy.FileUsage>(data['data']);
}
if (dataClassName == 'StoredFileNotFoundException') {
return deserialize<_i8o9pcsj.StoredFileNotFoundException>(data['data']);
}
if (dataClassName == 'InstanceBasics') {
return deserialize<_i8jixqx6.InstanceBasics>(data['data']);
}