feat(app): add application unit of work foundation

This commit is contained in:
Ashley Venn 2026-06-25 10:17:14 -07:00
parent edfb0cbba5
commit 2463fd7817
7 changed files with 1169 additions and 6 deletions

View file

@ -43,8 +43,8 @@ passed. The current suite has 143 passing tests.
| Reminder profiles | Gentle/persistent/strict/silent enum on project, task override, effective-profile resolver, typed reminder directives, and Free Slot suppression policy | Platform delivery remains outside the pure core | 13 |
| Timeline state | UI-independent item mapper, locked overlay state, compact selection | Complete Today query/read model, stable per-occurrence IDs, status metadata, correct “next flexible” exclusion, rollover notices | 14 |
| Backlog | Filters, sorts, staleness markers, quick capture | Persist backlog-entered timestamp, settings-backed thresholds, application queries/commands, typed results | 11, 14, 15 |
| Application layer | Domain services can be called directly | Coherent use cases that load state, invoke rules, persist all changes atomically, and return UI-ready results | 14 |
| Repository contracts | Basic task/project/locked/snapshot interfaces and in-memory fakes | Date/project/parent queries, archive/delete behavior, revisions, activity/settings repositories, unit of work, conformance suite | 14, 15 |
| Application layer | Application operation context, typed result/failure contracts, centralized scheduler input loader, and in-memory atomic unit-of-work foundation | Concrete Today/read-model queries and V1 command use cases that invoke domain rules and return UI-ready results | 14 |
| Repository contracts | Basic task/project/locked/snapshot interfaces, activity/settings/project-statistics/operation repository contracts, and in-memory staged unit of work | Date/project/parent queries, archive/delete behavior, revisions, complete persistence codecs, and adapter conformance suite | 14, 15 |
| Document mapping | Task, task-statistics, and project-statistics map round trips; field-name constants for other entities | Complete codecs for every repository entity, explicit stable codes, schema version, civil-time encoding, migration fixtures | 15 |
| MongoDB runtime | Committed target documented; no driver/runtime yet | Trusted runtime decision, actual adapter, indexes, transactions/optimistic concurrency, integration tests, secret handling | 16 |
| Backend acceptance | Historical unit test audit and handoff | End-to-end application scenarios, adapter conformance, migration/resilience/performance checks, final backend gate | 17 |

View file

@ -1,6 +1,6 @@
# V1 Block 14 — Application Use Cases and UI-Ready Read Models
Status: Planned
Status: In progress
Purpose: Add the orchestration layer that a future Flutter UI can call without
assembling scheduler inputs, coordinating multiple repositories, or manually
@ -10,6 +10,8 @@ keeping tasks, activities, statistics, and notices consistent.
Recommended Codex level: high
Status: Complete
Tasks:
- Introduce an application facade/use-case layer above the pure domain services.
@ -45,6 +47,31 @@ Acceptance criteria:
- Result codes are stable and do not depend on English text.
- The application layer has no Flutter or MongoDB imports.
Completed implementation:
- Added `src/application_layer.dart` as a pure Dart application boundary above
the existing domain services and repository interfaces.
- Added operation context, owner time-zone context, typed result/failure
contracts, expected failure/persistence exception wrappers, owner settings,
operation records, and unit-of-work repository contracts.
- Added an in-memory staged unit-of-work implementation that commits task,
activity, project-statistics, settings, and operation-record changes
atomically, rejects duplicate operation IDs, and rolls back on typed,
validation, persistence, or unexpected failures.
- Added `ApplicationSchedulingLoader` so application commands can centralize
repository loading into `SchedulingInput` instead of letting UI callers
persist partial scheduler results.
- Added contract tests for successful commit, rollback on typed failure,
rollback on persistence failure, duplicate operation IDs, typed validation
mapping, and centralized scheduler input loading.
Verification:
- `dart format lib test`: passed, 0 files changed after final format
- `dart analyze`: passed, no issues found
- `dart test`: passed, 249 tests
- `git diff --check`: passed
BREAKPOINT: Stop here. Confirm `extra high` mode before building Today state and
command orchestration.

View file

@ -14,6 +14,7 @@ The public library entry point is `lib/scheduler_core.dart`. It exports the
following source files:
- `src/models.dart`
- `src/application_layer.dart`
- `src/backlog.dart`
- `src/child_tasks.dart`
- `src/document_mapping.dart`
@ -39,6 +40,7 @@ changes from accidental API drift.
| File | Enums |
|---|---|
| `src/application_layer.dart` | `ApplicationFailureCode` |
| `src/models.dart` | `DomainValidationCode`, `TaskType`, `TaskStatus`, `PriorityLevel`, `RewardLevel`, `DifficultyLevel`, `ReminderProfile`, `BacklogTag` |
| `src/backlog.dart` | `BacklogFilter`, `BacklogSortKey`, `BacklogStalenessMarker` |
| `src/free_slots.dart` | `RequiredCommitmentScheduleStatus` |
@ -57,6 +59,7 @@ changes from accidental API drift.
| File | Public API |
|---|---|
| `src/application_layer.dart` | `OwnerTimeZoneContext`, `ApplicationOperationContext`, `ApplicationFailure`, `ApplicationResult`, `ApplicationFailureException`, `ApplicationPersistenceException`, `ApplicationOperationRecord`, `OwnerSettings`, `TaskActivityRepository`, `ProjectStatisticsRepository`, `OwnerSettingsRepository`, `ApplicationOperationRepository`, `ApplicationUnitOfWorkRepositories`, `ApplicationUnitOfWork`, `ApplicationSchedulingLoader`, `InMemoryApplicationUnitOfWork` |
| `src/models.dart` | `DomainValidationException`, `Task`, `ProjectProfile`, `TimeInterval` |
| `src/backlog.dart` | `BacklogStalenessSettings`, `BacklogView` |
| `src/child_tasks.dart` | `ChildTaskEntry`, `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, `ChildTaskBreakUpService`, `ChildTaskView`, `ChildTaskCompletionResult`, `ChildTaskCompletionService`, `ChildTaskSummary` |
@ -256,6 +259,19 @@ Chunk 13.5 intentionally changed the public API:
- Added typed reminder directives for normal delivery, suppression, deferral,
and required-task acknowledgement, including protected Free Slot suppression.
Chunk 14.1 intentionally changed the public API:
- Added `src/application_layer.dart` and exported it from
`scheduler_core.dart`.
- Added application operation context, owner time-zone context, typed
result/failure contracts, and expected application/persistence exceptions.
- Added activity, project-statistics, owner-settings, operation-record, and
unit-of-work repository contracts for application orchestration.
- Added `OwnerSettings`, `ApplicationOperationRecord`,
`ApplicationSchedulingLoader`, and `InMemoryApplicationUnitOfWork`.
- Added in-memory staged transaction behavior with exactly-once operation
records and rollback on typed failures or persistence failures.
## Repository contracts
The current repository surface is pure Dart and in-memory only:
@ -266,6 +282,8 @@ The current repository surface is pure Dart and in-memory only:
`InMemoryLockedBlockRepository`.
- Snapshot repository behavior is represented by
`InMemorySchedulingSnapshotRepository`.
- Application transaction behavior is represented by
`InMemoryApplicationUnitOfWork`.
Future MongoDB adapter work must remain behind repository interfaces and must
not import MongoDB APIs into the scheduling core.

View file

@ -63,6 +63,13 @@ Latest Block 13.5 verification result on 2026-06-25:
- `dart test`: passed, 243 tests
- `git diff --check`: passed
Latest Block 14.1 verification result on 2026-06-25:
- `dart format lib test`: passed, 0 files changed after final format
- `dart analyze`: passed, no issues found
- `dart test`: passed, 249 tests
- `git diff --check`: passed
Status values:
- `complete`: Current backend APIs and tests cover the requirement.
@ -90,7 +97,7 @@ Status values:
| MVP-AC-11 | Break a large task into child tasks with row-level priority, reward, and duration. | `ChildTaskEntry`, `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, `ChildTaskBreakUpService`, `ChildTaskView` | `test/child_tasks_test.dart` | complete | Chunk 13.3 adds deterministic break-up mutation results; persistence transaction wiring remains in Block 14. |
| MVP-AC-12 | Auto-complete parent tasks when all children are done and allow force-completing all children from the parent or a child. | `ChildTaskCompletionService`, `ChildTaskCompletionResult`, parent-child helpers, `TaskActivity` | `test/child_tasks_test.dart` | complete | Chunk 13.3 routes parent/child completion through lifecycle activities and preserves already-terminal child facts. |
| MVP-AC-13 | Display backlog staleness icons without per-task stale prompts. | `BacklogStalenessMarker`, `BacklogStalenessSettings`, `BacklogView` | `test/scheduling_engine_test.dart`, `test/edge_case_regression_test.dart` | incomplete | Backend marker exists; settings persistence and UI display remain in Blocks 14, 15, and 18. |
| MVP-AC-14 | Track baseline internal statistics needed for later reports and filtering. | `TaskStatistics`, `ProjectStatistics`, `TaskActivity`, `TaskActivityApplicationResult`, `TaskActivityAccountingService`, `ProjectStatisticsAggregationService`, selected action services | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/document_mapping_test.dart`, `test/task_lifecycle_test.dart`, `test/child_tasks_test.dart`, `test/project_statistics_test.dart` | incomplete | Chunks 13.1-13.4 add canonical activities, exactly-once task/project statistic accounting, parent/child completion metadata, and learned suggestion aggregates; atomic persistence remains in Block 14. |
| MVP-AC-14 | Track baseline internal statistics needed for later reports and filtering. | `TaskStatistics`, `ProjectStatistics`, `TaskActivity`, `TaskActivityApplicationResult`, `TaskActivityAccountingService`, `ProjectStatisticsAggregationService`, `ApplicationUnitOfWork`, selected action services | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/document_mapping_test.dart`, `test/task_lifecycle_test.dart`, `test/child_tasks_test.dart`, `test/project_statistics_test.dart`, `test/application_layer_test.dart` | incomplete | Chunks 13.1-13.4 add canonical activities, exactly-once task/project statistic accounting, parent/child completion metadata, and learned suggestion aggregates; Chunk 14.1 adds the atomic in-memory unit-of-work foundation; concrete command integration remains in Chunk 14.3. |
## Additional MVP backend requirements
@ -102,9 +109,9 @@ Status values:
| MVP-SUP-04 | Cancelled and no-longer-relevant are separate calm lifecycle outcomes. | `TaskStatus.cancelled`, `TaskStatus.noLongerRelevant`, `RequiredTaskAction`, `TaskActivityCode` | `test/required_task_actions_test.dart`, `test/task_lifecycle_test.dart` | complete | Chunk 13.1 centralizes both transitions and emits distinct activity codes. |
| MVP-SUP-05 | Free Slot is intentional rest that blocks normal flexible placement and suppresses normal flexible reminders. | `TaskType.freeSlot`, `OccupancyPolicy`, `FreeSlotService`, `ReminderPolicyService`, timeline tokens | `test/timeline_state_test.dart`, `test/occupancy_policy_test.dart`, `test/free_slots_test.dart`, `test/scheduling_invariants_test.dart`, `test/reminder_policy_test.dart` | complete | Blocks 12 and 13 cover scheduling protection, explicit required conflicts, and reminder suppression/directives at the backend policy level. |
| MVP-SUP-06 | Project defaults apply, learned suggestions stay optional, and task reminder overrides are possible. | `ProjectProfile`, `ProjectStatistics`, `ProjectSuggestionService`, `ProjectDefaultResolution`, `ReminderPolicyService`, `Task.reminderOverride`, `ReminderProfile` | `test/scheduling_engine_test.dart`, `test/domain_invariants_test.dart`, `test/project_statistics_test.dart`, `test/reminder_policy_test.dart` | complete | Chunk 13.5 adds effective reminder resolution without using learned suggestions as silent configuration. |
| MVP-SUP-07 | Repository boundaries prepare for MongoDB without coupling the scheduler to MongoDB APIs. | Repository interfaces, in-memory repositories, document mapping helpers | `test/repositories_test.dart`, `test/document_mapping_test.dart`, `test/persistence_edge_cases_test.dart` | incomplete | Complete codecs, revisions, unit of work, and runtime adapter remain in Blocks 15 and 16. |
| MVP-SUP-07 | Repository boundaries prepare for MongoDB without coupling the scheduler to MongoDB APIs. | Repository interfaces, in-memory repositories, `ApplicationUnitOfWork`, document mapping helpers | `test/repositories_test.dart`, `test/document_mapping_test.dart`, `test/persistence_edge_cases_test.dart`, `test/application_layer_test.dart` | incomplete | Chunk 14.1 adds pure Dart application transaction contracts and staged in-memory unit-of-work behavior; complete codecs, revisions, and runtime adapter remain in Blocks 15 and 16. |
| MVP-SUP-08 | Hidden locked time remains hidden by default, with explicit reveal as an overlay. | `LockedBlockOccurrence.hiddenByDefault`, `TimelineItemMapper.fromLockedOccurrence` | `test/timeline_state_test.dart`, `test/edge_case_regression_test.dart` | complete | Stable per-occurrence IDs and Today query read model remain in Block 14. |
| MVP-SUP-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `ProjectStatistics`, `TaskActivity`, `TaskActivityCode`, `SchedulingChange`, `SchedulingMovementCode`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart`, `test/project_statistics_test.dart` | incomplete | Chunks 13.1-13.4 add canonical transition/activity records plus exactly-once task/project statistic accounting; atomic persistence remains in Block 14. |
| MVP-SUP-09 | Push/backlog/restore movement should be activity/stat data, not shame language. | `TaskStatistics`, `ProjectStatistics`, `TaskActivity`, `TaskActivityCode`, `SchedulingChange`, `SchedulingMovementCode`, `ApplicationUnitOfWork`, action result objects | `test/scheduling_engine_test.dart`, `test/required_task_actions_test.dart`, `test/scheduling_invariants_test.dart`, `test/task_lifecycle_test.dart`, `test/project_statistics_test.dart`, `test/application_layer_test.dart` | incomplete | Chunks 13.1-13.4 add canonical transition/activity records plus exactly-once task/project statistic accounting; Chunk 14.1 adds atomic transaction contracts; concrete action use-case persistence remains in Chunk 14.3. |
| MVP-SUP-10 | The human spec lists `pushed` and `skipped` as statuses. | `TaskStatus` excludes both; movement and burnout compatibility are modeled as activity/stat metadata. | `test/domain_contracts_test.dart` | contradictory | Resolved by `V1_ADR_001_Lifecycle_Metadata_Reminder_Semantics.md`; the contradiction is kept visible so later chunks do not add movement labels as statuses. |
## Intentionally deferred human-spec items

View file

@ -17,6 +17,7 @@
// Keep this file small. Implementation details belong in `lib/src/`.
export 'src/models.dart';
export 'src/application_layer.dart';
export 'src/backlog.dart';
export 'src/child_tasks.dart';
export 'src/document_mapping.dart';

View file

@ -0,0 +1,817 @@
// Application orchestration contracts for UI-facing use cases.
//
// This layer sits above the pure domain services and repository interfaces. It
// gives future Flutter/use-case code one atomic boundary per user command
// without importing widgets, MongoDB clients, or platform notification APIs.
import 'backlog.dart';
import 'locked_time.dart';
import 'models.dart';
import 'project_statistics.dart';
import 'repositories.dart';
import 'scheduling_engine.dart';
import 'task_lifecycle.dart';
import 'time_contracts.dart';
/// Owner-local time context supplied to application operations.
class OwnerTimeZoneContext {
OwnerTimeZoneContext({
required String ownerId,
required String timeZoneId,
}) : ownerId = _requiredTrimmed(ownerId, 'ownerId'),
timeZoneId = _requiredTrimmed(timeZoneId, 'timeZoneId');
/// Authorization-neutral owner scope. V1 does not implement accounts.
final String ownerId;
/// IANA-style time-zone identifier chosen by the app boundary.
final String timeZoneId;
}
/// Per-operation application context.
class ApplicationOperationContext {
ApplicationOperationContext({
required String operationId,
required this.now,
required this.ownerTimeZone,
required this.clock,
required this.idGenerator,
}) : operationId = _requiredTrimmed(operationId, 'operationId');
/// Convenience constructor that captures [Clock.now] once.
factory ApplicationOperationContext.start({
required String operationId,
required OwnerTimeZoneContext ownerTimeZone,
required Clock clock,
required IdGenerator idGenerator,
}) {
return ApplicationOperationContext(
operationId: operationId,
now: clock.now(),
ownerTimeZone: ownerTimeZone,
clock: clock,
idGenerator: idGenerator,
);
}
/// Exactly-once key for this user command.
final String operationId;
/// Instant used by the operation. Use this instead of repeatedly reading the
/// clock during one command.
final DateTime now;
/// Owner scope and time-zone data for date-sensitive rules.
final OwnerTimeZoneContext ownerTimeZone;
/// Injected clock available to lower-level services that need one.
final Clock clock;
/// Injected ID generator for records created by the operation.
final IdGenerator idGenerator;
}
/// Stable application-layer result categories.
enum ApplicationFailureCode {
validation,
notFound,
conflict,
staleRevision,
noSlot,
duplicateOperation,
persistenceFailure,
unexpected,
}
/// Typed failure returned by application commands and queries.
class ApplicationFailure {
const ApplicationFailure({
required this.code,
this.entityId,
this.detailCode,
});
/// Stable machine-readable category.
final ApplicationFailureCode code;
/// Optional stable entity id related to the failure.
final String? entityId;
/// Optional narrower stable reason, such as a domain validation code name.
final String? detailCode;
}
/// Result wrapper for expected application success/failure paths.
class ApplicationResult<T> {
const ApplicationResult._({
this.value,
this.failure,
});
/// Successful result carrying [value].
factory ApplicationResult.success(T value) {
return ApplicationResult._(value: value);
}
/// Expected failure result.
factory ApplicationResult.failure(ApplicationFailure failure) {
return ApplicationResult._(failure: failure);
}
/// Successful value, if any.
final T? value;
/// Typed failure, if the operation did not succeed.
final ApplicationFailure? failure;
/// Whether the operation succeeded.
bool get isSuccess => failure == null;
/// Whether the operation failed with a typed application failure.
bool get isFailure => failure != null;
/// Return the value or throw if this result is a failure.
T get requireValue {
if (failure != null) {
throw StateError(
'Application result is a failure: ${failure!.code.name}');
}
return value as T;
}
}
/// Exception for expected failures raised inside a unit-of-work callback.
class ApplicationFailureException implements Exception {
const ApplicationFailureException(this.failure);
/// Failure to return from the application boundary.
final ApplicationFailure failure;
}
/// Exception representing persistence/driver failures at repository boundaries.
class ApplicationPersistenceException implements Exception {
const ApplicationPersistenceException();
}
/// Persisted exactly-once operation record.
class ApplicationOperationRecord {
ApplicationOperationRecord({
required String operationId,
required String ownerId,
required String operationName,
required this.committedAt,
}) : operationId = _requiredTrimmed(operationId, 'operationId'),
ownerId = _requiredTrimmed(ownerId, 'ownerId'),
operationName = _requiredTrimmed(operationName, 'operationName');
/// Exactly-once operation id.
final String operationId;
/// Authorization-neutral owner scope.
final String ownerId;
/// Stable use-case/command label.
final String operationName;
/// Commit instant.
final DateTime committedAt;
}
/// Owner-level settings needed by application use cases.
class OwnerSettings {
OwnerSettings({
required String ownerId,
required String timeZoneId,
WallTime? dayStart,
WallTime? dayEnd,
this.compactModeEnabled = false,
this.backlogStalenessSettings = const BacklogStalenessSettings(),
}) : ownerId = _requiredTrimmed(ownerId, 'ownerId'),
timeZoneId = _requiredTrimmed(timeZoneId, 'timeZoneId'),
dayStart = dayStart ?? WallTime(hour: 0, minute: 0),
dayEnd = dayEnd ?? WallTime(hour: 23, minute: 59) {
if (this.dayStart.compareTo(this.dayEnd) >= 0) {
throw ArgumentError.value(
{'dayStart': this.dayStart, 'dayEnd': this.dayEnd},
'dayEnd',
'Day end must be after day start.',
);
}
}
/// Authorization-neutral owner scope.
final String ownerId;
/// Owner time-zone id used by date-sensitive application use cases.
final String timeZoneId;
/// Default local day start.
final WallTime dayStart;
/// Default local day end.
final WallTime dayEnd;
/// Persisted compact-mode preference.
final bool compactModeEnabled;
/// Configurable backlog age thresholds.
final BacklogStalenessSettings backlogStalenessSettings;
/// Return a copy with selected settings changed.
OwnerSettings copyWith({
String? ownerId,
String? timeZoneId,
WallTime? dayStart,
WallTime? dayEnd,
bool? compactModeEnabled,
BacklogStalenessSettings? backlogStalenessSettings,
}) {
return OwnerSettings(
ownerId: ownerId ?? this.ownerId,
timeZoneId: timeZoneId ?? this.timeZoneId,
dayStart: dayStart ?? this.dayStart,
dayEnd: dayEnd ?? this.dayEnd,
compactModeEnabled: compactModeEnabled ?? this.compactModeEnabled,
backlogStalenessSettings:
backlogStalenessSettings ?? this.backlogStalenessSettings,
);
}
}
/// Repository contract for internal task activities.
abstract interface class TaskActivityRepository {
Future<TaskActivity?> findById(String id);
Future<List<TaskActivity>> findByOperationId(String operationId);
Future<List<TaskActivity>> findByTaskId(String taskId);
Future<List<TaskActivity>> findAll();
Future<void> save(TaskActivity activity);
Future<void> saveAll(Iterable<TaskActivity> activities);
}
/// Repository contract for project statistics aggregates.
abstract interface class ProjectStatisticsRepository {
Future<ProjectStatistics?> findByProjectId(String projectId);
Future<List<ProjectStatistics>> findAll();
Future<void> save(ProjectStatistics statistics);
}
/// Repository contract for owner settings.
abstract interface class OwnerSettingsRepository {
Future<OwnerSettings?> findByOwnerId(String ownerId);
Future<void> save(OwnerSettings settings);
}
/// Repository contract for exactly-once operation records.
abstract interface class ApplicationOperationRepository {
Future<ApplicationOperationRecord?> findById(String operationId);
Future<void> save(ApplicationOperationRecord operation);
}
/// Repository set available inside one application unit of work.
abstract interface class ApplicationUnitOfWorkRepositories {
TaskRepository get tasks;
ProjectRepository get projects;
LockedBlockRepository get lockedBlocks;
SchedulingSnapshotRepository get schedulingSnapshots;
TaskActivityRepository get taskActivities;
ProjectStatisticsRepository get projectStatistics;
OwnerSettingsRepository get ownerSettings;
ApplicationOperationRepository get operations;
}
/// Atomic application transaction boundary.
abstract interface class ApplicationUnitOfWork {
Future<ApplicationResult<T>> run<T>({
required ApplicationOperationContext context,
required String operationName,
required Future<ApplicationResult<T>> Function(
ApplicationUnitOfWorkRepositories repositories,
) action,
});
}
/// Repository loader for common scheduler input assembly.
class ApplicationSchedulingLoader {
const ApplicationSchedulingLoader(this.repositories);
/// Repositories for the current application operation.
final ApplicationUnitOfWorkRepositories repositories;
/// Load scheduler input for [window] from the repository boundary.
///
/// UI code should not persist partial [SchedulingResult] objects. Commands
/// should load authoritative state here, invoke domain services, then commit
/// resulting entities through a unit of work.
Future<SchedulingInput> loadInputForWindow({
required SchedulingWindow window,
List<TimeInterval> lockedIntervals = const <TimeInterval>[],
List<TimeInterval> requiredVisibleIntervals = const <TimeInterval>[],
}) async {
final scheduledTasks = await repositories.tasks.findScheduledInWindow(
window,
);
return SchedulingInput(
tasks: scheduledTasks,
window: window,
lockedIntervals: lockedIntervals,
requiredVisibleIntervals: requiredVisibleIntervals,
);
}
}
/// In-memory unit-of-work implementation for deterministic application tests.
class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork {
InMemoryApplicationUnitOfWork({
Iterable<Task> initialTasks = const <Task>[],
Iterable<ProjectProfile> initialProjects = const <ProjectProfile>[],
Iterable<LockedBlock> initialLockedBlocks = const <LockedBlock>[],
Iterable<LockedBlockOverride> initialLockedOverrides =
const <LockedBlockOverride>[],
Iterable<SchedulingStateSnapshot> initialSchedulingSnapshots =
const <SchedulingStateSnapshot>[],
Iterable<TaskActivity> initialTaskActivities = const <TaskActivity>[],
Iterable<ProjectStatistics> initialProjectStatistics =
const <ProjectStatistics>[],
Iterable<OwnerSettings> initialOwnerSettings = const <OwnerSettings>[],
Iterable<ApplicationOperationRecord> initialOperations =
const <ApplicationOperationRecord>[],
}) : _state = _InMemoryApplicationState(
tasksById: {
for (final task in initialTasks) task.id: task,
},
projectsById: {
for (final project in initialProjects) project.id: project,
},
lockedBlocksById: {
for (final block in initialLockedBlocks) block.id: block,
},
lockedOverridesById: {
for (final override in initialLockedOverrides)
override.id: override,
},
schedulingSnapshotsById: {
for (final snapshot in initialSchedulingSnapshots)
snapshot.id: snapshot,
},
taskActivitiesById: {
for (final activity in initialTaskActivities) activity.id: activity,
},
projectStatisticsByProjectId: {
for (final statistics in initialProjectStatistics)
statistics.projectId: statistics,
},
ownerSettingsByOwnerId: {
for (final settings in initialOwnerSettings)
settings.ownerId: settings,
},
operationsById: {
for (final operation in initialOperations)
operation.operationId: operation,
},
);
_InMemoryApplicationState _state;
bool _failNextCommit = false;
/// Current committed tasks, useful for contract tests.
List<Task> get currentTasks =>
List<Task>.unmodifiable(_state.tasksById.values);
/// Current committed activities, useful for contract tests.
List<TaskActivity> get currentTaskActivities {
return List<TaskActivity>.unmodifiable(_state.taskActivitiesById.values);
}
/// Current committed project statistics, useful for contract tests.
List<ProjectStatistics> get currentProjectStatistics {
return List<ProjectStatistics>.unmodifiable(
_state.projectStatisticsByProjectId.values,
);
}
/// Current committed owner settings, useful for contract tests.
List<OwnerSettings> get currentOwnerSettings {
return List<OwnerSettings>.unmodifiable(
_state.ownerSettingsByOwnerId.values);
}
/// Current committed operation records, useful for contract tests.
List<ApplicationOperationRecord> get currentOperations {
return List<ApplicationOperationRecord>.unmodifiable(
_state.operationsById.values,
);
}
/// Cause the next successful transaction to fail at commit time.
void failNextCommitForTesting() {
_failNextCommit = true;
}
@override
Future<ApplicationResult<T>> run<T>({
required ApplicationOperationContext context,
required String operationName,
required Future<ApplicationResult<T>> Function(
ApplicationUnitOfWorkRepositories repositories,
) action,
}) async {
if (_state.operationsById.containsKey(context.operationId)) {
return ApplicationResult.failure(
ApplicationFailure(
code: ApplicationFailureCode.duplicateOperation,
entityId: context.operationId,
),
);
}
final staged = _state.copy();
final repositories = _InMemoryApplicationRepositories(staged);
try {
final result = await action(repositories);
if (result.isFailure) {
return result;
}
staged.operationsById[context.operationId] = ApplicationOperationRecord(
operationId: context.operationId,
ownerId: context.ownerTimeZone.ownerId,
operationName: operationName,
committedAt: context.now,
);
if (_failNextCommit) {
_failNextCommit = false;
throw const ApplicationPersistenceException();
}
_state = staged;
return result;
} on ApplicationFailureException catch (error) {
return ApplicationResult.failure(error.failure);
} on DomainValidationException catch (error) {
return ApplicationResult.failure(
ApplicationFailure(
code: ApplicationFailureCode.validation,
detailCode: error.code.name,
),
);
} on ApplicationPersistenceException {
return ApplicationResult.failure(
const ApplicationFailure(
code: ApplicationFailureCode.persistenceFailure,
),
);
} catch (_) {
return ApplicationResult.failure(
const ApplicationFailure(code: ApplicationFailureCode.unexpected),
);
}
}
}
class _InMemoryApplicationState {
_InMemoryApplicationState({
required this.tasksById,
required this.projectsById,
required this.lockedBlocksById,
required this.lockedOverridesById,
required this.schedulingSnapshotsById,
required this.taskActivitiesById,
required this.projectStatisticsByProjectId,
required this.ownerSettingsByOwnerId,
required this.operationsById,
});
final Map<String, Task> tasksById;
final Map<String, ProjectProfile> projectsById;
final Map<String, LockedBlock> lockedBlocksById;
final Map<String, LockedBlockOverride> lockedOverridesById;
final Map<String, SchedulingStateSnapshot> schedulingSnapshotsById;
final Map<String, TaskActivity> taskActivitiesById;
final Map<String, ProjectStatistics> projectStatisticsByProjectId;
final Map<String, OwnerSettings> ownerSettingsByOwnerId;
final Map<String, ApplicationOperationRecord> operationsById;
_InMemoryApplicationState copy() {
return _InMemoryApplicationState(
tasksById: Map<String, Task>.of(tasksById),
projectsById: Map<String, ProjectProfile>.of(projectsById),
lockedBlocksById: Map<String, LockedBlock>.of(lockedBlocksById),
lockedOverridesById:
Map<String, LockedBlockOverride>.of(lockedOverridesById),
schedulingSnapshotsById:
Map<String, SchedulingStateSnapshot>.of(schedulingSnapshotsById),
taskActivitiesById: Map<String, TaskActivity>.of(taskActivitiesById),
projectStatisticsByProjectId:
Map<String, ProjectStatistics>.of(projectStatisticsByProjectId),
ownerSettingsByOwnerId: Map<String, OwnerSettings>.of(
ownerSettingsByOwnerId,
),
operationsById: Map<String, ApplicationOperationRecord>.of(
operationsById,
),
);
}
}
class _InMemoryApplicationRepositories
implements ApplicationUnitOfWorkRepositories {
_InMemoryApplicationRepositories(_InMemoryApplicationState state)
: tasks = _UnitOfWorkTaskRepository(state.tasksById),
projects = _UnitOfWorkProjectRepository(state.projectsById),
lockedBlocks = _UnitOfWorkLockedBlockRepository(
blocksById: state.lockedBlocksById,
overridesById: state.lockedOverridesById,
),
schedulingSnapshots = _UnitOfWorkSchedulingSnapshotRepository(
state.schedulingSnapshotsById,
),
taskActivities = _UnitOfWorkTaskActivityRepository(
state.taskActivitiesById,
),
projectStatistics = _UnitOfWorkProjectStatisticsRepository(
state.projectStatisticsByProjectId,
),
ownerSettings = _UnitOfWorkOwnerSettingsRepository(
state.ownerSettingsByOwnerId,
),
operations = _UnitOfWorkOperationRepository(state.operationsById);
@override
final TaskRepository tasks;
@override
final ProjectRepository projects;
@override
final LockedBlockRepository lockedBlocks;
@override
final SchedulingSnapshotRepository schedulingSnapshots;
@override
final TaskActivityRepository taskActivities;
@override
final ProjectStatisticsRepository projectStatistics;
@override
final OwnerSettingsRepository ownerSettings;
@override
final ApplicationOperationRepository operations;
}
class _UnitOfWorkTaskRepository implements TaskRepository {
_UnitOfWorkTaskRepository(this._tasksById);
final Map<String, Task> _tasksById;
@override
Future<Task?> findById(String id) async => _tasksById[id];
@override
Future<List<Task>> findAll() async {
return List<Task>.unmodifiable(_tasksById.values);
}
@override
Future<List<Task>> findByStatus(TaskStatus status) async {
return List<Task>.unmodifiable(
_tasksById.values.where((task) => task.status == status),
);
}
@override
Future<List<Task>> findScheduledInWindow(SchedulingWindow window) async {
return List<Task>.unmodifiable(
_tasksById.values.where((task) {
final start = task.scheduledStart;
final end = task.scheduledEnd;
if (start == null || end == null) {
return false;
}
return TimeInterval(start: start, end: end).overlaps(window.interval);
}),
);
}
@override
Future<void> save(Task task) async {
_tasksById[task.id] = task;
}
@override
Future<void> saveAll(Iterable<Task> tasks) async {
for (final task in tasks) {
_tasksById[task.id] = task;
}
}
}
class _UnitOfWorkProjectRepository implements ProjectRepository {
_UnitOfWorkProjectRepository(this._projectsById);
final Map<String, ProjectProfile> _projectsById;
@override
Future<ProjectProfile?> findById(String id) async => _projectsById[id];
@override
Future<List<ProjectProfile>> findAll() async {
return List<ProjectProfile>.unmodifiable(_projectsById.values);
}
@override
Future<void> save(ProjectProfile project) async {
_projectsById[project.id] = project;
}
}
class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository {
_UnitOfWorkLockedBlockRepository({
required Map<String, LockedBlock> blocksById,
required Map<String, LockedBlockOverride> overridesById,
}) : _blocksById = blocksById,
_overridesById = overridesById;
final Map<String, LockedBlock> _blocksById;
final Map<String, LockedBlockOverride> _overridesById;
@override
Future<LockedBlock?> findBlockById(String id) async => _blocksById[id];
@override
Future<List<LockedBlock>> findAllBlocks() async {
return List<LockedBlock>.unmodifiable(_blocksById.values);
}
@override
Future<LockedBlockOverride?> findOverrideById(String id) async {
return _overridesById[id];
}
@override
Future<List<LockedBlockOverride>> findAllOverrides() async {
return List<LockedBlockOverride>.unmodifiable(_overridesById.values);
}
@override
Future<void> saveBlock(LockedBlock block) async {
_blocksById[block.id] = block;
}
@override
Future<void> saveOverride(LockedBlockOverride override) async {
_overridesById[override.id] = override;
}
}
class _UnitOfWorkSchedulingSnapshotRepository
implements SchedulingSnapshotRepository {
_UnitOfWorkSchedulingSnapshotRepository(this._snapshotsById);
final Map<String, SchedulingStateSnapshot> _snapshotsById;
@override
Future<SchedulingStateSnapshot?> findById(String id) async {
return _snapshotsById[id];
}
@override
Future<List<SchedulingStateSnapshot>> findInWindow(
SchedulingWindow window,
) async {
return List<SchedulingStateSnapshot>.unmodifiable(
_snapshotsById.values.where(
(snapshot) => snapshot.window.interval.overlaps(window.interval),
),
);
}
@override
Future<void> save(SchedulingStateSnapshot snapshot) async {
_snapshotsById[snapshot.id] = snapshot;
}
}
class _UnitOfWorkTaskActivityRepository implements TaskActivityRepository {
_UnitOfWorkTaskActivityRepository(this._activitiesById);
final Map<String, TaskActivity> _activitiesById;
@override
Future<TaskActivity?> findById(String id) async => _activitiesById[id];
@override
Future<List<TaskActivity>> findByOperationId(String operationId) async {
return List<TaskActivity>.unmodifiable(
_activitiesById.values.where(
(activity) => activity.operationId == operationId,
),
);
}
@override
Future<List<TaskActivity>> findByTaskId(String taskId) async {
return List<TaskActivity>.unmodifiable(
_activitiesById.values.where((activity) => activity.taskId == taskId),
);
}
@override
Future<List<TaskActivity>> findAll() async {
return List<TaskActivity>.unmodifiable(_activitiesById.values);
}
@override
Future<void> save(TaskActivity activity) async {
_activitiesById[activity.id] = activity;
}
@override
Future<void> saveAll(Iterable<TaskActivity> activities) async {
for (final activity in activities) {
_activitiesById[activity.id] = activity;
}
}
}
class _UnitOfWorkProjectStatisticsRepository
implements ProjectStatisticsRepository {
_UnitOfWorkProjectStatisticsRepository(this._statisticsByProjectId);
final Map<String, ProjectStatistics> _statisticsByProjectId;
@override
Future<ProjectStatistics?> findByProjectId(String projectId) async {
return _statisticsByProjectId[projectId];
}
@override
Future<List<ProjectStatistics>> findAll() async {
return List<ProjectStatistics>.unmodifiable(_statisticsByProjectId.values);
}
@override
Future<void> save(ProjectStatistics statistics) async {
_statisticsByProjectId[statistics.projectId] = statistics;
}
}
class _UnitOfWorkOwnerSettingsRepository implements OwnerSettingsRepository {
_UnitOfWorkOwnerSettingsRepository(this._settingsByOwnerId);
final Map<String, OwnerSettings> _settingsByOwnerId;
@override
Future<OwnerSettings?> findByOwnerId(String ownerId) async {
return _settingsByOwnerId[ownerId];
}
@override
Future<void> save(OwnerSettings settings) async {
_settingsByOwnerId[settings.ownerId] = settings;
}
}
class _UnitOfWorkOperationRepository implements ApplicationOperationRepository {
_UnitOfWorkOperationRepository(this._operationsById);
final Map<String, ApplicationOperationRecord> _operationsById;
@override
Future<ApplicationOperationRecord?> findById(String operationId) async {
return _operationsById[operationId];
}
@override
Future<void> save(ApplicationOperationRecord operation) async {
_operationsById[operation.operationId] = operation;
}
}
String _requiredTrimmed(String value, String name) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
throw ArgumentError.value(value, name, 'Value is required.');
}
return trimmed;
}

View file

@ -0,0 +1,293 @@
import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('Application unit of work', () {
final now = DateTime.utc(2026, 6, 25, 16);
final context = appContext(operationId: 'op-1', now: now);
test(
'commits task, activity, aggregate, settings, and operation atomically',
() async {
final original = task(
id: 'task-1',
status: TaskStatus.planned,
createdAt: now,
);
final unitOfWork = InMemoryApplicationUnitOfWork(
initialTasks: [original],
);
final result = await unitOfWork.run<String>(
context: context,
operationName: 'complete-task',
action: (repositories) async {
final loaded = await repositories.tasks.findById(original.id);
final completed = loaded!.copyWith(
status: TaskStatus.completed,
completedAt: now,
updatedAt: now,
);
final activity = activityFor(
id: 'activity-1',
operationId: context.operationId,
task: completed,
occurredAt: now,
);
await repositories.tasks.save(completed);
await repositories.taskActivities.save(activity);
await repositories.projectStatistics.save(
ProjectStatistics(projectId: completed.projectId)
.recordCompletion(completedAt: now, durationMinutes: 30),
);
await repositories.ownerSettings.save(
OwnerSettings(
ownerId: 'owner-1', timeZoneId: 'America/Los_Angeles'),
);
return ApplicationResult.success(completed.id);
},
);
expect(result.isSuccess, isTrue);
expect(result.requireValue, 'task-1');
expect(unitOfWork.currentTasks.single.status, TaskStatus.completed);
expect(unitOfWork.currentTaskActivities.single.id, 'activity-1');
expect(
unitOfWork.currentProjectStatistics.single.completedTaskCount,
1,
);
expect(unitOfWork.currentOwnerSettings.single.ownerId, 'owner-1');
expect(unitOfWork.currentOperations.single.operationId, 'op-1');
expect(
unitOfWork.currentOperations.single.operationName, 'complete-task');
});
test('rolls back staged changes when the action returns a typed failure',
() async {
final original = task(
id: 'task-1',
status: TaskStatus.planned,
createdAt: now,
);
final unitOfWork = InMemoryApplicationUnitOfWork(
initialTasks: [original],
);
final result = await unitOfWork.run<void>(
context: context,
operationName: 'complete-task',
action: (repositories) async {
await repositories.tasks.save(
original.copyWith(status: TaskStatus.completed, updatedAt: now),
);
await repositories.taskActivities.save(
activityFor(
id: 'activity-1',
operationId: context.operationId,
task: original,
occurredAt: now,
),
);
return ApplicationResult.failure(
const ApplicationFailure(
code: ApplicationFailureCode.conflict,
entityId: 'task-1',
),
);
},
);
expect(result.failure!.code, ApplicationFailureCode.conflict);
expect(unitOfWork.currentTasks.single.status, TaskStatus.planned);
expect(unitOfWork.currentTaskActivities, isEmpty);
expect(unitOfWork.currentOperations, isEmpty);
});
test('rolls back staged changes when commit persistence fails', () async {
final original = task(
id: 'task-1',
status: TaskStatus.planned,
createdAt: now,
);
final unitOfWork = InMemoryApplicationUnitOfWork(
initialTasks: [original],
)..failNextCommitForTesting();
final result = await unitOfWork.run<void>(
context: context,
operationName: 'complete-task',
action: (repositories) async {
await repositories.tasks.save(
original.copyWith(status: TaskStatus.completed, updatedAt: now),
);
return ApplicationResult.success(null);
},
);
expect(result.failure!.code, ApplicationFailureCode.persistenceFailure);
expect(unitOfWork.currentTasks.single.status, TaskStatus.planned);
expect(unitOfWork.currentOperations, isEmpty);
});
test('rejects duplicate operation IDs without running the action',
() async {
final unitOfWork = InMemoryApplicationUnitOfWork();
var runCount = 0;
final first = await unitOfWork.run<String>(
context: context,
operationName: 'capture',
action: (_) async {
runCount += 1;
return ApplicationResult.success('done');
},
);
final duplicate = await unitOfWork.run<String>(
context: context,
operationName: 'capture',
action: (_) async {
runCount += 1;
return ApplicationResult.success('duplicate');
},
);
expect(first.requireValue, 'done');
expect(
duplicate.failure!.code, ApplicationFailureCode.duplicateOperation);
expect(duplicate.failure!.entityId, 'op-1');
expect(runCount, 1);
expect(unitOfWork.currentOperations, hasLength(1));
});
test('maps domain validation exceptions to stable validation failures',
() async {
final unitOfWork = InMemoryApplicationUnitOfWork();
final result = await unitOfWork.run<void>(
context: context,
operationName: 'bad-task',
action: (_) async {
Task(
id: '',
title: 'Bad task',
projectId: 'inbox',
type: TaskType.flexible,
status: TaskStatus.backlog,
createdAt: now,
updatedAt: now,
);
return ApplicationResult.success(null);
},
);
expect(result.failure!.code, ApplicationFailureCode.validation);
expect(
result.failure!.detailCode, DomainValidationCode.blankStableId.name);
expect(unitOfWork.currentOperations, isEmpty);
});
});
group('Application scheduling loader', () {
test('centralizes repository loading into scheduler input', () async {
final now = DateTime.utc(2026, 6, 25, 8);
final inWindow = task(
id: 'in-window',
status: TaskStatus.planned,
createdAt: now,
scheduledStart: DateTime.utc(2026, 6, 25, 10),
scheduledEnd: DateTime.utc(2026, 6, 25, 10, 30),
);
final outsideWindow = task(
id: 'outside-window',
status: TaskStatus.planned,
createdAt: now,
scheduledStart: DateTime.utc(2026, 6, 26, 10),
scheduledEnd: DateTime.utc(2026, 6, 26, 10, 30),
);
final unitOfWork = InMemoryApplicationUnitOfWork(
initialTasks: [inWindow, outsideWindow],
);
late SchedulingInput input;
final result = await unitOfWork.run<void>(
context: appContext(operationId: 'load', now: now),
operationName: 'load-input',
action: (repositories) async {
input = await ApplicationSchedulingLoader(repositories)
.loadInputForWindow(
window: SchedulingWindow(
start: DateTime.utc(2026, 6, 25, 0),
end: DateTime.utc(2026, 6, 26, 0),
),
lockedIntervals: [
TimeInterval(
start: DateTime.utc(2026, 6, 25, 12),
end: DateTime.utc(2026, 6, 25, 13),
),
],
);
return ApplicationResult.success(null);
},
);
expect(result.isSuccess, isTrue);
expect(input.tasks.map((task) => task.id), ['in-window']);
expect(input.lockedIntervals, hasLength(1));
});
});
}
ApplicationOperationContext appContext({
required String operationId,
required DateTime now,
}) {
return ApplicationOperationContext.start(
operationId: operationId,
ownerTimeZone: OwnerTimeZoneContext(
ownerId: 'owner-1',
timeZoneId: 'America/Los_Angeles',
),
clock: FixedClock(now),
idGenerator: SequentialIdGenerator(prefix: 'generated'),
);
}
Task task({
required String id,
required TaskStatus status,
required DateTime createdAt,
DateTime? scheduledStart,
DateTime? scheduledEnd,
}) {
return Task(
id: id,
title: 'Task $id',
projectId: 'inbox',
type: TaskType.flexible,
status: status,
durationMinutes: 30,
scheduledStart: scheduledStart,
scheduledEnd: scheduledEnd,
createdAt: createdAt,
updatedAt: createdAt,
);
}
TaskActivity activityFor({
required String id,
required String operationId,
required Task task,
required DateTime occurredAt,
}) {
return TaskActivity(
id: id,
operationId: operationId,
code: TaskActivityCode.completed,
taskId: task.id,
projectId: task.projectId,
occurredAt: occurredAt,
);
}