feat(train): strength plans and sessions (ADR 0035)

Strength plans of exercises from a catalogue or the person's own, in
blocks of one exercise or several done in turn (supersets, circuits),
each with its sets, reps or seconds, weights and rests; each a sealed
`strength_plan` row. Plans are marked with what the place chosen lacks,
and a session offers swaps moving the same way that the place allows.

A session runs on every device, set by set with the rest counted down,
each weight made up from the place's plates, bars and fixed weights with
the plate changes between sets, kept in strength_session.json after each
change so one cut off is carried on, and saved as a workout of the new
activity Strength with its sets, listed on its page; strength training
in Health Connect and FIT, no GPX.

Migration 20261102113104496-strength-plans is purely additive: it
creates the table strength_plan (id uuid v7, spaceId, keyId, the sealed
payload and its nonce), its index on spaceId, and a foreign key to
offline_sync_spaces with ON DELETE CASCADE, so deleting a person's sync
spaces deletes their plans; then the modules' version rows. The
gen_random_uuid_v7 function is created or replaced as in every
migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-02 13:18:12 +01:00
commit 508994be3a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
11 changed files with 27612 additions and 25 deletions

View file

@ -44,6 +44,7 @@ import 'routing/route_refused.dart' as _ix4aepwk;
import 'routing/route_stretch.dart' as _ic7f85bc;
import 'search/place.dart' as _iadh29yw;
import 'search/search_refused.dart' as _ihbxkwke;
import 'strength/strength_plan.dart' as _imwvu2mn;
import 'workout/workout.dart' as _i5b9b17z;
import 'workout/workout_series.dart' as _i92mefto;
export 'day/day.dart';
@ -65,6 +66,7 @@ export 'routing/route_refused.dart';
export 'routing/route_stretch.dart';
export 'search/place.dart';
export 'search/search_refused.dart';
export 'strength/strength_plan.dart';
export 'workout/workout.dart';
export 'workout/workout_series.dart';
export 'sync_tables.dart';
@ -570,6 +572,73 @@ class Protocol extends _is.DatabaseSerializationManager {
],
managed: true,
),
_isp.TableDefinition(
name: 'strength_plan',
dartName: 'StrengthPlan',
schema: 'public',
module: 'halkyon_train',
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: '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: 'strength_plan_fk_0',
columns: ['spaceId'],
referenceTable: 'offline_sync_spaces',
referenceTableSchema: 'public',
referenceColumns: ['id'],
onUpdate: _isp.ForeignKeyAction.noAction,
onDelete: _isp.ForeignKeyAction.cascade,
matchType: null,
),
],
indexes: [
_isp.IndexDefinition(
indexName: 'strength_plan_space_idx',
tableSpace: null,
elements: [
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'spaceId',
),
],
type: 'btree',
isUnique: false,
isPrimary: false,
),
],
managed: true,
),
_isp.TableDefinition(
name: 'training_place',
dartName: 'TrainingPlace',
@ -893,6 +962,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _ihbxkwke.SearchRefused) {
return _ihbxkwke.SearchRefused.fromJson(data) as T;
}
if (t == _imwvu2mn.StrengthPlan) {
return _imwvu2mn.StrengthPlan.fromJson(data) as T;
}
if (t == _i5b9b17z.Workout) {
return _i5b9b17z.Workout.fromJson(data) as T;
}
@ -961,6 +1033,9 @@ class Protocol extends _is.DatabaseSerializationManager {
return (data != null ? _ihbxkwke.SearchRefused.fromJson(data) : null)
as T;
}
if (t == _is.getType<_imwvu2mn.StrengthPlan?>()) {
return (data != null ? _imwvu2mn.StrengthPlan.fromJson(data) : null) as T;
}
if (t == _is.getType<_i5b9b17z.Workout?>()) {
return (data != null ? _i5b9b17z.Workout.fromJson(data) : null) as T;
}
@ -1041,6 +1116,7 @@ class Protocol extends _is.DatabaseSerializationManager {
_ic7f85bc.RouteStretch => 'RouteStretch',
_iadh29yw.Place => 'Place',
_ihbxkwke.SearchRefused => 'SearchRefused',
_imwvu2mn.StrengthPlan => 'StrengthPlan',
_i5b9b17z.Workout => 'Workout',
_i92mefto.WorkoutSeries => 'WorkoutSeries',
_ => null,
@ -1098,6 +1174,8 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'Place';
case _ihbxkwke.SearchRefused():
return 'SearchRefused';
case _imwvu2mn.StrengthPlan():
return 'StrengthPlan';
case _i5b9b17z.Workout():
return 'Workout';
case _i92mefto.WorkoutSeries():
@ -1199,6 +1277,9 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName == 'SearchRefused') {
return deserialize<_ihbxkwke.SearchRefused>(data['data']);
}
if (dataClassName == 'StrengthPlan') {
return deserialize<_imwvu2mn.StrengthPlan>(data['data']);
}
if (dataClassName == 'Workout') {
return deserialize<_i5b9b17z.Workout>(data['data']);
}
@ -1295,6 +1376,8 @@ class Protocol extends _is.DatabaseSerializationManager {
return _ia5j8fch.Profile.t;
case _i0mgeh8t.SavedRoute:
return _i0mgeh8t.SavedRoute.t;
case _imwvu2mn.StrengthPlan:
return _imwvu2mn.StrengthPlan.t;
case _i5b9b17z.Workout:
return _i5b9b17z.Workout.t;
case _i92mefto.WorkoutSeries:

View file

@ -0,0 +1,667 @@
/* 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 'dart:typed_data' as _idt;
import 'package:serverpod/serverpod.dart' as _is;
/// A strength plan (ADR 0035): its name and blocks of exercises with their
/// sets, repetitions, weights and rests, in its sealed payload (ADR 0030,
/// D7); the instance sees that it exists, its size and when it changed.
/// Changed when edited, the last writer winning. Synced.
abstract class StrengthPlan
implements _is.TableRow<_is.UuidValue?>, _is.ProtocolSerialization {
StrengthPlan._({
this.id,
this.spaceId,
this.keyId,
required this.payload,
this.nonce,
});
factory StrengthPlan({
_is.UuidValue? id,
int? spaceId,
String? keyId,
required _idt.ByteData payload,
_idt.ByteData? nonce,
}) = _StrengthPlanImpl;
factory StrengthPlan.fromJson(Map<String, dynamic> jsonSerialization) {
return StrengthPlan(
id: jsonSerialization['id'] == null
? null
: _is.UuidValueJsonExtension.fromJson(jsonSerialization['id']),
spaceId: jsonSerialization['spaceId'] as int?,
keyId: jsonSerialization['keyId'] as String?,
payload: _is.ByteDataJsonExtension.fromJson(jsonSerialization['payload']),
nonce: jsonSerialization['nonce'] == null
? null
: _is.ByteDataJsonExtension.fromJson(jsonSerialization['nonce']),
);
}
static final t = StrengthPlanTable();
static const db = StrengthPlanRepository._();
@override
_is.UuidValue? id;
/// The space owning this row. Maintained by the sync engine.
int? spaceId;
/// The key it is sealed with (ADR 0002's envelope); never null in Train.
String? keyId;
/// The content, sealed: UTF-8 JSON with a `schemaVersion` once opened.
_idt.ByteData payload;
/// The sealing's nonce (ADR 0002's envelope).
_idt.ByteData? nonce;
@override
_is.Table<_is.UuidValue?> get table => t;
/// Returns a shallow copy of this [StrengthPlan]
/// with some or all fields replaced by the given arguments.
@_is.useResult
StrengthPlan copyWith({
_is.UuidValue? id,
int? spaceId,
String? keyId,
_idt.ByteData? payload,
_idt.ByteData? nonce,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'StrengthPlan',
if (id != null) 'id': id?.toJson(),
if (spaceId != null) 'spaceId': spaceId,
if (keyId != null) 'keyId': keyId,
'payload': payload.toJson(),
if (nonce != null) 'nonce': nonce?.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'StrengthPlan',
if (id != null) 'id': id?.toJson(),
if (spaceId != null) 'spaceId': spaceId,
if (keyId != null) 'keyId': keyId,
'payload': payload.toJson(),
if (nonce != null) 'nonce': nonce?.toJson(),
};
}
static StrengthPlanInclude include() {
return StrengthPlanInclude._();
}
static StrengthPlanIncludeList includeList({
_is.WhereExpressionBuilder<StrengthPlanTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<StrengthPlanTable>? orderBy,
_is.OrderByListBuilder<StrengthPlanTable>? orderByList,
StrengthPlanInclude? include,
}) {
return StrengthPlanIncludeList._(
where: where,
limit: limit,
offset: offset,
orderBy: orderBy?.call(StrengthPlan.t),
orderByList: orderByList?.call(StrengthPlan.t),
include: include,
);
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _Undefined {}
class _StrengthPlanImpl extends StrengthPlan {
_StrengthPlanImpl({
_is.UuidValue? id,
int? spaceId,
String? keyId,
required _idt.ByteData payload,
_idt.ByteData? nonce,
}) : super._(
id: id,
spaceId: spaceId,
keyId: keyId,
payload: payload,
nonce: nonce,
);
/// Returns a shallow copy of this [StrengthPlan]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
StrengthPlan copyWith({
Object? id = _Undefined,
Object? spaceId = _Undefined,
Object? keyId = _Undefined,
_idt.ByteData? payload,
Object? nonce = _Undefined,
}) {
return StrengthPlan(
id: id is _is.UuidValue? ? id : this.id,
spaceId: spaceId is int? ? spaceId : this.spaceId,
keyId: keyId is String? ? keyId : this.keyId,
payload: payload ?? this.payload.clone(),
nonce: nonce is _idt.ByteData? ? nonce : this.nonce?.clone(),
);
}
}
class StrengthPlanUpdateTable extends _is.UpdateTable<StrengthPlanTable> {
StrengthPlanUpdateTable(super.table);
_is.ColumnValue<int, int> spaceId(int? value) => _is.ColumnValue(
table.spaceId,
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 StrengthPlanTable extends _is.Table<_is.UuidValue?> {
StrengthPlanTable({super.tableRelation}) : super(tableName: 'strength_plan') {
updateTable = StrengthPlanUpdateTable(this);
spaceId = _is.ColumnInt(
'spaceId',
this,
);
keyId = _is.ColumnString(
'keyId',
this,
);
payload = _is.ColumnByteData(
'payload',
this,
);
nonce = _is.ColumnByteData(
'nonce',
this,
);
}
late final StrengthPlanUpdateTable updateTable;
/// The space owning this row. Maintained by the sync engine.
late final _is.ColumnInt spaceId;
/// The key it is sealed with (ADR 0002's envelope); never null in Train.
late final _is.ColumnString keyId;
/// The content, sealed: UTF-8 JSON with a `schemaVersion` once opened.
late final _is.ColumnByteData payload;
/// The sealing's nonce (ADR 0002's envelope).
late final _is.ColumnByteData nonce;
@override
List<_is.Column> get columns => [
id,
spaceId,
keyId,
payload,
nonce,
];
}
class StrengthPlanInclude extends _is.IncludeObject {
StrengthPlanInclude._();
@override
Map<String, _is.Include?> get includes => {};
@override
_is.Table<_is.UuidValue?> get table => StrengthPlan.t;
}
class StrengthPlanIncludeList extends _is.IncludeList {
StrengthPlanIncludeList._({
_is.WhereExpressionBuilder<StrengthPlanTable>? where,
super.limit,
super.offset,
super.orderBy,
super.orderByList,
super.include,
}) {
super.where = where?.call(StrengthPlan.t);
}
@override
Map<String, _is.Include?> get includes => include?.includes ?? {};
@override
_is.Table<_is.UuidValue?> get table => StrengthPlan.t;
}
class StrengthPlanRepository {
const StrengthPlanRepository._();
/// Returns a list of [StrengthPlan]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<StrengthPlan>> find(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<StrengthPlanTable>? where,
int? limit,
int? offset,
_is.OrderByBuilder<StrengthPlanTable>? orderBy,
_is.OrderByListBuilder<StrengthPlanTable>? orderByList,
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.find<StrengthPlan>(
where: where?.call(StrengthPlan.t),
orderBy: orderBy?.call(StrengthPlan.t),
orderByList: orderByList?.call(StrengthPlan.t),
limit: limit,
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Returns the first matching [StrengthPlan] 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<StrengthPlan?> findFirstRow(
_is.DatabaseSession session, {
_is.WhereExpressionBuilder<StrengthPlanTable>? where,
int? offset,
_is.OrderByBuilder<StrengthPlanTable>? orderBy,
_is.OrderByListBuilder<StrengthPlanTable>? orderByList,
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findFirstRow<StrengthPlan>(
where: where?.call(StrengthPlan.t),
orderBy: orderBy?.call(StrengthPlan.t),
orderByList: orderByList?.call(StrengthPlan.t),
offset: offset,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Finds a single [StrengthPlan] by its [id] or null if no such row exists.
Future<StrengthPlan?> findById(
_is.DatabaseSession session,
_is.UuidValue id, {
_is.Transaction? transaction,
_is.LockMode? lockMode,
_is.LockBehavior? lockBehavior,
}) async {
return session.db.findById<StrengthPlan>(
id,
transaction: transaction,
lockMode: lockMode,
lockBehavior: lockBehavior,
);
}
/// Inserts all [StrengthPlan]s in the list and returns the inserted rows.
///
/// The returned [StrengthPlan]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<StrengthPlan>> insert(
_is.DatabaseSession session,
List<StrengthPlan> rows, {
_is.Transaction? transaction,
bool ignoreConflicts = false,
bool noReturn = false,
}) async {
return session.db.insert<StrengthPlan>(
rows,
transaction: transaction,
ignoreConflicts: ignoreConflicts,
noReturn: noReturn,
);
}
/// Inserts a single [StrengthPlan] and returns the inserted row.
///
/// The returned [StrengthPlan] will have its `id` field set.
Future<StrengthPlan> insertRow(
_is.DatabaseSession session,
StrengthPlan row, {
_is.Transaction? transaction,
}) async {
return session.db.insertRow<StrengthPlan>(
row,
transaction: transaction,
);
}
/// Upserts all [StrengthPlan]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 [StrengthPlan]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<StrengthPlan>> upsert(
_is.DatabaseSession session,
List<StrengthPlan> rows, {
required _is.ColumnSelections<StrengthPlanTable> conflictColumns,
_is.ColumnSelections<StrengthPlanTable>? updateColumns,
_is.WhereExpressionBuilder<StrengthPlanTable>? updateWhere,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.upsert<StrengthPlan>(
rows,
conflictColumns: conflictColumns(StrengthPlan.t),
updateColumns: updateColumns?.call(StrengthPlan.t),
updateWhere: updateWhere?.call(StrengthPlan.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Upserts a single [StrengthPlan] 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 [StrengthPlan] will have its `id` field set.
Future<StrengthPlan?> upsertRow(
_is.DatabaseSession session,
StrengthPlan row, {
required _is.ColumnSelections<StrengthPlanTable> conflictColumns,
_is.ColumnSelections<StrengthPlanTable>? updateColumns,
_is.WhereExpressionBuilder<StrengthPlanTable>? updateWhere,
_is.Transaction? transaction,
}) async {
return session.db.upsertRow<StrengthPlan>(
row,
conflictColumns: conflictColumns(StrengthPlan.t),
updateColumns: updateColumns?.call(StrengthPlan.t),
updateWhere: updateWhere?.call(StrengthPlan.t),
transaction: transaction,
);
}
/// Updates all [StrengthPlan]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<StrengthPlan>> update(
_is.DatabaseSession session,
List<StrengthPlan> rows, {
_is.ColumnSelections<StrengthPlanTable>? columns,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.update<StrengthPlan>(
rows,
columns: columns?.call(StrengthPlan.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Updates a single [StrengthPlan]. 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<StrengthPlan> updateRow(
_is.DatabaseSession session,
StrengthPlan row, {
_is.ColumnSelections<StrengthPlanTable>? columns,
_is.Transaction? transaction,
}) async {
return session.db.updateRow<StrengthPlan>(
row,
columns: columns?.call(StrengthPlan.t),
transaction: transaction,
);
}
/// Updates a single [StrengthPlan] by its [id] with the specified [columnValues].
/// Returns the updated row or null if no row with the given id exists.
Future<StrengthPlan?> updateById(
_is.DatabaseSession session,
_is.UuidValue id, {
required _is.ColumnValueListBuilder<StrengthPlanUpdateTable> columnValues,
_is.Transaction? transaction,
}) async {
return session.db.updateById<StrengthPlan>(
id,
columnValues: columnValues(StrengthPlan.t.updateTable),
transaction: transaction,
);
}
/// Updates all [StrengthPlan]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<StrengthPlan>> updateWhere(
_is.DatabaseSession session, {
required _is.ColumnValueListBuilder<StrengthPlanUpdateTable> columnValues,
required _is.WhereExpressionBuilder<StrengthPlanTable> where,
int? limit,
int? offset,
_is.OrderByBuilder<StrengthPlanTable>? orderBy,
_is.OrderByListBuilder<StrengthPlanTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.updateWhere<StrengthPlan>(
columnValues: columnValues(StrengthPlan.t.updateTable),
where: where(StrengthPlan.t),
limit: limit,
offset: offset,
orderBy: orderBy?.call(StrengthPlan.t),
orderByList: orderByList?.call(StrengthPlan.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes all [StrengthPlan]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<StrengthPlan>> delete(
_is.DatabaseSession session,
List<StrengthPlan> rows, {
_is.OrderByBuilder<StrengthPlanTable>? orderBy,
_is.OrderByListBuilder<StrengthPlanTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.delete<StrengthPlan>(
rows,
orderBy: orderBy?.call(StrengthPlan.t),
orderByList: orderByList?.call(StrengthPlan.t),
transaction: transaction,
noReturn: noReturn,
);
}
/// Deletes a single [StrengthPlan].
Future<StrengthPlan> deleteRow(
_is.DatabaseSession session,
StrengthPlan row, {
_is.Transaction? transaction,
}) async {
return session.db.deleteRow<StrengthPlan>(
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<StrengthPlan>> deleteWhere(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<StrengthPlanTable> where,
_is.OrderByBuilder<StrengthPlanTable>? orderBy,
_is.OrderByListBuilder<StrengthPlanTable>? orderByList,
_is.Transaction? transaction,
bool noReturn = false,
}) async {
return session.db.deleteWhere<StrengthPlan>(
where: where(StrengthPlan.t),
orderBy: orderBy?.call(StrengthPlan.t),
orderByList: orderByList?.call(StrengthPlan.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<StrengthPlanTable>? where,
int? limit,
_is.Transaction? transaction,
}) async {
return session.db.count<StrengthPlan>(
where: where?.call(StrengthPlan.t),
limit: limit,
transaction: transaction,
);
}
/// Acquires row-level locks on [StrengthPlan] rows matching the [where] expression.
Future<void> lockRows(
_is.DatabaseSession session, {
required _is.WhereExpressionBuilder<StrengthPlanTable> where,
required _is.LockMode lockMode,
required _is.Transaction transaction,
_is.LockBehavior lockBehavior = _is.LockBehavior.wait,
}) async {
return session.db.lockRows<StrengthPlan>(
where: where(StrengthPlan.t),
lockMode: lockMode,
lockBehavior: lockBehavior,
transaction: transaction,
);
}
}

View file

@ -19,6 +19,7 @@ import 'plan/plan.dart' as _i45prw73;
import 'privacy/content_key.dart' as _imed3x5d;
import 'profile/profile.dart' as _ia5j8fch;
import 'route/route.dart' as _i0mgeh8t;
import 'strength/strength_plan.dart' as _imwvu2mn;
import 'workout/workout.dart' as _i5b9b17z;
import 'workout/workout_series.dart' as _i92mefto;
@ -33,6 +34,7 @@ final List<_is.Table> syncTables = [
_i45prw73.Plan.t,
_ia5j8fch.Profile.t,
_i0mgeh8t.SavedRoute.t,
_imwvu2mn.StrengthPlan.t,
_icbb9j80.TrainingPlace.t,
_i5b9b17z.Workout.t,
_i92mefto.WorkoutSeries.t,

View file

@ -0,0 +1,18 @@
### A strength plan (ADR 0035): its name and blocks of exercises with their
### sets, repetitions, weights and rests, in its sealed payload (ADR 0030,
### D7); the instance sees that it exists, its size and when it changed.
### Changed when edited, the last writer winning. Synced.
class: StrengthPlan
table: strength_plan
database: sync
fields:
### The key it is sealed with (ADR 0002's envelope); never null in Train.
keyId: String?
### The content, sealed: UTF-8 JSON with a `schemaVersion` once opened.
payload: ByteData
### The sealing's nonce (ADR 0002's envelope).
nonce: ByteData?
indexes:
# Deleting a space finds its rows through this index (see content_key).
strength_plan_space_idx:
fields: spaceId

View file

@ -0,0 +1,94 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "createTable",
"createTable": {
"__className__": "serverpod.TableDefinition",
"name": "strength_plan",
"dartName": "StrengthPlan",
"module": "halkyon_train",
"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": "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": "strength_plan_fk_0",
"columns": [
"spaceId"
],
"referenceTable": "offline_sync_spaces",
"referenceTableSchema": "public",
"referenceColumns": [
"id"
],
"onUpdate": 3,
"onDelete": 4
}
],
"indexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "strength_plan_space_idx",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
}
],
"type": "btree",
"isUnique": false,
"isPrimary": false
}
],
"managed": true
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,114 @@
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 "strength_plan" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid_v7(),
"spaceId" bigint,
"keyId" text,
"payload" bytea NOT NULL,
"nonce" bytea
);
-- Indexes
CREATE INDEX "strength_plan_space_idx" ON "strength_plan" USING btree ("spaceId");
--
-- ACTION CREATE FOREIGN KEY
--
ALTER TABLE ONLY "strength_plan"
ADD CONSTRAINT "strength_plan_fk_0"
FOREIGN KEY("spaceId")
REFERENCES "offline_sync_spaces"("id")
ON DELETE CASCADE
ON UPDATE NO ACTION;
--
-- MIGRATION VERSION FOR halkyon_train
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_train', '20261102113104496-strength-plans', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261102113104496-strength-plans', "timestamp" = now();
--
-- MIGRATION VERSION FOR halkyon_base
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_base', '20261026140329453-assistant-budgets', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261026140329453-assistant-budgets', "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;

View file

@ -11,3 +11,4 @@
20261028211508606-plans
20261028231042668-routes
20261102103211357-training-places
20261102113104496-strength-plans

View file

@ -70,6 +70,7 @@ void main() {
'plan',
'route',
'training_place',
'strength_plan',
]) {
expect(await rowsOf(alice, table), 1, reason: table);
}
@ -97,6 +98,7 @@ void main() {
'plan',
'route',
'training_place',
'strength_plan',
]) {
expect(await rowsOf(alice, table), 0, reason: table);
expect(await rowsOf(bob, table), 1, reason: table);
@ -189,4 +191,9 @@ Future<void> _workoutOf(Session session, UuidValue person) =>
TrainingPlace(keyId: 'p1.0.key', payload: sealed, nonce: sealed),
transaction: transaction,
);
await StrengthPlan.db.insertRow(
session,
StrengthPlan(keyId: 'p1.0.key', payload: sealed, nonce: sealed),
transaction: transaction,
);
});