From 2463fd7817a087eff34803fa18261fe60cd17a9b Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Thu, 25 Jun 2026 10:17:14 -0700 Subject: [PATCH] feat(app): add application unit of work foundation --- .../V1_BACKEND_COMPLETION_GAP_MATRIX.md | 4 +- ...CK_14_Application_Use_Cases_Read_Models.md | 29 +- .../V1_PUBLIC_API_BASELINE.md | 18 + .../V1_REQUIREMENTS_TRACEABILITY_MATRIX.md | 13 +- lib/scheduler_core.dart | 1 + lib/src/application_layer.dart | 817 ++++++++++++++++++ test/application_layer_test.dart | 293 +++++++ 7 files changed, 1169 insertions(+), 6 deletions(-) create mode 100644 lib/src/application_layer.dart create mode 100644 test/application_layer_test.dart diff --git a/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md b/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md index 02bd009..ee0baf9 100644 --- a/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_BACKEND_COMPLETION_GAP_MATRIX.md @@ -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 | diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_14_Application_Use_Cases_Read_Models.md b/Codex Documentation/Current Software Plan/V1_BLOCK_14_Application_Use_Cases_Read_Models.md index 13a8381..36961af 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_14_Application_Use_Cases_Read_Models.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_14_Application_Use_Cases_Read_Models.md @@ -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. diff --git a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md index 575a523..e5d1f02 100644 --- a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md +++ b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md @@ -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. diff --git a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md index ae8bee5..fa745ff 100644 --- a/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md +++ b/Codex Documentation/Current Software Plan/V1_REQUIREMENTS_TRACEABILITY_MATRIX.md @@ -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 diff --git a/lib/scheduler_core.dart b/lib/scheduler_core.dart index a08aacf..7e70c67 100644 --- a/lib/scheduler_core.dart +++ b/lib/scheduler_core.dart @@ -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'; diff --git a/lib/src/application_layer.dart b/lib/src/application_layer.dart new file mode 100644 index 0000000..450c274 --- /dev/null +++ b/lib/src/application_layer.dart @@ -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 { + 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 findById(String id); + + Future> findByOperationId(String operationId); + + Future> findByTaskId(String taskId); + + Future> findAll(); + + Future save(TaskActivity activity); + + Future saveAll(Iterable activities); +} + +/// Repository contract for project statistics aggregates. +abstract interface class ProjectStatisticsRepository { + Future findByProjectId(String projectId); + + Future> findAll(); + + Future save(ProjectStatistics statistics); +} + +/// Repository contract for owner settings. +abstract interface class OwnerSettingsRepository { + Future findByOwnerId(String ownerId); + + Future save(OwnerSettings settings); +} + +/// Repository contract for exactly-once operation records. +abstract interface class ApplicationOperationRepository { + Future findById(String operationId); + + Future 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> run({ + required ApplicationOperationContext context, + required String operationName, + required Future> 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 loadInputForWindow({ + required SchedulingWindow window, + List lockedIntervals = const [], + List requiredVisibleIntervals = const [], + }) 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 initialTasks = const [], + Iterable initialProjects = const [], + Iterable initialLockedBlocks = const [], + Iterable initialLockedOverrides = + const [], + Iterable initialSchedulingSnapshots = + const [], + Iterable initialTaskActivities = const [], + Iterable initialProjectStatistics = + const [], + Iterable initialOwnerSettings = const [], + Iterable initialOperations = + const [], + }) : _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 get currentTasks => + List.unmodifiable(_state.tasksById.values); + + /// Current committed activities, useful for contract tests. + List get currentTaskActivities { + return List.unmodifiable(_state.taskActivitiesById.values); + } + + /// Current committed project statistics, useful for contract tests. + List get currentProjectStatistics { + return List.unmodifiable( + _state.projectStatisticsByProjectId.values, + ); + } + + /// Current committed owner settings, useful for contract tests. + List get currentOwnerSettings { + return List.unmodifiable( + _state.ownerSettingsByOwnerId.values); + } + + /// Current committed operation records, useful for contract tests. + List get currentOperations { + return List.unmodifiable( + _state.operationsById.values, + ); + } + + /// Cause the next successful transaction to fail at commit time. + void failNextCommitForTesting() { + _failNextCommit = true; + } + + @override + Future> run({ + required ApplicationOperationContext context, + required String operationName, + required Future> 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 tasksById; + final Map projectsById; + final Map lockedBlocksById; + final Map lockedOverridesById; + final Map schedulingSnapshotsById; + final Map taskActivitiesById; + final Map projectStatisticsByProjectId; + final Map ownerSettingsByOwnerId; + final Map operationsById; + + _InMemoryApplicationState copy() { + return _InMemoryApplicationState( + tasksById: Map.of(tasksById), + projectsById: Map.of(projectsById), + lockedBlocksById: Map.of(lockedBlocksById), + lockedOverridesById: + Map.of(lockedOverridesById), + schedulingSnapshotsById: + Map.of(schedulingSnapshotsById), + taskActivitiesById: Map.of(taskActivitiesById), + projectStatisticsByProjectId: + Map.of(projectStatisticsByProjectId), + ownerSettingsByOwnerId: Map.of( + ownerSettingsByOwnerId, + ), + operationsById: Map.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 _tasksById; + + @override + Future findById(String id) async => _tasksById[id]; + + @override + Future> findAll() async { + return List.unmodifiable(_tasksById.values); + } + + @override + Future> findByStatus(TaskStatus status) async { + return List.unmodifiable( + _tasksById.values.where((task) => task.status == status), + ); + } + + @override + Future> findScheduledInWindow(SchedulingWindow window) async { + return List.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 save(Task task) async { + _tasksById[task.id] = task; + } + + @override + Future saveAll(Iterable tasks) async { + for (final task in tasks) { + _tasksById[task.id] = task; + } + } +} + +class _UnitOfWorkProjectRepository implements ProjectRepository { + _UnitOfWorkProjectRepository(this._projectsById); + + final Map _projectsById; + + @override + Future findById(String id) async => _projectsById[id]; + + @override + Future> findAll() async { + return List.unmodifiable(_projectsById.values); + } + + @override + Future save(ProjectProfile project) async { + _projectsById[project.id] = project; + } +} + +class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository { + _UnitOfWorkLockedBlockRepository({ + required Map blocksById, + required Map overridesById, + }) : _blocksById = blocksById, + _overridesById = overridesById; + + final Map _blocksById; + final Map _overridesById; + + @override + Future findBlockById(String id) async => _blocksById[id]; + + @override + Future> findAllBlocks() async { + return List.unmodifiable(_blocksById.values); + } + + @override + Future findOverrideById(String id) async { + return _overridesById[id]; + } + + @override + Future> findAllOverrides() async { + return List.unmodifiable(_overridesById.values); + } + + @override + Future saveBlock(LockedBlock block) async { + _blocksById[block.id] = block; + } + + @override + Future saveOverride(LockedBlockOverride override) async { + _overridesById[override.id] = override; + } +} + +class _UnitOfWorkSchedulingSnapshotRepository + implements SchedulingSnapshotRepository { + _UnitOfWorkSchedulingSnapshotRepository(this._snapshotsById); + + final Map _snapshotsById; + + @override + Future findById(String id) async { + return _snapshotsById[id]; + } + + @override + Future> findInWindow( + SchedulingWindow window, + ) async { + return List.unmodifiable( + _snapshotsById.values.where( + (snapshot) => snapshot.window.interval.overlaps(window.interval), + ), + ); + } + + @override + Future save(SchedulingStateSnapshot snapshot) async { + _snapshotsById[snapshot.id] = snapshot; + } +} + +class _UnitOfWorkTaskActivityRepository implements TaskActivityRepository { + _UnitOfWorkTaskActivityRepository(this._activitiesById); + + final Map _activitiesById; + + @override + Future findById(String id) async => _activitiesById[id]; + + @override + Future> findByOperationId(String operationId) async { + return List.unmodifiable( + _activitiesById.values.where( + (activity) => activity.operationId == operationId, + ), + ); + } + + @override + Future> findByTaskId(String taskId) async { + return List.unmodifiable( + _activitiesById.values.where((activity) => activity.taskId == taskId), + ); + } + + @override + Future> findAll() async { + return List.unmodifiable(_activitiesById.values); + } + + @override + Future save(TaskActivity activity) async { + _activitiesById[activity.id] = activity; + } + + @override + Future saveAll(Iterable activities) async { + for (final activity in activities) { + _activitiesById[activity.id] = activity; + } + } +} + +class _UnitOfWorkProjectStatisticsRepository + implements ProjectStatisticsRepository { + _UnitOfWorkProjectStatisticsRepository(this._statisticsByProjectId); + + final Map _statisticsByProjectId; + + @override + Future findByProjectId(String projectId) async { + return _statisticsByProjectId[projectId]; + } + + @override + Future> findAll() async { + return List.unmodifiable(_statisticsByProjectId.values); + } + + @override + Future save(ProjectStatistics statistics) async { + _statisticsByProjectId[statistics.projectId] = statistics; + } +} + +class _UnitOfWorkOwnerSettingsRepository implements OwnerSettingsRepository { + _UnitOfWorkOwnerSettingsRepository(this._settingsByOwnerId); + + final Map _settingsByOwnerId; + + @override + Future findByOwnerId(String ownerId) async { + return _settingsByOwnerId[ownerId]; + } + + @override + Future save(OwnerSettings settings) async { + _settingsByOwnerId[settings.ownerId] = settings; + } +} + +class _UnitOfWorkOperationRepository implements ApplicationOperationRepository { + _UnitOfWorkOperationRepository(this._operationsById); + + final Map _operationsById; + + @override + Future findById(String operationId) async { + return _operationsById[operationId]; + } + + @override + Future 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; +} diff --git a/test/application_layer_test.dart b/test/application_layer_test.dart new file mode 100644 index 0000000..11ed25c --- /dev/null +++ b/test/application_layer_test.dart @@ -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( + 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( + 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( + 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( + context: context, + operationName: 'capture', + action: (_) async { + runCount += 1; + return ApplicationResult.success('done'); + }, + ); + final duplicate = await unitOfWork.run( + 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( + 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( + 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, + ); +}