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
18 changed files with 112084 additions and 348 deletions
|
|
@ -1,201 +1,96 @@
|
|||
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_base_server/base.dart' as base;
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/protocol.dart' show CloudStorageEntry;
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.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.
|
||||
class AssetEndpoint extends Endpoint {
|
||||
@override
|
||||
bool get requireLogin => true;
|
||||
|
||||
/// The largest file accepted: a PDF (decided 2026-10-03; 100 MB since
|
||||
/// 2026-11-09, for the books of the old app's library).
|
||||
static const maxBytes = 100 * 1024 * 1024;
|
||||
/// The largest file accepted: a PDF.
|
||||
static const maxBytes = base.FileStore.defaultMaxBytes;
|
||||
|
||||
/// The largest file of any other kind, an image or a sound. A larger one
|
||||
/// is checked to be a PDF once stored, and deleted otherwise.
|
||||
static const maxOtherBytes = 20 * 1024 * 1024;
|
||||
/// What encrypting adds to a file at most (ADR 0019).
|
||||
static const encryptionOverhead = base.FileStore.encryptionOverhead;
|
||||
|
||||
/// What encrypting adds to a file at most (ADR 0019): its key's ID, the
|
||||
/// nonce, the signature and the cipher's tag, with room to spare.
|
||||
static const encryptionOverhead = 4 * 1024;
|
||||
|
||||
/// 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.
|
||||
Future<String?> startUpload(
|
||||
Session session,
|
||||
String sha256,
|
||||
int byteSize, {
|
||||
bool? encrypted,
|
||||
}) async {
|
||||
_checkHash(sha256);
|
||||
final most = maxBytes + (encrypted ?? false ? encryptionOverhead : 0);
|
||||
if (byteSize <= 0 || byteSize > most) {
|
||||
throw ArgumentError.value(byteSize, 'byteSize', 'is out of range');
|
||||
}
|
||||
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.
|
||||
// UPDATE "serverpod_cloud_storage" SET "addedTime" = $1
|
||||
// WHERE "storageId" = 'private' AND "path" = $2
|
||||
await CloudStorageEntry.db.updateWhere(
|
||||
session,
|
||||
columnValues: (t) => [t.addedTime(clock.now().toUtc())],
|
||||
where: (t) => t.storageId.equals(assetStorage) & t.path.equals(path),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
checkRoom(await roomOf(session, space), byteSize);
|
||||
return await session.storage.createUploadDescription(
|
||||
storageId: assetStorage,
|
||||
path: path,
|
||||
options: UploadOptions(contentLength: byteSize, maxFileSize: most),
|
||||
);
|
||||
}
|
||||
}) => _inLearnTerms(
|
||||
() => base.startFileUpload(
|
||||
session,
|
||||
sha256,
|
||||
byteSize,
|
||||
encrypted: encrypted ?? false,
|
||||
),
|
||||
);
|
||||
|
||||
/// 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.
|
||||
Future<bool> finishUpload(
|
||||
Session session,
|
||||
String sha256, {
|
||||
bool? encrypted,
|
||||
}) async {
|
||||
_checkHash(sha256);
|
||||
final path = assetPath(_caller(session), sha256);
|
||||
if (!await session.storage.verifyUpload(
|
||||
storageId: assetStorage,
|
||||
path: path,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
final bytes = await session.storage.retrieveFile(
|
||||
storageId: assetStorage,
|
||||
path: path,
|
||||
);
|
||||
if (_hashOf(bytes) != sha256 ||
|
||||
(encrypted ?? false
|
||||
? !_isSealed(bytes)
|
||||
: bytes.lengthInBytes > maxOtherBytes && !_isPdf(bytes)) ||
|
||||
bytes.lengthInBytes > maxBytes + encryptionOverhead) {
|
||||
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;
|
||||
}
|
||||
}) => _inLearnTerms(
|
||||
() => base.finishFileUpload(
|
||||
session,
|
||||
sha256,
|
||||
encrypted: encrypted ?? false,
|
||||
),
|
||||
);
|
||||
|
||||
/// 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));
|
||||
final usage = await base.fileUsageOf(session);
|
||||
return FileUsage(
|
||||
usedBytes: usage.usedBytes,
|
||||
limitBytes: usage.limitBytes,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
Future<ByteData> download(Session session, UuidValue assetId) async {
|
||||
final asset = await Asset.db.findById(session, assetId);
|
||||
final spaceId = asset?.spaceId;
|
||||
final space = spaceId == null
|
||||
? null
|
||||
: await OfflineSyncSpace.db.findById(session, spaceId);
|
||||
final caller = _caller(session);
|
||||
if (asset == null ||
|
||||
space == null ||
|
||||
!(space.uuidSpaceId == caller ||
|
||||
await OfflineSyncSpaces(
|
||||
session,
|
||||
).roleOf(user: caller, space: space.uuidSpaceId) !=
|
||||
null)) {
|
||||
try {
|
||||
return await base.downloadFile(session, assetId);
|
||||
} on base.StoredFileNotFoundException {
|
||||
throw NotFoundException(what: 'file');
|
||||
}
|
||||
}
|
||||
|
||||
/// [call], its refusal of room in Learn's model.
|
||||
Future<T> _inLearnTerms<T>(Future<T> Function() call) async {
|
||||
try {
|
||||
return await session.storage.retrieveFile(
|
||||
storageId: assetStorage,
|
||||
path: assetPath(space.uuidSpaceId, asset.sha256),
|
||||
return await call();
|
||||
} on base.FileLimitExceededException catch (refused) {
|
||||
throw FileLimitExceededException(
|
||||
instanceFull: refused.instanceFull,
|
||||
limitBytes: refused.limitBytes,
|
||||
usedBytes: refused.usedBytes,
|
||||
);
|
||||
} on CloudStorageFileNotFoundException {
|
||||
throw NotFoundException(what: 'file');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the file with [sha256] of the space [space] is stored.
|
||||
String assetPath(UuidValue space, String sha256) =>
|
||||
'$assetPathPrefix${space.uuid}/$sha256';
|
||||
base.storedFilePath(space, sha256);
|
||||
|
||||
/// The start of every stored file's path; a space's files are below
|
||||
/// `<prefix><space>/`.
|
||||
const assetPathPrefix = 'spaces/';
|
||||
/// The start of every stored file's path.
|
||||
const assetPathPrefix = base.storedFilePathPrefix;
|
||||
|
||||
/// The storage of every person's files.
|
||||
const assetStorage = 'private';
|
||||
|
||||
UuidValue _caller(Session session) =>
|
||||
UuidValue.withValidation(session.authenticated!.userIdentifier);
|
||||
|
||||
void _checkHash(String sha256) {
|
||||
if (!RegExp(r'^[0-9a-f]{64}$').hasMatch(sha256)) {
|
||||
throw ArgumentError.value(sha256, 'sha256', 'is no SHA-256 in hex');
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [bytes] are a PDF's: they start as every PDF does.
|
||||
bool _isPdf(ByteData bytes) {
|
||||
const signature = '%PDF-';
|
||||
return bytes.lengthInBytes >= signature.length &&
|
||||
String.fromCharCodes(
|
||||
Uint8List.sublistView(bytes, 0, signature.length),
|
||||
) ==
|
||||
signature;
|
||||
}
|
||||
|
||||
/// Whether [bytes] are a sealed file's (ADR 0019): they start as the app
|
||||
/// seals every file, `HLKF` and its format's version, 1. What follows can't
|
||||
/// be looked into, but a readable file said to be sealed can't skip the
|
||||
/// PDF check unnoticed.
|
||||
bool _isSealed(ByteData bytes) {
|
||||
const magic = 'HLKF';
|
||||
return bytes.lengthInBytes > magic.length + 1 &&
|
||||
String.fromCharCodes(Uint8List.sublistView(bytes, 0, magic.length)) ==
|
||||
magic &&
|
||||
bytes.getUint8(magic.length) == 1;
|
||||
}
|
||||
|
||||
String _hashOf(ByteData bytes) =>
|
||||
sha256.convert(Uint8List.sublistView(bytes)).toString();
|
||||
const assetStorage = base.fileStorage;
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
### 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
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:clock/clock.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_pruning.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||
|
||||
/// How much room people's files may take (decided 2026-10-02): a person
|
||||
/// some, and more 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_IDENTIFIED_MB` (500) and
|
||||
/// `FILE_LIMIT_INSTANCE_MB` (15000; 0 for none). Someone signed in without
|
||||
/// `identified`, an anonymous account left from before (ADR 0020, D25),
|
||||
/// keeps nothing. 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 [identifiedBytes] and [instanceBytes] (null for none).
|
||||
const FileLimits({
|
||||
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(
|
||||
identifiedBytes: mib('FILE_LIMIT_IDENTIFIED_MB', 500),
|
||||
instanceBytes: instance > 0 ? instance : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 _spaceBytes(session, space, finishedOnly: finishedOnly),
|
||||
limit:
|
||||
allowance?.maxBytes ??
|
||||
(session.authenticated!.scopes.contains(identified)
|
||||
? limits.identifiedBytes
|
||||
: 0),
|
||||
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 of [space] take: those a live asset row of it
|
||||
/// names, and those claimed within [filePruningGrace] (an upload's row may
|
||||
/// sync after it), unfinished uploads included unless [finishedOnly]. A
|
||||
/// file no live row names any more, as one a private studyset's rewrite
|
||||
/// replaced (ADR 0019), counts no longer, though pruning keeps it a while.
|
||||
Future<int> _spaceBytes(
|
||||
Session session,
|
||||
UuidValue space, {
|
||||
required bool finishedOnly,
|
||||
}) async {
|
||||
final prefix = '$assetPathPrefix${space.uuid}/';
|
||||
final cutoff = clock
|
||||
.now()
|
||||
.toUtc()
|
||||
.subtract(filePruningGrace)
|
||||
.toIso8601String()
|
||||
.replaceFirst('Z', '');
|
||||
// SELECT "path", octet_length("byteData"), "addedTime" >= $3
|
||||
// FROM "serverpod_cloud_storage"
|
||||
// WHERE "storageId" = 'private' AND "path" LIKE $1 [AND "verified"]
|
||||
final rows = await session.db.unsafeQuery(
|
||||
'SELECT "path", octet_length("byteData")::bigint, '
|
||||
'"addedTime" >= @cutoff::timestamp '
|
||||
'FROM "serverpod_cloud_storage" '
|
||||
'WHERE "storageId" = @storage AND "path" LIKE @prefix'
|
||||
'${finishedOnly ? ' AND "verified"' : ''}',
|
||||
parameters: QueryParameters.named({
|
||||
'storage': assetStorage,
|
||||
'prefix': '$prefix%',
|
||||
'cutoff': cutoff,
|
||||
}),
|
||||
);
|
||||
if (rows.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
// SELECT "id" FROM "offline_sync_spaces" WHERE "uuidSpaceId" = $1
|
||||
final spaceId = (await OfflineSyncSpace.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.uuidSpaceId.equals(space),
|
||||
))?.id;
|
||||
// SELECT "sha256" FROM "asset" WHERE "spaceId" = $1 (tombstones hidden)
|
||||
final live = spaceId == null
|
||||
? const <String>{}
|
||||
: {
|
||||
for (final asset in await Asset.db.find(
|
||||
session,
|
||||
where: (t) => t.spaceId.equals(spaceId),
|
||||
))
|
||||
asset.sha256,
|
||||
};
|
||||
var used = 0;
|
||||
for (final [path as String, bytes as int, recent as bool] in rows) {
|
||||
if (recent || live.contains(path.substring(prefix.length))) {
|
||||
used += bytes;
|
||||
}
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
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_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||
|
||||
/// How long a stored file stays after it was last claimed (uploaded, or
|
||||
/// asked for again), whether or not an asset row refers to it: a device
|
||||
/// uploads a file around the time its row syncs, not necessarily after.
|
||||
const filePruningGrace = Duration(days: 7);
|
||||
|
||||
/// Deletes the stored files nobody can reach any more (ADR 0004, rule 5), at
|
||||
/// most [limit] per call, and returns how many: last claimed before
|
||||
/// [filePruningGrace] ago, and either never verified (an upload that wasn't
|
||||
/// finished) or not referred to by any asset row of their space that still
|
||||
/// exists (a deleted row is a tombstone, which the sync engine hides).
|
||||
Future<int> pruneUnreferencedFiles(
|
||||
Session session, {
|
||||
required DateTime now,
|
||||
int limit = 500,
|
||||
}) async {
|
||||
// The columns hold UTC as timestamp without time zone.
|
||||
final timestamp = now
|
||||
.toUtc()
|
||||
.subtract(filePruningGrace)
|
||||
.toIso8601String()
|
||||
.replaceFirst('Z', '');
|
||||
final candidates = await session.db.unsafeQuery(
|
||||
'SELECT "path", "verified" FROM "serverpod_cloud_storage" '
|
||||
'WHERE "storageId" = @storage AND "path" LIKE @prefix '
|
||||
'AND "addedTime" < @cutoff::timestamp '
|
||||
'ORDER BY "addedTime" LIMIT @limit',
|
||||
parameters: QueryParameters.named({
|
||||
'storage': assetStorage,
|
||||
'prefix': '$assetPathPrefix%',
|
||||
'cutoff': timestamp,
|
||||
'limit': limit,
|
||||
}),
|
||||
);
|
||||
|
||||
// The candidates' hashes, by space; a path of another form isn't ours to
|
||||
// judge.
|
||||
final hashesBySpace = <UuidValue, Set<String>>{};
|
||||
final unreferenced = <String>{};
|
||||
for (final [path as String, verified as bool] in candidates) {
|
||||
final match = _assetPathPattern.firstMatch(path);
|
||||
if (match == null) {
|
||||
continue;
|
||||
}
|
||||
if (!verified) {
|
||||
unreferenced.add(path);
|
||||
} else {
|
||||
(hashesBySpace[UuidValue.withValidation(match[1]!)] ??= {}).add(
|
||||
match[2]!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (final MapEntry(key: space, value: hashes) in hashesBySpace.entries) {
|
||||
final spaceId = (await OfflineSyncSpace.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.uuidSpaceId.equals(space),
|
||||
))?.id;
|
||||
final referenced = spaceId == null
|
||||
? const <String>{}
|
||||
: {
|
||||
for (final asset in await Asset.db.find(
|
||||
session,
|
||||
where: (t) => t.spaceId.equals(spaceId) & t.sha256.inSet(hashes),
|
||||
))
|
||||
asset.sha256,
|
||||
};
|
||||
unreferenced.addAll([
|
||||
for (final hash in hashes.difference(referenced)) assetPath(space, hash),
|
||||
]);
|
||||
}
|
||||
if (unreferenced.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// A file claimed again meanwhile stays. Plain SQL, since the ORM's delete
|
||||
// would read every deleted file to return it.
|
||||
return await session.db.unsafeExecute(
|
||||
'DELETE FROM "serverpod_cloud_storage" '
|
||||
'WHERE "storageId" = @storage AND "path" = ANY(@paths::text[]) '
|
||||
'AND "addedTime" < @cutoff::timestamp',
|
||||
parameters: QueryParameters.named({
|
||||
'storage': assetStorage,
|
||||
'paths': unreferenced.toList(),
|
||||
'cutoff': timestamp,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// A stored file's path: `spaces/<space>/<sha256>`.
|
||||
final _assetPathPattern = RegExp(
|
||||
'^${RegExp.escape(assetPathPrefix)}'
|
||||
'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/'
|
||||
r'([0-9a-f]{64})$',
|
||||
);
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_learn_server/src/asset/file_pruning.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
/// Deletes the stored files nobody can reach any more; the server schedules
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||
|
||||
/// The largest readable file of any kind but a PDF, an image or a sound:
|
||||
/// 20 MB. A larger one is checked to be a PDF once stored, and deleted
|
||||
/// otherwise (ADR 0004, rule 5).
|
||||
const maxOtherBytes = 20 * 1024 * 1024;
|
||||
|
||||
/// Learn's files in halkyon_base's store (Halkyon's ADR 0043, D6): the
|
||||
/// `asset` rows name them, a file is read by the members of the space its
|
||||
/// row is in, and a readable file over [maxOtherBytes] must be a PDF; up to
|
||||
/// 100 MB, the module's default (decided 2026-10-03; 100 MB since
|
||||
/// 2026-11-09, for the books of the old app's library).
|
||||
const learnFiles = FileStore(
|
||||
fileOfRow: _fileOfAsset,
|
||||
liveHashes: _liveAssetHashes,
|
||||
accepts: _accepts,
|
||||
);
|
||||
|
||||
/// The file the asset row [assetId] names, where the caller may read its
|
||||
/// space: its owner's, or one they were granted; null for a deleted row
|
||||
/// (the sync engine hides tombstones) and any other.
|
||||
Future<StoredFileRef?> _fileOfAsset(Session session, UuidValue assetId) async {
|
||||
// SELECT * FROM "asset" WHERE "id" = $1 (tombstones hidden)
|
||||
final asset = await Asset.db.findById(session, assetId);
|
||||
final spaceId = asset?.spaceId;
|
||||
// SELECT * FROM "offline_sync_spaces" WHERE "id" = $1
|
||||
final space = spaceId == null
|
||||
? null
|
||||
: await OfflineSyncSpace.db.findById(session, spaceId);
|
||||
final caller = UuidValue.withValidation(
|
||||
session.authenticated!.userIdentifier,
|
||||
);
|
||||
if (asset == null ||
|
||||
space == null ||
|
||||
!(space.uuidSpaceId == caller ||
|
||||
await OfflineSyncSpaces(
|
||||
session,
|
||||
).roleOf(user: caller, space: space.uuidSpaceId) !=
|
||||
null)) {
|
||||
return null;
|
||||
}
|
||||
return (space: space.uuidSpaceId, sha256: asset.sha256);
|
||||
}
|
||||
|
||||
/// Which of [hashes] (every one, where null) the live asset rows of [space]
|
||||
/// name.
|
||||
Future<Set<String>> _liveAssetHashes(
|
||||
Session session,
|
||||
UuidValue space,
|
||||
Set<String>? hashes,
|
||||
) async {
|
||||
// SELECT "id" FROM "offline_sync_spaces" WHERE "uuidSpaceId" = $1
|
||||
final spaceId = (await OfflineSyncSpace.db.findFirstRow(
|
||||
session,
|
||||
where: (t) => t.uuidSpaceId.equals(space),
|
||||
))?.id;
|
||||
if (spaceId == null) {
|
||||
return const {};
|
||||
}
|
||||
// SELECT "sha256" FROM "asset" WHERE "spaceId" = $1 [AND "sha256" IN …]
|
||||
// (tombstones hidden)
|
||||
return {
|
||||
for (final asset in await Asset.db.find(
|
||||
session,
|
||||
where: (t) => hashes == null
|
||||
? t.spaceId.equals(spaceId)
|
||||
: t.spaceId.equals(spaceId) & t.sha256.inSet(hashes),
|
||||
))
|
||||
asset.sha256,
|
||||
};
|
||||
}
|
||||
|
||||
/// A sealed file can't be looked into, and the module checked its header;
|
||||
/// a readable one over [maxOtherBytes] must be a PDF.
|
||||
bool _accepts(ByteData bytes, {required bool encrypted}) =>
|
||||
encrypted || bytes.lengthInBytes <= maxOtherBytes || _isPdf(bytes);
|
||||
|
||||
/// Whether [bytes] are a PDF's: they start as every PDF does.
|
||||
bool _isPdf(ByteData bytes) {
|
||||
const signature = '%PDF-';
|
||||
return bytes.lengthInBytes >= signature.length &&
|
||||
String.fromCharCodes(
|
||||
Uint8List.sublistView(bytes, 0, signature.length),
|
||||
) ==
|
||||
signature;
|
||||
}
|
||||
|
|
@ -75,12 +75,6 @@ Future<AfterDeletion?> deleteLearnData(
|
|||
where: (t) => t.authUserId.equals(authUserId),
|
||||
transaction: transaction,
|
||||
);
|
||||
// DELETE FROM "file_allowance" WHERE "authUserId" = $1
|
||||
await FileAllowance.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(authUserId),
|
||||
transaction: transaction,
|
||||
);
|
||||
// Whether they chose to be counted, and the copies their device reported
|
||||
// (ADR 0019, D3).
|
||||
// DELETE FROM "counted_learner" WHERE "authUserId" = $1
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_learn_server/src/asset/learn_files.dart';
|
||||
import 'package:halkyon_learn_server/src/auth/account.dart';
|
||||
import 'package:halkyon_learn_server/src/instance/app_compatibility.dart';
|
||||
|
||||
|
|
@ -17,7 +18,8 @@ const upstreamSourceCodeUrl =
|
|||
/// works with apps from [minimumAppBuild] on, deletes Learn's own data of a
|
||||
/// person who goes ([deleteLearnData]), profile pages name the app at their
|
||||
/// foot, and it holds the instance's register with [accounts]' members
|
||||
/// ([learnAccounts]). The server sets it up before it starts (`server.dart`),
|
||||
/// ([learnAccounts]), and keeps people's files, which its asset rows name
|
||||
/// ([learnFiles], Halkyon's ADR 0043). The server sets it up before it starts (`server.dart`),
|
||||
/// and so do tests that delete people or read profiles.
|
||||
HalkyonBase learnBase({HoldsRegister accounts = const HoldsRegister()}) =>
|
||||
HalkyonBase(
|
||||
|
|
@ -34,6 +36,7 @@ HalkyonBase learnBase({HoldsRegister accounts = const HoldsRegister()}) =>
|
|||
),
|
||||
privacyPagePoints: _privacyPagePoints,
|
||||
accounts: accounts,
|
||||
files: learnFiles,
|
||||
);
|
||||
|
||||
/// Learn's part in the instance's accounts (ADR 0016), from the operator's
|
||||
|
|
|
|||
|
|
@ -1,638 +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/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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -40,7 +40,6 @@ 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 'assistant/assistant_conversation.dart' as _ikd6xdrt;
|
||||
|
|
@ -109,7 +108,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';
|
||||
|
|
@ -1930,56 +1928,6 @@ 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: 'learning_copy',
|
||||
dartName: 'LearningCopy',
|
||||
|
|
@ -3786,9 +3734,6 @@ 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;
|
||||
}
|
||||
|
|
@ -3993,10 +3938,6 @@ 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)
|
||||
|
|
@ -4404,7 +4345,6 @@ 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',
|
||||
_ikd6xdrt.AssistantConversation => 'AssistantConversation',
|
||||
|
|
@ -4491,8 +4431,6 @@ 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():
|
||||
|
|
@ -4670,9 +4608,6 @@ 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']);
|
||||
}
|
||||
|
|
@ -4950,8 +4885,6 @@ class Protocol extends _is.DatabaseSerializationManager {
|
|||
switch (t) {
|
||||
case _ipb7vkxr.Asset:
|
||||
return _ipb7vkxr.Asset.t;
|
||||
case _i9uv0f1n.FileAllowance:
|
||||
return _i9uv0f1n.FileAllowance.t;
|
||||
case _ikd6xdrt.AssistantConversation:
|
||||
return _ikd6xdrt.AssistantConversation.t;
|
||||
case _i5vmmfj2.AssistantTurn:
|
||||
|
|
|
|||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
BEGIN;
|
||||
|
||||
|
||||
--
|
||||
-- MIGRATION VERSION FOR halkyon_learn
|
||||
--
|
||||
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||
VALUES ('halkyon_learn', '20261110225358224-file-store-in-halkyon-base', now())
|
||||
ON CONFLICT ("module")
|
||||
DO UPDATE SET "version" = '20261110225358224-file-store-in-halkyon-base', "timestamp" = now();
|
||||
|
||||
--
|
||||
-- MIGRATION VERSION FOR halkyon_base
|
||||
--
|
||||
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||
VALUES ('halkyon_base', '20261110225132890-file-store', now())
|
||||
ON CONFLICT ("module")
|
||||
DO UPDATE SET "version" = '20261110225132890-file-store', "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 halkyon_oidc
|
||||
--
|
||||
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
|
||||
VALUES ('halkyon_oidc', '20261023180644988', now())
|
||||
ON CONFLICT ("module")
|
||||
DO UPDATE SET "version" = '20261023180644988', "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;
|
||||
|
|
@ -52,3 +52,4 @@
|
|||
20261025165102861-assistants
|
||||
20261025184004860-assistant-conversations
|
||||
20261026140329810-assistant-budgets
|
||||
20261110225358224-file-store-in-halkyon-base
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue