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

View file

@ -4,14 +4,18 @@ import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/asset/asset_endpoint.dart';
import 'package:halkyon_learn_server/src/asset/file_limits.dart';
import 'package:halkyon_learn_server/src/asset/file_pruning.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:halkyon_learn_server/src/asset/learn_files.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart'
hide FileLimitExceededException, FileUsage;
import 'package:halkyon_learn_server/src/generated/protocol.dart'
as learn
show FileLimitExceededException;
import 'package:serverpod/protocol.dart' show CloudStorageEntry;
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:test/test.dart';
import '../support/auth_fixtures.dart';
import '../test_tools/serverpod_test_tools.dart';
void main() {
@ -31,6 +35,8 @@ void main() {
// The test pod is built without the generated Serverpod class, which
// sets up sync for the running server.
Serverpod.instance.initializeOfflineSync(syncTables: syncTables);
// The files are halkyon_base's, Learn's rows naming them (ADR 0043).
useLearnBase(sessionBuilder);
session = sessionBuilder.build();
alice = const Uuid().v7obj();
bob = const Uuid().v7obj();
@ -172,7 +178,7 @@ void main() {
test('keeps a file over 20 MB only when it is a PDF, which may take '
'up to 100 MB', () async {
const size = AssetEndpoint.maxOtherBytes + 1;
const size = maxOtherBytes + 1;
final pdf = Uint8List(size)
..setAll(0, utf8.encode('%PDF-1.7'))
..fillRange(8, size, 32);
@ -201,7 +207,7 @@ void main() {
'an encrypted file (ADR 0019) over 20 MB is kept, as nothing can '
'look into it, a little larger than 100 MB too, and no larger',
() async {
const size = AssetEndpoint.maxOtherBytes + 1;
const size = maxOtherBytes + 1;
final sealed = Uint8List(size)
..fillRange(0, size, 7)
..setAll(0, [...utf8.encode('HLKF'), 1]);
@ -245,7 +251,7 @@ void main() {
'keeps no file said to be encrypted that is no sealed file, as a '
'readable one would skip the PDF check',
() async {
const size = AssetEndpoint.maxOtherBytes + 1;
const size = maxOtherBytes + 1;
final readable = Uint8List(size)..fillRange(0, size, 9);
final wrongVersion = Uint8List(size)
..fillRange(0, size, 9)
@ -405,7 +411,7 @@ void main() {
Matcher refused({required bool instanceFull, required int used}) =>
throwsA(
isA<FileLimitExceededException>()
isA<learn.FileLimitExceededException>()
.having((e) => e.instanceFull, 'instanceFull', instanceFull)
.having((e) => e.usedBytes, 'usedBytes', used),
);

View file

@ -2,7 +2,6 @@ import 'dart:typed_data';
import 'package:halkyon_base_server/base.dart';
import 'package:halkyon_learn_server/src/asset/asset_endpoint.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
import 'package:test/test.dart';