diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_15_Persistence_Schema_Codecs_Repositories.md b/Codex Documentation/Current Software Plan/V1_BLOCK_15_Persistence_Schema_Codecs_Repositories.md index 67ed45d..f162c78 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_15_Persistence_Schema_Codecs_Repositories.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_15_Persistence_Schema_Codecs_Repositories.md @@ -221,7 +221,7 @@ Verification: - `dart format lib test`: passed, 0 files changed after final format - `dart analyze`: passed, no issues found -- `dart test`: passed, 289 tests +- `dart test`: passed, 293 tests - `git diff --check`: passed BREAKPOINT: Stop here. Confirm `high` mode before expanding repository/query @@ -231,6 +231,8 @@ contracts and index specifications. Recommended Codex level: high +Status: Complete + Tasks: Expand repository interfaces and in-memory implementations to support: @@ -274,6 +276,40 @@ Acceptance criteria: - Existing in-memory tests are migrated and pass. - Interfaces remain persistence-client-independent. +Completed implementation: + +- Added adapter-neutral repository mutation/result contracts: + `RepositoryFailureCode`, `RepositoryFailure`, `RepositoryMutationResult`, + `RepositoryRecord`, `RepositoryPageRequest`, `RepositoryPage`, and + `BacklogCandidateQuery`. +- Expanded task repository contracts and in-memory implementations for owner, + project, parent, backlog-candidate, local-day/window, record/revision, + insert, and compare-and-set save behavior. +- Expanded project and locked-time repositories with owner-scoped paged queries, + revision records, compare-and-set saves, duplicate insert failures, archive + behavior, and date-scoped locked override queries. +- Expanded application-layer repositories for task activity append/query, + project statistics/settings revision saves, notice acknowledgement insert and + paging, and idempotent operation lookup/insert. +- Updated the in-memory application unit of work to stage/copy owner and + revision metadata across transactions. +- Shifted Today, project-default, recovery, and planning-state loading to + owner/date-scoped repository queries where those paths do not need wider task + family context. +- Added reusable core repository conformance tests covering query semantics, + deterministic pagination, archive behavior, optimistic revisions, duplicate + IDs, and immutable result pages. +- Added application unit-of-work contract tests for activity append conflicts, + owner settings stale revisions, notice duplicate inserts, and duplicate + operation IDs. + +Verification: + +- `dart format lib test`: passed, 0 files changed after final format +- `dart analyze`: passed, no issues found +- `dart test`: passed, 289 tests +- `git diff --check`: passed + ## Chunk 15.5 — Index and data-integrity contract tests Recommended Codex level: high 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 b5682cc..59699be 100644 --- a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md +++ b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md @@ -370,6 +370,23 @@ Chunk 15.3 intentionally changed the public API: - Legacy backlog tasks now carry explicit provenance when `backlogEnteredAt` is approximated from `createdAt`. +Chunk 15.4 intentionally changed the public API: + +- Added adapter-neutral repository conflict/paging/record contracts: + `RepositoryFailureCode`, `RepositoryFailure`, `RepositoryMutationResult`, + `RepositoryRecord`, `RepositoryPageRequest`, `RepositoryPage`, and + `BacklogCandidateQuery`. +- Expanded `TaskRepository` with owner, project, parent, backlog-candidate, + local-day/window, record/revision, insert, and compare-and-set save methods. +- Expanded `ProjectRepository` and `LockedBlockRepository` with owner-scoped + paged queries, revision records, compare-and-set saves, duplicate insert + failures, archive methods, and date-scoped locked override lookup. +- Expanded application-layer repository interfaces for task activity owner + queries/appends, project statistics/settings revision saves, notice + acknowledgement insert/paging, and idempotent operation lookup/insert. +- In-memory unit-of-work repositories now track owner/revision metadata in the + staged transaction state. + ## Repository contracts The current repository surface is pure Dart and in-memory only: diff --git a/lib/src/application_commands.dart b/lib/src/application_commands.dart index 63efcee..0cf1858 100644 --- a/lib/src/application_commands.dart +++ b/lib/src/application_commands.dart @@ -215,7 +215,6 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { - final beforeTasks = await repositories.tasks.findAll(); final result = quickCaptureService.capture( QuickCaptureRequest( id: context.idGenerator.nextId(), @@ -243,8 +242,8 @@ class V1ApplicationCommandUseCases { repositories: repositories, commandCode: commandCode, context: context, - beforeTasks: beforeTasks, - afterTasks: [...beforeTasks, result.task], + beforeTasks: const [], + afterTasks: [result.task], activities: const [], readHint: ApplicationCommandReadHint( affectedTaskIds: [result.task.id], @@ -334,9 +333,14 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { - final state = - await _loadPlanningState(repositories, context, localDate); - final task = _taskById(state.tasks, taskId); + var state = await _loadPlanningState(repositories, context, localDate); + var task = _taskById(state.tasks, taskId); + if (task == null) { + task = await repositories.tasks.findById(taskId); + if (task != null) { + state = state.withTask(task); + } + } if (task == null) { return _failure(ApplicationFailureCode.notFound, entityId: taskId); } @@ -432,8 +436,7 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { - final tasks = await repositories.tasks.findAll(); - final task = _taskById(tasks, taskId); + final task = await repositories.tasks.findById(taskId); if (task == null) { return _failure(ApplicationFailureCode.notFound, entityId: taskId); } @@ -457,13 +460,12 @@ class V1ApplicationCommandUseCases { detailCode: TaskTransitionOutcomeCode.invalidState.name, ); } - final afterTasks = _replaceTask(tasks, actionResult.task); return _persist( repositories: repositories, commandCode: commandCode, context: context, - beforeTasks: tasks, - afterTasks: afterTasks, + beforeTasks: [task], + afterTasks: [actionResult.task], activities: actionResult.activities, readHint: ApplicationCommandReadHint( affectedTaskIds: [taskId], @@ -488,9 +490,14 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { - final state = - await _loadPlanningState(repositories, context, localDate); - final task = _taskById(state.tasks, taskId); + var state = await _loadPlanningState(repositories, context, localDate); + var task = _taskById(state.tasks, taskId); + if (task == null) { + task = await repositories.tasks.findById(taskId); + if (task != null) { + state = state.withTask(task); + } + } if (task == null) { return _failure(ApplicationFailureCode.notFound, entityId: taskId); } @@ -548,9 +555,14 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { - final state = - await _loadPlanningState(repositories, context, localDate); - final task = _taskById(state.tasks, taskId); + var state = await _loadPlanningState(repositories, context, localDate); + var task = _taskById(state.tasks, taskId); + if (task == null) { + task = await repositories.tasks.findById(taskId); + if (task != null) { + state = state.withTask(task); + } + } if (task == null) { return _failure(ApplicationFailureCode.notFound, entityId: taskId); } @@ -664,7 +676,6 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { - final tasks = await repositories.tasks.findAll(); final freeSlot = freeSlotService.create( id: context.idGenerator.nextId(), title: title, @@ -678,8 +689,8 @@ class V1ApplicationCommandUseCases { repositories: repositories, commandCode: commandCode, context: context, - beforeTasks: tasks, - afterTasks: [...tasks, freeSlot], + beforeTasks: const [], + afterTasks: [freeSlot], activities: const [], readHint: ApplicationCommandReadHint( localDate: localDate, @@ -706,8 +717,7 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { - final tasks = await repositories.tasks.findAll(); - final task = _taskById(tasks, taskId); + final task = await repositories.tasks.findById(taskId); if (task == null) { return _failure(ApplicationFailureCode.notFound, entityId: taskId); } @@ -727,8 +737,8 @@ class V1ApplicationCommandUseCases { repositories: repositories, commandCode: commandCode, context: context, - beforeTasks: tasks, - afterTasks: _replaceTask(tasks, updated), + beforeTasks: [task], + afterTasks: [updated], activities: const [], readHint: ApplicationCommandReadHint( localDate: localDate, @@ -751,8 +761,7 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { - final tasks = await repositories.tasks.findAll(); - final task = _taskById(tasks, taskId); + final task = await repositories.tasks.findById(taskId); if (task == null) { return _failure(ApplicationFailureCode.notFound, entityId: taskId); } @@ -776,8 +785,8 @@ class V1ApplicationCommandUseCases { repositories: repositories, commandCode: commandCode, context: context, - beforeTasks: tasks, - afterTasks: _replaceTask(tasks, removed), + beforeTasks: [task], + afterTasks: [removed], activities: const [], readHint: ApplicationCommandReadHint( localDate: localDate, @@ -934,9 +943,14 @@ class V1ApplicationCommandUseCases { context: context, operationName: commandCode.name, action: (repositories) async { - final state = - await _loadPlanningState(repositories, context, localDate); - final task = _taskById(state.tasks, taskId); + var state = await _loadPlanningState(repositories, context, localDate); + var task = _taskById(state.tasks, taskId); + if (task == null) { + task = await repositories.tasks.findById(taskId); + if (task != null) { + state = state.withTask(task); + } + } if (task == null) { return _failure(ApplicationFailureCode.notFound, entityId: taskId); } @@ -1045,10 +1059,22 @@ class V1ApplicationCommandUseCases { timeZoneId: settings.timeZoneId, ); final window = SchedulingWindow(start: dayStart, end: dayEnd); - final tasks = await repositories.tasks.findAll(); + final tasks = (await repositories.tasks.findForLocalDay( + ownerId: ownerId, + localDate: localDate, + window: window, + )) + .items; final lockedExpansion = expandLockedBlocksForDay( - blocks: await repositories.lockedBlocks.findAllBlocks(), - overrides: await repositories.lockedBlocks.findAllOverrides(), + blocks: (await repositories.lockedBlocks.findBlocksByOwner( + ownerId: ownerId, + )) + .items, + overrides: (await repositories.lockedBlocks.findOverridesForDate( + ownerId: ownerId, + date: localDate, + )) + .items, date: localDate, timeZoneId: settings.timeZoneId, timeZoneResolver: timeZoneResolver, @@ -1168,6 +1194,17 @@ class _PlanningState { lockedIntervals: lockedIntervals, ); } + + _PlanningState withTask(Task task) { + if (tasks.any((existing) => existing.id == task.id)) { + return this; + } + return _PlanningState( + tasks: [task, ...tasks], + window: window, + lockedIntervals: lockedIntervals, + ); + } } ApplicationResult _failure( diff --git a/lib/src/application_layer.dart b/lib/src/application_layer.dart index 8e71641..9787319 100644 --- a/lib/src/application_layer.dart +++ b/lib/src/application_layer.dart @@ -265,27 +265,56 @@ abstract interface class TaskActivityRepository { Future> findByTaskId(String taskId); + Future> findByProjectId(String projectId); + Future> findAll(); + Future> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + Future save(TaskActivity activity); Future saveAll(Iterable activities); + + Future append({ + required TaskActivity activity, + required String ownerId, + }); } /// Repository contract for project statistics aggregates. abstract interface class ProjectStatisticsRepository { Future findByProjectId(String projectId); + Future?> findRecordByProjectId( + String projectId, + ); + Future> findAll(); Future save(ProjectStatistics statistics); + + Future saveIfRevision({ + required ProjectStatistics statistics, + required String ownerId, + required int expectedRevision, + }); } /// Repository contract for owner settings. abstract interface class OwnerSettingsRepository { Future findByOwnerId(String ownerId); + Future?> findRecordByOwnerId(String ownerId); + Future save(OwnerSettings settings); + + Future saveIfRevision({ + required OwnerSettings settings, + required int expectedRevision, + }); } /// Repository contract for consumed one-time notices. @@ -297,14 +326,32 @@ abstract interface class NoticeAcknowledgementRepository { Future> findByOwnerId(String ownerId); + Future> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + Future save(NoticeAcknowledgementRecord record); + + Future insert( + NoticeAcknowledgementRecord record, + ); } /// Repository contract for exactly-once operation records. abstract interface class ApplicationOperationRepository { Future findById(String operationId); + Future findByIdempotencyKey({ + required String ownerId, + required String operationId, + }); + Future save(ApplicationOperationRecord operation); + + Future insertIfAbsent( + ApplicationOperationRecord operation, + ); } /// Repository set available inside one application unit of work. @@ -396,16 +443,41 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { tasksById: { for (final task in initialTasks) task.id: task, }, + taskOwnersById: { + for (final task in initialTasks) task.id: 'owner-1', + }, + taskRevisionsById: { + for (final task in initialTasks) task.id: 1, + }, projectsById: { for (final project in initialProjects) project.id: project, }, + projectOwnersById: { + for (final project in initialProjects) project.id: 'owner-1', + }, + projectRevisionsById: { + for (final project in initialProjects) project.id: 1, + }, lockedBlocksById: { for (final block in initialLockedBlocks) block.id: block, }, + lockedBlockOwnersById: { + for (final block in initialLockedBlocks) block.id: 'owner-1', + }, + lockedBlockRevisionsById: { + for (final block in initialLockedBlocks) block.id: 1, + }, lockedOverridesById: { for (final override in initialLockedOverrides) override.id: override, }, + lockedOverrideOwnersById: { + for (final override in initialLockedOverrides) + override.id: 'owner-1', + }, + lockedOverrideRevisionsById: { + for (final override in initialLockedOverrides) override.id: 1, + }, schedulingSnapshotsById: { for (final snapshot in initialSchedulingSnapshots) snapshot.id: snapshot, @@ -413,14 +485,29 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { taskActivitiesById: { for (final activity in initialTaskActivities) activity.id: activity, }, + taskActivityOwnersById: { + for (final activity in initialTaskActivities) + activity.id: 'owner-1', + }, projectStatisticsByProjectId: { for (final statistics in initialProjectStatistics) statistics.projectId: statistics, }, + projectStatisticsOwnersByProjectId: { + for (final statistics in initialProjectStatistics) + statistics.projectId: 'owner-1', + }, + projectStatisticsRevisionsByProjectId: { + for (final statistics in initialProjectStatistics) + statistics.projectId: 1, + }, ownerSettingsByOwnerId: { for (final settings in initialOwnerSettings) settings.ownerId: settings, }, + ownerSettingsRevisionsByOwnerId: { + for (final settings in initialOwnerSettings) settings.ownerId: 1, + }, noticeAcknowledgementsByScopedId: { for (final acknowledgement in initialNoticeAcknowledgements) _noticeAcknowledgementKey( @@ -428,6 +515,13 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { noticeId: acknowledgement.noticeId, ): acknowledgement, }, + noticeAcknowledgementRevisionsByScopedId: { + for (final acknowledgement in initialNoticeAcknowledgements) + _noticeAcknowledgementKey( + ownerId: acknowledgement.ownerId, + noticeId: acknowledgement.noticeId, + ): 1, + }, operationsById: { for (final operation in initialOperations) operation.operationId: operation, @@ -615,48 +709,101 @@ class InMemoryApplicationUnitOfWork implements ApplicationUnitOfWork { class _InMemoryApplicationState { _InMemoryApplicationState({ required this.tasksById, + required this.taskOwnersById, + required this.taskRevisionsById, required this.projectsById, + required this.projectOwnersById, + required this.projectRevisionsById, required this.lockedBlocksById, + required this.lockedBlockOwnersById, + required this.lockedBlockRevisionsById, required this.lockedOverridesById, + required this.lockedOverrideOwnersById, + required this.lockedOverrideRevisionsById, required this.schedulingSnapshotsById, required this.taskActivitiesById, + required this.taskActivityOwnersById, required this.projectStatisticsByProjectId, + required this.projectStatisticsOwnersByProjectId, + required this.projectStatisticsRevisionsByProjectId, required this.ownerSettingsByOwnerId, + required this.ownerSettingsRevisionsByOwnerId, required this.noticeAcknowledgementsByScopedId, + required this.noticeAcknowledgementRevisionsByScopedId, required this.operationsById, }); final Map tasksById; + final Map taskOwnersById; + final Map taskRevisionsById; final Map projectsById; + final Map projectOwnersById; + final Map projectRevisionsById; final Map lockedBlocksById; + final Map lockedBlockOwnersById; + final Map lockedBlockRevisionsById; final Map lockedOverridesById; + final Map lockedOverrideOwnersById; + final Map lockedOverrideRevisionsById; final Map schedulingSnapshotsById; final Map taskActivitiesById; + final Map taskActivityOwnersById; final Map projectStatisticsByProjectId; + final Map projectStatisticsOwnersByProjectId; + final Map projectStatisticsRevisionsByProjectId; final Map ownerSettingsByOwnerId; + final Map ownerSettingsRevisionsByOwnerId; final Map noticeAcknowledgementsByScopedId; + final Map noticeAcknowledgementRevisionsByScopedId; final Map operationsById; _InMemoryApplicationState copy() { return _InMemoryApplicationState( tasksById: Map.of(tasksById), + taskOwnersById: Map.of(taskOwnersById), + taskRevisionsById: Map.of(taskRevisionsById), projectsById: Map.of(projectsById), + projectOwnersById: Map.of(projectOwnersById), + projectRevisionsById: Map.of(projectRevisionsById), lockedBlocksById: Map.of(lockedBlocksById), + lockedBlockOwnersById: Map.of(lockedBlockOwnersById), + lockedBlockRevisionsById: Map.of( + lockedBlockRevisionsById, + ), lockedOverridesById: Map.of(lockedOverridesById), + lockedOverrideOwnersById: Map.of( + lockedOverrideOwnersById, + ), + lockedOverrideRevisionsById: Map.of( + lockedOverrideRevisionsById, + ), schedulingSnapshotsById: Map.of(schedulingSnapshotsById), taskActivitiesById: Map.of(taskActivitiesById), + taskActivityOwnersById: Map.of(taskActivityOwnersById), projectStatisticsByProjectId: Map.of(projectStatisticsByProjectId), + projectStatisticsOwnersByProjectId: Map.of( + projectStatisticsOwnersByProjectId, + ), + projectStatisticsRevisionsByProjectId: Map.of( + projectStatisticsRevisionsByProjectId, + ), ownerSettingsByOwnerId: Map.of( ownerSettingsByOwnerId, ), + ownerSettingsRevisionsByOwnerId: Map.of( + ownerSettingsRevisionsByOwnerId, + ), noticeAcknowledgementsByScopedId: Map.of( noticeAcknowledgementsByScopedId, ), + noticeAcknowledgementRevisionsByScopedId: Map.of( + noticeAcknowledgementRevisionsByScopedId, + ), operationsById: Map.of( operationsById, ), @@ -667,26 +814,43 @@ class _InMemoryApplicationState { class _InMemoryApplicationRepositories implements ApplicationUnitOfWorkRepositories { _InMemoryApplicationRepositories(_InMemoryApplicationState state) - : tasks = _UnitOfWorkTaskRepository(state.tasksById), - projects = _UnitOfWorkProjectRepository(state.projectsById), + : tasks = _UnitOfWorkTaskRepository( + tasksById: state.tasksById, + ownersById: state.taskOwnersById, + revisionsById: state.taskRevisionsById, + ), + projects = _UnitOfWorkProjectRepository( + projectsById: state.projectsById, + ownersById: state.projectOwnersById, + revisionsById: state.projectRevisionsById, + ), lockedBlocks = _UnitOfWorkLockedBlockRepository( blocksById: state.lockedBlocksById, + blockOwnersById: state.lockedBlockOwnersById, + blockRevisionsById: state.lockedBlockRevisionsById, overridesById: state.lockedOverridesById, + overrideOwnersById: state.lockedOverrideOwnersById, + overrideRevisionsById: state.lockedOverrideRevisionsById, ), schedulingSnapshots = _UnitOfWorkSchedulingSnapshotRepository( state.schedulingSnapshotsById, ), taskActivities = _UnitOfWorkTaskActivityRepository( - state.taskActivitiesById, + activitiesById: state.taskActivitiesById, + ownersById: state.taskActivityOwnersById, ), projectStatistics = _UnitOfWorkProjectStatisticsRepository( - state.projectStatisticsByProjectId, + statisticsByProjectId: state.projectStatisticsByProjectId, + ownersByProjectId: state.projectStatisticsOwnersByProjectId, + revisionsByProjectId: state.projectStatisticsRevisionsByProjectId, ), ownerSettings = _UnitOfWorkOwnerSettingsRepository( - state.ownerSettingsByOwnerId, + settingsByOwnerId: state.ownerSettingsByOwnerId, + revisionsByOwnerId: state.ownerSettingsRevisionsByOwnerId, ), noticeAcknowledgements = _UnitOfWorkNoticeAcknowledgementRepository( - state.noticeAcknowledgementsByScopedId, + recordsByScopedId: state.noticeAcknowledgementsByScopedId, + revisionsByScopedId: state.noticeAcknowledgementRevisionsByScopedId, ), operations = _UnitOfWorkOperationRepository(state.operationsById); @@ -719,13 +883,34 @@ class _InMemoryApplicationRepositories } class _UnitOfWorkTaskRepository implements TaskRepository { - _UnitOfWorkTaskRepository(this._tasksById); + _UnitOfWorkTaskRepository({ + required Map tasksById, + required Map ownersById, + required Map revisionsById, + }) : _tasksById = tasksById, + _ownersById = ownersById, + _revisionsById = revisionsById; final Map _tasksById; + final Map _ownersById; + final Map _revisionsById; @override Future findById(String id) async => _tasksById[id]; + @override + Future?> findRecordById(String id) async { + final task = _tasksById[id]; + if (task == null) { + return null; + } + return RepositoryRecord( + value: task, + ownerId: _ownerFor(id), + revision: _revisionFor(id), + ); + } + @override Future> findAll() async { return List.unmodifiable(_tasksById.values); @@ -738,89 +923,566 @@ class _UnitOfWorkTaskRepository implements TaskRepository { ); } + @override + Future> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where((task) => _ownerFor(task.id) == ownerId) + .toList(growable: false) + ..sort(_compareTaskIds); + return _page(tasks, page); + } + + @override + Future> findByProjectId({ + required String ownerId, + required String projectId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == ownerId && task.projectId == projectId, + ) + .toList(growable: false) + ..sort(_compareTaskIds); + return _page(tasks, page); + } + + @override + Future> findByParentTaskId({ + required String ownerId, + required String parentTaskId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == ownerId && + task.parentTaskId == parentTaskId, + ) + .toList(growable: false) + ..sort(_compareTaskIds); + return _page(tasks, page); + } + + @override + Future> findBacklogCandidates( + BacklogCandidateQuery query, + ) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == query.ownerId && + task.status == TaskStatus.backlog && + (query.projectId == null || task.projectId == query.projectId) && + query.tags.every(task.backlogTags.contains), + ) + .toList(growable: false) + ..sort(_compareBacklogCandidates); + return _page(tasks, query.page); + } + @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); + return _taskOverlapsWindow(task, window); }), ); } + @override + Future> findScheduledInWindowForOwner({ + required String ownerId, + required SchedulingWindow window, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == ownerId && + _taskOverlapsWindow(task, window), + ) + .toList(growable: false) + ..sort(_compareScheduledTasks); + return _page(tasks, page); + } + + @override + Future> findForLocalDay({ + required String ownerId, + required CivilDate localDate, + required SchedulingWindow window, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) { + return findScheduledInWindowForOwner( + ownerId: ownerId, + window: window, + page: page, + ); + } + @override Future save(Task task) async { _tasksById[task.id] = task; + _ownersById.putIfAbsent(task.id, () => 'owner-1'); + _revisionsById[task.id] = _revisionFor(task.id) + 1; } @override Future saveAll(Iterable tasks) async { for (final task in tasks) { - _tasksById[task.id] = task; + await save(task); } } + + @override + Future insert({ + required Task task, + required String ownerId, + }) async { + if (_tasksById.containsKey(task.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: task.id, + ), + ); + } + _tasksById[task.id] = task; + _ownersById[task.id] = ownerId; + _revisionsById[task.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + @override + Future saveIfRevision({ + required Task task, + required String ownerId, + required int expectedRevision, + }) async { + if (!_tasksById.containsKey(task.id) || _ownerFor(task.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, entityId: task.id), + ); + } + final actualRevision = _revisionFor(task.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: task.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _tasksById[task.id] = task; + _ownersById[task.id] = ownerId; + _revisionsById[task.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + String _ownerFor(String id) => _ownersById[id] ?? 'owner-1'; + + int _revisionFor(String id) => _revisionsById[id] ?? 1; } class _UnitOfWorkProjectRepository implements ProjectRepository { - _UnitOfWorkProjectRepository(this._projectsById); + _UnitOfWorkProjectRepository({ + required Map projectsById, + required Map ownersById, + required Map revisionsById, + }) : _projectsById = projectsById, + _ownersById = ownersById, + _revisionsById = revisionsById; final Map _projectsById; + final Map _ownersById; + final Map _revisionsById; @override Future findById(String id) async => _projectsById[id]; + @override + Future?> findRecordById(String id) async { + final project = _projectsById[id]; + if (project == null) { + return null; + } + return RepositoryRecord( + value: project, + ownerId: _ownerFor(id), + revision: _revisionFor(id), + archivedAt: project.archivedAt, + ); + } + @override Future> findAll() async { return List.unmodifiable(_projectsById.values); } + @override + Future> findByOwner({ + required String ownerId, + bool includeArchived = false, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final projects = _projectsById.values + .where( + (project) => + _ownerFor(project.id) == ownerId && + (includeArchived || !project.isArchived), + ) + .toList(growable: false) + ..sort(_compareProjects); + return _page(projects, page); + } + @override Future save(ProjectProfile project) async { _projectsById[project.id] = project; + _ownersById.putIfAbsent(project.id, () => 'owner-1'); + _revisionsById[project.id] = _revisionFor(project.id) + 1; } + + @override + Future insert({ + required ProjectProfile project, + required String ownerId, + }) async { + if (_projectsById.containsKey(project.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: project.id, + ), + ); + } + _projectsById[project.id] = project; + _ownersById[project.id] = ownerId; + _revisionsById[project.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + @override + Future saveIfRevision({ + required ProjectProfile project, + required String ownerId, + required int expectedRevision, + }) async { + if (!_projectsById.containsKey(project.id) || + _ownerFor(project.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: project.id, + ), + ); + } + final actualRevision = _revisionFor(project.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: project.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _projectsById[project.id] = project; + _ownersById[project.id] = ownerId; + _revisionsById[project.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + @override + Future archive({ + required String projectId, + required String ownerId, + required int expectedRevision, + required DateTime archivedAt, + }) async { + final project = _projectsById[projectId]; + if (project == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: projectId, + ), + ); + } + return saveIfRevision( + project: project.copyWith(archivedAt: archivedAt), + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + } + + String _ownerFor(String id) => _ownersById[id] ?? 'owner-1'; + + int _revisionFor(String id) => _revisionsById[id] ?? 1; } class _UnitOfWorkLockedBlockRepository implements LockedBlockRepository { _UnitOfWorkLockedBlockRepository({ required Map blocksById, + required Map blockOwnersById, + required Map blockRevisionsById, required Map overridesById, + required Map overrideOwnersById, + required Map overrideRevisionsById, }) : _blocksById = blocksById, - _overridesById = overridesById; + _blockOwnersById = blockOwnersById, + _blockRevisionsById = blockRevisionsById, + _overridesById = overridesById, + _overrideOwnersById = overrideOwnersById, + _overrideRevisionsById = overrideRevisionsById; final Map _blocksById; + final Map _blockOwnersById; + final Map _blockRevisionsById; final Map _overridesById; + final Map _overrideOwnersById; + final Map _overrideRevisionsById; @override Future findBlockById(String id) async => _blocksById[id]; + @override + Future?> findBlockRecordById(String id) async { + final block = _blocksById[id]; + if (block == null) { + return null; + } + return RepositoryRecord( + value: block, + ownerId: _blockOwnerFor(id), + revision: _blockRevisionFor(id), + archivedAt: block.archivedAt, + ); + } + @override Future> findAllBlocks() async { return List.unmodifiable(_blocksById.values); } + @override + Future> findBlocksByOwner({ + required String ownerId, + bool includeArchived = false, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final blocks = _blocksById.values + .where( + (block) => + _blockOwnerFor(block.id) == ownerId && + (includeArchived || !block.isArchived), + ) + .toList(growable: false) + ..sort(_compareLockedBlocks); + return _page(blocks, page); + } + @override Future findOverrideById(String id) async { return _overridesById[id]; } + @override + Future?> findOverrideRecordById( + String id, + ) async { + final override = _overridesById[id]; + if (override == null) { + return null; + } + return RepositoryRecord( + value: override, + ownerId: _overrideOwnerFor(id), + revision: _overrideRevisionFor(id), + ); + } + @override Future> findAllOverrides() async { return List.unmodifiable(_overridesById.values); } + @override + Future> findOverridesForDate({ + required String ownerId, + required CivilDate date, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final overrides = _overridesById.values + .where( + (override) => + _overrideOwnerFor(override.id) == ownerId && + override.date == date, + ) + .toList(growable: false) + ..sort(_compareLockedOverrides); + return _page(overrides, page); + } + @override Future saveBlock(LockedBlock block) async { _blocksById[block.id] = block; + _blockOwnersById.putIfAbsent(block.id, () => 'owner-1'); + _blockRevisionsById[block.id] = _blockRevisionFor(block.id) + 1; } @override Future saveOverride(LockedBlockOverride override) async { _overridesById[override.id] = override; + _overrideOwnersById.putIfAbsent(override.id, () => 'owner-1'); + _overrideRevisionsById[override.id] = _overrideRevisionFor(override.id) + 1; } + + @override + Future insertBlock({ + required LockedBlock block, + required String ownerId, + }) async { + if (_blocksById.containsKey(block.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: block.id, + ), + ); + } + _blocksById[block.id] = block; + _blockOwnersById[block.id] = ownerId; + _blockRevisionsById[block.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + @override + Future saveBlockIfRevision({ + required LockedBlock block, + required String ownerId, + required int expectedRevision, + }) async { + if (!_blocksById.containsKey(block.id) || + _blockOwnerFor(block.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: block.id, + ), + ); + } + final actualRevision = _blockRevisionFor(block.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: block.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _blocksById[block.id] = block; + _blockOwnersById[block.id] = ownerId; + _blockRevisionsById[block.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + @override + Future archiveBlock({ + required String blockId, + required String ownerId, + required int expectedRevision, + required DateTime archivedAt, + }) async { + final block = _blocksById[blockId]; + if (block == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: blockId, + ), + ); + } + return saveBlockIfRevision( + block: block.copyWith(updatedAt: archivedAt, archivedAt: archivedAt), + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + } + + @override + Future insertOverride({ + required LockedBlockOverride override, + required String ownerId, + }) async { + if (_overridesById.containsKey(override.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: override.id, + ), + ); + } + _overridesById[override.id] = override; + _overrideOwnersById[override.id] = ownerId; + _overrideRevisionsById[override.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + @override + Future saveOverrideIfRevision({ + required LockedBlockOverride override, + required String ownerId, + required int expectedRevision, + }) async { + if (!_overridesById.containsKey(override.id) || + _overrideOwnerFor(override.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: override.id, + ), + ); + } + final actualRevision = _overrideRevisionFor(override.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: override.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _overridesById[override.id] = override; + _overrideOwnersById[override.id] = ownerId; + _overrideRevisionsById[override.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + String _blockOwnerFor(String id) => _blockOwnersById[id] ?? 'owner-1'; + + int _blockRevisionFor(String id) => _blockRevisionsById[id] ?? 1; + + String _overrideOwnerFor(String id) => _overrideOwnersById[id] ?? 'owner-1'; + + int _overrideRevisionFor(String id) => _overrideRevisionsById[id] ?? 1; } class _UnitOfWorkSchedulingSnapshotRepository @@ -852,9 +1514,14 @@ class _UnitOfWorkSchedulingSnapshotRepository } class _UnitOfWorkTaskActivityRepository implements TaskActivityRepository { - _UnitOfWorkTaskActivityRepository(this._activitiesById); + _UnitOfWorkTaskActivityRepository({ + required Map activitiesById, + required Map ownersById, + }) : _activitiesById = activitiesById, + _ownersById = ownersById; final Map _activitiesById; + final Map _ownersById; @override Future findById(String id) async => _activitiesById[id]; @@ -875,35 +1542,101 @@ class _UnitOfWorkTaskActivityRepository implements TaskActivityRepository { ); } + @override + Future> findByProjectId(String projectId) async { + return List.unmodifiable( + _activitiesById.values.where( + (activity) => activity.projectId == projectId, + ), + ); + } + @override Future> findAll() async { return List.unmodifiable(_activitiesById.values); } + @override + Future> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final activities = _activitiesById.values + .where((activity) => _ownerFor(activity.id) == ownerId) + .toList(growable: false) + ..sort(_compareActivities); + return _page(activities, page); + } + @override Future save(TaskActivity activity) async { _activitiesById[activity.id] = activity; + _ownersById.putIfAbsent(activity.id, () => 'owner-1'); } @override Future saveAll(Iterable activities) async { for (final activity in activities) { _activitiesById[activity.id] = activity; + _ownersById.putIfAbsent(activity.id, () => 'owner-1'); } } + + @override + Future append({ + required TaskActivity activity, + required String ownerId, + }) async { + if (_activitiesById.containsKey(activity.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: activity.id, + ), + ); + } + _activitiesById[activity.id] = activity; + _ownersById[activity.id] = ownerId; + return RepositoryMutationResult.success(revision: 1); + } + + String _ownerFor(String id) => _ownersById[id] ?? 'owner-1'; } class _UnitOfWorkProjectStatisticsRepository implements ProjectStatisticsRepository { - _UnitOfWorkProjectStatisticsRepository(this._statisticsByProjectId); + _UnitOfWorkProjectStatisticsRepository({ + required Map statisticsByProjectId, + required Map ownersByProjectId, + required Map revisionsByProjectId, + }) : _statisticsByProjectId = statisticsByProjectId, + _ownersByProjectId = ownersByProjectId, + _revisionsByProjectId = revisionsByProjectId; final Map _statisticsByProjectId; + final Map _ownersByProjectId; + final Map _revisionsByProjectId; @override Future findByProjectId(String projectId) async { return _statisticsByProjectId[projectId]; } + @override + Future?> findRecordByProjectId( + String projectId, + ) async { + final statistics = _statisticsByProjectId[projectId]; + if (statistics == null) { + return null; + } + return RepositoryRecord( + value: statistics, + ownerId: _ownersByProjectId[projectId] ?? 'owner-1', + revision: _revisionsByProjectId[projectId] ?? 1, + ); + } + @override Future> findAll() async { return List.unmodifiable(_statisticsByProjectId.values); @@ -912,30 +1645,125 @@ class _UnitOfWorkProjectStatisticsRepository @override Future save(ProjectStatistics statistics) async { _statisticsByProjectId[statistics.projectId] = statistics; + _ownersByProjectId.putIfAbsent(statistics.projectId, () => 'owner-1'); + _revisionsByProjectId[statistics.projectId] = + (_revisionsByProjectId[statistics.projectId] ?? 1) + 1; + } + + @override + Future saveIfRevision({ + required ProjectStatistics statistics, + required String ownerId, + required int expectedRevision, + }) async { + final projectId = statistics.projectId; + if (!_statisticsByProjectId.containsKey(projectId) || + (_ownersByProjectId[projectId] ?? 'owner-1') != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: projectId, + ), + ); + } + final actualRevision = _revisionsByProjectId[projectId] ?? 1; + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: projectId, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _statisticsByProjectId[projectId] = statistics; + _ownersByProjectId[projectId] = ownerId; + _revisionsByProjectId[projectId] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); } } class _UnitOfWorkOwnerSettingsRepository implements OwnerSettingsRepository { - _UnitOfWorkOwnerSettingsRepository(this._settingsByOwnerId); + _UnitOfWorkOwnerSettingsRepository({ + required Map settingsByOwnerId, + required Map revisionsByOwnerId, + }) : _settingsByOwnerId = settingsByOwnerId, + _revisionsByOwnerId = revisionsByOwnerId; final Map _settingsByOwnerId; + final Map _revisionsByOwnerId; @override Future findByOwnerId(String ownerId) async { return _settingsByOwnerId[ownerId]; } + @override + Future?> findRecordByOwnerId( + String ownerId, + ) async { + final settings = _settingsByOwnerId[ownerId]; + if (settings == null) { + return null; + } + return RepositoryRecord( + value: settings, + ownerId: ownerId, + revision: _revisionsByOwnerId[ownerId] ?? 1, + ); + } + @override Future save(OwnerSettings settings) async { _settingsByOwnerId[settings.ownerId] = settings; + _revisionsByOwnerId[settings.ownerId] = + (_revisionsByOwnerId[settings.ownerId] ?? 1) + 1; + } + + @override + Future saveIfRevision({ + required OwnerSettings settings, + required int expectedRevision, + }) async { + final ownerId = settings.ownerId; + if (!_settingsByOwnerId.containsKey(ownerId)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: ownerId, + ), + ); + } + final actualRevision = _revisionsByOwnerId[ownerId] ?? 1; + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: ownerId, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _settingsByOwnerId[ownerId] = settings; + _revisionsByOwnerId[ownerId] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); } } class _UnitOfWorkNoticeAcknowledgementRepository implements NoticeAcknowledgementRepository { - _UnitOfWorkNoticeAcknowledgementRepository(this._recordsByScopedId); + _UnitOfWorkNoticeAcknowledgementRepository({ + required Map recordsByScopedId, + required Map revisionsByScopedId, + }) : _recordsByScopedId = recordsByScopedId, + _revisionsByScopedId = revisionsByScopedId; final Map _recordsByScopedId; + final Map _revisionsByScopedId; @override Future findById({ @@ -955,12 +1783,47 @@ class _UnitOfWorkNoticeAcknowledgementRepository ); } + @override + Future> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final records = _recordsByScopedId.values + .where((record) => record.ownerId == ownerId) + .toList(growable: false) + ..sort(_compareNoticeAcknowledgements); + return _page(records, page); + } + @override Future save(NoticeAcknowledgementRecord record) async { - _recordsByScopedId[_noticeAcknowledgementKey( + final key = _noticeAcknowledgementKey( ownerId: record.ownerId, noticeId: record.noticeId, - )] = record; + ); + _recordsByScopedId[key] = record; + _revisionsByScopedId[key] = (_revisionsByScopedId[key] ?? 1) + 1; + } + + @override + Future insert( + NoticeAcknowledgementRecord record, + ) async { + final key = _noticeAcknowledgementKey( + ownerId: record.ownerId, + noticeId: record.noticeId, + ); + if (_recordsByScopedId.containsKey(key)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: record.noticeId, + ), + ); + } + _recordsByScopedId[key] = record; + _revisionsByScopedId[key] = 1; + return RepositoryMutationResult.success(revision: 1); } } @@ -974,10 +1837,38 @@ class _UnitOfWorkOperationRepository implements ApplicationOperationRepository { return _operationsById[operationId]; } + @override + Future findByIdempotencyKey({ + required String ownerId, + required String operationId, + }) async { + final operation = _operationsById[operationId]; + if (operation == null || operation.ownerId != ownerId) { + return null; + } + return operation; + } + @override Future save(ApplicationOperationRecord operation) async { _operationsById[operation.operationId] = operation; } + + @override + Future insertIfAbsent( + ApplicationOperationRecord operation, + ) async { + if (_operationsById.containsKey(operation.operationId)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateOperation, + entityId: operation.operationId, + ), + ); + } + _operationsById[operation.operationId] = operation; + return RepositoryMutationResult.success(revision: 1); + } } String _noticeAcknowledgementKey({ @@ -987,6 +1878,100 @@ String _noticeAcknowledgementKey({ return '$ownerId::$noticeId'; } +RepositoryPage _page(List sortedItems, RepositoryPageRequest request) { + final offset = int.tryParse(request.cursor ?? '') ?? 0; + final safeOffset = offset.clamp(0, sortedItems.length); + final end = (safeOffset + request.limit).clamp(0, sortedItems.length); + final items = sortedItems.sublist(safeOffset, end); + final nextCursor = end < sortedItems.length ? end.toString() : null; + return RepositoryPage( + items: List.unmodifiable(items), + nextCursor: nextCursor, + ); +} + +bool _taskOverlapsWindow(Task task, SchedulingWindow window) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + if (start == null || end == null) { + return false; + } + return TimeInterval(start: start, end: end).overlaps(window.interval); +} + +int _compareTaskIds(Task left, Task right) => left.id.compareTo(right.id); + +int _compareScheduledTasks(Task left, Task right) { + final leftStart = left.scheduledStart; + final rightStart = right.scheduledStart; + if (leftStart != null && rightStart != null) { + final startComparison = leftStart.compareTo(rightStart); + if (startComparison != 0) { + return startComparison; + } + } + return left.id.compareTo(right.id); +} + +int _compareBacklogCandidates(Task left, Task right) { + final createdComparison = left.createdAt.compareTo(right.createdAt); + if (createdComparison != 0) { + return createdComparison; + } + return left.id.compareTo(right.id); +} + +int _compareProjects(ProjectProfile left, ProjectProfile right) { + final nameComparison = left.name.compareTo(right.name); + if (nameComparison != 0) { + return nameComparison; + } + return left.id.compareTo(right.id); +} + +int _compareLockedBlocks(LockedBlock left, LockedBlock right) { + final nameComparison = left.name.compareTo(right.name); + if (nameComparison != 0) { + return nameComparison; + } + return left.id.compareTo(right.id); +} + +int _compareLockedOverrides( + LockedBlockOverride left, + LockedBlockOverride right, +) { + final dateComparison = left.date.compareTo(right.date); + if (dateComparison != 0) { + return dateComparison; + } + final createdComparison = left.createdAt.compareTo(right.createdAt); + if (createdComparison != 0) { + return createdComparison; + } + return left.id.compareTo(right.id); +} + +int _compareActivities(TaskActivity left, TaskActivity right) { + final occurredComparison = left.occurredAt.compareTo(right.occurredAt); + if (occurredComparison != 0) { + return occurredComparison; + } + return left.id.compareTo(right.id); +} + +int _compareNoticeAcknowledgements( + NoticeAcknowledgementRecord left, + NoticeAcknowledgementRecord right, +) { + final acknowledgedComparison = + left.acknowledgedAt.compareTo(right.acknowledgedAt); + if (acknowledgedComparison != 0) { + return acknowledgedComparison; + } + return left.noticeId.compareTo(right.noticeId); +} + String _requiredTrimmed(String value, String name) { final trimmed = value.trim(); if (trimmed.isEmpty) { diff --git a/lib/src/application_management.dart b/lib/src/application_management.dart index 7030721..568d212 100644 --- a/lib/src/application_management.dart +++ b/lib/src/application_management.dart @@ -312,7 +312,11 @@ class V1ApplicationManagementUseCases { ) { return applicationStore.read( action: (repositories) async { - final projects = await repositories.projects.findAll(); + final projects = (await repositories.projects.findByOwner( + ownerId: request.context.ownerTimeZone.ownerId, + includeArchived: request.includeArchived, + )) + .items; final resolutions = []; final sortedProjects = [...projects] ..sort((a, b) => a.name.compareTo(b.name)); diff --git a/lib/src/application_recovery.dart b/lib/src/application_recovery.dart index a064b42..9e8f480 100644 --- a/lib/src/application_recovery.dart +++ b/lib/src/application_recovery.dart @@ -146,8 +146,15 @@ class V1AppOpenRecoveryUseCases { sourceWindow: sourceWindow, targetWindow: targetWindow, ); - final blocks = await repositories.lockedBlocks.findAllBlocks(); - final overrides = await repositories.lockedBlocks.findAllOverrides(); + final blocks = (await repositories.lockedBlocks.findBlocksByOwner( + ownerId: context.ownerTimeZone.ownerId, + )) + .items; + final overrides = (await repositories.lockedBlocks.findOverridesForDate( + ownerId: context.ownerTimeZone.ownerId, + date: targetDate, + )) + .items; final lockedExpansion = expandLockedBlocksForDay( blocks: blocks, overrides: overrides, diff --git a/lib/src/repositories.dart b/lib/src/repositories.dart index d6c618b..70e601d 100644 --- a/lib/src/repositories.dart +++ b/lib/src/repositories.dart @@ -7,26 +7,187 @@ import 'locked_time.dart'; import 'models.dart'; import 'scheduling_engine.dart'; +import 'time_contracts.dart'; + +/// Stable repository mutation failure categories. +enum RepositoryFailureCode { + notFound, + staleRevision, + duplicateId, + duplicateOperation, +} + +/// Typed repository failure for optimistic writes and uniqueness checks. +class RepositoryFailure { + const RepositoryFailure({ + required this.code, + this.entityId, + this.expectedRevision, + this.actualRevision, + }); + + final RepositoryFailureCode code; + final String? entityId; + final int? expectedRevision; + final int? actualRevision; +} + +/// Mutation result that avoids throwing for expected repository conflicts. +class RepositoryMutationResult { + const RepositoryMutationResult._({ + this.revision, + this.failure, + }); + + factory RepositoryMutationResult.success({required int revision}) { + return RepositoryMutationResult._(revision: revision); + } + + factory RepositoryMutationResult.failure(RepositoryFailure failure) { + return RepositoryMutationResult._(failure: failure); + } + + final int? revision; + final RepositoryFailure? failure; + + bool get isSuccess => failure == null; +} + +/// Repository value plus adapter-neutral metadata. +class RepositoryRecord { + const RepositoryRecord({ + required this.value, + required this.ownerId, + required this.revision, + this.archivedAt, + }); + + final T value; + final String ownerId; + final int revision; + final DateTime? archivedAt; + + bool get isArchived => archivedAt != null; +} + +/// Cursor contract for bounded repository queries. +class RepositoryPageRequest { + const RepositoryPageRequest({ + this.cursor, + this.limit = 100, + }) : assert(limit > 0); + + /// Adapter-neutral cursor. In-memory repositories use an integer offset. + final String? cursor; + + /// Maximum number of records to return. + final int limit; +} + +/// One bounded page of repository query results. +class RepositoryPage { + const RepositoryPage({ + required this.items, + this.nextCursor, + }); + + final List items; + final String? nextCursor; + + bool get hasMore => nextCursor != null; +} + +/// Status/project filters for backlog candidate queries. +class BacklogCandidateQuery { + const BacklogCandidateQuery({ + required this.ownerId, + this.projectId, + this.tags = const {}, + this.page = const RepositoryPageRequest(), + }); + + final String ownerId; + final String? projectId; + final Set tags; + final RepositoryPageRequest page; +} /// Repository contract for task documents. abstract interface class TaskRepository { /// Return a task by stable id, or null when it does not exist. Future findById(String id); + /// Return a task plus owner/revision metadata. + Future?> findRecordById(String id); + /// Return all tasks currently known to the repository. Future> findAll(); /// Return tasks with a lifecycle status. Future> findByStatus(TaskStatus status); + /// Return owner-scoped tasks in deterministic id order. + Future> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Return tasks assigned to [projectId]. + Future> findByProjectId({ + required String ownerId, + required String projectId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Return direct children owned by [parentTaskId]. + Future> findByParentTaskId({ + required String ownerId, + required String parentTaskId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Return backlog candidates ordered by backlog age, then id. + Future> findBacklogCandidates( + BacklogCandidateQuery query, + ); + /// Return tasks that overlap [window] by scheduled start/end. Future> findScheduledInWindow(SchedulingWindow window); + /// Return owner-scoped scheduled tasks that overlap [window]. + Future> findScheduledInWindowForOwner({ + required String ownerId, + required SchedulingWindow window, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + + /// Return tasks in a local-day window. Civil date is supplied for adapters + /// that store date-derived keys; in-memory filtering uses [window]. + Future> findForLocalDay({ + required String ownerId, + required CivilDate localDate, + required SchedulingWindow window, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + /// Save or replace [task] by stable id. Future save(Task task); /// Save or replace every task by stable id. Future saveAll(Iterable tasks); + + /// Insert [task] if its id is unused. + Future insert({ + required Task task, + required String ownerId, + }); + + /// Compare-and-set save by expected repository revision. + Future saveIfRevision({ + required Task task, + required String ownerId, + required int expectedRevision, + }); } /// Repository contract for project profile documents. @@ -34,11 +195,42 @@ abstract interface class ProjectRepository { /// Return a project by stable id, or null when it does not exist. Future findById(String id); + /// Return a project plus owner/revision metadata. + Future?> findRecordById(String id); + /// Return all projects currently known to the repository. Future> findAll(); + /// Return owner-scoped projects, optionally including archived profiles. + Future> findByOwner({ + required String ownerId, + bool includeArchived = false, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + /// Save or replace [project] by stable id. Future save(ProjectProfile project); + + /// Insert [project] if its id is unused. + Future insert({ + required ProjectProfile project, + required String ownerId, + }); + + /// Compare-and-set save by expected repository revision. + Future saveIfRevision({ + required ProjectProfile project, + required String ownerId, + required int expectedRevision, + }); + + /// Archive a project without deleting task history. + Future archive({ + required String projectId, + required String ownerId, + required int expectedRevision, + required DateTime archivedAt, + }); } /// Repository contract for locked-block and one-day override documents. @@ -46,20 +238,71 @@ abstract interface class LockedBlockRepository { /// Return a locked block by stable id, or null when it does not exist. Future findBlockById(String id); + /// Return a locked block plus owner/revision metadata. + Future?> findBlockRecordById(String id); + /// Return all locked block definitions. Future> findAllBlocks(); + /// Return owner-scoped locked blocks, optionally including archived blocks. + Future> findBlocksByOwner({ + required String ownerId, + bool includeArchived = false, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + /// Return an override by stable id, or null when it does not exist. Future findOverrideById(String id); + /// Return an override plus owner/revision metadata. + Future?> findOverrideRecordById( + String id, + ); + /// Return all one-day overrides. Future> findAllOverrides(); + /// Return owner-scoped one-day overrides for [date]. + Future> findOverridesForDate({ + required String ownerId, + required CivilDate date, + RepositoryPageRequest page = const RepositoryPageRequest(), + }); + /// Save or replace [block] by stable id. Future saveBlock(LockedBlock block); /// Save or replace [override] by stable id. Future saveOverride(LockedBlockOverride override); + + Future insertBlock({ + required LockedBlock block, + required String ownerId, + }); + + Future saveBlockIfRevision({ + required LockedBlock block, + required String ownerId, + required int expectedRevision, + }); + + Future archiveBlock({ + required String blockId, + required String ownerId, + required int expectedRevision, + required DateTime archivedAt, + }); + + Future insertOverride({ + required LockedBlockOverride override, + required String ownerId, + }); + + Future saveOverrideIfRevision({ + required LockedBlockOverride override, + required String ownerId, + required int expectedRevision, + }); } /// Snapshot of one scheduling operation or persisted planning state. @@ -149,18 +392,42 @@ abstract interface class SchedulingSnapshotRepository { /// In-memory task repository useful for tests and early application wiring. class InMemoryTaskRepository implements TaskRepository { - InMemoryTaskRepository([Iterable initialTasks = const []]) - : _tasksById = { + InMemoryTaskRepository([ + Iterable initialTasks = const [], + this.defaultOwnerId = 'owner-1', + ]) : _tasksById = { for (final task in initialTasks) task.id: task, + }, + _ownersById = { + for (final task in initialTasks) task.id: defaultOwnerId, + }, + _revisionsById = { + for (final task in initialTasks) task.id: 1, }; final Map _tasksById; + final Map _ownersById; + final Map _revisionsById; + final String defaultOwnerId; @override Future findById(String id) async { return _tasksById[id]; } + @override + Future?> findRecordById(String id) async { + final task = _tasksById[id]; + if (task == null) { + return null; + } + return RepositoryRecord( + value: task, + ownerId: _ownerFor(id), + revision: _revisionFor(id), + ); + } + @override Future> findAll() async { return List.unmodifiable(_tasksById.values); @@ -173,57 +440,326 @@ class InMemoryTaskRepository implements TaskRepository { ); } + @override + Future> findByOwner({ + required String ownerId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where((task) => _ownerFor(task.id) == ownerId) + .toList(growable: false) + ..sort(_compareTaskIds); + return _page(tasks, page); + } + + @override + Future> findByProjectId({ + required String ownerId, + required String projectId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == ownerId && task.projectId == projectId, + ) + .toList(growable: false) + ..sort(_compareTaskIds); + return _page(tasks, page); + } + + @override + Future> findByParentTaskId({ + required String ownerId, + required String parentTaskId, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == ownerId && + task.parentTaskId == parentTaskId, + ) + .toList(growable: false) + ..sort(_compareTaskIds); + return _page(tasks, page); + } + + @override + Future> findBacklogCandidates( + BacklogCandidateQuery query, + ) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == query.ownerId && + task.status == TaskStatus.backlog && + (query.projectId == null || task.projectId == query.projectId) && + query.tags.every(task.backlogTags.contains), + ) + .toList(growable: false) + ..sort(_compareBacklogCandidates); + return _page(tasks, query.page); + } + @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); + return _taskOverlapsWindow(task, window); }), ); } + @override + Future> findScheduledInWindowForOwner({ + required String ownerId, + required SchedulingWindow window, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final tasks = _tasksById.values + .where( + (task) => + _ownerFor(task.id) == ownerId && + _taskOverlapsWindow( + task, + window, + ), + ) + .toList(growable: false) + ..sort(_compareScheduledTasks); + return _page(tasks, page); + } + + @override + Future> findForLocalDay({ + required String ownerId, + required CivilDate localDate, + required SchedulingWindow window, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) { + return findScheduledInWindowForOwner( + ownerId: ownerId, + window: window, + page: page, + ); + } + @override Future save(Task task) async { _tasksById[task.id] = task; + _ownersById.putIfAbsent(task.id, () => defaultOwnerId); + _revisionsById[task.id] = _revisionFor(task.id) + 1; } @override Future saveAll(Iterable tasks) async { for (final task in tasks) { - _tasksById[task.id] = task; + await save(task); } } + + @override + Future insert({ + required Task task, + required String ownerId, + }) async { + if (_tasksById.containsKey(task.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: task.id, + ), + ); + } + _tasksById[task.id] = task; + _ownersById[task.id] = ownerId; + _revisionsById[task.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + @override + Future saveIfRevision({ + required Task task, + required String ownerId, + required int expectedRevision, + }) async { + final current = _tasksById[task.id]; + if (current == null || _ownerFor(task.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, entityId: task.id), + ); + } + final actualRevision = _revisionFor(task.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: task.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _tasksById[task.id] = task; + _ownersById[task.id] = ownerId; + _revisionsById[task.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + String _ownerFor(String id) => _ownersById[id] ?? defaultOwnerId; + + int _revisionFor(String id) => _revisionsById[id] ?? 1; } /// In-memory project repository useful for tests and early application wiring. class InMemoryProjectRepository implements ProjectRepository { InMemoryProjectRepository( - [Iterable initialProjects = const []]) + [Iterable initialProjects = const [], + this.defaultOwnerId = 'owner-1']) : _projectsById = { for (final project in initialProjects) project.id: project, + }, + _ownersById = { + for (final project in initialProjects) project.id: defaultOwnerId, + }, + _revisionsById = { + for (final project in initialProjects) project.id: 1, }; final Map _projectsById; + final Map _ownersById; + final Map _revisionsById; + final String defaultOwnerId; @override Future findById(String id) async { return _projectsById[id]; } + @override + Future?> findRecordById(String id) async { + final project = _projectsById[id]; + if (project == null) { + return null; + } + return RepositoryRecord( + value: project, + ownerId: _ownerFor(id), + revision: _revisionFor(id), + archivedAt: project.archivedAt, + ); + } + @override Future> findAll() async { return List.unmodifiable(_projectsById.values); } + @override + Future> findByOwner({ + required String ownerId, + bool includeArchived = false, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final projects = _projectsById.values + .where( + (project) => + _ownerFor(project.id) == ownerId && + (includeArchived || !project.isArchived), + ) + .toList(growable: false) + ..sort(_compareProjects); + return _page(projects, page); + } + @override Future save(ProjectProfile project) async { _projectsById[project.id] = project; + _ownersById.putIfAbsent(project.id, () => defaultOwnerId); + _revisionsById[project.id] = _revisionFor(project.id) + 1; } + + @override + Future insert({ + required ProjectProfile project, + required String ownerId, + }) async { + if (_projectsById.containsKey(project.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: project.id, + ), + ); + } + _projectsById[project.id] = project; + _ownersById[project.id] = ownerId; + _revisionsById[project.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + @override + Future saveIfRevision({ + required ProjectProfile project, + required String ownerId, + required int expectedRevision, + }) async { + final current = _projectsById[project.id]; + if (current == null || _ownerFor(project.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: project.id, + ), + ); + } + final actualRevision = _revisionFor(project.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: project.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _projectsById[project.id] = project; + _ownersById[project.id] = ownerId; + _revisionsById[project.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + @override + Future archive({ + required String projectId, + required String ownerId, + required int expectedRevision, + required DateTime archivedAt, + }) async { + final project = _projectsById[projectId]; + if (project == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: projectId, + ), + ); + } + return saveIfRevision( + project: project.copyWith(archivedAt: archivedAt), + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + } + + String _ownerFor(String id) => _ownersById[id] ?? defaultOwnerId; + + int _revisionFor(String id) => _revisionsById[id] ?? 1; } /// In-memory locked-time repository useful for tests and early wiring. @@ -231,45 +767,266 @@ class InMemoryLockedBlockRepository implements LockedBlockRepository { InMemoryLockedBlockRepository({ Iterable initialBlocks = const [], Iterable initialOverrides = const [], + this.defaultOwnerId = 'owner-1', }) : _blocksById = { for (final block in initialBlocks) block.id: block, }, + _blockOwnersById = { + for (final block in initialBlocks) block.id: defaultOwnerId, + }, + _blockRevisionsById = { + for (final block in initialBlocks) block.id: 1, + }, _overridesById = { for (final override in initialOverrides) override.id: override, + }, + _overrideOwnersById = { + for (final override in initialOverrides) override.id: defaultOwnerId, + }, + _overrideRevisionsById = { + for (final override in initialOverrides) override.id: 1, }; final Map _blocksById; + final Map _blockOwnersById; + final Map _blockRevisionsById; final Map _overridesById; + final Map _overrideOwnersById; + final Map _overrideRevisionsById; + final String defaultOwnerId; @override Future findBlockById(String id) async { return _blocksById[id]; } + @override + Future?> findBlockRecordById(String id) async { + final block = _blocksById[id]; + if (block == null) { + return null; + } + return RepositoryRecord( + value: block, + ownerId: _blockOwnerFor(id), + revision: _blockRevisionFor(id), + archivedAt: block.archivedAt, + ); + } + @override Future> findAllBlocks() async { return List.unmodifiable(_blocksById.values); } + @override + Future> findBlocksByOwner({ + required String ownerId, + bool includeArchived = false, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final blocks = _blocksById.values + .where( + (block) => + _blockOwnerFor(block.id) == ownerId && + (includeArchived || !block.isArchived), + ) + .toList(growable: false) + ..sort(_compareLockedBlocks); + return _page(blocks, page); + } + @override Future findOverrideById(String id) async { return _overridesById[id]; } + @override + Future?> findOverrideRecordById( + String id, + ) async { + final override = _overridesById[id]; + if (override == null) { + return null; + } + return RepositoryRecord( + value: override, + ownerId: _overrideOwnerFor(id), + revision: _overrideRevisionFor(id), + ); + } + @override Future> findAllOverrides() async { return List.unmodifiable(_overridesById.values); } + @override + Future> findOverridesForDate({ + required String ownerId, + required CivilDate date, + RepositoryPageRequest page = const RepositoryPageRequest(), + }) async { + final overrides = _overridesById.values + .where( + (override) => + _overrideOwnerFor(override.id) == ownerId && + override.date == date, + ) + .toList(growable: false) + ..sort(_compareLockedOverrides); + return _page(overrides, page); + } + @override Future saveBlock(LockedBlock block) async { _blocksById[block.id] = block; + _blockOwnersById.putIfAbsent(block.id, () => defaultOwnerId); + _blockRevisionsById[block.id] = _blockRevisionFor(block.id) + 1; } @override Future saveOverride(LockedBlockOverride override) async { _overridesById[override.id] = override; + _overrideOwnersById.putIfAbsent(override.id, () => defaultOwnerId); + _overrideRevisionsById[override.id] = _overrideRevisionFor(override.id) + 1; } + + @override + Future insertBlock({ + required LockedBlock block, + required String ownerId, + }) async { + if (_blocksById.containsKey(block.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: block.id, + ), + ); + } + _blocksById[block.id] = block; + _blockOwnersById[block.id] = ownerId; + _blockRevisionsById[block.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + @override + Future saveBlockIfRevision({ + required LockedBlock block, + required String ownerId, + required int expectedRevision, + }) async { + final current = _blocksById[block.id]; + if (current == null || _blockOwnerFor(block.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: block.id, + ), + ); + } + final actualRevision = _blockRevisionFor(block.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: block.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _blocksById[block.id] = block; + _blockOwnersById[block.id] = ownerId; + _blockRevisionsById[block.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + @override + Future archiveBlock({ + required String blockId, + required String ownerId, + required int expectedRevision, + required DateTime archivedAt, + }) async { + final block = _blocksById[blockId]; + if (block == null) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: blockId, + ), + ); + } + return saveBlockIfRevision( + block: block.copyWith(updatedAt: archivedAt, archivedAt: archivedAt), + ownerId: ownerId, + expectedRevision: expectedRevision, + ); + } + + @override + Future insertOverride({ + required LockedBlockOverride override, + required String ownerId, + }) async { + if (_overridesById.containsKey(override.id)) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.duplicateId, + entityId: override.id, + ), + ); + } + _overridesById[override.id] = override; + _overrideOwnersById[override.id] = ownerId; + _overrideRevisionsById[override.id] = 1; + return RepositoryMutationResult.success(revision: 1); + } + + @override + Future saveOverrideIfRevision({ + required LockedBlockOverride override, + required String ownerId, + required int expectedRevision, + }) async { + final current = _overridesById[override.id]; + if (current == null || _overrideOwnerFor(override.id) != ownerId) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.notFound, + entityId: override.id, + ), + ); + } + final actualRevision = _overrideRevisionFor(override.id); + if (actualRevision != expectedRevision) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.staleRevision, + entityId: override.id, + expectedRevision: expectedRevision, + actualRevision: actualRevision, + ), + ); + } + final nextRevision = actualRevision + 1; + _overridesById[override.id] = override; + _overrideOwnersById[override.id] = ownerId; + _overrideRevisionsById[override.id] = nextRevision; + return RepositoryMutationResult.success(revision: nextRevision); + } + + String _blockOwnerFor(String id) => _blockOwnersById[id] ?? defaultOwnerId; + + int _blockRevisionFor(String id) => _blockRevisionsById[id] ?? 1; + + String _overrideOwnerFor(String id) => + _overrideOwnersById[id] ?? defaultOwnerId; + + int _overrideRevisionFor(String id) => _overrideRevisionsById[id] ?? 1; } /// In-memory snapshot repository useful for tests and early application wiring. @@ -304,3 +1061,77 @@ class InMemorySchedulingSnapshotRepository _snapshotsById[snapshot.id] = snapshot; } } + +RepositoryPage _page(List sortedItems, RepositoryPageRequest request) { + final offset = int.tryParse(request.cursor ?? '') ?? 0; + final safeOffset = offset.clamp(0, sortedItems.length); + final end = (safeOffset + request.limit).clamp(0, sortedItems.length); + final items = sortedItems.sublist(safeOffset, end); + final nextCursor = end < sortedItems.length ? end.toString() : null; + return RepositoryPage( + items: List.unmodifiable(items), + nextCursor: nextCursor, + ); +} + +bool _taskOverlapsWindow(Task task, SchedulingWindow window) { + final start = task.scheduledStart; + final end = task.scheduledEnd; + if (start == null || end == null) { + return false; + } + return TimeInterval(start: start, end: end).overlaps(window.interval); +} + +int _compareTaskIds(Task left, Task right) => left.id.compareTo(right.id); + +int _compareScheduledTasks(Task left, Task right) { + final leftStart = left.scheduledStart; + final rightStart = right.scheduledStart; + if (leftStart != null && rightStart != null) { + final startComparison = leftStart.compareTo(rightStart); + if (startComparison != 0) { + return startComparison; + } + } + return left.id.compareTo(right.id); +} + +int _compareBacklogCandidates(Task left, Task right) { + final createdComparison = left.createdAt.compareTo(right.createdAt); + if (createdComparison != 0) { + return createdComparison; + } + return left.id.compareTo(right.id); +} + +int _compareProjects(ProjectProfile left, ProjectProfile right) { + final nameComparison = left.name.compareTo(right.name); + if (nameComparison != 0) { + return nameComparison; + } + return left.id.compareTo(right.id); +} + +int _compareLockedBlocks(LockedBlock left, LockedBlock right) { + final nameComparison = left.name.compareTo(right.name); + if (nameComparison != 0) { + return nameComparison; + } + return left.id.compareTo(right.id); +} + +int _compareLockedOverrides( + LockedBlockOverride left, + LockedBlockOverride right, +) { + final dateComparison = left.date.compareTo(right.date); + if (dateComparison != 0) { + return dateComparison; + } + final createdComparison = left.createdAt.compareTo(right.createdAt); + if (createdComparison != 0) { + return createdComparison; + } + return left.id.compareTo(right.id); +} diff --git a/lib/src/today_state.dart b/lib/src/today_state.dart index e4df6e1..f019b8b 100644 --- a/lib/src/today_state.dart +++ b/lib/src/today_state.dart @@ -327,10 +327,26 @@ class GetTodayStateQuery { ); final window = SchedulingWindow(start: dayStart, end: dayEnd); - final projects = await repositories.projects.findAll(); - final tasks = await repositories.tasks.findScheduledInWindow(window); - final blocks = await repositories.lockedBlocks.findAllBlocks(); - final overrides = await repositories.lockedBlocks.findAllOverrides(); + final projects = (await repositories.projects.findByOwner( + ownerId: ownerId, + includeArchived: true, + )) + .items; + final tasks = (await repositories.tasks.findForLocalDay( + ownerId: ownerId, + localDate: request.date, + window: window, + )) + .items; + final blocks = (await repositories.lockedBlocks.findBlocksByOwner( + ownerId: ownerId, + )) + .items; + final overrides = (await repositories.lockedBlocks.findOverridesForDate( + ownerId: ownerId, + date: request.date, + )) + .items; final snapshots = await repositories.schedulingSnapshots.findInWindow(window); final acknowledgedNoticeIds = diff --git a/test/application_layer_test.dart b/test/application_layer_test.dart index 11ed25c..bd53de6 100644 --- a/test/application_layer_test.dart +++ b/test/application_layer_test.dart @@ -188,6 +188,123 @@ void main() { result.failure!.detailCode, DomainValidationCode.blankStableId.name); expect(unitOfWork.currentOperations, isEmpty); }); + + test( + 'repository contracts expose activity settings notice and operation ' + 'conflicts', () async { + final unitOfWork = InMemoryApplicationUnitOfWork( + initialOwnerSettings: [ + OwnerSettings(ownerId: 'owner-1', timeZoneId: 'America/Los_Angeles'), + ], + ); + + final result = await unitOfWork.run( + context: appContext(operationId: 'repo-contracts', now: now), + operationName: 'repo-contracts', + action: (repositories) async { + final activity = activityFor( + id: 'activity-1', + operationId: 'manual-op', + task: task( + id: 'task-1', + status: TaskStatus.completed, + createdAt: now, + ), + occurredAt: now, + ); + final appended = await repositories.taskActivities.append( + activity: activity, + ownerId: 'owner-1', + ); + final duplicateActivity = await repositories.taskActivities.append( + activity: activity, + ownerId: 'owner-1', + ); + final activities = await repositories.taskActivities.findByOwner( + ownerId: 'owner-1', + ); + + final settingsRecord = + await repositories.ownerSettings.findRecordByOwnerId('owner-1'); + final staleSettings = await repositories.ownerSettings.saveIfRevision( + settings: settingsRecord!.value.copyWith(compactModeEnabled: true), + expectedRevision: 99, + ); + final savedSettings = await repositories.ownerSettings.saveIfRevision( + settings: settingsRecord.value.copyWith(compactModeEnabled: true), + expectedRevision: settingsRecord.revision, + ); + + final acknowledgement = NoticeAcknowledgementRecord( + noticeId: 'snapshot:0', + ownerId: 'owner-1', + acknowledgedAt: now, + ); + final insertedNotice = + await repositories.noticeAcknowledgements.insert(acknowledgement); + final duplicateNotice = + await repositories.noticeAcknowledgements.insert(acknowledgement); + final notices = await repositories.noticeAcknowledgements.findByOwner( + ownerId: 'owner-1', + ); + + final operation = ApplicationOperationRecord( + operationId: 'manual-op', + ownerId: 'owner-1', + operationName: 'manual', + committedAt: now, + ); + final insertedOperation = + await repositories.operations.insertIfAbsent(operation); + final duplicateOperation = + await repositories.operations.insertIfAbsent(operation); + final idempotentOperation = + await repositories.operations.findByIdempotencyKey( + ownerId: 'owner-1', + operationId: 'manual-op', + ); + + expect(appended.isSuccess, isTrue); + expect( + duplicateActivity.failure!.code, + RepositoryFailureCode.duplicateId, + ); + expect(activities.items.map((activity) => activity.id), [ + 'activity-1', + ]); + expect( + staleSettings.failure!.code, + RepositoryFailureCode.staleRevision, + ); + expect(savedSettings.revision, 2); + expect(insertedNotice.isSuccess, isTrue); + expect( + duplicateNotice.failure!.code, + RepositoryFailureCode.duplicateId, + ); + expect(notices.items.map((notice) => notice.noticeId), [ + 'snapshot:0', + ]); + expect(insertedOperation.isSuccess, isTrue); + expect( + duplicateOperation.failure!.code, + RepositoryFailureCode.duplicateOperation, + ); + expect(idempotentOperation!.operationName, 'manual'); + return ApplicationResult.success(null); + }, + ); + + expect(result.isSuccess, isTrue); + expect(unitOfWork.currentTaskActivities.single.id, 'activity-1'); + expect(unitOfWork.currentOwnerSettings.single.compactModeEnabled, isTrue); + expect(unitOfWork.currentNoticeAcknowledgements.single.noticeId, + 'snapshot:0'); + expect( + unitOfWork.currentOperations.map((operation) => operation.operationId), + containsAll(['manual-op', 'repo-contracts']), + ); + }); }); group('Application scheduling loader', () { diff --git a/test/repositories_test.dart b/test/repositories_test.dart index e97659e..cf43b53 100644 --- a/test/repositories_test.dart +++ b/test/repositories_test.dart @@ -122,6 +122,214 @@ void main() { ); }); }); + + runCoreRepositoryConformance( + createTaskRepository: () => InMemoryTaskRepository( + [ + task( + id: 'backlog-a', + title: 'Backlog A', + status: TaskStatus.backlog, + createdAt: DateTime.utc(2026, 6, 20), + ), + task( + id: 'backlog-b', + title: 'Backlog B', + status: TaskStatus.backlog, + createdAt: DateTime.utc(2026, 6, 21), + ).copyWith(projectId: 'home', parentTaskId: 'parent'), + task( + id: 'scheduled', + title: 'Scheduled', + status: TaskStatus.planned, + createdAt: DateTime.utc(2026, 6, 21), + scheduledStart: DateTime.utc(2026, 6, 22, 10), + scheduledEnd: DateTime.utc(2026, 6, 22, 10, 30), + ).copyWith(projectId: 'home'), + ], + ), + createProjectRepository: () => InMemoryProjectRepository([ + ProjectProfile(id: 'home', name: 'Home', colorKey: 'home-blue'), + ProjectProfile(id: 'work', name: 'Work', colorKey: 'work-green'), + ]), + createLockedBlockRepository: () => InMemoryLockedBlockRepository( + initialBlocks: [ + LockedBlock( + id: 'work', + name: 'Work', + startTime: ClockTime(hour: 9, minute: 0), + endTime: ClockTime(hour: 17, minute: 0), + recurrence: LockedBlockRecurrence.weekly( + weekdays: {LockedWeekday.monday}, + ), + createdAt: DateTime.utc(2026, 6, 20), + updatedAt: DateTime.utc(2026, 6, 20), + ), + ], + initialOverrides: [ + LockedBlockOverride.remove( + id: 'holiday', + lockedBlockId: 'work', + date: CivilDate(2026, 6, 22), + createdAt: DateTime.utc(2026, 6, 20), + ), + ], + ), + ); +} + +void runCoreRepositoryConformance({ + required TaskRepository Function() createTaskRepository, + required ProjectRepository Function() createProjectRepository, + required LockedBlockRepository Function() createLockedBlockRepository, +}) { + group('Core repository conformance', () { + const ownerId = 'owner-1'; + + test('task queries are owner-scoped, ordered, paged, and revisioned', + () async { + final repository = createTaskRepository(); + final firstPage = await repository.findByOwner( + ownerId: ownerId, + page: const RepositoryPageRequest(limit: 2), + ); + final secondPage = await repository.findByOwner( + ownerId: ownerId, + page: RepositoryPageRequest(cursor: firstPage.nextCursor, limit: 2), + ); + final homeTasks = await repository.findByProjectId( + ownerId: ownerId, + projectId: 'home', + ); + final children = await repository.findByParentTaskId( + ownerId: ownerId, + parentTaskId: 'parent', + ); + final backlog = await repository.findBacklogCandidates( + const BacklogCandidateQuery(ownerId: ownerId, projectId: 'home'), + ); + final localDay = await repository.findForLocalDay( + ownerId: ownerId, + localDate: CivilDate(2026, 6, 22), + window: SchedulingWindow( + start: DateTime.utc(2026, 6, 22), + end: DateTime.utc(2026, 6, 23), + ), + ); + final record = await repository.findRecordById('scheduled'); + final updated = record!.value.copyWith( + title: 'Updated scheduled', + updatedAt: DateTime.utc(2026, 6, 22, 12), + ); + + expect(firstPage.items.map((task) => task.id), [ + 'backlog-a', + 'backlog-b', + ]); + expect(firstPage.hasMore, isTrue); + expect(secondPage.items.map((task) => task.id), ['scheduled']); + expect(homeTasks.items.map((task) => task.id), [ + 'backlog-b', + 'scheduled', + ]); + expect(children.items.map((task) => task.id), ['backlog-b']); + expect(backlog.items.map((task) => task.id), ['backlog-b']); + expect(localDay.items.map((task) => task.id), ['scheduled']); + expect(record.ownerId, ownerId); + expect(record.revision, 1); + + final stale = await repository.saveIfRevision( + task: updated, + ownerId: ownerId, + expectedRevision: 99, + ); + expect(stale.failure!.code, RepositoryFailureCode.staleRevision); + expect(stale.failure!.actualRevision, 1); + + final saved = await repository.saveIfRevision( + task: updated, + ownerId: ownerId, + expectedRevision: 1, + ); + expect(saved.isSuccess, isTrue); + expect(saved.revision, 2); + expect((await repository.findRecordById('scheduled'))!.revision, 2); + + expect( + () => firstPage.items.add(record.value), + throwsUnsupportedError, + ); + }); + + test('project queries hide archived records by default and honor revisions', + () async { + final repository = createProjectRepository(); + final record = await repository.findRecordById('home'); + + final archived = await repository.archive( + projectId: 'home', + ownerId: ownerId, + expectedRevision: record!.revision, + archivedAt: DateTime.utc(2026, 6, 25), + ); + final visible = await repository.findByOwner(ownerId: ownerId); + final includingArchived = await repository.findByOwner( + ownerId: ownerId, + includeArchived: true, + ); + + expect(archived.isSuccess, isTrue); + expect(visible.items.map((project) => project.id), ['work']); + expect(includingArchived.items.map((project) => project.id), [ + 'home', + 'work', + ]); + expect( + (await repository.findRecordById('home'))!.archivedAt, + DateTime.utc(2026, 6, 25), + ); + expect( + (await repository.insert( + project: ProjectProfile(id: 'home', name: 'Home', colorKey: 'again'), + ownerId: ownerId, + )) + .failure! + .code, + RepositoryFailureCode.duplicateId, + ); + }); + + test('locked queries find date-scoped overrides and archive by revision', + () async { + final repository = createLockedBlockRepository(); + final blockRecord = await repository.findBlockRecordById('work'); + final overrides = await repository.findOverridesForDate( + ownerId: ownerId, + date: CivilDate(2026, 6, 22), + ); + + final archived = await repository.archiveBlock( + blockId: 'work', + ownerId: ownerId, + expectedRevision: blockRecord!.revision, + archivedAt: DateTime.utc(2026, 6, 25), + ); + final visible = await repository.findBlocksByOwner(ownerId: ownerId); + final includingArchived = await repository.findBlocksByOwner( + ownerId: ownerId, + includeArchived: true, + ); + + expect(overrides.items.map((override) => override.id), ['holiday']); + expect(archived.isSuccess, isTrue); + expect(visible.items, isEmpty); + expect(includingArchived.items.map((block) => block.id), ['work']); + expect( + (await repository.findBlockRecordById('work'))!.archivedAt, + DateTime.utc(2026, 6, 25), + ); + }); + }); } Task task({