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
12 changed files with 112084 additions and 348 deletions

View file

@ -0,0 +1,71 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "file_allowance",
"dartName": "FileAllowance",
"module": "halkyon_base",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 6,
"isNullable": false,
"columnDefault": "serial",
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "authUserId",
"fieldName": "authUserId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "maxBytes",
"fieldName": "maxBytes",
"columnType": 6,
"isNullable": false,
"dartType": "int"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "note",
"fieldName": "note",
"columnType": 0,
"isNullable": true,
"dartType": "String?"
}
],
"foreignKeys": [],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "file_allowance_person_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "authUserId"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
}
],
"managed": true
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,6 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -34,6 +34,8 @@ part '20261025135744595-waiting-accounts/migration.dart';
part '20261025165102861-assistants/migration.dart';
part '20261025184004860-assistant-conversations/migration.dart';
part '20261026140329810-assistant-budgets/migration.dart';
part '20261110225336783-file-store-in-halkyon-base/migration.dart';
part '20261110225358224-file-store-in-halkyon-base/migration.dart';
/// Migration registry for the client-side database.
class MigrationRegistry {
@ -70,6 +72,8 @@ class MigrationRegistry {
_Migration20261025165102861_assistants(),
_Migration20261025184004860_assistant_conversations(),
_Migration20261026140329810_assistant_budgets(),
_Migration20261110225336783_file_store_in_halkyon_base(),
_Migration20261110225358224_file_store_in_halkyon_base(),
];
/// List of all client-side database migration versions.

View file

@ -1,128 +0,0 @@
/* 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__': 'FileAllowance',
if (id != null) 'id': id,
'authUserId': authUserId.toJson(),
'maxBytes': maxBytes,
if (note != null) 'note': note,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': '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

@ -65,16 +65,12 @@ import 'protocol.dart' as _il2as5qe;
import 'sync_tables.dart' as _ii3f3u05;
import 'package:halkyon_learn_client/migrations/migration_registry.dart';
/// The files of people's content (ADR 0004, rule 5): images, sounds and
/// PDFs, stored in the server's private storage at a path derived from the space
/// and the file's SHA-256, so a file added twice is stored once.
///
/// Uploading goes into the caller's own space, by the file's hash, before or
/// after its asset row syncs: the server asks for the file only when it
/// lacks it, and keeps it only when its content has that hash. Downloading
/// goes by the asset row, from a space the caller may read; any other asks
/// as if the file weren't there. Files no asset row refers to any more are
/// pruned ([pruneUnreferencedFiles]).
/// The files of people's content (ADR 0004, rule 5), kept by halkyon_base
/// since Halkyon's ADR 0043 (its `files` endpoint, Learn's rows named by
/// `learnFiles`). This endpoint stays for Learn's released apps alone (D7):
/// each call is the module's, its refusals and room in Learn's own models,
/// whose names are on the wire. Dropped once `minimumAppBuild` passes the
/// first build calling the module.
/// {@category Endpoint}
class EndpointAsset extends _isc.EndpointRef {
EndpointAsset(_isc.EndpointCaller caller) : super(caller);
@ -82,11 +78,9 @@ class EndpointAsset extends _isc.EndpointRef {
@override
String get name => 'asset';
/// How to upload the caller's file with [sha256] (lowercase hex) of
/// [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]).
/// How to upload the caller's file with [sha256] of [byteSize] bytes,
/// [encrypted] where a private studyset's; null when the server has it
/// already. Throws a [FileLimitExceededException] when it doesn't fit.
_ida.Future<String?> startUpload(
String sha256,
int byteSize, {
@ -102,12 +96,7 @@ class EndpointAsset extends _isc.EndpointRef {
);
/// 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
/// 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: it must start as a sealed file does
/// (`HLKF`, version 1), or it is deleted, and its size alone counts, as
/// the quotas' sizes do.
/// stored now.
_ida.Future<bool> finishUpload(
String sha256, {
bool? encrypted,

View file

@ -40,7 +40,6 @@ import 'package:serverpod_database/serverpod_database.dart' as _isd;
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart'
as _ipulbpi2;
import 'asset/asset.dart' as _ipb7vkxr;
import 'asset/file_allowance.dart' as _i9uv0f1n;
import 'asset/file_limit_exceeded_exception.dart' as _it5d94hq;
import 'asset/file_usage.dart' as _iurwo9k8;
import 'assistant/assistant_conversation.dart' as _ikd6xdrt;
@ -101,7 +100,6 @@ import 'tag/learning_path_tag.dart' as _i31v2f8r;
import 'tag/note_tag.dart' as _itkfn57x;
import 'tag/tag.dart' as _ie8t3ar4;
export 'asset/asset.dart';
export 'asset/file_allowance.dart';
export 'asset/file_limit_exceeded_exception.dart';
export 'asset/file_usage.dart';
export 'assistant/assistant_conversation.dart';
@ -2575,9 +2573,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _ipb7vkxr.Asset) {
return _ipb7vkxr.Asset.fromJson(data) as T;
}
if (t == _i9uv0f1n.FileAllowance) {
return _i9uv0f1n.FileAllowance.fromJson(data) as T;
}
if (t == _it5d94hq.FileLimitExceededException) {
return _it5d94hq.FileLimitExceededException.fromJson(data) as T;
}
@ -2758,10 +2753,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _isc.getType<_ipb7vkxr.Asset?>()) {
return (data != null ? _ipb7vkxr.Asset.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i9uv0f1n.FileAllowance?>()) {
return (data != null ? _i9uv0f1n.FileAllowance.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_it5d94hq.FileLimitExceededException?>()) {
return (data != null
? _it5d94hq.FileLimitExceededException.fromJson(data)
@ -3136,7 +3127,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
static String? getClassNameForType(Type type) {
return switch (type) {
_ipb7vkxr.Asset => 'Asset',
_i9uv0f1n.FileAllowance => 'FileAllowance',
_it5d94hq.FileLimitExceededException => 'FileLimitExceededException',
_iurwo9k8.FileUsage => 'FileUsage',
_ikd6xdrt.AssistantConversation => 'AssistantConversation',
@ -3215,8 +3205,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
switch (data) {
case _ipb7vkxr.Asset():
return 'Asset';
case _i9uv0f1n.FileAllowance():
return 'FileAllowance';
case _it5d94hq.FileLimitExceededException():
return 'FileLimitExceededException';
case _iurwo9k8.FileUsage():
@ -3374,9 +3362,6 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'Asset') {
return deserialize<_ipb7vkxr.Asset>(data['data']);
}
if (dataClassName == 'FileAllowance') {
return deserialize<_i9uv0f1n.FileAllowance>(data['data']);
}
if (dataClassName == 'FileLimitExceededException') {
return deserialize<_it5d94hq.FileLimitExceededException>(data['data']);
}