Limit how much room people's files take
An anonymous person may keep 100 MB of images and sounds, an identified one 500 MB, and one person more by a file_allowance row the operator adds; all of them together 15 GB, since every file is in the database and in each dump the server keeps (decided 2026-10-02). Each comes from the environment (FILE_LIMIT_*_MB), with these defaults, documented in deploy/.env.example. The server checks when an upload starts, counting unfinished uploads so they can't fill the disk unnoticed, and again when it finishes, counting what is kept, so uploads started side by side can't overshoot; a refusal is a FileLimitExceededException, and an upload refused as it finishes is deleted. deletePerson deletes the allowance with the person. Migration: the new table file_allowance, only added. The app keeps a refused file waiting on the device and stops that round of uploads; the Anki import warns when a deck's files may not fit in the room left (asset.usage()). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
53b5d56e79
commit
ca69f74fd9
21 changed files with 9884 additions and 20 deletions
|
|
@ -2,6 +2,7 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:crypto/crypto.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:serverpod/protocol.dart' show CloudStorageEntry;
|
||||
|
|
@ -26,7 +27,8 @@ class AssetEndpoint extends Endpoint {
|
|||
static const maxBytes = 20 * 1024 * 1024;
|
||||
|
||||
/// How to upload the caller's file with [sha256] (lowercase hex) of
|
||||
/// [byteSize] bytes; null when the server has it already.
|
||||
/// [byteSize] bytes; null when the server has it already. Throws a
|
||||
/// [FileLimitExceededException] when it doesn't fit (see [FileLimits]).
|
||||
Future<String?> startUpload(
|
||||
Session session,
|
||||
String sha256,
|
||||
|
|
@ -36,7 +38,8 @@ class AssetEndpoint extends Endpoint {
|
|||
if (byteSize <= 0 || byteSize > maxBytes) {
|
||||
throw ArgumentError.value(byteSize, 'byteSize', 'is out of range');
|
||||
}
|
||||
final path = assetPath(_caller(session), sha256);
|
||||
final space = _caller(session);
|
||||
final path = assetPath(space, sha256);
|
||||
if (await session.storage.fileExists(storageId: assetStorage, path: path)) {
|
||||
// The file is claimed anew, so pruning leaves it alone for a while
|
||||
// even if the row that claims it hasn't synced yet.
|
||||
|
|
@ -49,6 +52,7 @@ class AssetEndpoint extends Endpoint {
|
|||
);
|
||||
return null;
|
||||
}
|
||||
checkRoom(await roomOf(session, space), byteSize);
|
||||
return await session.storage.createUploadDescription(
|
||||
storageId: assetStorage,
|
||||
path: path,
|
||||
|
|
@ -57,7 +61,8 @@ class AssetEndpoint extends Endpoint {
|
|||
}
|
||||
|
||||
/// Completes the upload of the caller's file with [sha256]; whether it is
|
||||
/// stored now. A file whose content has another hash is deleted.
|
||||
/// stored now. A file whose content has another hash is deleted, and so is
|
||||
/// one that no longer fits, with a [FileLimitExceededException].
|
||||
Future<bool> finishUpload(Session session, String sha256) async {
|
||||
_checkHash(sha256);
|
||||
final path = assetPath(_caller(session), sha256);
|
||||
|
|
@ -75,9 +80,26 @@ class AssetEndpoint extends Endpoint {
|
|||
await session.storage.deleteFile(storageId: assetStorage, path: path);
|
||||
return false;
|
||||
}
|
||||
// Checked again now that it is stored: uploads started side by side each
|
||||
// fitted on their own.
|
||||
try {
|
||||
checkRoom(
|
||||
await roomOf(session, _caller(session), finishedOnly: true),
|
||||
0,
|
||||
);
|
||||
} on FileLimitExceededException {
|
||||
await session.storage.deleteFile(storageId: assetStorage, path: path);
|
||||
rethrow;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// How much room the caller's files take, and may take.
|
||||
Future<FileUsage> usage(Session session) async {
|
||||
final room = await roomOf(session, _caller(session));
|
||||
return FileUsage(usedBytes: room.used, limitBytes: effectiveLimit(room));
|
||||
}
|
||||
|
||||
/// The file of the asset [assetId]. Throws a [NotFoundException] when the
|
||||
/// asset isn't in a space the caller may read, or its file isn't uploaded
|
||||
/// yet.
|
||||
|
|
|
|||
16
halkyon_learn_server/lib/src/asset/file_allowance.spy.yaml
Normal file
16
halkyon_learn_server/lib/src/asset/file_allowance.spy.yaml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
### 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.
|
||||
class: FileAllowance
|
||||
table: file_allowance
|
||||
fields:
|
||||
### The person: the AuthUser's id.
|
||||
authUserId: UuidValue
|
||||
### How many bytes of files they may keep.
|
||||
maxBytes: int
|
||||
### Why, for the operator (e.g. "asked by mail, 2026-10-02").
|
||||
note: String?
|
||||
indexes:
|
||||
file_allowance_person_idx:
|
||||
fields: authUserId
|
||||
unique: true
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
### 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.
|
||||
exception: FileLimitExceededException
|
||||
fields:
|
||||
### Whether the instance is full, rather than the caller's own room.
|
||||
instanceFull: bool
|
||||
### What the caller may keep, in bytes.
|
||||
limitBytes: int
|
||||
### What the caller keeps now, in bytes.
|
||||
usedBytes: int
|
||||
145
halkyon_learn_server/lib/src/asset/file_limits.dart
Normal file
145
halkyon_learn_server/lib/src/asset/file_limits.dart
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_learn_server/src/asset/asset_endpoint.dart';
|
||||
import 'package:halkyon_learn_server/src/auth/scopes.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
/// How much room people's files may take (decided 2026-10-02): an anonymous
|
||||
/// person little, since anonymous accounts are meant to become identified
|
||||
/// ones; an identified person more, and more again by a `FileAllowance` the
|
||||
/// operator grants; and all of them together no more than the instance has
|
||||
/// room for. Each comes from the environment, in MiB, with a default:
|
||||
/// `FILE_LIMIT_ANONYMOUS_MB` (100), `FILE_LIMIT_IDENTIFIED_MB` (500) and
|
||||
/// `FILE_LIMIT_INSTANCE_MB` (15000; 0 for none). Every file is in the
|
||||
/// database and in each dump the server keeps: with three dumps, and a
|
||||
/// fourth while a deploy writes its own, files take five times their size on
|
||||
/// the disk.
|
||||
class FileLimits {
|
||||
/// Limits of [anonymousBytes], [identifiedBytes] and [instanceBytes] (null
|
||||
/// for none).
|
||||
const FileLimits({
|
||||
required this.anonymousBytes,
|
||||
required this.identifiedBytes,
|
||||
required this.instanceBytes,
|
||||
});
|
||||
|
||||
/// The limits the environment sets, else the defaults.
|
||||
factory FileLimits.fromEnvironment([Map<String, String>? environment]) {
|
||||
final env = environment ?? Platform.environment;
|
||||
int mib(String name, int fallback) =>
|
||||
(int.tryParse(env[name] ?? '') ?? fallback) * _mib;
|
||||
final instance = mib('FILE_LIMIT_INSTANCE_MB', 15000);
|
||||
return FileLimits(
|
||||
anonymousBytes: mib('FILE_LIMIT_ANONYMOUS_MB', 100),
|
||||
identifiedBytes: mib('FILE_LIMIT_IDENTIFIED_MB', 500),
|
||||
instanceBytes: instance > 0 ? instance : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// What an anonymous person may keep, in bytes.
|
||||
final int anonymousBytes;
|
||||
|
||||
/// What an identified person may keep without an allowance, in bytes.
|
||||
final int identifiedBytes;
|
||||
|
||||
/// What everyone together may keep, in bytes; null for no limit.
|
||||
final int? instanceBytes;
|
||||
}
|
||||
|
||||
/// The limits the server works with.
|
||||
FileLimits get fileLimits => _fileLimits ??= FileLimits.fromEnvironment();
|
||||
|
||||
/// Makes [limits] the ones the server works with, for tests; null goes back
|
||||
/// to the environment's.
|
||||
set fileLimits(FileLimits? limits) => _fileLimits = limits;
|
||||
|
||||
FileLimits? _fileLimits;
|
||||
|
||||
const _mib = 1024 * 1024;
|
||||
|
||||
/// How much room the caller has: what their files take, what they may keep,
|
||||
/// and what the instance's files take in all.
|
||||
typedef Room = ({int used, int limit, int instanceUsed});
|
||||
|
||||
/// The room of the caller of [session], whose space is [space]. Uploads not
|
||||
/// finished yet take room too, unless [finishedOnly]: an upload starting
|
||||
/// counts them, or unfinished uploads could fill the disk unnoticed; an
|
||||
/// upload finishing counts only what is kept, itself included.
|
||||
Future<Room> roomOf(
|
||||
Session session,
|
||||
UuidValue space, {
|
||||
bool finishedOnly = false,
|
||||
}) async {
|
||||
final limits = fileLimits;
|
||||
final person = UuidValue.withValidation(
|
||||
session.authenticated!.userIdentifier,
|
||||
);
|
||||
final allowance = await FileAllowance.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
);
|
||||
return (
|
||||
used: await _storedBytes(
|
||||
session,
|
||||
'$assetPathPrefix${space.uuid}/',
|
||||
finishedOnly: finishedOnly,
|
||||
),
|
||||
limit:
|
||||
allowance?.maxBytes ??
|
||||
(session.authenticated!.scopes.contains(identified)
|
||||
? limits.identifiedBytes
|
||||
: limits.anonymousBytes),
|
||||
instanceUsed: await _storedBytes(
|
||||
session,
|
||||
assetPathPrefix,
|
||||
finishedOnly: finishedOnly,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Throws a [FileLimitExceededException] unless [room] takes [bytes] more.
|
||||
void checkRoom(Room room, int bytes) {
|
||||
final instance = fileLimits.instanceBytes;
|
||||
final instanceFull = instance != null && room.instanceUsed + bytes > instance;
|
||||
if (instanceFull || room.used + bytes > room.limit) {
|
||||
throw FileLimitExceededException(
|
||||
instanceFull: instanceFull,
|
||||
limitBytes: room.limit,
|
||||
usedBytes: room.used,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the caller may keep in all: their limit, or less when the instance
|
||||
/// has less room left.
|
||||
int effectiveLimit(Room room) {
|
||||
final instance = fileLimits.instanceBytes;
|
||||
if (instance == null) {
|
||||
return room.limit;
|
||||
}
|
||||
final left = instance - room.instanceUsed;
|
||||
return room.used + left < room.limit ? room.used + left : room.limit;
|
||||
}
|
||||
|
||||
/// What the stored files below [prefix] take: unfinished uploads included,
|
||||
/// unless [finishedOnly].
|
||||
Future<int> _storedBytes(
|
||||
Session session,
|
||||
String prefix, {
|
||||
required bool finishedOnly,
|
||||
}) async {
|
||||
// SELECT SUM(octet_length("byteData")) FROM "serverpod_cloud_storage"
|
||||
// WHERE "storageId" = 'private' AND "path" LIKE $1 [AND "verified"]
|
||||
final rows = await session.db.unsafeQuery(
|
||||
'SELECT COALESCE(SUM(octet_length("byteData")), 0)::bigint '
|
||||
'FROM "serverpod_cloud_storage" '
|
||||
'WHERE "storageId" = @storage AND "path" LIKE @prefix'
|
||||
'${finishedOnly ? ' AND "verified"' : ''}',
|
||||
parameters: QueryParameters.named({
|
||||
'storage': assetStorage,
|
||||
'prefix': '$prefix%',
|
||||
}),
|
||||
);
|
||||
return rows.first.first! as int;
|
||||
}
|
||||
8
halkyon_learn_server/lib/src/asset/file_usage.spy.yaml
Normal file
8
halkyon_learn_server/lib/src/asset/file_usage.spy.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
### How much room the caller's files take, and how much they may take.
|
||||
class: FileUsage
|
||||
fields:
|
||||
### What their files take now, in bytes.
|
||||
usedBytes: int
|
||||
### What they may keep, in bytes: their own limit, or less when the instance
|
||||
### has less room left.
|
||||
limitBytes: int
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:halkyon_learn_server/src/asset/asset_endpoint.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/protocol.dart' show CloudStorageEntry;
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
|
@ -43,6 +44,12 @@ Future<void> deletePerson(Session session, UuidValue authUserId) =>
|
|||
t.path.like('${assetPath(authUserId, '')}%'),
|
||||
transaction: transaction,
|
||||
);
|
||||
// DELETE FROM "file_allowance" WHERE "authUserId" = $1
|
||||
await FileAllowance.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(authUserId),
|
||||
transaction: transaction,
|
||||
);
|
||||
// DELETE FROM "serverpod_auth_core_user" WHERE "id" = $1
|
||||
await AuthServices.instance.authUsers.delete(
|
||||
session,
|
||||
|
|
|
|||
638
halkyon_learn_server/lib/src/generated/asset/file_allowance.dart
Normal file
638
halkyon_learn_server/lib/src/generated/asset/file_allowance.dart
Normal file
|
|
@ -0,0 +1,638 @@
|
|||
/* 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/serverpod.dart' as _is;
|
||||
|
||||
/// 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 _is.TableRow<int?>, _is.ProtocolSerialization {
|
||||
FileAllowance._({
|
||||
this.id,
|
||||
required this.authUserId,
|
||||
required this.maxBytes,
|
||||
this.note,
|
||||
});
|
||||
|
||||
factory FileAllowance({
|
||||
int? id,
|
||||
required _is.UuidValue authUserId,
|
||||
required int maxBytes,
|
||||
String? note,
|
||||
}) = _FileAllowanceImpl;
|
||||
|
||||
factory FileAllowance.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return FileAllowance(
|
||||
id: jsonSerialization['id'] as int?,
|
||||
authUserId: _is.UuidValueJsonExtension.fromJson(
|
||||
jsonSerialization['authUserId'],
|
||||
),
|
||||
maxBytes: jsonSerialization['maxBytes'] as int,
|
||||
note: jsonSerialization['note'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
static final t = FileAllowanceTable();
|
||||
|
||||
static const db = FileAllowanceRepository._();
|
||||
|
||||
@override
|
||||
int? id;
|
||||
|
||||
/// The person: the AuthUser's id.
|
||||
_is.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;
|
||||
|
||||
@override
|
||||
_is.Table<int?> get table => t;
|
||||
|
||||
/// Returns a shallow copy of this [FileAllowance]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
FileAllowance copyWith({
|
||||
int? id,
|
||||
_is.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,
|
||||
};
|
||||
}
|
||||
|
||||
static FileAllowanceInclude include() {
|
||||
return FileAllowanceInclude._();
|
||||
}
|
||||
|
||||
static FileAllowanceIncludeList includeList({
|
||||
_is.WhereExpressionBuilder<FileAllowanceTable>? where,
|
||||
int? limit,
|
||||
int? offset,
|
||||
_is.OrderByBuilder<FileAllowanceTable>? orderBy,
|
||||
_is.OrderByListBuilder<FileAllowanceTable>? orderByList,
|
||||
FileAllowanceInclude? include,
|
||||
}) {
|
||||
return FileAllowanceIncludeList._(
|
||||
where: where,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
orderBy: orderBy?.call(FileAllowance.t),
|
||||
orderByList: orderByList?.call(FileAllowance.t),
|
||||
include: include,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _is.SerializationManager.encode(this);
|
||||
}
|
||||
}
|
||||
|
||||
class _Undefined {}
|
||||
|
||||
class _FileAllowanceImpl extends FileAllowance {
|
||||
_FileAllowanceImpl({
|
||||
int? id,
|
||||
required _is.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.
|
||||
@_is.useResult
|
||||
@override
|
||||
FileAllowance copyWith({
|
||||
Object? id = _Undefined,
|
||||
_is.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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FileAllowanceUpdateTable extends _is.UpdateTable<FileAllowanceTable> {
|
||||
FileAllowanceUpdateTable(super.table);
|
||||
|
||||
_is.ColumnValue<_is.UuidValue, _is.UuidValue> authUserId(
|
||||
_is.UuidValue value,
|
||||
) => _is.ColumnValue(
|
||||
table.authUserId,
|
||||
value,
|
||||
);
|
||||
|
||||
_is.ColumnValue<int, int> maxBytes(int value) => _is.ColumnValue(
|
||||
table.maxBytes,
|
||||
value,
|
||||
);
|
||||
|
||||
_is.ColumnValue<String, String> note(String? value) => _is.ColumnValue(
|
||||
table.note,
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
class FileAllowanceTable extends _is.Table<int?> {
|
||||
FileAllowanceTable({super.tableRelation})
|
||||
: super(tableName: 'file_allowance') {
|
||||
updateTable = FileAllowanceUpdateTable(this);
|
||||
authUserId = _is.ColumnUuid(
|
||||
'authUserId',
|
||||
this,
|
||||
);
|
||||
maxBytes = _is.ColumnInt(
|
||||
'maxBytes',
|
||||
this,
|
||||
);
|
||||
note = _is.ColumnString(
|
||||
'note',
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
late final FileAllowanceUpdateTable updateTable;
|
||||
|
||||
/// The person: the AuthUser's id.
|
||||
late final _is.ColumnUuid authUserId;
|
||||
|
||||
/// How many bytes of files they may keep.
|
||||
late final _is.ColumnInt maxBytes;
|
||||
|
||||
/// Why, for the operator (e.g. "asked by mail, 2026-10-02").
|
||||
late final _is.ColumnString note;
|
||||
|
||||
@override
|
||||
List<_is.Column> get columns => [
|
||||
id,
|
||||
authUserId,
|
||||
maxBytes,
|
||||
note,
|
||||
];
|
||||
}
|
||||
|
||||
class FileAllowanceInclude extends _is.IncludeObject {
|
||||
FileAllowanceInclude._();
|
||||
|
||||
@override
|
||||
Map<String, _is.Include?> get includes => {};
|
||||
|
||||
@override
|
||||
_is.Table<int?> get table => FileAllowance.t;
|
||||
}
|
||||
|
||||
class FileAllowanceIncludeList extends _is.IncludeList {
|
||||
FileAllowanceIncludeList._({
|
||||
_is.WhereExpressionBuilder<FileAllowanceTable>? where,
|
||||
super.limit,
|
||||
super.offset,
|
||||
super.orderBy,
|
||||
super.orderByList,
|
||||
super.include,
|
||||
}) {
|
||||
super.where = where?.call(FileAllowance.t);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, _is.Include?> get includes => include?.includes ?? {};
|
||||
|
||||
@override
|
||||
_is.Table<int?> get table => FileAllowance.t;
|
||||
}
|
||||
|
||||
class FileAllowanceRepository {
|
||||
const FileAllowanceRepository._();
|
||||
|
||||
/// Returns a list of [FileAllowance]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<FileAllowance>> find(
|
||||
_is.DatabaseSession session, {
|
||||
_is.WhereExpressionBuilder<FileAllowanceTable>? where,
|
||||
int? limit,
|
||||
int? offset,
|
||||
_is.OrderByBuilder<FileAllowanceTable>? orderBy,
|
||||
_is.OrderByListBuilder<FileAllowanceTable>? orderByList,
|
||||
_is.Transaction? transaction,
|
||||
_is.LockMode? lockMode,
|
||||
_is.LockBehavior? lockBehavior,
|
||||
}) async {
|
||||
return session.db.find<FileAllowance>(
|
||||
where: where?.call(FileAllowance.t),
|
||||
orderBy: orderBy?.call(FileAllowance.t),
|
||||
orderByList: orderByList?.call(FileAllowance.t),
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
transaction: transaction,
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the first matching [FileAllowance] 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<FileAllowance?> findFirstRow(
|
||||
_is.DatabaseSession session, {
|
||||
_is.WhereExpressionBuilder<FileAllowanceTable>? where,
|
||||
int? offset,
|
||||
_is.OrderByBuilder<FileAllowanceTable>? orderBy,
|
||||
_is.OrderByListBuilder<FileAllowanceTable>? orderByList,
|
||||
_is.Transaction? transaction,
|
||||
_is.LockMode? lockMode,
|
||||
_is.LockBehavior? lockBehavior,
|
||||
}) async {
|
||||
return session.db.findFirstRow<FileAllowance>(
|
||||
where: where?.call(FileAllowance.t),
|
||||
orderBy: orderBy?.call(FileAllowance.t),
|
||||
orderByList: orderByList?.call(FileAllowance.t),
|
||||
offset: offset,
|
||||
transaction: transaction,
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
);
|
||||
}
|
||||
|
||||
/// Finds a single [FileAllowance] by its [id] or null if no such row exists.
|
||||
Future<FileAllowance?> findById(
|
||||
_is.DatabaseSession session,
|
||||
int id, {
|
||||
_is.Transaction? transaction,
|
||||
_is.LockMode? lockMode,
|
||||
_is.LockBehavior? lockBehavior,
|
||||
}) async {
|
||||
return session.db.findById<FileAllowance>(
|
||||
id,
|
||||
transaction: transaction,
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
);
|
||||
}
|
||||
|
||||
/// Inserts all [FileAllowance]s in the list and returns the inserted rows.
|
||||
///
|
||||
/// The returned [FileAllowance]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<FileAllowance>> insert(
|
||||
_is.DatabaseSession session,
|
||||
List<FileAllowance> rows, {
|
||||
_is.Transaction? transaction,
|
||||
bool ignoreConflicts = false,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.insert<FileAllowance>(
|
||||
rows,
|
||||
transaction: transaction,
|
||||
ignoreConflicts: ignoreConflicts,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Inserts a single [FileAllowance] and returns the inserted row.
|
||||
///
|
||||
/// The returned [FileAllowance] will have its `id` field set.
|
||||
Future<FileAllowance> insertRow(
|
||||
_is.DatabaseSession session,
|
||||
FileAllowance row, {
|
||||
_is.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.insertRow<FileAllowance>(
|
||||
row,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Upserts all [FileAllowance]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 [FileAllowance]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<FileAllowance>> upsert(
|
||||
_is.DatabaseSession session,
|
||||
List<FileAllowance> rows, {
|
||||
required _is.ColumnSelections<FileAllowanceTable> conflictColumns,
|
||||
_is.ColumnSelections<FileAllowanceTable>? updateColumns,
|
||||
_is.WhereExpressionBuilder<FileAllowanceTable>? updateWhere,
|
||||
_is.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.upsert<FileAllowance>(
|
||||
rows,
|
||||
conflictColumns: conflictColumns(FileAllowance.t),
|
||||
updateColumns: updateColumns?.call(FileAllowance.t),
|
||||
updateWhere: updateWhere?.call(FileAllowance.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Upserts a single [FileAllowance] 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 [FileAllowance] will have its `id` field set.
|
||||
Future<FileAllowance?> upsertRow(
|
||||
_is.DatabaseSession session,
|
||||
FileAllowance row, {
|
||||
required _is.ColumnSelections<FileAllowanceTable> conflictColumns,
|
||||
_is.ColumnSelections<FileAllowanceTable>? updateColumns,
|
||||
_is.WhereExpressionBuilder<FileAllowanceTable>? updateWhere,
|
||||
_is.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.upsertRow<FileAllowance>(
|
||||
row,
|
||||
conflictColumns: conflictColumns(FileAllowance.t),
|
||||
updateColumns: updateColumns?.call(FileAllowance.t),
|
||||
updateWhere: updateWhere?.call(FileAllowance.t),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates all [FileAllowance]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<FileAllowance>> update(
|
||||
_is.DatabaseSession session,
|
||||
List<FileAllowance> rows, {
|
||||
_is.ColumnSelections<FileAllowanceTable>? columns,
|
||||
_is.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.update<FileAllowance>(
|
||||
rows,
|
||||
columns: columns?.call(FileAllowance.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates a single [FileAllowance]. 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<FileAllowance> updateRow(
|
||||
_is.DatabaseSession session,
|
||||
FileAllowance row, {
|
||||
_is.ColumnSelections<FileAllowanceTable>? columns,
|
||||
_is.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.updateRow<FileAllowance>(
|
||||
row,
|
||||
columns: columns?.call(FileAllowance.t),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates a single [FileAllowance] by its [id] with the specified [columnValues].
|
||||
/// Returns the updated row or null if no row with the given id exists.
|
||||
Future<FileAllowance?> updateById(
|
||||
_is.DatabaseSession session,
|
||||
int id, {
|
||||
required _is.ColumnValueListBuilder<FileAllowanceUpdateTable> columnValues,
|
||||
_is.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.updateById<FileAllowance>(
|
||||
id,
|
||||
columnValues: columnValues(FileAllowance.t.updateTable),
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Updates all [FileAllowance]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<FileAllowance>> updateWhere(
|
||||
_is.DatabaseSession session, {
|
||||
required _is.ColumnValueListBuilder<FileAllowanceUpdateTable> columnValues,
|
||||
required _is.WhereExpressionBuilder<FileAllowanceTable> where,
|
||||
int? limit,
|
||||
int? offset,
|
||||
_is.OrderByBuilder<FileAllowanceTable>? orderBy,
|
||||
_is.OrderByListBuilder<FileAllowanceTable>? orderByList,
|
||||
_is.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.updateWhere<FileAllowance>(
|
||||
columnValues: columnValues(FileAllowance.t.updateTable),
|
||||
where: where(FileAllowance.t),
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
orderBy: orderBy?.call(FileAllowance.t),
|
||||
orderByList: orderByList?.call(FileAllowance.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes all [FileAllowance]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<FileAllowance>> delete(
|
||||
_is.DatabaseSession session,
|
||||
List<FileAllowance> rows, {
|
||||
_is.OrderByBuilder<FileAllowanceTable>? orderBy,
|
||||
_is.OrderByListBuilder<FileAllowanceTable>? orderByList,
|
||||
_is.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.delete<FileAllowance>(
|
||||
rows,
|
||||
orderBy: orderBy?.call(FileAllowance.t),
|
||||
orderByList: orderByList?.call(FileAllowance.t),
|
||||
transaction: transaction,
|
||||
noReturn: noReturn,
|
||||
);
|
||||
}
|
||||
|
||||
/// Deletes a single [FileAllowance].
|
||||
Future<FileAllowance> deleteRow(
|
||||
_is.DatabaseSession session,
|
||||
FileAllowance row, {
|
||||
_is.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.deleteRow<FileAllowance>(
|
||||
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<FileAllowance>> deleteWhere(
|
||||
_is.DatabaseSession session, {
|
||||
required _is.WhereExpressionBuilder<FileAllowanceTable> where,
|
||||
_is.OrderByBuilder<FileAllowanceTable>? orderBy,
|
||||
_is.OrderByListBuilder<FileAllowanceTable>? orderByList,
|
||||
_is.Transaction? transaction,
|
||||
bool noReturn = false,
|
||||
}) async {
|
||||
return session.db.deleteWhere<FileAllowance>(
|
||||
where: where(FileAllowance.t),
|
||||
orderBy: orderBy?.call(FileAllowance.t),
|
||||
orderByList: orderByList?.call(FileAllowance.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(
|
||||
_is.DatabaseSession session, {
|
||||
_is.WhereExpressionBuilder<FileAllowanceTable>? where,
|
||||
int? limit,
|
||||
_is.Transaction? transaction,
|
||||
}) async {
|
||||
return session.db.count<FileAllowance>(
|
||||
where: where?.call(FileAllowance.t),
|
||||
limit: limit,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Acquires row-level locks on [FileAllowance] rows matching the [where] expression.
|
||||
Future<void> lockRows(
|
||||
_is.DatabaseSession session, {
|
||||
required _is.WhereExpressionBuilder<FileAllowanceTable> where,
|
||||
required _is.LockMode lockMode,
|
||||
required _is.Transaction transaction,
|
||||
_is.LockBehavior lockBehavior = _is.LockBehavior.wait,
|
||||
}) async {
|
||||
return session.db.lockRows<FileAllowance>(
|
||||
where: where(FileAllowance.t),
|
||||
lockMode: lockMode,
|
||||
lockBehavior: lockBehavior,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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/serverpod.dart' as _is;
|
||||
|
||||
/// 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
|
||||
_is.SerializableException,
|
||||
_is.SerializableModel,
|
||||
_is.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: _is.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.
|
||||
@_is.useResult
|
||||
FileLimitExceededException copyWith({
|
||||
bool? instanceFull,
|
||||
int? limitBytes,
|
||||
int? usedBytes,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'FileLimitExceededException',
|
||||
'instanceFull': instanceFull,
|
||||
'limitBytes': limitBytes,
|
||||
'usedBytes': usedBytes,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': '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.
|
||||
@_is.useResult
|
||||
@override
|
||||
FileLimitExceededException copyWith({
|
||||
bool? instanceFull,
|
||||
int? limitBytes,
|
||||
int? usedBytes,
|
||||
}) {
|
||||
return FileLimitExceededException(
|
||||
instanceFull: instanceFull ?? this.instanceFull,
|
||||
limitBytes: limitBytes ?? this.limitBytes,
|
||||
usedBytes: usedBytes ?? this.usedBytes,
|
||||
);
|
||||
}
|
||||
}
|
||||
95
halkyon_learn_server/lib/src/generated/asset/file_usage.dart
Normal file
95
halkyon_learn_server/lib/src/generated/asset/file_usage.dart
Normal 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/serverpod.dart' as _is;
|
||||
|
||||
/// How much room the caller's files take, and how much they may take.
|
||||
abstract class FileUsage
|
||||
implements _is.SerializableModel, _is.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.
|
||||
@_is.useResult
|
||||
FileUsage copyWith({
|
||||
int? usedBytes,
|
||||
int? limitBytes,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'__className__': 'FileUsage',
|
||||
'usedBytes': usedBytes,
|
||||
'limitBytes': limitBytes,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJsonForProtocol() {
|
||||
return {
|
||||
'__className__': 'FileUsage',
|
||||
'usedBytes': usedBytes,
|
||||
'limitBytes': limitBytes,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _is.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.
|
||||
@_is.useResult
|
||||
@override
|
||||
FileUsage copyWith({
|
||||
int? usedBytes,
|
||||
int? limitBytes,
|
||||
}) {
|
||||
return FileUsage(
|
||||
usedBytes: usedBytes ?? this.usedBytes,
|
||||
limitBytes: limitBytes ?? this.limitBytes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -123,6 +123,17 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
params['sha256'],
|
||||
),
|
||||
),
|
||||
'usage': _is.MethodConnector(
|
||||
name: 'usage',
|
||||
params: {},
|
||||
call:
|
||||
(
|
||||
_is.Session session,
|
||||
Map<String, dynamic> params,
|
||||
) async => (endpoints['asset'] as _ibwtj1i8.AssetEndpoint).usage(
|
||||
session,
|
||||
),
|
||||
),
|
||||
'download': _is.MethodConnector(
|
||||
name: 'download',
|
||||
params: {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
|
|||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart'
|
||||
as _izehhkf5;
|
||||
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 'auth/device.dart' as _i6c5ny7n;
|
||||
import 'auth/github_sign_in_refusal.dart' as _ir1gd9p5;
|
||||
import 'auth/github_sign_in_refused_exception.dart' as _i0xfcu5n;
|
||||
|
|
@ -35,6 +38,9 @@ import 'studyset/studyset.dart' as _ikbclmae;
|
|||
import 'tag/card_tag.dart' as _i6jz6nyq;
|
||||
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 'auth/device.dart';
|
||||
export 'auth/github_sign_in_refusal.dart';
|
||||
export 'auth/github_sign_in_refused_exception.dart';
|
||||
|
|
@ -472,6 +478,56 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
],
|
||||
managed: true,
|
||||
),
|
||||
_isp.TableDefinition(
|
||||
name: 'file_allowance',
|
||||
dartName: 'FileAllowance',
|
||||
schema: 'public',
|
||||
module: 'halkyon_learn',
|
||||
columns: [
|
||||
_isp.ColumnDefinition(
|
||||
name: 'id',
|
||||
columnType: _isp.ColumnType.bigint,
|
||||
isNullable: false,
|
||||
dartType: 'int?',
|
||||
columnDefault: 'serial',
|
||||
),
|
||||
_isp.ColumnDefinition(
|
||||
name: 'authUserId',
|
||||
columnType: _isp.ColumnType.uuid,
|
||||
isNullable: false,
|
||||
dartType: 'UuidValue',
|
||||
),
|
||||
_isp.ColumnDefinition(
|
||||
name: 'maxBytes',
|
||||
columnType: _isp.ColumnType.bigint,
|
||||
isNullable: false,
|
||||
dartType: 'int',
|
||||
),
|
||||
_isp.ColumnDefinition(
|
||||
name: 'note',
|
||||
columnType: _isp.ColumnType.text,
|
||||
isNullable: true,
|
||||
dartType: 'String?',
|
||||
),
|
||||
],
|
||||
foreignKeys: [],
|
||||
indexes: [
|
||||
_isp.IndexDefinition(
|
||||
indexName: 'file_allowance_person_idx',
|
||||
tableSpace: null,
|
||||
elements: [
|
||||
_isp.IndexElementDefinition(
|
||||
type: _isp.IndexElementDefinitionType.column,
|
||||
definition: 'authUserId',
|
||||
),
|
||||
],
|
||||
type: 'btree',
|
||||
isUnique: true,
|
||||
isPrimary: false,
|
||||
),
|
||||
],
|
||||
managed: true,
|
||||
),
|
||||
_isp.TableDefinition(
|
||||
name: 'person_settings',
|
||||
dartName: 'PersonSettings',
|
||||
|
|
@ -876,6 +932,15 @@ class Protocol extends _is.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;
|
||||
}
|
||||
if (t == _iurwo9k8.FileUsage) {
|
||||
return _iurwo9k8.FileUsage.fromJson(data) as T;
|
||||
}
|
||||
if (t == _i6c5ny7n.Device) {
|
||||
return _i6c5ny7n.Device.fromJson(data) as T;
|
||||
}
|
||||
|
|
@ -915,6 +980,19 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
if (t == _is.getType<_ipb7vkxr.Asset?>()) {
|
||||
return (data != null ? _ipb7vkxr.Asset.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _is.getType<_i9uv0f1n.FileAllowance?>()) {
|
||||
return (data != null ? _i9uv0f1n.FileAllowance.fromJson(data) : null)
|
||||
as T;
|
||||
}
|
||||
if (t == _is.getType<_it5d94hq.FileLimitExceededException?>()) {
|
||||
return (data != null
|
||||
? _it5d94hq.FileLimitExceededException.fromJson(data)
|
||||
: null)
|
||||
as T;
|
||||
}
|
||||
if (t == _is.getType<_iurwo9k8.FileUsage?>()) {
|
||||
return (data != null ? _iurwo9k8.FileUsage.fromJson(data) : null) as T;
|
||||
}
|
||||
if (t == _is.getType<_i6c5ny7n.Device?>()) {
|
||||
return (data != null ? _i6c5ny7n.Device.fromJson(data) : null) as T;
|
||||
}
|
||||
|
|
@ -987,6 +1065,9 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
static String? getClassNameForType(Type type) {
|
||||
return switch (type) {
|
||||
_ipb7vkxr.Asset => 'Asset',
|
||||
_i9uv0f1n.FileAllowance => 'FileAllowance',
|
||||
_it5d94hq.FileLimitExceededException => 'FileLimitExceededException',
|
||||
_iurwo9k8.FileUsage => 'FileUsage',
|
||||
_i6c5ny7n.Device => 'Device',
|
||||
_ir1gd9p5.GitHubSignInRefusal => 'GitHubSignInRefusal',
|
||||
_i0xfcu5n.GitHubSignInRefusedException => 'GitHubSignInRefusedException',
|
||||
|
|
@ -1018,6 +1099,12 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
switch (data) {
|
||||
case _ipb7vkxr.Asset():
|
||||
return 'Asset';
|
||||
case _i9uv0f1n.FileAllowance():
|
||||
return 'FileAllowance';
|
||||
case _it5d94hq.FileLimitExceededException():
|
||||
return 'FileLimitExceededException';
|
||||
case _iurwo9k8.FileUsage():
|
||||
return 'FileUsage';
|
||||
case _i6c5ny7n.Device():
|
||||
return 'Device';
|
||||
case _ir1gd9p5.GitHubSignInRefusal():
|
||||
|
|
@ -1077,6 +1164,15 @@ class Protocol extends _is.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']);
|
||||
}
|
||||
if (dataClassName == 'FileUsage') {
|
||||
return deserialize<_iurwo9k8.FileUsage>(data['data']);
|
||||
}
|
||||
if (dataClassName == 'Device') {
|
||||
return deserialize<_i6c5ny7n.Device>(data['data']);
|
||||
}
|
||||
|
|
@ -1167,6 +1263,8 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
switch (t) {
|
||||
case _ipb7vkxr.Asset:
|
||||
return _ipb7vkxr.Asset.t;
|
||||
case _i9uv0f1n.FileAllowance:
|
||||
return _i9uv0f1n.FileAllowance.t;
|
||||
case _ipiw1cmx.Card:
|
||||
return _ipiw1cmx.Card.t;
|
||||
case _ipbyuef1.CardRevision:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
asset:
|
||||
- startUpload:
|
||||
- finishUpload:
|
||||
- usage:
|
||||
- download:
|
||||
account:
|
||||
- delete:
|
||||
|
|
|
|||
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_learn",
|
||||
"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
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
BEGIN;
|
||||
|
||||
--
|
||||
-- ACTION CREATE TABLE
|
||||
--
|
||||
CREATE TABLE "file_allowance" (
|
||||
"id" bigserial PRIMARY KEY,
|
||||
"authUserId" uuid NOT NULL,
|
||||
"maxBytes" bigint NOT NULL,
|
||||
"note" text
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE UNIQUE INDEX "file_allowance_person_idx" ON "file_allowance" USING btree ("authUserId");
|
||||
|
||||
|
||||
--
|
||||
-- MIGRATION VERSION FOR halkyon_learn
|
||||
--
|
||||
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||
VALUES ('halkyon_learn', '20261002035419149-file-allowance', now())
|
||||
ON CONFLICT ("module")
|
||||
DO UPDATE SET "version" = '20261002035419149-file-allowance', "timestamp" = now();
|
||||
|
||||
--
|
||||
-- MIGRATION VERSION FOR serverpod
|
||||
--
|
||||
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||
VALUES ('serverpod', '20260824182259319', now())
|
||||
ON CONFLICT ("module")
|
||||
DO UPDATE SET "version" = '20260824182259319', "timestamp" = now();
|
||||
|
||||
--
|
||||
-- MIGRATION VERSION FOR serverpod_auth_idp
|
||||
--
|
||||
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||
VALUES ('serverpod_auth_idp', '20260924105404509', now())
|
||||
ON CONFLICT ("module")
|
||||
DO UPDATE SET "version" = '20260924105404509', "timestamp" = now();
|
||||
|
||||
--
|
||||
-- MIGRATION VERSION FOR serverpod_offline_sync
|
||||
--
|
||||
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||
VALUES ('serverpod_offline_sync', '20260914143806119', now())
|
||||
ON CONFLICT ("module")
|
||||
DO UPDATE SET "version" = '20260914143806119', "timestamp" = now();
|
||||
|
||||
--
|
||||
-- MIGRATION VERSION FOR serverpod_auth_core
|
||||
--
|
||||
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||
VALUES ('serverpod_auth_core', '20260924105232991', now())
|
||||
ON CONFLICT ("module")
|
||||
DO UPDATE SET "version" = '20260924105232991', "timestamp" = now();
|
||||
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -9,3 +9,4 @@
|
|||
20260930122235111-cards
|
||||
20260930145136285-reviews
|
||||
20261001064926911-tree-tags-assets
|
||||
20261002035419149-file-allowance
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import 'dart:typed_data';
|
|||
|
||||
import 'package:crypto/crypto.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/auth/scopes.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/protocol.dart' show CloudStorageEntry;
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
|
@ -36,6 +38,11 @@ void main() {
|
|||
});
|
||||
|
||||
tearDown(() async {
|
||||
fileLimits = null;
|
||||
await FileAllowance.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.inSet(spaces),
|
||||
);
|
||||
for (final space in spaces) {
|
||||
await session.db.unsafeExecute(
|
||||
'DELETE FROM "serverpod_cloud_storage" WHERE "path" LIKE @prefix',
|
||||
|
|
@ -51,26 +58,37 @@ void main() {
|
|||
spaces.clear();
|
||||
});
|
||||
|
||||
TestSessionBuilder signedIn(UuidValue person) => sessionBuilder.copyWith(
|
||||
TestSessionBuilder signedIn(
|
||||
UuidValue person, {
|
||||
Set<Scope> scopes = const {},
|
||||
}) => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
person.uuid,
|
||||
{},
|
||||
scopes,
|
||||
),
|
||||
);
|
||||
|
||||
/// Adds [bytes] as [person], as the app does: asks how to upload them,
|
||||
/// uploads them unless the server has them, and finishes the upload.
|
||||
Future<bool> add(UuidValue person, List<int> bytes, {String? as}) async {
|
||||
Future<bool> add(
|
||||
UuidValue person,
|
||||
List<int> bytes, {
|
||||
String? as,
|
||||
Set<Scope> scopes = const {},
|
||||
}) async {
|
||||
final hash = as ?? sha256.convert(bytes).toString();
|
||||
final description = await endpoints.asset.startUpload(
|
||||
signedIn(person),
|
||||
signedIn(person, scopes: scopes),
|
||||
hash,
|
||||
bytes.length,
|
||||
);
|
||||
if (description != null) {
|
||||
await _upload(session, description, bytes);
|
||||
}
|
||||
return await endpoints.asset.finishUpload(signedIn(person), hash);
|
||||
return await endpoints.asset.finishUpload(
|
||||
signedIn(person, scopes: scopes),
|
||||
hash,
|
||||
);
|
||||
}
|
||||
|
||||
test('refuses callers who are not signed in', () async {
|
||||
|
|
@ -273,6 +291,158 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
group('room', () {
|
||||
setUp(() {
|
||||
fileLimits = const FileLimits(
|
||||
anonymousBytes: 10,
|
||||
identifiedBytes: 20,
|
||||
instanceBytes: 1000,
|
||||
);
|
||||
});
|
||||
|
||||
Matcher refused({required bool instanceFull, required int used}) =>
|
||||
throwsA(
|
||||
isA<FileLimitExceededException>()
|
||||
.having((e) => e.instanceFull, 'instanceFull', instanceFull)
|
||||
.having((e) => e.usedBytes, 'usedBytes', used),
|
||||
);
|
||||
|
||||
test('an anonymous person keeps no more than their room', () async {
|
||||
expect(await add(alice, List.filled(8, 1)), isTrue);
|
||||
|
||||
await expectLater(
|
||||
endpoints.asset.startUpload(
|
||||
signedIn(alice),
|
||||
sha256.convert([2, 2, 2]).toString(),
|
||||
3,
|
||||
),
|
||||
refused(instanceFull: false, used: 8),
|
||||
);
|
||||
});
|
||||
|
||||
test('an identified person keeps more', () async {
|
||||
expect(
|
||||
await add(alice, List.filled(15, 1), scopes: {identified}),
|
||||
isTrue,
|
||||
);
|
||||
await expectLater(
|
||||
add(bob, List.filled(15, 1)),
|
||||
refused(instanceFull: false, used: 0),
|
||||
);
|
||||
});
|
||||
|
||||
test('an allowance gives one person more, and nobody else', () async {
|
||||
await FileAllowance.db.insertRow(
|
||||
session,
|
||||
FileAllowance(authUserId: alice, maxBytes: 40),
|
||||
);
|
||||
|
||||
expect(await add(alice, List.filled(30, 1)), isTrue);
|
||||
await expectLater(
|
||||
add(bob, List.filled(30, 2)),
|
||||
refused(instanceFull: false, used: 0),
|
||||
);
|
||||
});
|
||||
|
||||
test('the instance keeps no more than its room', () async {
|
||||
// Whatever other tests left behind counts too: the room is set
|
||||
// from what the instance holds now.
|
||||
fileLimits = FileLimits(
|
||||
anonymousBytes: 10,
|
||||
identifiedBytes: 20,
|
||||
instanceBytes: await _instanceBytes(session) + 12,
|
||||
);
|
||||
expect(await add(alice, List.filled(8, 1)), isTrue);
|
||||
|
||||
await expectLater(
|
||||
add(bob, List.filled(5, 2)),
|
||||
refused(instanceFull: true, used: 0),
|
||||
);
|
||||
});
|
||||
|
||||
test('uploads started side by side are checked again as they '
|
||||
'finish', () async {
|
||||
final first = List.filled(6, 1);
|
||||
final second = List.filled(6, 2);
|
||||
String hash(List<int> bytes) => sha256.convert(bytes).toString();
|
||||
// Both started before either arrives: each fits on its own.
|
||||
final descriptions = [
|
||||
for (final bytes in [first, second])
|
||||
(await endpoints.asset.startUpload(
|
||||
signedIn(alice),
|
||||
hash(bytes),
|
||||
bytes.length,
|
||||
))!,
|
||||
];
|
||||
await _upload(session, descriptions[0], first);
|
||||
await _upload(session, descriptions[1], second);
|
||||
|
||||
expect(
|
||||
await endpoints.asset.finishUpload(signedIn(alice), hash(first)),
|
||||
isTrue,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.asset.finishUpload(signedIn(alice), hash(second)),
|
||||
refused(instanceFull: false, used: 12),
|
||||
);
|
||||
expect(
|
||||
await session.storage.fileExists(
|
||||
storageId: assetStorage,
|
||||
path: assetPath(alice, hash(second)),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('uploads never finished take room from the next', () async {
|
||||
final unfinished = List.filled(6, 1);
|
||||
await _upload(
|
||||
session,
|
||||
(await endpoints.asset.startUpload(
|
||||
signedIn(alice),
|
||||
sha256.convert(unfinished).toString(),
|
||||
unfinished.length,
|
||||
))!,
|
||||
unfinished,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.asset.startUpload(
|
||||
signedIn(alice),
|
||||
sha256.convert(List.filled(6, 2)).toString(),
|
||||
6,
|
||||
),
|
||||
refused(instanceFull: false, used: 6),
|
||||
);
|
||||
});
|
||||
|
||||
test('tells how much room the files take, and may take', () async {
|
||||
await add(alice, List.filled(4, 1));
|
||||
|
||||
final usage = await endpoints.asset.usage(signedIn(alice));
|
||||
expect((usage.usedBytes, usage.limitBytes), (4, 10));
|
||||
|
||||
fileLimits = FileLimits(
|
||||
anonymousBytes: 10,
|
||||
identifiedBytes: 20,
|
||||
instanceBytes: await _instanceBytes(session) + 2,
|
||||
);
|
||||
final tight = await endpoints.asset.usage(signedIn(alice));
|
||||
expect((tight.usedBytes, tight.limitBytes), (4, 6));
|
||||
});
|
||||
|
||||
test('refuses callers who are not signed in', () async {
|
||||
await expectLater(
|
||||
endpoints.asset.usage(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
),
|
||||
),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('pruning', () {
|
||||
final now = DateTime.now().toUtc();
|
||||
DateTime daysAgo(int days) => now.subtract(Duration(days: days));
|
||||
|
|
@ -445,6 +615,14 @@ Future<void> _upload(
|
|||
);
|
||||
}
|
||||
|
||||
/// What the stored files of everyone take, in bytes.
|
||||
Future<int> _instanceBytes(Session session) async =>
|
||||
(await session.db.unsafeQuery(
|
||||
'SELECT COALESCE(SUM(octet_length("byteData")), 0)::bigint '
|
||||
'FROM "serverpod_cloud_storage" WHERE "path" LIKE \'spaces/%\'',
|
||||
)).first.first!
|
||||
as int;
|
||||
|
||||
/// Makes the stored file with [hash] of [space] last claimed [at].
|
||||
Future<void> _claimed(
|
||||
Session session,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:typed_data';
|
|||
import 'package:halkyon_learn_server/src/asset/asset_endpoint.dart';
|
||||
import 'package:halkyon_learn_server/src/auth/account.dart';
|
||||
import 'package:halkyon_learn_server/src/auth/scopes.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';
|
||||
|
|
@ -45,6 +46,10 @@ void main() {
|
|||
'else', () async {
|
||||
final annasFile = await _fileOf(session, anna);
|
||||
final bensFile = await _fileOf(session, ben);
|
||||
await FileAllowance.db.insert(session, [
|
||||
FileAllowance(authUserId: anna, maxBytes: 1),
|
||||
FileAllowance(authUserId: ben, maxBytes: 1),
|
||||
]);
|
||||
|
||||
await endpoints.account.delete(
|
||||
sessionBuilder.copyWith(
|
||||
|
|
@ -66,6 +71,16 @@ void main() {
|
|||
expect(await _sessionsOf(session, ben), 1);
|
||||
expect(await _studysetExists(session, bensStudyset), isTrue);
|
||||
expect(await _fileExists(session, bensFile), isTrue);
|
||||
expect(
|
||||
[
|
||||
for (final allowance in await FileAllowance.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.inSet({anna, ben}),
|
||||
))
|
||||
allowance.authUserId,
|
||||
],
|
||||
[ben],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue