feat: PDF sources: read, take notes and cards from pages, deadlines
Some checks failed
CI / check (push) Failing after 26s
CI / deploy (push) Has been skipped

PDFs live in a studyset's new Files tab: uploaded from Add material
(a file the studyset holds already asks first), each row with its
pages, cards, notes and size, how far it is read and its reading
deadline; renamed, given a deadline (a page or a chapter by a day),
marked as read, started afresh, deleted with a moment to undo.

The reader (/pdfs/:id) opens where reading left off: on wide windows
a rail of tools (search, fit, zoom, fullscreen), a page counter with
the chapter, and a sidebar; on phones a top bar, a page scrubber and
a sheet. The sidebar holds the PDF's flashcards, by page or chapter,
made from selected text or written there, each tied to its page and
tagged with its chapter; and the page's note, an ordinary note tied
to the page. Screenshots of a region become images that know their
page. Cards and screenshots lead back to their page; reading on past
a chapter with cards suggests studying it. Home shows the deadlines
running and to come, and a timeline of them.

Server: pdf_source rows (content in the payload, last writer wins)
and an append-only pdf_reading log, the furthest page derived from
it so devices never conflict. Files up to 50 MB for PDFs (20 MB for
others; a larger file that isn't a PDF is deleted once stored). A
deleted PDF takes its file's asset row along unless another PDF has
the same file. Card payload 4 and note payload 2 keep the page.

The reader draws with pdfrx (PDFium) behind PdfEngine in
Dependencies; widget tests use a fake, and
integration_test/pdf_engine_test.dart checks the real engine on a
device or the desktop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-03 14:40:08 +02:00
commit b96db731c1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 34926 additions and 58 deletions

View file

@ -9,8 +9,8 @@ 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 and sounds,
/// stored in the server's private storage at a path derived from the space
/// 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
@ -23,8 +23,12 @@ class AssetEndpoint extends Endpoint {
@override
bool get requireLogin => true;
/// The largest file accepted.
static const maxBytes = 20 * 1024 * 1024;
/// The largest file accepted: a PDF (decided 2026-10-03).
static const maxBytes = 50 * 1024 * 1024;
/// 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;
/// How to upload the caller's file with [sha256] (lowercase hex) of
/// [byteSize] bytes; null when the server has it already. Throws a
@ -62,7 +66,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, and so is
/// one that no longer fits, with a [FileLimitExceededException].
/// one over [maxOtherBytes] that isn't a PDF, and one that no longer fits,
/// with a [FileLimitExceededException].
Future<bool> finishUpload(Session session, String sha256) async {
_checkHash(sha256);
final path = assetPath(_caller(session), sha256);
@ -76,7 +81,8 @@ class AssetEndpoint extends Endpoint {
storageId: assetStorage,
path: path,
);
if (_hashOf(bytes) != sha256) {
if (_hashOf(bytes) != sha256 ||
(bytes.lengthInBytes > maxOtherBytes && !_isPdf(bytes))) {
await session.storage.deleteFile(storageId: assetStorage, path: path);
return false;
}
@ -150,5 +156,15 @@ void _checkHash(String sha256) {
}
}
/// 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;
}
String _hashOf(ByteData bytes) =>
sha256.convert(Uint8List.sublistView(bytes)).toString();

View file

@ -0,0 +1,780 @@
/* 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: dead_code, unnecessary_null_comparison
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_learn_server/src/generated/protocol.dart' as _i5lsenq5;
import 'package:serverpod/serverpod.dart' as _is;
import '../pdf_source/pdf_source.dart' as _ijdmmu1w;
/// How far a PDF has been read, as an entry that is never changed once written:
/// the furthest page isn't stored but derived from these, as a card's schedule
/// is from its reviews, so reading on two devices never conflicts. An entry is
/// written only when the furthest page grows, so a PDF has at most an entry per
/// page per fresh start. Synced; the entry goes with its PDF.
abstract class PdfReading
implements _is.TableRow<_is.UuidValue?>, _is.ProtocolSerialization {
PdfReading._({
this.id,
this.spaceId,
required this.pdfSourceId,
this.pdfSource,
required this.readAt,
required this.kind,
required this.page,
});
factory PdfReading({
_is.UuidValue? id,
int? spaceId,
required _is.UuidValue pdfSourceId,
_ijdmmu1w.PdfSource? pdfSource,
required DateTime readAt,
required String kind,
required int page,
}) = _PdfReadingImpl;
factory PdfReading.fromJson(Map<String, dynamic> jsonSerialization) {
return PdfReading(
id: jsonSerialization['id'] == null
? null
: _is.UuidValueJsonExtension.fromJson(jsonSerialization['id']),
spaceId: jsonSerialization['spaceId'] as int?,
pdfSourceId: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['pdfSourceId'],
),
pdfSource: jsonSerialization['pdfSource'] == null
? null
: _i5lsenq5.Protocol().deserialize<_ijdmmu1w.PdfSource>(
jsonSerialization['pdfSource'],
),
readAt: _is.DateTimeJsonExtension.fromJson(jsonSerialization['readAt']),
kind: jsonSerialization['kind'] as String,
page: jsonSerialization['page'] as int,
);
}
static final t = PdfReadingTable();
static const db = PdfReadingRepository._();
@override
_is.UuidValue? id;
/// The space owning this row. Maintained by the sync engine.
int? spaceId;
_is.UuidValue pdfSourceId;
/// The PDF read.
_ijdmmu1w.PdfSource? pdfSource;
/// When, in UTC.
DateTime readAt;
/// 'reached' for a page reached (marking the PDF finished reaches its last
/// page); 'reset' for a fresh start: the furthest page is the furthest
/// reached since the last one.
String kind;
/// The page reached, from 1; 0 for 'reset'.
int page;
@override
_is.Table<_is.UuidValue?> get table => t;
/// Returns a shallow copy of this [PdfReading]
/// with some or all fields replaced by the given arguments.
@_is.useResult
PdfReading copyWith({
_is.UuidValue? id,
int? spaceId,
_is.UuidValue? pdfSourceId,
_ijdmmu1w.PdfSource? pdfSource,
DateTime? readAt,
String? kind,
int? page,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'PdfReading',
if (id != null) 'id': id?.toJson(),
if (spaceId != null) 'spaceId': spaceId,
'pdfSourceId': pdfSourceId.toJson(),
if (pdfSource != null) 'pdfSource': pdfSource?.toJson(),
'readAt': readAt.toJson(),
'kind': kind,
'page': page,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'PdfReading',
if (id != null) 'id': id?.toJson(),
if (spaceId != null) 'spaceId': spaceId,
'pdfSourceId': pdfSourceId.toJson(),
if (pdfSource != null) 'pdfSource': pdfSource?.toJsonForProtocol(),
'readAt': readAt.toJson(),
'kind': kind,
'page': page,
};
}
static PdfReadingInclude include({_ijdmmu1w.PdfSourceInclude? pdfSource}) {
return PdfReadingInclude._(pdfSource: pdfSource);
}
static PdfReadingIncludeList includeList({
_is.WhereExpressionBuilder<PdfReadingTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<PdfReadingTable>? orderBy,
_is.OrderByListBuilder<PdfReadingTable>? orderByList,
PdfReadingInclude? include,
}) {
return PdfReadingIncludeList._(
where: where,
limit: limit,
offset: offset,
orderBy: orderBy?.call(PdfReading.t),
orderByList: orderByList?.call(PdfReading.t),
include: include,
);
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _PdfReadingImpl extends PdfReading {
_PdfReadingImpl({
_is.UuidValue? id,
int? spaceId,
required _is.UuidValue pdfSourceId,
_ijdmmu1w.PdfSource? pdfSource,
required DateTime readAt,
required String kind,
required int page,
}) : super._(
id: id,
spaceId: spaceId,
pdfSourceId: pdfSourceId,
pdfSource: pdfSource,
readAt: readAt,
kind: kind,
page: page,
);
/// Returns a shallow copy of this [PdfReading]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
PdfReading copyWith({
Object? id = _Undefined,
Object? spaceId = _Undefined,
_is.UuidValue? pdfSourceId,
Object? pdfSource = _Undefined,
DateTime? readAt,
String? kind,
int? page,
}) {
return PdfReading(
id: id is _is.UuidValue? ? id : this.id,
spaceId: spaceId is int? ? spaceId : this.spaceId,
pdfSourceId: pdfSourceId ?? this.pdfSourceId,
pdfSource: pdfSource is _ijdmmu1w.PdfSource?
? pdfSource
: this.pdfSource?.copyWith(),
readAt: readAt ?? this.readAt,
kind: kind ?? this.kind,
page: page ?? this.page,
);
}
}
class PdfReadingUpdateTable extends _is.UpdateTable<PdfReadingTable> {
PdfReadingUpdateTable(super.table);
_is.ColumnValue<int, int> spaceId(int? value) => _is.ColumnValue(
table.spaceId,
value,
);
_is.ColumnValue<_is.UuidValue, _is.UuidValue> pdfSourceId(
_is.UuidValue value,
) => _is.ColumnValue(
table.pdfSourceId,
value,
);
_is.ColumnValue<DateTime, DateTime> readAt(DateTime value) => _is.ColumnValue(
table.readAt,
value,
);
_is.ColumnValue<String, String> kind(String value) => _is.ColumnValue(
table.kind,
value,
);
_is.ColumnValue<int, int> page(int value) => _is.ColumnValue(
table.page,
value,
);
}
class PdfReadingTable extends _is.Table<_is.UuidValue?> {
PdfReadingTable({super.tableRelation}) : super(tableName: 'pdf_reading') {
updateTable = PdfReadingUpdateTable(this);
spaceId = _is.ColumnInt(
'spaceId',
this,
);
pdfSourceId = _is.ColumnUuid(
'pdfSourceId',
this,
);
readAt = _is.ColumnDateTime(
'readAt',
this,
);
kind = _is.ColumnString(
'kind',
this,
);
page = _is.ColumnInt(
'page',
this,
);
}
late final PdfReadingUpdateTable updateTable;
/// The space owning this row. Maintained by the sync engine.
late final _is.ColumnInt spaceId;
late final _is.ColumnUuid pdfSourceId;
/// The PDF read.
_ijdmmu1w.PdfSourceTable? _pdfSource;
/// When, in UTC.
late final _is.ColumnDateTime readAt;
/// 'reached' for a page reached (marking the PDF finished reaches its last
/// page); 'reset' for a fresh start: the furthest page is the furthest
/// reached since the last one.
late final _is.ColumnString kind;
/// The page reached, from 1; 0 for 'reset'.
late final _is.ColumnInt page;
_ijdmmu1w.PdfSourceTable get pdfSource {
if (_pdfSource != null) return _pdfSource!;
_pdfSource = _is.createRelationTable(
relationFieldName: 'pdfSource',
field: PdfReading.t.pdfSourceId,
foreignField: _ijdmmu1w.PdfSource.t.id,
tableRelation: tableRelation,
createTable: (foreignTableRelation) =>
_ijdmmu1w.PdfSourceTable(tableRelation: foreignTableRelation),
);
return _pdfSource!;
}
@override
List<_is.Column> get columns => [
id,
spaceId,
pdfSourceId,
readAt,
kind,
page,
];
@override
_is.Table? getRelationTable(String relationField) {
if (relationField == 'pdfSource') {
return pdfSource;
}
return null;
}
}
class PdfReadingInclude extends _is.IncludeObject {
PdfReadingInclude._({_ijdmmu1w.PdfSourceInclude? pdfSource}) {
_pdfSource = pdfSource;
}
_ijdmmu1w.PdfSourceInclude? _pdfSource;
@override
Map<String, _is.Include?> get includes => {'pdfSource': _pdfSource};
@override
_is.Table<_is.UuidValue?> get table => PdfReading.t;
}
class PdfReadingIncludeList extends _is.IncludeList {
PdfReadingIncludeList._({
_is.WhereExpressionBuilder<PdfReadingTable>? where,
super.limit,
super.offset,
super.orderBy,
super.orderByList,
super.include,
}) {
super.where = where?.call(PdfReading.t);
}
@override
Map<String, _is.Include?> get includes => include?.includes ?? {};
@override
_is.Table<_is.UuidValue?> get table => PdfReading.t;
}
class PdfReadingRepository {
const PdfReadingRepository._();
final attachRow = const PdfReadingAttachRowRepository._();
/// Returns a list of [PdfReading]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<PdfReading>> find(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<PdfReadingTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<PdfReadingTable>? orderBy,
_is.OrderByListBuilder<PdfReadingTable>? orderByList,
_is.Transaction? transaction,
PdfReadingInclude? include,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.find<PdfReading>(
where: where?.call(PdfReading.t),
orderBy: orderBy?.call(PdfReading.t),
orderByList: orderByList?.call(PdfReading.t),
limit: limit,
offset: offset,
transaction: transaction,
include: include,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Returns the first matching [PdfReading] 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<PdfReading?> findFirstRow(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<PdfReadingTable>? where,
int? offset,
_is.OrderByBuilder<PdfReadingTable>? orderBy,
_is.OrderByListBuilder<PdfReadingTable>? orderByList,
_is.Transaction? transaction,
PdfReadingInclude? include,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findFirstRow<PdfReading>(
where: where?.call(PdfReading.t),
orderBy: orderBy?.call(PdfReading.t),
orderByList: orderByList?.call(PdfReading.t),
offset: offset,
transaction: transaction,
include: include,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Finds a single [PdfReading] by its [id] or null if no such row exists.
Future<PdfReading?> findById(
_is.DatabaseSession session,
_is.UuidValue id, {
_is.Transaction? transaction,
PdfReadingInclude? include,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findById<PdfReading>(
id,
transaction: transaction,
include: include,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Inserts all [PdfReading]s in the list and returns the inserted rows.
///
/// The returned [PdfReading]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<PdfReading>> insert(
_is.DatabaseSession session,
List<PdfReading> rows, {
_is.Transaction? transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
return session.db.insert<PdfReading>(
rows,
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
}
/// Inserts a single [PdfReading] and returns the inserted row.
///
/// The returned [PdfReading] will have its `id` field set.
Future<PdfReading> insertRow(
_is.DatabaseSession session,
PdfReading row, {
_is.Transaction? transaction,
}) async {
return session.db.insertRow<PdfReading>(
row,
transaction: transaction,
);
}
/// Upserts all [PdfReading]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 [PdfReading]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<PdfReading>> upsert(
_is.DatabaseSession session,
List<PdfReading> rows, {
required _is.ColumnSelections<PdfReadingTable> conflictColumns,
_is.ColumnSelections<PdfReadingTable>? updateColumns,
_is.WhereExpressionBuilder<PdfReadingTable>? updateWhere,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.upsert<PdfReading>(
rows,
conflictColumns: conflictColumns(PdfReading.t),
updateColumns: updateColumns?.call(PdfReading.t),
updateWhere: updateWhere?.call(PdfReading.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Upserts a single [PdfReading] 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 [PdfReading] will have its `id` field set.
Future<PdfReading?> upsertRow(
_is.DatabaseSession session,
PdfReading row, {
required _is.ColumnSelections<PdfReadingTable> conflictColumns,
_is.ColumnSelections<PdfReadingTable>? updateColumns,
_is.WhereExpressionBuilder<PdfReadingTable>? updateWhere,
_is.Transaction? transaction,
}) async {
return session.db.upsertRow<PdfReading>(
row,
conflictColumns: conflictColumns(PdfReading.t),
updateColumns: updateColumns?.call(PdfReading.t),
updateWhere: updateWhere?.call(PdfReading.t),
transaction: transaction,
);
}
/// Updates all [PdfReading]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<PdfReading>> update(
_is.DatabaseSession session,
List<PdfReading> rows, {
_is.ColumnSelections<PdfReadingTable>? columns,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.update<PdfReading>(
rows,
columns: columns?.call(PdfReading.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Updates a single [PdfReading]. 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<PdfReading> updateRow(
_is.DatabaseSession session,
PdfReading row, {
_is.ColumnSelections<PdfReadingTable>? columns,
_is.Transaction? transaction,
}) async {
return session.db.updateRow<PdfReading>(
row,
columns: columns?.call(PdfReading.t),
transaction: transaction,
);
}
/// Updates a single [PdfReading] by its [id] with the specified [columnValues].
/// Returns the updated row or null if no row with the given id exists.
Future<PdfReading?> updateById(
_is.DatabaseSession session,
_is.UuidValue id, {
required _is.ColumnValueListBuilder<PdfReadingUpdateTable> columnValues,
_is.Transaction? transaction,
}) async {
return session.db.updateById<PdfReading>(
id,
columnValues: columnValues(PdfReading.t.updateTable),
transaction: transaction,
);
}
/// Updates all [PdfReading]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<PdfReading>> updateWhere(
_is.DatabaseSession session, {
required _is.ColumnValueListBuilder<PdfReadingUpdateTable> columnValues,
required _is.WhereExpressionBuilder<PdfReadingTable> where,
int? limit,
int? offset,
_is.OrderByBuilder<PdfReadingTable>? orderBy,
_is.OrderByListBuilder<PdfReadingTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.updateWhere<PdfReading>(
columnValues: columnValues(PdfReading.t.updateTable),
where: where(PdfReading.t),
limit: limit,
offset: offset,
orderBy: orderBy?.call(PdfReading.t),
orderByList: orderByList?.call(PdfReading.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes all [PdfReading]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<PdfReading>> delete(
_is.DatabaseSession session,
List<PdfReading> rows, {
_is.OrderByBuilder<PdfReadingTable>? orderBy,
_is.OrderByListBuilder<PdfReadingTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.delete<PdfReading>(
rows,
orderBy: orderBy?.call(PdfReading.t),
orderByList: orderByList?.call(PdfReading.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes a single [PdfReading].
Future<PdfReading> deleteRow(
_is.DatabaseSession session,
PdfReading row, {
_is.Transaction? transaction,
}) async {
return session.db.deleteRow<PdfReading>(
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<PdfReading>> deleteWhere(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<PdfReadingTable> where,
_is.OrderByBuilder<PdfReadingTable>? orderBy,
_is.OrderByListBuilder<PdfReadingTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.deleteWhere<PdfReading>(
where: where(PdfReading.t),
orderBy: orderBy?.call(PdfReading.t),
orderByList: orderByList?.call(PdfReading.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<PdfReadingTable>? where,
int? limit,
_is.Transaction? transaction,
}) async {
return session.db.count<PdfReading>(
where: where?.call(PdfReading.t),
limit: limit,
transaction: transaction,
);
}
/// Acquires row-level locks on [PdfReading] rows matching the [where] expression.
Future<void> lockRows(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<PdfReadingTable> where,
required _is.LockMode lockMode,
required _is.Transaction transaction,
_is.LockBehavior lockBehavior = _is.LockBehavior.wait,
}) async {
return session.db.lockRows<PdfReading>(
where: where(PdfReading.t),
lockMode: lockMode,
lockBehavior: lockBehavior,
transaction: transaction,
);
}
}
class PdfReadingAttachRowRepository {
const PdfReadingAttachRowRepository._();
/// Creates a relation between the given [PdfReading] and [PdfSource]
/// by setting the [PdfReading]'s foreign key `pdfSourceId` to refer to the [PdfSource].
Future<void> pdfSource(
_is.DatabaseSession session,
PdfReading pdfReading,
_ijdmmu1w.PdfSource pdfSource, {
_is.Transaction? transaction,
}) async {
if (pdfReading.id == null) {
throw ArgumentError.notNull('pdfReading.id');
}
if (pdfSource.id == null) {
throw ArgumentError.notNull('pdfSource.id');
}
var $pdfReading = pdfReading.copyWith(pdfSourceId: pdfSource.id);
await session.db.updateRow<PdfReading>(
$pdfReading,
columns: [PdfReading.t.pdfSourceId],
transaction: transaction,
);
}
}

View file

@ -0,0 +1,787 @@
/* 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: dead_code, unnecessary_null_comparison
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:typed_data' as _idt;
import 'package:halkyon_learn_server/src/generated/protocol.dart' as _i5lsenq5;
import 'package:serverpod/serverpod.dart' as _is;
import '../studyset/studyset.dart' as _ie5k1jc2;
/// A PDF in a studyset, to read and learn from: its cards and notes can be tied
/// to its pages. Synced; its space owns it.
///
/// Its content (title, the name the file came with, the file, its page count
/// once known, a reading deadline) lives in the payload, last writer wins, as a
/// learning path's: rarely edited on two devices at once. The file itself is
/// an asset (ADR 0004), named in the payload by the asset row's id.
abstract class PdfSource
implements _is.TableRow<_is.UuidValue?>, _is.ProtocolSerialization {
PdfSource._({
this.id,
this.spaceId,
required this.studysetId,
this.studyset,
this.keyId,
required this.payload,
this.nonce,
});
factory PdfSource({
_is.UuidValue? id,
int? spaceId,
required _is.UuidValue studysetId,
_ie5k1jc2.Studyset? studyset,
String? keyId,
required _idt.ByteData payload,
_idt.ByteData? nonce,
}) = _PdfSourceImpl;
factory PdfSource.fromJson(Map<String, dynamic> jsonSerialization) {
return PdfSource(
id: jsonSerialization['id'] == null
? null
: _is.UuidValueJsonExtension.fromJson(jsonSerialization['id']),
spaceId: jsonSerialization['spaceId'] as int?,
studysetId: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['studysetId'],
),
studyset: jsonSerialization['studyset'] == null
? null
: _i5lsenq5.Protocol().deserialize<_ie5k1jc2.Studyset>(
jsonSerialization['studyset'],
),
keyId: jsonSerialization['keyId'] as String?,
payload: _is.ByteDataJsonExtension.fromJson(jsonSerialization['payload']),
nonce: jsonSerialization['nonce'] == null
? null
: _is.ByteDataJsonExtension.fromJson(jsonSerialization['nonce']),
);
}
static final t = PdfSourceTable();
static const db = PdfSourceRepository._();
@override
_is.UuidValue? id;
/// The space owning this row. Maintained by the sync engine.
int? spaceId;
_is.UuidValue studysetId;
/// The studyset the PDF belongs to; the PDF goes with it.
_ie5k1jc2.Studyset? studyset;
/// The encryption key's id; always null (ADR 0002, rule 6).
String? keyId;
/// The content as UTF-8 JSON: {"schemaVersion", "title", "fileName", "asset",
/// "pageCount", "deadline"}; the app's pdf_source_payload.dart describes each
/// schema.
_idt.ByteData payload;
/// The encryption nonce; always null, like keyId.
_idt.ByteData? nonce;
@override
_is.Table<_is.UuidValue?> get table => t;
/// Returns a shallow copy of this [PdfSource]
/// with some or all fields replaced by the given arguments.
@_is.useResult
PdfSource copyWith({
_is.UuidValue? id,
int? spaceId,
_is.UuidValue? studysetId,
_ie5k1jc2.Studyset? studyset,
String? keyId,
_idt.ByteData? payload,
_idt.ByteData? nonce,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'PdfSource',
if (id != null) 'id': id?.toJson(),
if (spaceId != null) 'spaceId': spaceId,
'studysetId': studysetId.toJson(),
if (studyset != null) 'studyset': studyset?.toJson(),
if (keyId != null) 'keyId': keyId,
'payload': payload.toJson(),
if (nonce != null) 'nonce': nonce?.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'PdfSource',
if (id != null) 'id': id?.toJson(),
if (spaceId != null) 'spaceId': spaceId,
'studysetId': studysetId.toJson(),
if (studyset != null) 'studyset': studyset?.toJsonForProtocol(),
if (keyId != null) 'keyId': keyId,
'payload': payload.toJson(),
if (nonce != null) 'nonce': nonce?.toJson(),
};
}
static PdfSourceInclude include({_ie5k1jc2.StudysetInclude? studyset}) {
return PdfSourceInclude._(studyset: studyset);
}
static PdfSourceIncludeList includeList({
_is.WhereExpressionBuilder<PdfSourceTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<PdfSourceTable>? orderBy,
_is.OrderByListBuilder<PdfSourceTable>? orderByList,
PdfSourceInclude? include,
}) {
return PdfSourceIncludeList._(
where: where,
limit: limit,
offset: offset,
orderBy: orderBy?.call(PdfSource.t),
orderByList: orderByList?.call(PdfSource.t),
include: include,
);
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _PdfSourceImpl extends PdfSource {
_PdfSourceImpl({
_is.UuidValue? id,
int? spaceId,
required _is.UuidValue studysetId,
_ie5k1jc2.Studyset? studyset,
String? keyId,
required _idt.ByteData payload,
_idt.ByteData? nonce,
}) : super._(
id: id,
spaceId: spaceId,
studysetId: studysetId,
studyset: studyset,
keyId: keyId,
payload: payload,
nonce: nonce,
);
/// Returns a shallow copy of this [PdfSource]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
PdfSource copyWith({
Object? id = _Undefined,
Object? spaceId = _Undefined,
_is.UuidValue? studysetId,
Object? studyset = _Undefined,
Object? keyId = _Undefined,
_idt.ByteData? payload,
Object? nonce = _Undefined,
}) {
return PdfSource(
id: id is _is.UuidValue? ? id : this.id,
spaceId: spaceId is int? ? spaceId : this.spaceId,
studysetId: studysetId ?? this.studysetId,
studyset: studyset is _ie5k1jc2.Studyset?
? studyset
: this.studyset?.copyWith(),
keyId: keyId is String? ? keyId : this.keyId,
payload: payload ?? this.payload.clone(),
nonce: nonce is _idt.ByteData? ? nonce : this.nonce?.clone(),
);
}
}
class PdfSourceUpdateTable extends _is.UpdateTable<PdfSourceTable> {
PdfSourceUpdateTable(super.table);
_is.ColumnValue<int, int> spaceId(int? value) => _is.ColumnValue(
table.spaceId,
value,
);
_is.ColumnValue<_is.UuidValue, _is.UuidValue> studysetId(
_is.UuidValue value,
) => _is.ColumnValue(
table.studysetId,
value,
);
_is.ColumnValue<String, String> keyId(String? value) => _is.ColumnValue(
table.keyId,
value,
);
_is.ColumnValue<_idt.ByteData, _idt.ByteData> payload(_idt.ByteData value) =>
_is.ColumnValue(
table.payload,
value,
);
_is.ColumnValue<_idt.ByteData, _idt.ByteData> nonce(_idt.ByteData? value) =>
_is.ColumnValue(
table.nonce,
value,
);
}
class PdfSourceTable extends _is.Table<_is.UuidValue?> {
PdfSourceTable({super.tableRelation}) : super(tableName: 'pdf_source') {
updateTable = PdfSourceUpdateTable(this);
spaceId = _is.ColumnInt(
'spaceId',
this,
);
studysetId = _is.ColumnUuid(
'studysetId',
this,
);
keyId = _is.ColumnString(
'keyId',
this,
);
payload = _is.ColumnByteData(
'payload',
this,
);
nonce = _is.ColumnByteData(
'nonce',
this,
);
}
late final PdfSourceUpdateTable updateTable;
/// The space owning this row. Maintained by the sync engine.
late final _is.ColumnInt spaceId;
late final _is.ColumnUuid studysetId;
/// The studyset the PDF belongs to; the PDF goes with it.
_ie5k1jc2.StudysetTable? _studyset;
/// The encryption key's id; always null (ADR 0002, rule 6).
late final _is.ColumnString keyId;
/// The content as UTF-8 JSON: {"schemaVersion", "title", "fileName", "asset",
/// "pageCount", "deadline"}; the app's pdf_source_payload.dart describes each
/// schema.
late final _is.ColumnByteData payload;
/// The encryption nonce; always null, like keyId.
late final _is.ColumnByteData nonce;
_ie5k1jc2.StudysetTable get studyset {
if (_studyset != null) return _studyset!;
_studyset = _is.createRelationTable(
relationFieldName: 'studyset',
field: PdfSource.t.studysetId,
foreignField: _ie5k1jc2.Studyset.t.id,
tableRelation: tableRelation,
createTable: (foreignTableRelation) =>
_ie5k1jc2.StudysetTable(tableRelation: foreignTableRelation),
);
return _studyset!;
}
@override
List<_is.Column> get columns => [
id,
spaceId,
studysetId,
keyId,
payload,
nonce,
];
@override
_is.Table? getRelationTable(String relationField) {
if (relationField == 'studyset') {
return studyset;
}
return null;
}
}
class PdfSourceInclude extends _is.IncludeObject {
PdfSourceInclude._({_ie5k1jc2.StudysetInclude? studyset}) {
_studyset = studyset;
}
_ie5k1jc2.StudysetInclude? _studyset;
@override
Map<String, _is.Include?> get includes => {'studyset': _studyset};
@override
_is.Table<_is.UuidValue?> get table => PdfSource.t;
}
class PdfSourceIncludeList extends _is.IncludeList {
PdfSourceIncludeList._({
_is.WhereExpressionBuilder<PdfSourceTable>? where,
super.limit,
super.offset,
super.orderBy,
super.orderByList,
super.include,
}) {
super.where = where?.call(PdfSource.t);
}
@override
Map<String, _is.Include?> get includes => include?.includes ?? {};
@override
_is.Table<_is.UuidValue?> get table => PdfSource.t;
}
class PdfSourceRepository {
const PdfSourceRepository._();
final attachRow = const PdfSourceAttachRowRepository._();
/// Returns a list of [PdfSource]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<PdfSource>> find(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<PdfSourceTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<PdfSourceTable>? orderBy,
_is.OrderByListBuilder<PdfSourceTable>? orderByList,
_is.Transaction? transaction,
PdfSourceInclude? include,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.find<PdfSource>(
where: where?.call(PdfSource.t),
orderBy: orderBy?.call(PdfSource.t),
orderByList: orderByList?.call(PdfSource.t),
limit: limit,
offset: offset,
transaction: transaction,
include: include,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Returns the first matching [PdfSource] 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<PdfSource?> findFirstRow(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<PdfSourceTable>? where,
int? offset,
_is.OrderByBuilder<PdfSourceTable>? orderBy,
_is.OrderByListBuilder<PdfSourceTable>? orderByList,
_is.Transaction? transaction,
PdfSourceInclude? include,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findFirstRow<PdfSource>(
where: where?.call(PdfSource.t),
orderBy: orderBy?.call(PdfSource.t),
orderByList: orderByList?.call(PdfSource.t),
offset: offset,
transaction: transaction,
include: include,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Finds a single [PdfSource] by its [id] or null if no such row exists.
Future<PdfSource?> findById(
_is.DatabaseSession session,
_is.UuidValue id, {
_is.Transaction? transaction,
PdfSourceInclude? include,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findById<PdfSource>(
id,
transaction: transaction,
include: include,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Inserts all [PdfSource]s in the list and returns the inserted rows.
///
/// The returned [PdfSource]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<PdfSource>> insert(
_is.DatabaseSession session,
List<PdfSource> rows, {
_is.Transaction? transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
return session.db.insert<PdfSource>(
rows,
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
}
/// Inserts a single [PdfSource] and returns the inserted row.
///
/// The returned [PdfSource] will have its `id` field set.
Future<PdfSource> insertRow(
_is.DatabaseSession session,
PdfSource row, {
_is.Transaction? transaction,
}) async {
return session.db.insertRow<PdfSource>(
row,
transaction: transaction,
);
}
/// Upserts all [PdfSource]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 [PdfSource]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<PdfSource>> upsert(
_is.DatabaseSession session,
List<PdfSource> rows, {
required _is.ColumnSelections<PdfSourceTable> conflictColumns,
_is.ColumnSelections<PdfSourceTable>? updateColumns,
_is.WhereExpressionBuilder<PdfSourceTable>? updateWhere,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.upsert<PdfSource>(
rows,
conflictColumns: conflictColumns(PdfSource.t),
updateColumns: updateColumns?.call(PdfSource.t),
updateWhere: updateWhere?.call(PdfSource.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Upserts a single [PdfSource] 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 [PdfSource] will have its `id` field set.
Future<PdfSource?> upsertRow(
_is.DatabaseSession session,
PdfSource row, {
required _is.ColumnSelections<PdfSourceTable> conflictColumns,
_is.ColumnSelections<PdfSourceTable>? updateColumns,
_is.WhereExpressionBuilder<PdfSourceTable>? updateWhere,
_is.Transaction? transaction,
}) async {
return session.db.upsertRow<PdfSource>(
row,
conflictColumns: conflictColumns(PdfSource.t),
updateColumns: updateColumns?.call(PdfSource.t),
updateWhere: updateWhere?.call(PdfSource.t),
transaction: transaction,
);
}
/// Updates all [PdfSource]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<PdfSource>> update(
_is.DatabaseSession session,
List<PdfSource> rows, {
_is.ColumnSelections<PdfSourceTable>? columns,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.update<PdfSource>(
rows,
columns: columns?.call(PdfSource.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Updates a single [PdfSource]. 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<PdfSource> updateRow(
_is.DatabaseSession session,
PdfSource row, {
_is.ColumnSelections<PdfSourceTable>? columns,
_is.Transaction? transaction,
}) async {
return session.db.updateRow<PdfSource>(
row,
columns: columns?.call(PdfSource.t),
transaction: transaction,
);
}
/// Updates a single [PdfSource] by its [id] with the specified [columnValues].
/// Returns the updated row or null if no row with the given id exists.
Future<PdfSource?> updateById(
_is.DatabaseSession session,
_is.UuidValue id, {
required _is.ColumnValueListBuilder<PdfSourceUpdateTable> columnValues,
_is.Transaction? transaction,
}) async {
return session.db.updateById<PdfSource>(
id,
columnValues: columnValues(PdfSource.t.updateTable),
transaction: transaction,
);
}
/// Updates all [PdfSource]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<PdfSource>> updateWhere(
_is.DatabaseSession session, {
required _is.ColumnValueListBuilder<PdfSourceUpdateTable> columnValues,
required _is.WhereExpressionBuilder<PdfSourceTable> where,
int? limit,
int? offset,
_is.OrderByBuilder<PdfSourceTable>? orderBy,
_is.OrderByListBuilder<PdfSourceTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.updateWhere<PdfSource>(
columnValues: columnValues(PdfSource.t.updateTable),
where: where(PdfSource.t),
limit: limit,
offset: offset,
orderBy: orderBy?.call(PdfSource.t),
orderByList: orderByList?.call(PdfSource.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes all [PdfSource]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<PdfSource>> delete(
_is.DatabaseSession session,
List<PdfSource> rows, {
_is.OrderByBuilder<PdfSourceTable>? orderBy,
_is.OrderByListBuilder<PdfSourceTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.delete<PdfSource>(
rows,
orderBy: orderBy?.call(PdfSource.t),
orderByList: orderByList?.call(PdfSource.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes a single [PdfSource].
Future<PdfSource> deleteRow(
_is.DatabaseSession session,
PdfSource row, {
_is.Transaction? transaction,
}) async {
return session.db.deleteRow<PdfSource>(
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<PdfSource>> deleteWhere(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<PdfSourceTable> where,
_is.OrderByBuilder<PdfSourceTable>? orderBy,
_is.OrderByListBuilder<PdfSourceTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.deleteWhere<PdfSource>(
where: where(PdfSource.t),
orderBy: orderBy?.call(PdfSource.t),
orderByList: orderByList?.call(PdfSource.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<PdfSourceTable>? where,
int? limit,
_is.Transaction? transaction,
}) async {
return session.db.count<PdfSource>(
where: where?.call(PdfSource.t),
limit: limit,
transaction: transaction,
);
}
/// Acquires row-level locks on [PdfSource] rows matching the [where] expression.
Future<void> lockRows(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<PdfSourceTable> where,
required _is.LockMode lockMode,
required _is.Transaction transaction,
_is.LockBehavior lockBehavior = _is.LockBehavior.wait,
}) async {
return session.db.lockRows<PdfSource>(
where: where(PdfSource.t),
lockMode: lockMode,
lockBehavior: lockBehavior,
transaction: transaction,
);
}
}
class PdfSourceAttachRowRepository {
const PdfSourceAttachRowRepository._();
/// Creates a relation between the given [PdfSource] and [Studyset]
/// by setting the [PdfSource]'s foreign key `studysetId` to refer to the [Studyset].
Future<void> studyset(
_is.DatabaseSession session,
PdfSource pdfSource,
_ie5k1jc2.Studyset studyset, {
_is.Transaction? transaction,
}) async {
if (pdfSource.id == null) {
throw ArgumentError.notNull('pdfSource.id');
}
if (studyset.id == null) {
throw ArgumentError.notNull('studyset.id');
}
var $pdfSource = pdfSource.copyWith(studysetId: studyset.id);
await session.db.updateRow<PdfSource>(
$pdfSource,
columns: [PdfSource.t.studysetId],
transaction: transaction,
);
}
}

View file

@ -38,6 +38,8 @@ import 'learning_path/learning_path_step.dart' as _irnpk9dc;
import 'learning_path/learning_path_step_revision.dart' as _i59qfu1l;
import 'note/note.dart' as _i9njjhyv;
import 'note/note_revision.dart' as _i6j5dng4;
import 'pdf_source/pdf_reading.dart' as _i4l20c7i;
import 'pdf_source/pdf_source.dart' as _iz838ro9;
import 'review/review_log.dart' as _irzjbdqz;
import 'settings/person_settings.dart' as _irnhx5e5;
import 'studyset/studyset.dart' as _ikbclmae;
@ -62,6 +64,8 @@ export 'learning_path/learning_path_step.dart';
export 'learning_path/learning_path_step_revision.dart';
export 'note/note.dart';
export 'note/note_revision.dart';
export 'pdf_source/pdf_reading.dart';
export 'pdf_source/pdf_source.dart';
export 'review/review_log.dart';
export 'settings/person_settings.dart';
export 'studyset/studyset.dart';
@ -1384,6 +1388,204 @@ class Protocol extends _is.DatabaseSerializationManager {
],
managed: true,
),
_isp.TableDefinition(
name: 'pdf_reading',
dartName: 'PdfReading',
schema: 'public',
module: 'halkyon_learn',
columns: [
_isp.ColumnDefinition(
name: 'id',
columnType: _isp.ColumnType.uuid,
isNullable: false,
dartType: 'UuidValue?',
columnDefault: 'random_v7',
),
_isp.ColumnDefinition(
name: 'spaceId',
columnType: _isp.ColumnType.bigint,
isNullable: true,
dartType: 'int?',
),
_isp.ColumnDefinition(
name: 'pdfSourceId',
columnType: _isp.ColumnType.uuid,
isNullable: false,
dartType: 'UuidValue',
),
_isp.ColumnDefinition(
name: 'readAt',
columnType: _isp.ColumnType.timestampWithoutTimeZone,
isNullable: false,
dartType: 'DateTime',
),
_isp.ColumnDefinition(
name: 'kind',
columnType: _isp.ColumnType.text,
isNullable: false,
dartType: 'String',
),
_isp.ColumnDefinition(
name: 'page',
columnType: _isp.ColumnType.bigint,
isNullable: false,
dartType: 'int',
),
],
foreignKeys: [
_isp.ForeignKeyDefinition(
constraintName: 'pdf_reading_fk_0',
columns: ['spaceId'],
referenceTable: 'offline_sync_spaces',
referenceTableSchema: 'public',
referenceColumns: ['id'],
onUpdate: _isp.ForeignKeyAction.noAction,
onDelete: _isp.ForeignKeyAction.cascade,
matchType: null,
),
_isp.ForeignKeyDefinition(
constraintName: 'pdf_reading_fk_1',
columns: ['pdfSourceId'],
referenceTable: 'pdf_source',
referenceTableSchema: 'public',
referenceColumns: ['id'],
onUpdate: _isp.ForeignKeyAction.noAction,
onDelete: _isp.ForeignKeyAction.cascade,
matchType: null,
deferrable: _isp.DeferrableConstraint.initiallyDeferred,
),
],
indexes: [
_isp.IndexDefinition(
indexName: 'pdf_reading_space_idx',
tableSpace: null,
elements: [
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'spaceId',
),
],
type: 'btree',
isUnique: false,
isPrimary: false,
),
_isp.IndexDefinition(
indexName: 'pdf_reading_source_idx',
tableSpace: null,
elements: [
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'pdfSourceId',
),
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'readAt',
),
],
type: 'btree',
isUnique: false,
isPrimary: false,
),
],
managed: true,
),
_isp.TableDefinition(
name: 'pdf_source',
dartName: 'PdfSource',
schema: 'public',
module: 'halkyon_learn',
columns: [
_isp.ColumnDefinition(
name: 'id',
columnType: _isp.ColumnType.uuid,
isNullable: false,
dartType: 'UuidValue?',
columnDefault: 'random_v7',
),
_isp.ColumnDefinition(
name: 'spaceId',
columnType: _isp.ColumnType.bigint,
isNullable: true,
dartType: 'int?',
),
_isp.ColumnDefinition(
name: 'studysetId',
columnType: _isp.ColumnType.uuid,
isNullable: false,
dartType: 'UuidValue',
),
_isp.ColumnDefinition(
name: 'keyId',
columnType: _isp.ColumnType.text,
isNullable: true,
dartType: 'String?',
),
_isp.ColumnDefinition(
name: 'payload',
columnType: _isp.ColumnType.bytea,
isNullable: false,
dartType: 'dart:typed_data:ByteData',
),
_isp.ColumnDefinition(
name: 'nonce',
columnType: _isp.ColumnType.bytea,
isNullable: true,
dartType: 'dart:typed_data:ByteData?',
),
],
foreignKeys: [
_isp.ForeignKeyDefinition(
constraintName: 'pdf_source_fk_0',
columns: ['spaceId'],
referenceTable: 'offline_sync_spaces',
referenceTableSchema: 'public',
referenceColumns: ['id'],
onUpdate: _isp.ForeignKeyAction.noAction,
onDelete: _isp.ForeignKeyAction.cascade,
matchType: null,
),
_isp.ForeignKeyDefinition(
constraintName: 'pdf_source_fk_1',
columns: ['studysetId'],
referenceTable: 'studyset',
referenceTableSchema: 'public',
referenceColumns: ['id'],
onUpdate: _isp.ForeignKeyAction.noAction,
onDelete: _isp.ForeignKeyAction.cascade,
matchType: null,
deferrable: _isp.DeferrableConstraint.initiallyDeferred,
),
],
indexes: [
_isp.IndexDefinition(
indexName: 'pdf_source_space_idx',
tableSpace: null,
elements: [
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'spaceId',
),
],
type: 'btree',
isUnique: false,
isPrimary: false,
),
_isp.IndexDefinition(
indexName: 'pdf_source_studyset_idx',
tableSpace: null,
elements: [
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'studysetId',
),
],
type: 'btree',
isUnique: false,
isPrimary: false,
),
],
managed: true,
),
_isp.TableDefinition(
name: 'person_settings',
dartName: 'PersonSettings',
@ -1836,6 +2038,12 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _i6j5dng4.NoteRevision) {
return _i6j5dng4.NoteRevision.fromJson(data) as T;
}
if (t == _i4l20c7i.PdfReading) {
return _i4l20c7i.PdfReading.fromJson(data) as T;
}
if (t == _iz838ro9.PdfSource) {
return _iz838ro9.PdfSource.fromJson(data) as T;
}
if (t == _irzjbdqz.ReviewLog) {
return _irzjbdqz.ReviewLog.fromJson(data) as T;
}
@ -1924,6 +2132,12 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _is.getType<_i6j5dng4.NoteRevision?>()) {
return (data != null ? _i6j5dng4.NoteRevision.fromJson(data) : null) as T;
}
if (t == _is.getType<_i4l20c7i.PdfReading?>()) {
return (data != null ? _i4l20c7i.PdfReading.fromJson(data) : null) as T;
}
if (t == _is.getType<_iz838ro9.PdfSource?>()) {
return (data != null ? _iz838ro9.PdfSource.fromJson(data) : null) as T;
}
if (t == _is.getType<_irzjbdqz.ReviewLog?>()) {
return (data != null ? _irzjbdqz.ReviewLog.fromJson(data) : null) as T;
}
@ -1991,6 +2205,8 @@ class Protocol extends _is.DatabaseSerializationManager {
_i59qfu1l.LearningPathStepRevision => 'LearningPathStepRevision',
_i9njjhyv.Note => 'Note',
_i6j5dng4.NoteRevision => 'NoteRevision',
_i4l20c7i.PdfReading => 'PdfReading',
_iz838ro9.PdfSource => 'PdfSource',
_irzjbdqz.ReviewLog => 'ReviewLog',
_irnhx5e5.PersonSettings => 'PersonSettings',
_ikbclmae.Studyset => 'Studyset',
@ -2049,6 +2265,10 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'Note';
case _i6j5dng4.NoteRevision():
return 'NoteRevision';
case _i4l20c7i.PdfReading():
return 'PdfReading';
case _iz838ro9.PdfSource():
return 'PdfSource';
case _irzjbdqz.ReviewLog():
return 'ReviewLog';
case _irnhx5e5.PersonSettings():
@ -2146,6 +2366,12 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName == 'NoteRevision') {
return deserialize<_i6j5dng4.NoteRevision>(data['data']);
}
if (dataClassName == 'PdfReading') {
return deserialize<_i4l20c7i.PdfReading>(data['data']);
}
if (dataClassName == 'PdfSource') {
return deserialize<_iz838ro9.PdfSource>(data['data']);
}
if (dataClassName == 'ReviewLog') {
return deserialize<_irzjbdqz.ReviewLog>(data['data']);
}
@ -2239,6 +2465,10 @@ class Protocol extends _is.DatabaseSerializationManager {
return _i9njjhyv.Note.t;
case _i6j5dng4.NoteRevision:
return _i6j5dng4.NoteRevision.t;
case _i4l20c7i.PdfReading:
return _i4l20c7i.PdfReading.t;
case _iz838ro9.PdfSource:
return _iz838ro9.PdfSource.t;
case _irzjbdqz.ReviewLog:
return _irzjbdqz.ReviewLog.t;
case _irnhx5e5.PersonSettings:

View file

@ -20,6 +20,8 @@ import 'learning_path/learning_path_step.dart' as _irnpk9dc;
import 'learning_path/learning_path_step_revision.dart' as _i59qfu1l;
import 'note/note.dart' as _i9njjhyv;
import 'note/note_revision.dart' as _i6j5dng4;
import 'pdf_source/pdf_reading.dart' as _i4l20c7i;
import 'pdf_source/pdf_source.dart' as _iz838ro9;
import 'review/review_log.dart' as _irzjbdqz;
import 'settings/person_settings.dart' as _irnhx5e5;
import 'studyset/studyset.dart' as _ikbclmae;
@ -44,6 +46,8 @@ final List<_is.Table> syncTables = [
_i9njjhyv.Note.t,
_i6j5dng4.NoteRevision.t,
_itkfn57x.NoteTag.t,
_i4l20c7i.PdfReading.t,
_iz838ro9.PdfSource.t,
_irnhx5e5.PersonSettings.t,
_irzjbdqz.ReviewLog.t,
_ikbclmae.Studyset.t,

View file

@ -0,0 +1,26 @@
### How far a PDF has been read, as an entry that is never changed once written:
### the furthest page isn't stored but derived from these, as a card's schedule
### is from its reviews, so reading on two devices never conflicts. An entry is
### written only when the furthest page grows, so a PDF has at most an entry per
### page per fresh start. Synced; the entry goes with its PDF.
class: PdfReading
table: pdf_reading
database: sync
fields:
### The PDF read.
pdfSource: PdfSource?, relation(onDelete=Cascade, deferred)
### When, in UTC.
readAt: DateTime
### 'reached' for a page reached (marking the PDF finished reaches its last
### page); 'reset' for a fresh start: the furthest page is the furthest
### reached since the last one.
kind: String
### The page reached, from 1; 0 for 'reset'.
page: int
indexes:
# Deleting a space finds its rows through this index (see studyset.spy.yaml).
pdf_reading_space_idx:
fields: spaceId
# A PDF's entries, in time order.
pdf_reading_source_idx:
fields: pdfSourceId, readAt

View file

@ -0,0 +1,28 @@
### A PDF in a studyset, to read and learn from: its cards and notes can be tied
### to its pages. Synced; its space owns it.
###
### Its content (title, the name the file came with, the file, its page count
### once known, a reading deadline) lives in the payload, last writer wins, as a
### learning path's: rarely edited on two devices at once. The file itself is
### an asset (ADR 0004), named in the payload by the asset row's id.
class: PdfSource
table: pdf_source
database: sync
fields:
### The studyset the PDF belongs to; the PDF goes with it.
studyset: Studyset?, relation(onDelete=Cascade, deferred)
### The encryption key's id; always null (ADR 0002, rule 6).
keyId: String?
### The content as UTF-8 JSON: {"schemaVersion", "title", "fileName", "asset",
### "pageCount", "deadline"}; the app's pdf_source_payload.dart describes each
### schema.
payload: ByteData
### The encryption nonce; always null, like keyId.
nonce: ByteData?
indexes:
# Deleting a space finds its rows through this index (see studyset.spy.yaml).
pdf_source_space_idx:
fields: spaceId
# A studyset's PDFs.
pdf_source_studyset_idx:
fields: studysetId

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,260 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "pdf_reading",
"dartName": "PdfReading",
"module": "halkyon_learn",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "spaceId",
"fieldName": "spaceId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "pdfSourceId",
"fieldName": "pdfSourceId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "readAt",
"fieldName": "readAt",
"columnType": 4,
"isNullable": false,
"dartType": "DateTime"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "kind",
"fieldName": "kind",
"columnType": 0,
"isNullable": false,
"dartType": "String"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "page",
"fieldName": "page",
"columnType": 6,
"isNullable": false,
"dartType": "int"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "pdf_reading_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
},
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "pdf_reading_fk_1",
"columns": [
"pdfSourceId"
],
"referenceTable": "pdf_source",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4,
"deferrable": "initiallyDeferred"
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "pdf_reading_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
},
{
"__className__": "serverpod.IndexDefinition",
"indexName": "pdf_reading_source_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "pdfSourceId"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "readAt"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
}
},
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "pdf_source",
"dartName": "PdfSource",
"module": "halkyon_learn",
"schema": "public",
"columns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "id",
"fieldName": "id",
"columnType": 7,
"isNullable": false,
"columnDefault": "random_v7",
"dartType": "UuidValue?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "spaceId",
"fieldName": "spaceId",
"columnType": 6,
"isNullable": true,
"dartType": "int?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "studysetId",
"fieldName": "studysetId",
"columnType": 7,
"isNullable": false,
"dartType": "UuidValue"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "keyId",
"fieldName": "keyId",
"columnType": 0,
"isNullable": true,
"dartType": "String?"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "payload",
"fieldName": "payload",
"columnType": 5,
"isNullable": false,
"dartType": "dart:typed_data:ByteData"
},
{
"__className__": "serverpod.ColumnDefinition",
"name": "nonce",
"fieldName": "nonce",
"columnType": 5,
"isNullable": true,
"dartType": "dart:typed_data:ByteData?"
}
],
"foreignKeys": [
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "pdf_source_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
},
{
"__className__": "serverpod.ForeignKeyDefinition",
"constraintName": "pdf_source_fk_1",
"columns": [
"studysetId"
],
"referenceTable": "studyset",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4,
"deferrable": "initiallyDeferred"
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "pdf_source_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
},
{
"__className__": "serverpod.IndexDefinition",
"indexName": "pdf_source_studyset_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "studysetId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,140 @@
BEGIN;
--
-- Function: gen_random_uuid_v7()
-- Source: https://gist.github.com/kjmph/5bd772b2c2df145aa645b837da7eca74
-- License: MIT (copyright notice included on the generator source code).
--
create or replace function gen_random_uuid_v7()
returns uuid
as $$
begin
-- use random v4 uuid as starting point (which has the same variant we need)
-- then overlay timestamp
-- then set version 7 by flipping the 2 and 1 bit in the version 4 string
return encode(
set_bit(
set_bit(
overlay(uuid_send(gen_random_uuid())
placing substring(int8send(floor(extract(epoch from clock_timestamp()) * 1000)::bigint) from 3)
from 1 for 6
),
52, 1
),
53, 1
),
'hex')::uuid;
end
$$
language plpgsql
volatile;
--
-- ACTION CREATE TABLE
--
CREATE TABLE "pdf_reading" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid_v7(),
"spaceId" bigint,
"pdfSourceId" uuid NOT NULL,
"readAt" timestamp without time zone NOT NULL,
"kind" text NOT NULL,
"page" bigint NOT NULL
);
-- Indexes
CREATE INDEX "pdf_reading_space_idx" ON "pdf_reading" USING btree ("spaceId");
CREATE INDEX "pdf_reading_source_idx" ON "pdf_reading" USING btree ("pdfSourceId", "readAt");
--
-- ACTION CREATE TABLE
--
CREATE TABLE "pdf_source" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid_v7(),
"spaceId" bigint,
"studysetId" uuid NOT NULL,
"keyId" text,
"payload" bytea NOT NULL,
"nonce" bytea
);
-- Indexes
CREATE INDEX "pdf_source_space_idx" ON "pdf_source" USING btree ("spaceId");
CREATE INDEX "pdf_source_studyset_idx" ON "pdf_source" USING btree ("studysetId");
--
-- ACTION CREATE FOREIGN KEY
--
ALTER TABLE ONLY "pdf_reading"
ADD CONSTRAINT "pdf_reading_fk_0"
FOREIGN KEY("spaceId")
REFERENCES "offline_sync_spaces"("id")
ON DELETE CASCADE
ON UPDATE NO ACTION;
ALTER TABLE ONLY "pdf_reading"
ADD CONSTRAINT "pdf_reading_fk_1"
FOREIGN KEY("pdfSourceId")
REFERENCES "pdf_source"("id")
ON DELETE CASCADE
ON UPDATE NO ACTION
DEFERRABLE INITIALLY DEFERRED;
--
-- ACTION CREATE FOREIGN KEY
--
ALTER TABLE ONLY "pdf_source"
ADD CONSTRAINT "pdf_source_fk_0"
FOREIGN KEY("spaceId")
REFERENCES "offline_sync_spaces"("id")
ON DELETE CASCADE
ON UPDATE NO ACTION;
ALTER TABLE ONLY "pdf_source"
ADD CONSTRAINT "pdf_source_fk_1"
FOREIGN KEY("studysetId")
REFERENCES "studyset"("id")
ON DELETE CASCADE
ON UPDATE NO ACTION
DEFERRABLE INITIALLY DEFERRED;
--
-- MIGRATION VERSION FOR halkyon_learn
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_learn', '20261003110855698-pdf-sources', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261003110855698-pdf-sources', "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;

View file

@ -12,3 +12,4 @@
20261002035419149-file-allowance
20261002191122122-notes
20261002211346701-learning-paths
20261003110855698-pdf-sources

View file

@ -156,6 +156,33 @@ void main() {
);
});
test('keeps a file over 20 MB only when it is a PDF, which may take '
'up to 50 MB', () async {
const size = AssetEndpoint.maxOtherBytes + 1;
final pdf = Uint8List(size)
..setAll(0, utf8.encode('%PDF-1.7'))
..fillRange(8, size, 32);
final other = Uint8List(size)..fillRange(0, size, 32);
final otherHash = sha256.convert(other).toString();
expect(await add(alice, pdf), isTrue);
expect(await add(alice, other), isFalse);
// What isn't a PDF is gone: the server asks for it anew.
expect(
await endpoints.asset.startUpload(signedIn(alice), otherHash, size),
isNotNull,
);
await expectLater(
endpoints.asset.startUpload(
signedIn(alice),
otherHash,
AssetEndpoint.maxBytes + 1,
),
throwsArgumentError,
);
});
test('finishes no upload that never came', () async {
expect(
await endpoints.asset.finishUpload(signedIn(alice), imageHash),