fix: apply post-review fixes to Drift database layer

- Add @TableIndex on tasks.user_id and task_completions.user_id (FK scan perf)
- Wrap incrementStreak in transaction to prevent lost-update race
- Sentinel pattern in TaskEntity.copyWith and PetStateEntity.copyWith so
  nullable fields (deadline, snoozeUntil, photoPath, energyToday, etc.)
  can be explicitly cleared to null
- Remove unused TaskCompletionEntity import from task_repository.dart
- Harden completeTask: getSingleOrNull + ArgumentError instead of getSingle throw
- Add doc comment to getTodayTasks clarifying no date filter (pending = today)
- Regenerate app_database.g.dart (CREATE INDEX statements added)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
TrochtaOndrej
2026-03-18 00:12:40 +01:00
parent bb0dcdf83f
commit 8256e57118
6 changed files with 68 additions and 32 deletions
+13 -2
View File
@@ -1912,12 +1912,23 @@ abstract class _$AppDatabase extends GeneratedDatabase {
late final $PetStatesTable petStates = $PetStatesTable(this);
late final $TaskCompletionsTable taskCompletions =
$TaskCompletionsTable(this);
late final Index idxTasksUserId = Index(
'idx_tasks_user_id', 'CREATE INDEX idx_tasks_user_id ON tasks (user_id)');
late final Index idxTaskCompletionsUserId = Index(
'idx_task_completions_user_id',
'CREATE INDEX idx_task_completions_user_id ON task_completions (user_id)');
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities =>
[users, tasks, petStates, taskCompletions];
List<DatabaseSchemaEntity> get allSchemaEntities => [
users,
tasks,
petStates,
taskCompletions,
idxTasksUserId,
idxTaskCompletionsUserId
];
}
typedef $$UsersTableCreateCompanionBuilder = UsersCompanion Function({
+2
View File
@@ -17,6 +17,7 @@ class Users extends Table {
}
/// Tasks — the core task items with sync support.
@TableIndex(name: 'idx_tasks_user_id', columns: {#userId})
class Tasks extends Table {
TextColumn get id => text()();
TextColumn get userId => text().references(Users, #id)();
@@ -61,6 +62,7 @@ class PetStates extends Table {
}
/// Task completion log — records each completion for rewards and analytics.
@TableIndex(name: 'idx_task_completions_user_id', columns: {#userId})
class TaskCompletions extends Table {
TextColumn get id => text()();
TextColumn get userId => text().references(Users, #id)();
+11 -9
View File
@@ -49,16 +49,18 @@ class PetRepository {
}
Future<void> incrementStreak(String userId) async {
final row = await (_db.select(_db.petStates)
..where((t) => t.userId.equals(userId)))
.getSingle();
await _db.transaction(() async {
final row = await (_db.select(_db.petStates)
..where((t) => t.userId.equals(userId)))
.getSingle();
await (_db.update(_db.petStates)
..where((t) => t.userId.equals(userId)))
.write(PetStatesCompanion(
streakDays: Value(row.streakDays + 1),
updatedAt: Value(DateTime.now()),
));
await (_db.update(_db.petStates)
..where((t) => t.userId.equals(userId)))
.write(PetStatesCompanion(
streakDays: Value(row.streakDays + 1),
updatedAt: Value(DateTime.now()),
));
});
}
Future<void> insertPetState(PetStateEntity entity) async {
+11 -5
View File
@@ -1,7 +1,6 @@
import 'package:drift/drift.dart';
import 'package:uuid/uuid.dart';
import '../../domain/entities/task_completion_entity.dart';
import '../../domain/entities/task_entity.dart';
import '../../domain/entities/task_status.dart';
import '../db/app_database.dart';
@@ -25,6 +24,9 @@ class TaskRepository {
return rows.map(_toEntity).toList();
}
/// Returns all [TaskStatus.pending] tasks for [userId], ordered by sortOrder.
/// "Today's tasks" in this app means all pending tasks — there is no
/// date-based filter; energy level and pet mood drive prioritisation instead.
Future<List<TaskEntity>> getTodayTasks(String userId) async {
final rows = await (_db.select(_db.tasks)
..where((t) =>
@@ -67,23 +69,27 @@ class TaskRepository {
await (_db.delete(_db.tasks)..where((t) => t.id.equals(id))).go();
}
/// Marks a task as done and creates a [TaskCompletionEntity] log entry.
/// Marks a task as done and creates a completion log entry atomically.
///
/// Throws [ArgumentError] if [taskId] does not exist.
Future<void> completeTask(String taskId) async {
await _db.transaction(() async {
final task = await (_db.select(_db.tasks)
..where((t) => t.id.equals(taskId)))
.getSingle();
.getSingleOrNull();
if (task == null) {
throw ArgumentError.value(taskId, 'taskId', 'Task not found');
}
final now = DateTime.now();
// Update task status to done.
await (_db.update(_db.tasks)..where((t) => t.id.equals(taskId)))
.write(TasksCompanion(
status: Value(TaskStatus.done.name),
updatedAt: Value(now),
));
// Log the completion.
await _db.into(_db.taskCompletions).insert(TaskCompletionsCompanion(
id: Value(_uuid.v4()),
userId: Value(task.userId),
+11 -4
View File
@@ -1,3 +1,6 @@
// Sentinel distinguishes "not provided" from "explicitly null" in copyWith.
const _unset = Object();
/// Pure domain entity for pet state.
class PetStateEntity {
final String id;
@@ -23,8 +26,8 @@ class PetStateEntity {
PetStateEntity copyWith({
String? id,
String? userId,
int? energyToday,
DateTime? energyDate,
Object? energyToday = _unset,
Object? energyDate = _unset,
int? streakDays,
int? totalTasksDone,
String? unlockedItems,
@@ -33,8 +36,12 @@ class PetStateEntity {
return PetStateEntity(
id: id ?? this.id,
userId: userId ?? this.userId,
energyToday: energyToday ?? this.energyToday,
energyDate: energyDate ?? this.energyDate,
energyToday: identical(energyToday, _unset)
? this.energyToday
: energyToday as int?,
energyDate: identical(energyDate, _unset)
? this.energyDate
: energyDate as DateTime?,
streakDays: streakDays ?? this.streakDays,
totalTasksDone: totalTasksDone ?? this.totalTasksDone,
unlockedItems: unlockedItems ?? this.unlockedItems,
+20 -12
View File
@@ -1,5 +1,8 @@
import 'task_status.dart';
// Sentinel distinguishes "not provided" from "explicitly null" in copyWith.
const _unset = Object();
/// Pure domain entity for a task.
class TaskEntity {
final String id;
@@ -40,35 +43,40 @@ class TaskEntity {
String? id,
String? userId,
String? title,
String? description,
Object? description = _unset,
int? energyLevel,
TaskStatus? status,
bool? isSelfCare,
DateTime? deadline,
String? photoPath,
DateTime? snoozeUntil,
Object? deadline = _unset,
Object? photoPath = _unset,
Object? snoozeUntil = _unset,
int? sortOrder,
DateTime? createdAt,
DateTime? updatedAt,
DateTime? syncedAt,
String? localId,
Object? syncedAt = _unset,
Object? localId = _unset,
}) {
return TaskEntity(
id: id ?? this.id,
userId: userId ?? this.userId,
title: title ?? this.title,
description: description ?? this.description,
description:
identical(description, _unset) ? this.description : description as String?,
energyLevel: energyLevel ?? this.energyLevel,
status: status ?? this.status,
isSelfCare: isSelfCare ?? this.isSelfCare,
deadline: deadline ?? this.deadline,
photoPath: photoPath ?? this.photoPath,
snoozeUntil: snoozeUntil ?? this.snoozeUntil,
deadline: identical(deadline, _unset) ? this.deadline : deadline as DateTime?,
photoPath:
identical(photoPath, _unset) ? this.photoPath : photoPath as String?,
snoozeUntil: identical(snoozeUntil, _unset)
? this.snoozeUntil
: snoozeUntil as DateTime?,
sortOrder: sortOrder ?? this.sortOrder,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
syncedAt: syncedAt ?? this.syncedAt,
localId: localId ?? this.localId,
syncedAt:
identical(syncedAt, _unset) ? this.syncedAt : syncedAt as DateTime?,
localId: identical(localId, _unset) ? this.localId : localId as String?,
);
}
}