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:
parent
59fb9c4d6d
commit
b7f0aaaec7
12 changed files with 112084 additions and 348 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,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
|
||||
}
|
||||
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,6 @@
|
|||
{
|
||||
"__className__": "serverpod.DatabaseMigration",
|
||||
"actions": [],
|
||||
"warnings": [],
|
||||
"migrationApiVersion": 1
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue