From 3dc73e8d43f0d94a519708bdf1858151adf7d2bd Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Thu, 25 Jun 2026 11:43:28 -0700 Subject: [PATCH] feat(data): add v1 document codecs --- ..._Persistence_Schema_Codecs_Repositories.md | 37 + .../V1_PUBLIC_API_BASELINE.md | 53 +- lib/src/document_mapping.dart | 2216 ++++++++++++++--- lib/src/persistence_contract.dart | 215 +- test/document_mapping_test.dart | 482 +++- test/persistence_edge_cases_test.dart | 73 +- 6 files changed, 2694 insertions(+), 382 deletions(-) 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 b01cce9..cfb5c92 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 @@ -83,6 +83,8 @@ and migrations. Recommended Codex level: extra high +Status: Complete + Tasks: - Implement plain-Dart map codecs for every V1 persisted entity from Chunk 15.1. @@ -117,6 +119,41 @@ Acceptance criteria: - Nullable fields can be preserved and explicitly cleared. - All codec tests pass without a database runtime. +Completed implementation: + +- Replaced the partial document mapper with V1 map codecs for every Chunk 15.1 + persisted entity: tasks, task statistics, project profiles, project + statistics, locked recurrence, locked blocks, locked overrides, task + activities, owner settings, backlog staleness settings, notice + acknowledgements, operation records, scheduling snapshots, windows, + intervals, notices, changes, and overlaps. +- Added explicit stable encode/decode tables for all persisted enum/code values + instead of relying on Dart enum `.name`. +- Added `DocumentMappingFailureCode`, `DocumentMappingException`, and + `DocumentMetadata` so malformed V1 documents fail with typed mapping + categories. +- Added common V1 metadata validation for `schemaVersion`, `_id`, `ownerId`, + positive `revision`, `createdAt`, and `updatedAt`. +- Expanded persistence field-name contracts to include common metadata, archive + fields, owner settings, notice acknowledgement, operation record, scheduling + snapshot, notice-code, and retention fields. +- Preserved null/clear behavior for optional fields and validated required + nullable V1 fields even when the current domain object does not carry that + metadata directly. +- Added forward-compatible unknown-field tolerance by ignoring extra document + fields during decoding. +- Added exhaustive codec tests for full and nullable task documents, configured + projects, project statistics with applied activity IDs, locked time civil + dates/wall times, settings, activities, acknowledgements, operation records, + scheduling snapshots, stable code tables, and malformed document failures. + +Verification: + +- `dart format lib test`: passed, 0 files changed after final format +- `dart analyze`: passed, no issues found +- `dart test`: passed, 281 tests +- `git diff --check`: passed + ## Chunk 15.3 — Legacy schema migration and fixture suite Recommended Codex level: extra 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 60e0c39..09aab5c 100644 --- a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md +++ b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md @@ -16,6 +16,8 @@ following source files: - `src/models.dart` - `src/application_commands.dart` - `src/application_layer.dart` +- `src/application_management.dart` +- `src/application_recovery.dart` - `src/backlog.dart` - `src/child_tasks.dart` - `src/document_mapping.dart` @@ -43,7 +45,10 @@ changes from accidental API drift. | File | Enums | |---|---| | `src/application_commands.dart` | `ApplicationCommandCode` | +| `src/application_management.dart` | `ApplicationManagementCommandCode`, `OwnerSettingsWarningCode` | +| `src/application_recovery.dart` | `AppOpenRecoveryOutcome` | | `src/application_layer.dart` | `ApplicationFailureCode` | +| `src/document_mapping.dart` | `DocumentMappingFailureCode` | | `src/models.dart` | `DomainValidationCode`, `TaskType`, `TaskStatus`, `PriorityLevel`, `RewardLevel`, `DifficultyLevel`, `ReminderProfile`, `BacklogTag` | | `src/backlog.dart` | `BacklogFilter`, `BacklogSortKey`, `BacklogStalenessMarker` | | `src/free_slots.dart` | `RequiredCommitmentScheduleStatus` | @@ -64,15 +69,17 @@ changes from accidental API drift. | File | Public API | |---|---| | `src/application_commands.dart` | `ApplicationChildTaskDraft`, `ApplicationCommandReadHint`, `ApplicationCommandResult`, `V1ApplicationCommandUseCases` | -| `src/application_layer.dart` | `OwnerTimeZoneContext`, `ApplicationOperationContext`, `ApplicationFailure`, `ApplicationResult`, `ApplicationFailureException`, `ApplicationPersistenceException`, `ApplicationOperationRecord`, `OwnerSettings`, `TaskActivityRepository`, `ProjectStatisticsRepository`, `OwnerSettingsRepository`, `ApplicationOperationRepository`, `ApplicationUnitOfWorkRepositories`, `ApplicationUnitOfWork`, `ApplicationSchedulingLoader`, `InMemoryApplicationUnitOfWork` | +| `src/application_layer.dart` | `OwnerTimeZoneContext`, `ApplicationOperationContext`, `ApplicationFailure`, `ApplicationResult`, `ApplicationFailureException`, `ApplicationPersistenceException`, `ApplicationOperationRecord`, `NoticeAcknowledgementRecord`, `OwnerSettings`, `TaskActivityRepository`, `ProjectStatisticsRepository`, `OwnerSettingsRepository`, `NoticeAcknowledgementRepository`, `ApplicationOperationRepository`, `ApplicationUnitOfWorkRepositories`, `ApplicationUnitOfWork`, `ApplicationSchedulingLoader`, `InMemoryApplicationUnitOfWork` | +| `src/application_management.dart` | `GetBacklogRequest`, `BacklogItemReadModel`, `BacklogQueryResult`, `GetProjectDefaultsRequest`, `ProjectDefaultsQueryResult`, `ProjectProfileDraft`, `ProjectProfileUpdate`, `LockedBlockDraft`, `LockedBlockUpdate`, `OwnerSettingsUpdateResult`, `OwnerSettingsUpdate`, `NoticeAcknowledgementResult`, `V1ApplicationManagementUseCases` | +| `src/application_recovery.dart` | `RunAppOpenRecoveryRequest`, `AppOpenRecoveryResult`, `V1AppOpenRecoveryUseCases` | | `src/models.dart` | `DomainValidationException`, `Task`, `ProjectProfile`, `TimeInterval` | | `src/backlog.dart` | `BacklogStalenessSettings`, `BacklogView` | | `src/child_tasks.dart` | `ChildTaskEntry`, `ChildTaskBreakUpRequest`, `ChildTaskBreakUpResult`, `ChildTaskBreakUpService`, `ChildTaskView`, `ChildTaskCompletionResult`, `ChildTaskCompletionService`, `ChildTaskSummary` | -| `src/document_mapping.dart` | `TaskDocumentExtension`, `TaskStatisticsDocumentExtension`, `ProjectStatisticsDocumentExtension` | +| `src/document_mapping.dart` | `DocumentMappingException`, `DocumentMetadata`, `PersistenceEnumMapping`, `TaskDocumentMapping`, `TaskStatisticsDocumentMapping`, `ProjectDocumentMapping`, `ProjectStatisticsDocumentMapping`, `LockedBlockRecurrenceDocumentMapping`, `LockedBlockDocumentMapping`, `LockedBlockOverrideDocumentMapping`, `TaskActivityDocumentMapping`, `OwnerSettingsDocumentMapping`, `BacklogStalenessDocumentMapping`, `NoticeAcknowledgementDocumentMapping`, `ApplicationOperationDocumentMapping`, `SchedulingSnapshotDocumentMapping`, `SchedulingWindowDocumentMapping`, `TimeIntervalDocumentMapping`, `SchedulingNoticeDocumentMapping`, `SchedulingChangeDocumentMapping`, `SchedulingOverlapDocumentMapping`, `TaskDocumentExtension`, `TaskStatisticsDocumentExtension`, `ProjectStatisticsDocumentExtension` | | `src/free_slots.dart` | `ProtectedFreeSlotConflict`, `RequiredCommitmentScheduleResult`, `FreeSlotService` | | `src/locked_time.dart` | `ClockTime`, `LockedBlockRecurrence`, `LockedBlock`, `LockedBlockOccurrence`, `LockedBlockOverride`, `LockedScheduleExpansion`, `expandLockedBlocksForDay`, `lockedSchedulingIntervalsForDay`, `trackCompletedDuringLockedHours`, `completedDuringLockedHoursMinutes` | | `src/occupancy_policy.dart` | `OccupancyEntry`, `OccupancyPolicy` | -| `src/persistence_contract.dart` | `PersistenceEnumName` | +| `src/persistence_contract.dart` | `v1SchemaVersion`, `PersistenceDateTimeConvention`, `PersistenceCivilDateConvention`, `PersistenceWallTimeConvention`, `PersistenceEnumName`, V1 document field-name classes | | `src/project_statistics.dart` | `ProjectStatistics`, `ProjectStatisticsApplicationResult`, `ProjectStatisticsAggregationService`, `ProjectSuggestionPolicy`, `ProjectSuggestion`, `ProjectSuggestionSet`, `ProjectDefaultResolution`, `ProjectSuggestionService` | | `src/quick_capture.dart` | `QuickCaptureRequest`, `QuickCaptureResult`, `QuickCaptureService` | | `src/reminder_policy.dart` | `EffectiveReminderProfile`, `ReminderDirective`, `ReminderPolicyService` | @@ -113,7 +120,7 @@ Current notable model fields: `actualStart`, `actualEnd`, `completedAt`, `parentTaskId`, `backlogTags`, `reminderOverride`, `createdAt`, `updatedAt`, `stats` - `ProjectProfile`: `id`, `name`, `colorKey`, default priority/reward/ - difficulty/reminder profile/duration fields + difficulty/reminder profile/duration fields, `archivedAt` - `ProjectStatistics`: completion count, duration sample counts, completion-time buckets, pushes-before-completion totals, reward/difficulty distributions, and reminder-profile observations @@ -309,6 +316,44 @@ Chunk 14.3 intentionally changed the public API: not found, stale revision, no slot, validation, persistence, and duplicate operation outcomes. +Chunk 14.4 intentionally changed the public API: + +- Added `src/application_management.dart` and exported it from + `scheduler_core.dart`. +- Added management query/command contracts for backlog, project defaults, + project create/update/archive, locked-block create/update/archive, one-day + locked overrides, owner settings updates, and notice acknowledgement. +- Added `ProjectProfile.archivedAt` and `LockedBlock.archivedAt` soft-archive + fields. +- Added `NoticeAcknowledgementRecord` and + `NoticeAcknowledgementRepository`. +- Added stable notice IDs to `TodayPendingNotice` and Today-state filtering for + acknowledged notices. + +Chunk 14.5 intentionally changed the public API: + +- Added `src/application_recovery.dart` and exported it from + `scheduler_core.dart`. +- Added `AppOpenRecoveryOutcome`, `RunAppOpenRecoveryRequest`, + `AppOpenRecoveryResult`, and `V1AppOpenRecoveryUseCases`. +- Added `InMemoryApplicationUnitOfWork.currentSchedulingSnapshots` for + application/recovery contract tests. + +Chunk 15.2 intentionally changed the public API: + +- Replaced legacy enum `.name` document encoding in `document_mapping.dart` with + explicit stable V1 code tables through `PersistenceEnumMapping`. +- Added `DocumentMappingFailureCode`, `DocumentMappingException`, and + `DocumentMetadata`. +- Expanded document codecs to every V1 persisted entity: project profiles, + project statistics, locked blocks/overrides, activities, owner settings, + notice acknowledgements, operation records, scheduling snapshots, notices, + changes, overlaps, windows, intervals, civil dates, and wall times. +- Changed top-level document `toDocument` helpers/extensions to require owner + scope and V1 metadata where the domain model does not carry it directly. +- Expanded `persistence_contract.dart` with `v1SchemaVersion`, common document + fields, and V1 field sets for every persisted repository entity. + ## Repository contracts The current repository surface is pure Dart and in-memory only: diff --git a/lib/src/document_mapping.dart b/lib/src/document_mapping.dart index 02be7fb..d84474c 100644 --- a/lib/src/document_mapping.dart +++ b/lib/src/document_mapping.dart @@ -1,100 +1,391 @@ -// MongoDB-friendly document mapping helpers. +// MongoDB-friendly V1 document mapping helpers. // // These helpers use plain Dart map shapes only. They do not import MongoDB APIs, // create clients, or perform database I/O. +import 'application_layer.dart'; +import 'backlog.dart'; +import 'locked_time.dart'; import 'models.dart'; import 'persistence_contract.dart'; import 'project_statistics.dart'; +import 'repositories.dart'; +import 'scheduling_engine.dart'; +import 'task_lifecycle.dart'; import 'task_statistics.dart'; +import 'time_contracts.dart'; -/// Stable enum encode/decode helpers for document maps. +/// Stable document mapping failure categories. +enum DocumentMappingFailureCode { + missingField, + wrongType, + unknownCode, + invalidSchemaVersion, + invalidRevision, + invalidValue, +} + +/// Typed mapping failure for malformed V1 documents. +class DocumentMappingException implements Exception { + const DocumentMappingException({ + required this.code, + this.fieldName, + this.detailCode, + }); + + /// Stable category for caller branching. + final DocumentMappingFailureCode code; + + /// Field related to the failure, when known. + final String? fieldName; + + /// Optional narrower reason. + final String? detailCode; + + @override + String toString() { + return 'DocumentMappingException($code, field: $fieldName, detail: ' + '$detailCode)'; + } +} + +/// Common metadata decoded from a V1 top-level document. +class DocumentMetadata { + const DocumentMetadata({ + required this.id, + required this.ownerId, + required this.revision, + required this.createdAt, + required this.updatedAt, + }); + + final String id; + final String ownerId; + final int revision; + final DateTime createdAt; + final DateTime updatedAt; +} + +/// Stable enum encode/decode helpers for V1 document maps. abstract final class PersistenceEnumMapping { static String? encodeNullable(Enum? value) { - return value?.persistenceName; + return value == null ? null : encode(value); } + static String encode(Enum value) { + if (value is TaskType) return encodeTaskType(value); + if (value is TaskStatus) return encodeTaskStatus(value); + if (value is PriorityLevel) return encodePriority(value); + if (value is RewardLevel) return encodeReward(value); + if (value is DifficultyLevel) return encodeDifficulty(value); + if (value is ReminderProfile) return encodeReminderProfile(value); + if (value is BacklogTag) return encodeBacklogTag(value); + if (value is LockedWeekday) return encodeLockedWeekday(value); + if (value is LockedBlockOverrideType) { + return encodeLockedBlockOverrideType(value); + } + if (value is TaskActivityCode) return encodeTaskActivityCode(value); + if (value is SchedulingNoticeType) return encodeSchedulingNoticeType(value); + if (value is SchedulingIssueCode) return encodeSchedulingIssueCode(value); + if (value is SchedulingMovementCode) { + return encodeSchedulingMovementCode(value); + } + if (value is SchedulingConflictCode) { + return encodeSchedulingConflictCode(value); + } + if (value is ProjectCompletionTimeBucket) { + return encodeProjectCompletionTimeBucket(value); + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.unknownCode, + detailCode: value.runtimeType.toString(), + ); + } + + static String encodeTaskType(TaskType value) => switch (value) { + TaskType.flexible => 'flexible', + TaskType.inflexible => 'inflexible', + TaskType.critical => 'critical', + TaskType.locked => 'locked', + TaskType.surprise => 'surprise', + TaskType.freeSlot => 'free_slot', + }; + static TaskType decodeTaskType(String value) { - return _decodeEnum(TaskType.values, value, 'TaskType'); + return _decodeCode(_taskTypeByCode, value, 'TaskType'); } + static String encodeTaskStatus(TaskStatus value) => switch (value) { + TaskStatus.planned => 'planned', + TaskStatus.active => 'active', + TaskStatus.completed => 'completed', + TaskStatus.missed => 'missed', + TaskStatus.cancelled => 'cancelled', + TaskStatus.noLongerRelevant => 'no_longer_relevant', + TaskStatus.backlog => 'backlog', + }; + static TaskStatus decodeTaskStatus(String value) { - return _decodeEnum(TaskStatus.values, value, 'TaskStatus'); + return _decodeCode(_taskStatusByCode, value, 'TaskStatus'); } + static String encodePriority(PriorityLevel value) => switch (value) { + PriorityLevel.veryLow => 'very_low', + PriorityLevel.low => 'low', + PriorityLevel.medium => 'medium', + PriorityLevel.high => 'high', + PriorityLevel.veryHigh => 'very_high', + }; + static PriorityLevel? decodeNullablePriority(String? value) { return value == null ? null - : _decodeEnum(PriorityLevel.values, value, 'PriorityLevel'); + : _decodeCode(_priorityByCode, value, 'PriorityLevel'); } + static String encodeReward(RewardLevel value) => switch (value) { + RewardLevel.notSet => 'not_set', + RewardLevel.veryLow => 'very_low', + RewardLevel.low => 'low', + RewardLevel.medium => 'medium', + RewardLevel.high => 'high', + RewardLevel.veryHigh => 'very_high', + }; + static RewardLevel decodeReward(String value) { - return _decodeEnum(RewardLevel.values, value, 'RewardLevel'); + return _decodeCode(_rewardByCode, value, 'RewardLevel'); } + static String encodeDifficulty(DifficultyLevel value) => switch (value) { + DifficultyLevel.notSet => 'not_set', + DifficultyLevel.veryEasy => 'very_easy', + DifficultyLevel.easy => 'easy', + DifficultyLevel.medium => 'medium', + DifficultyLevel.hard => 'hard', + DifficultyLevel.veryHard => 'very_hard', + }; + static DifficultyLevel decodeDifficulty(String value) { - return _decodeEnum(DifficultyLevel.values, value, 'DifficultyLevel'); + return _decodeCode(_difficultyByCode, value, 'DifficultyLevel'); } + static String encodeReminderProfile(ReminderProfile value) => switch (value) { + ReminderProfile.silent => 'silent', + ReminderProfile.gentle => 'gentle', + ReminderProfile.persistent => 'persistent', + ReminderProfile.strict => 'strict', + }; + static ReminderProfile? decodeNullableReminderProfile(String? value) { return value == null ? null - : _decodeEnum(ReminderProfile.values, value, 'ReminderProfile'); + : _decodeCode(_reminderProfileByCode, value, 'ReminderProfile'); } + static String encodeBacklogTag(BacklogTag value) => switch (value) { + BacklogTag.wishlist => 'wishlist', + }; + static BacklogTag decodeBacklogTag(String value) { - return _decodeEnum(BacklogTag.values, value, 'BacklogTag'); + return _decodeCode(_backlogTagByCode, value, 'BacklogTag'); } + static String encodeLockedWeekday(LockedWeekday value) => switch (value) { + LockedWeekday.monday => 'monday', + LockedWeekday.tuesday => 'tuesday', + LockedWeekday.wednesday => 'wednesday', + LockedWeekday.thursday => 'thursday', + LockedWeekday.friday => 'friday', + LockedWeekday.saturday => 'saturday', + LockedWeekday.sunday => 'sunday', + }; + + static LockedWeekday decodeLockedWeekday(String value) { + return _decodeCode(_lockedWeekdayByCode, value, 'LockedWeekday'); + } + + static String encodeLockedBlockOverrideType( + LockedBlockOverrideType value, + ) => + switch (value) { + LockedBlockOverrideType.remove => 'remove', + LockedBlockOverrideType.replace => 'replace', + LockedBlockOverrideType.add => 'add', + }; + + static LockedBlockOverrideType decodeLockedBlockOverrideType(String value) { + return _decodeCode( + _lockedBlockOverrideTypeByCode, + value, + 'LockedBlockOverrideType', + ); + } + + static String encodeTaskActivityCode(TaskActivityCode value) => + switch (value) { + TaskActivityCode.completed => 'completed', + TaskActivityCode.missed => 'missed', + TaskActivityCode.cancelled => 'cancelled', + TaskActivityCode.noLongerRelevant => 'no_longer_relevant', + TaskActivityCode.manuallyPushed => 'manually_pushed', + TaskActivityCode.automaticallyPushed => 'automatically_pushed', + TaskActivityCode.movedToBacklog => 'moved_to_backlog', + TaskActivityCode.restoredFromBacklog => 'restored_from_backlog', + TaskActivityCode.activated => 'activated', + }; + + static TaskActivityCode decodeTaskActivityCode(String value) { + return _decodeCode(_taskActivityCodeByCode, value, 'TaskActivityCode'); + } + + static String encodeSchedulingNoticeType(SchedulingNoticeType value) => + switch (value) { + SchedulingNoticeType.info => 'info', + SchedulingNoticeType.moved => 'moved', + SchedulingNoticeType.overlap => 'overlap', + SchedulingNoticeType.noFit => 'no_fit', + SchedulingNoticeType.overflow => 'overflow', + }; + + static SchedulingNoticeType decodeSchedulingNoticeType(String value) { + return _decodeCode( + _schedulingNoticeTypeByCode, + value, + 'SchedulingNoticeType', + ); + } + + static String encodeSchedulingIssueCode(SchedulingIssueCode value) => + switch (value) { + SchedulingIssueCode.taskNotFound => 'task_not_found', + SchedulingIssueCode.invalidTaskState => 'invalid_task_state', + SchedulingIssueCode.missingDuration => 'missing_duration', + SchedulingIssueCode.missingScheduledSlot => 'missing_scheduled_slot', + SchedulingIssueCode.nonPositiveDuration => 'non_positive_duration', + SchedulingIssueCode.noAvailableSlot => 'no_available_slot', + SchedulingIssueCode.unfinishedTasksCouldNotFit => + 'unfinished_tasks_could_not_fit', + SchedulingIssueCode.noUnfinishedFlexibleTasks => + 'no_unfinished_flexible_tasks', + SchedulingIssueCode.duplicateSurpriseLog => 'duplicate_surprise_log', + }; + + static SchedulingIssueCode? decodeNullableSchedulingIssueCode(String? value) { + return value == null + ? null + : _decodeCode( + _schedulingIssueCodeByCode, + value, + 'SchedulingIssueCode', + ); + } + + static String encodeSchedulingMovementCode(SchedulingMovementCode value) => + switch (value) { + SchedulingMovementCode.backlogTaskInserted => 'backlog_task_inserted', + SchedulingMovementCode.flexibleTaskMovedToMakeRoom => + 'flexible_task_moved_to_make_room', + SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot => + 'flexible_task_pushed_to_next_available_slot', + SchedulingMovementCode.flexibleTaskMovedToTomorrow => + 'flexible_task_moved_to_tomorrow', + SchedulingMovementCode.unfinishedFlexibleTasksRolledOver => + 'unfinished_flexible_tasks_rolled_over', + SchedulingMovementCode.flexibleTaskMovedToBacklog => + 'flexible_task_moved_to_backlog', + SchedulingMovementCode.requiredCommitmentScheduled => + 'required_commitment_scheduled', + }; + + static SchedulingMovementCode? decodeNullableSchedulingMovementCode( + String? value, + ) { + return value == null + ? null + : _decodeCode( + _schedulingMovementCodeByCode, + value, + 'SchedulingMovementCode', + ); + } + + static String encodeSchedulingConflictCode(SchedulingConflictCode value) => + switch (value) { + SchedulingConflictCode.flexibleTaskOverlapsBlockedTime => + 'flexible_task_overlaps_blocked_time', + SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime => + 'surprise_task_overlaps_required_visible_time', + SchedulingConflictCode.requiredCommitmentOverlapsProtectedFreeSlot => + 'required_commitment_overlaps_protected_free_slot', + }; + + static SchedulingConflictCode? decodeNullableSchedulingConflictCode( + String? value, + ) { + return value == null + ? null + : _decodeCode( + _schedulingConflictCodeByCode, + value, + 'SchedulingConflictCode', + ); + } + + static String encodeProjectCompletionTimeBucket( + ProjectCompletionTimeBucket value, + ) => + switch (value) { + ProjectCompletionTimeBucket.overnight => 'overnight', + ProjectCompletionTimeBucket.morning => 'morning', + ProjectCompletionTimeBucket.afternoon => 'afternoon', + ProjectCompletionTimeBucket.evening => 'evening', + ProjectCompletionTimeBucket.night => 'night', + }; + static ProjectCompletionTimeBucket decodeProjectCompletionTimeBucket( String value, ) { - return _decodeEnum( - ProjectCompletionTimeBucket.values, + return _decodeCode( + _projectCompletionTimeBucketByCode, value, 'ProjectCompletionTimeBucket', ); } - - static T _decodeEnum( - Iterable values, - String persistedName, - String enumName, - ) { - for (final value in values) { - if (value.persistenceName == persistedName) { - return value; - } - } - - throw ArgumentError.value( - persistedName, - enumName, - 'Unknown persistence enum name.', - ); - } } /// Document mapping for [Task]. abstract final class TaskDocumentMapping { - /// Convert [task] to a MongoDB-friendly document-shaped map. static Map toDocument( Task task, { + required String ownerId, + int revision = 1, bool clearSchedule = false, + DateTime? backlogEnteredAt, + String? backlogEnteredAtProvenance, }) { final scheduledStart = clearSchedule ? null : task.scheduledStart; final scheduledEnd = clearSchedule ? null : task.scheduledEnd; return { - TaskDocumentFields.id: task.id, + ..._commonFields( + id: task.id, + ownerId: ownerId, + revision: revision, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + ), TaskDocumentFields.title: task.title, TaskDocumentFields.projectId: task.projectId, - TaskDocumentFields.type: task.type.persistenceName, - TaskDocumentFields.status: task.status.persistenceName, - TaskDocumentFields.priority: - PersistenceEnumMapping.encodeNullable(task.priority), - TaskDocumentFields.reward: task.reward.persistenceName, - TaskDocumentFields.difficulty: task.difficulty.persistenceName, + TaskDocumentFields.type: PersistenceEnumMapping.encodeTaskType(task.type), + TaskDocumentFields.status: + PersistenceEnumMapping.encodeTaskStatus(task.status), + TaskDocumentFields.priority: task.priority == null + ? null + : PersistenceEnumMapping.encodePriority(task.priority!), + TaskDocumentFields.reward: + PersistenceEnumMapping.encodeReward(task.reward), + TaskDocumentFields.difficulty: + PersistenceEnumMapping.encodeDifficulty(task.difficulty), TaskDocumentFields.durationMinutes: task.durationMinutes, TaskDocumentFields.scheduledStart: _dateTimeToStored(scheduledStart), TaskDocumentFields.scheduledEnd: _dateTimeToStored(scheduledEnd), @@ -102,84 +393,113 @@ abstract final class TaskDocumentMapping { TaskDocumentFields.actualEnd: _dateTimeToStored(task.actualEnd), TaskDocumentFields.completedAt: _dateTimeToStored(task.completedAt), TaskDocumentFields.parentTaskId: task.parentTaskId, - TaskDocumentFields.backlogTags: - task.backlogTags.map((tag) => tag.persistenceName).toList(), - TaskDocumentFields.reminderOverride: - PersistenceEnumMapping.encodeNullable(task.reminderOverride), - TaskDocumentFields.createdAt: - PersistenceDateTimeConvention.toStoredString(task.createdAt), - TaskDocumentFields.updatedAt: - PersistenceDateTimeConvention.toStoredString(task.updatedAt), - TaskDocumentFields.stats: task.stats.toDocument(), + TaskDocumentFields.backlogTags: task.backlogTags + .map(PersistenceEnumMapping.encodeBacklogTag) + .toList(), + TaskDocumentFields.reminderOverride: task.reminderOverride == null + ? null + : PersistenceEnumMapping.encodeReminderProfile( + task.reminderOverride!), + TaskDocumentFields.stats: TaskStatisticsDocumentMapping.toDocument( + task.stats, + ), + TaskDocumentFields.backlogEnteredAt: _dateTimeToStored(backlogEnteredAt), + TaskDocumentFields.backlogEnteredAtProvenance: backlogEnteredAtProvenance, }; } - /// Rebuild a [Task] from a document-shaped map. static Task fromDocument(Map document) { - return Task( - id: _requiredString(document, TaskDocumentFields.id), - title: _requiredString(document, TaskDocumentFields.title), - projectId: _requiredString(document, TaskDocumentFields.projectId), - type: PersistenceEnumMapping.decodeTaskType( - _requiredString(document, TaskDocumentFields.type), - ), - status: PersistenceEnumMapping.decodeTaskStatus( - _requiredString(document, TaskDocumentFields.status), - ), - priority: PersistenceEnumMapping.decodeNullablePriority( - _nullableString(document, TaskDocumentFields.priority), - ), - reward: PersistenceEnumMapping.decodeReward( - _requiredString(document, TaskDocumentFields.reward), - ), - difficulty: PersistenceEnumMapping.decodeDifficulty( - _requiredString(document, TaskDocumentFields.difficulty), - ), - durationMinutes: - _nullableInt(document, TaskDocumentFields.durationMinutes), - scheduledStart: - _nullableDateTime(document, TaskDocumentFields.scheduledStart), - scheduledEnd: - _nullableDateTime(document, TaskDocumentFields.scheduledEnd), - actualStart: _nullableDateTime(document, TaskDocumentFields.actualStart), - actualEnd: _nullableDateTime(document, TaskDocumentFields.actualEnd), - completedAt: _nullableDateTime(document, TaskDocumentFields.completedAt), - parentTaskId: _nullableString(document, TaskDocumentFields.parentTaskId), - backlogTags: _backlogTagsFromDocument(document), - reminderOverride: PersistenceEnumMapping.decodeNullableReminderProfile( - _nullableString(document, TaskDocumentFields.reminderOverride), - ), - createdAt: _requiredDateTime(document, TaskDocumentFields.createdAt), - updatedAt: _requiredDateTime(document, TaskDocumentFields.updatedAt), - stats: TaskStatisticsDocumentMapping.fromDocument( - _requiredMap(document, TaskDocumentFields.stats), - ), - ); + return _mapInvalidValues(() { + _readCommon(document); + return Task( + id: _requiredString(document, TaskDocumentFields.id), + title: _requiredString(document, TaskDocumentFields.title), + projectId: _requiredString(document, TaskDocumentFields.projectId), + type: PersistenceEnumMapping.decodeTaskType( + _requiredString(document, TaskDocumentFields.type), + ), + status: PersistenceEnumMapping.decodeTaskStatus( + _requiredString(document, TaskDocumentFields.status), + ), + priority: PersistenceEnumMapping.decodeNullablePriority( + _requiredNullableString(document, TaskDocumentFields.priority), + ), + reward: PersistenceEnumMapping.decodeReward( + _requiredString(document, TaskDocumentFields.reward), + ), + difficulty: PersistenceEnumMapping.decodeDifficulty( + _requiredString(document, TaskDocumentFields.difficulty), + ), + durationMinutes: + _requiredNullableInt(document, TaskDocumentFields.durationMinutes), + scheduledStart: _requiredNullableDateTime( + document, + TaskDocumentFields.scheduledStart, + ), + scheduledEnd: _requiredNullableDateTime( + document, + TaskDocumentFields.scheduledEnd, + ), + actualStart: _requiredNullableDateTime( + document, + TaskDocumentFields.actualStart, + ), + actualEnd: _requiredNullableDateTime( + document, + TaskDocumentFields.actualEnd, + ), + completedAt: _requiredNullableDateTime( + document, + TaskDocumentFields.completedAt, + ), + parentTaskId: _requiredNullableString( + document, + TaskDocumentFields.parentTaskId, + ), + backlogTags: _backlogTagsFromDocument(document), + reminderOverride: PersistenceEnumMapping.decodeNullableReminderProfile( + _requiredNullableString( + document, + TaskDocumentFields.reminderOverride, + ), + ), + createdAt: _requiredDateTime(document, TaskDocumentFields.createdAt), + updatedAt: _requiredDateTime(document, TaskDocumentFields.updatedAt), + stats: TaskStatisticsDocumentMapping.fromDocument( + _requiredMap(document, TaskDocumentFields.stats), + ), + ).._validateTaskDocumentMetadata(document); + }); } static Set _backlogTagsFromDocument( Map document, ) { - final rawTags = document[TaskDocumentFields.backlogTags]; - if (rawTags == null) { - return const {}; - } - if (rawTags is! Iterable) { - throw ArgumentError.value( - rawTags, - TaskDocumentFields.backlogTags, - 'Expected an iterable of persisted backlog tag names.', - ); - } - return rawTags - .map((tag) => PersistenceEnumMapping.decodeBacklogTag(tag as String)) - .toSet(); + final rawTags = _requiredList(document, TaskDocumentFields.backlogTags); + return rawTags.map((tag) { + if (tag is! String) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: TaskDocumentFields.backlogTags, + ); + } + return PersistenceEnumMapping.decodeBacklogTag(tag); + }).toSet(); + } +} + +extension on Task { + void _validateTaskDocumentMetadata(Map document) { + _requiredNullableDateTime(document, TaskDocumentFields.backlogEnteredAt); + _requiredNullableString( + document, + TaskDocumentFields.backlogEnteredAtProvenance, + ); } } /// Document mapping for [TaskStatistics]. abstract final class TaskStatisticsDocumentMapping { - /// Convert [stats] to a MongoDB-friendly embedded document-shaped map. static Map toDocument(TaskStatistics stats) { return { TaskStatisticsDocumentFields.skippedDuringBurnoutCount: @@ -207,145 +527,1033 @@ abstract final class TaskStatisticsDocumentMapping { }; } - /// Rebuild [TaskStatistics] from an embedded document-shaped map. static TaskStatistics fromDocument(Map document) { - return TaskStatistics( - skippedDuringBurnoutCount: _intOrZero( - document, - TaskStatisticsDocumentFields.skippedDuringBurnoutCount, + return _mapInvalidValues(() { + return TaskStatistics( + skippedDuringBurnoutCount: _requiredInt( + document, + TaskStatisticsDocumentFields.skippedDuringBurnoutCount, + ), + manuallyPushedCount: _requiredInt( + document, + TaskStatisticsDocumentFields.manuallyPushedCount, + ), + autoPushedCount: _requiredInt( + document, + TaskStatisticsDocumentFields.autoPushedCount, + ), + movedToBacklogCount: _requiredInt( + document, + TaskStatisticsDocumentFields.movedToBacklogCount, + ), + restoredFromBacklogCount: _requiredInt( + document, + TaskStatisticsDocumentFields.restoredFromBacklogCount, + ), + missedCount: _requiredInt( + document, + TaskStatisticsDocumentFields.missedCount, + ), + cancelledCount: _requiredInt( + document, + TaskStatisticsDocumentFields.cancelledCount, + ), + completedLateCount: _requiredInt( + document, + TaskStatisticsDocumentFields.completedLateCount, + ), + completedDuringLockedHoursCount: _requiredInt( + document, + TaskStatisticsDocumentFields.completedDuringLockedHoursCount, + ), + completedDuringLockedHoursMinutes: _requiredInt( + document, + TaskStatisticsDocumentFields.completedDuringLockedHoursMinutes, + ), + completedAfterShieldCount: _requiredInt( + document, + TaskStatisticsDocumentFields.completedAfterShieldCount, + ), + completedAfterPushCount: _requiredInt( + document, + TaskStatisticsDocumentFields.completedAfterPushCount, + ), + totalPushesBeforeCompletion: _requiredInt( + document, + TaskStatisticsDocumentFields.totalPushesBeforeCompletion, + ), + ); + }); + } +} + +/// Document mapping for [ProjectProfile]. +abstract final class ProjectDocumentMapping { + static Map toDocument( + ProjectProfile project, { + required String ownerId, + required DateTime createdAt, + required DateTime updatedAt, + int revision = 1, + }) { + return { + ..._commonFields( + id: project.id, + ownerId: ownerId, + revision: revision, + createdAt: createdAt, + updatedAt: updatedAt, ), - manuallyPushedCount: _intOrZero( - document, - TaskStatisticsDocumentFields.manuallyPushedCount, + ProjectDocumentFields.name: project.name, + ProjectDocumentFields.colorKey: project.colorKey, + ProjectDocumentFields.defaultPriority: + PersistenceEnumMapping.encodePriority(project.defaultPriority), + ProjectDocumentFields.defaultReward: + PersistenceEnumMapping.encodeReward(project.defaultReward), + ProjectDocumentFields.defaultDifficulty: + PersistenceEnumMapping.encodeDifficulty(project.defaultDifficulty), + ProjectDocumentFields.defaultReminderProfile: + PersistenceEnumMapping.encodeReminderProfile( + project.defaultReminderProfile, ), - autoPushedCount: _intOrZero( - document, - TaskStatisticsDocumentFields.autoPushedCount, - ), - movedToBacklogCount: _intOrZero( - document, - TaskStatisticsDocumentFields.movedToBacklogCount, - ), - restoredFromBacklogCount: _intOrZero( - document, - TaskStatisticsDocumentFields.restoredFromBacklogCount, - ), - missedCount: _intOrZero( - document, - TaskStatisticsDocumentFields.missedCount, - ), - cancelledCount: _intOrZero( - document, - TaskStatisticsDocumentFields.cancelledCount, - ), - completedLateCount: _intOrZero( - document, - TaskStatisticsDocumentFields.completedLateCount, - ), - completedDuringLockedHoursCount: _intOrZero( - document, - TaskStatisticsDocumentFields.completedDuringLockedHoursCount, - ), - completedDuringLockedHoursMinutes: _intOrZero( - document, - TaskStatisticsDocumentFields.completedDuringLockedHoursMinutes, - ), - completedAfterShieldCount: _intOrZero( - document, - TaskStatisticsDocumentFields.completedAfterShieldCount, - ), - completedAfterPushCount: _intOrZero( - document, - TaskStatisticsDocumentFields.completedAfterPushCount, - ), - totalPushesBeforeCompletion: _intOrZero( - document, - TaskStatisticsDocumentFields.totalPushesBeforeCompletion, - ), - ); + ProjectDocumentFields.defaultDurationMinutes: + project.defaultDurationMinutes, + ProjectDocumentFields.archivedAt: _dateTimeToStored(project.archivedAt), + }; + } + + static ProjectProfile fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + return ProjectProfile( + id: _requiredString(document, ProjectDocumentFields.id), + name: _requiredString(document, ProjectDocumentFields.name), + colorKey: _requiredString(document, ProjectDocumentFields.colorKey), + defaultPriority: PersistenceEnumMapping.decodeNullablePriority( + _requiredString(document, ProjectDocumentFields.defaultPriority), + )!, + defaultReward: PersistenceEnumMapping.decodeReward( + _requiredString(document, ProjectDocumentFields.defaultReward), + ), + defaultDifficulty: PersistenceEnumMapping.decodeDifficulty( + _requiredString(document, ProjectDocumentFields.defaultDifficulty), + ), + defaultReminderProfile: + PersistenceEnumMapping.decodeNullableReminderProfile( + _requiredString( + document, + ProjectDocumentFields.defaultReminderProfile, + ), + )!, + defaultDurationMinutes: _requiredNullableInt( + document, + ProjectDocumentFields.defaultDurationMinutes, + ), + archivedAt: _requiredNullableDateTime( + document, + ProjectDocumentFields.archivedAt, + ), + ); + }); } } /// Document mapping for [ProjectStatistics]. abstract final class ProjectStatisticsDocumentMapping { - /// Convert [statistics] to a MongoDB-friendly document-shaped map. - static Map toDocument(ProjectStatistics statistics) { + static Map toDocument( + ProjectStatistics statistics, { + required String ownerId, + required DateTime createdAt, + required DateTime updatedAt, + int revision = 1, + Iterable appliedActivityIds = const [], + }) { return { - ProjectStatisticsDocumentFields.id: statistics.projectId, + ..._commonFields( + id: statistics.projectId, + ownerId: ownerId, + revision: revision, + createdAt: createdAt, + updatedAt: updatedAt, + ), + ProjectStatisticsDocumentFields.projectId: statistics.projectId, ProjectStatisticsDocumentFields.completedTaskCount: statistics.completedTaskCount, ProjectStatisticsDocumentFields.durationMinuteCounts: _intKeyCountMapToDocument(statistics.durationMinuteCounts), ProjectStatisticsDocumentFields.completionTimeBucketCounts: - _enumCountMapToDocument(statistics.completionTimeBucketCounts), + _enumCountMapToDocument( + statistics.completionTimeBucketCounts, + PersistenceEnumMapping.encodeProjectCompletionTimeBucket, + ), ProjectStatisticsDocumentFields.totalPushesBeforeCompletion: statistics.totalPushesBeforeCompletion, ProjectStatisticsDocumentFields.completedAfterPushCount: statistics.completedAfterPushCount, - ProjectStatisticsDocumentFields.rewardCounts: - _enumCountMapToDocument(statistics.rewardCounts), - ProjectStatisticsDocumentFields.difficultyCounts: - _enumCountMapToDocument(statistics.difficultyCounts), + ProjectStatisticsDocumentFields.rewardCounts: _enumCountMapToDocument( + statistics.rewardCounts, + PersistenceEnumMapping.encodeReward, + ), + ProjectStatisticsDocumentFields.difficultyCounts: _enumCountMapToDocument( + statistics.difficultyCounts, + PersistenceEnumMapping.encodeDifficulty, + ), ProjectStatisticsDocumentFields.reminderProfileCounts: - _enumCountMapToDocument(statistics.reminderProfileCounts), + _enumCountMapToDocument( + statistics.reminderProfileCounts, + PersistenceEnumMapping.encodeReminderProfile, + ), + ProjectStatisticsDocumentFields.appliedActivityIds: + appliedActivityIds.toList(growable: false), }; } - /// Rebuild [ProjectStatistics] from a document-shaped map. static ProjectStatistics fromDocument(Map document) { - return ProjectStatistics( - projectId: _requiredString(document, ProjectStatisticsDocumentFields.id), - completedTaskCount: _intOrZero( - document, - ProjectStatisticsDocumentFields.completedTaskCount, - ), - durationMinuteCounts: _intKeyCountMapFromDocument( - document, - ProjectStatisticsDocumentFields.durationMinuteCounts, - ), - completionTimeBucketCounts: _enumCountMapFromDocument( - document, - ProjectStatisticsDocumentFields.completionTimeBucketCounts, - PersistenceEnumMapping.decodeProjectCompletionTimeBucket, - ), - totalPushesBeforeCompletion: _intOrZero( - document, - ProjectStatisticsDocumentFields.totalPushesBeforeCompletion, - ), - completedAfterPushCount: _intOrZero( - document, - ProjectStatisticsDocumentFields.completedAfterPushCount, - ), - rewardCounts: _enumCountMapFromDocument( - document, - ProjectStatisticsDocumentFields.rewardCounts, - PersistenceEnumMapping.decodeReward, - ), - difficultyCounts: _enumCountMapFromDocument( - document, - ProjectStatisticsDocumentFields.difficultyCounts, - PersistenceEnumMapping.decodeDifficulty, - ), - reminderProfileCounts: _enumCountMapFromDocument( - document, - ProjectStatisticsDocumentFields.reminderProfileCounts, - (value) => PersistenceEnumMapping.decodeNullableReminderProfile(value)!, - ), - ); + return _mapInvalidValues(() { + _readCommon(document); + return ProjectStatistics( + projectId: _requiredString( + document, + ProjectStatisticsDocumentFields.projectId, + ), + completedTaskCount: _requiredInt( + document, + ProjectStatisticsDocumentFields.completedTaskCount, + ), + durationMinuteCounts: _intKeyCountMapFromDocument( + document, + ProjectStatisticsDocumentFields.durationMinuteCounts, + ), + completionTimeBucketCounts: _enumCountMapFromDocument( + document, + ProjectStatisticsDocumentFields.completionTimeBucketCounts, + PersistenceEnumMapping.decodeProjectCompletionTimeBucket, + ), + totalPushesBeforeCompletion: _requiredInt( + document, + ProjectStatisticsDocumentFields.totalPushesBeforeCompletion, + ), + completedAfterPushCount: _requiredInt( + document, + ProjectStatisticsDocumentFields.completedAfterPushCount, + ), + rewardCounts: _enumCountMapFromDocument( + document, + ProjectStatisticsDocumentFields.rewardCounts, + PersistenceEnumMapping.decodeReward, + ), + difficultyCounts: _enumCountMapFromDocument( + document, + ProjectStatisticsDocumentFields.difficultyCounts, + PersistenceEnumMapping.decodeDifficulty, + ), + reminderProfileCounts: _enumCountMapFromDocument( + document, + ProjectStatisticsDocumentFields.reminderProfileCounts, + (value) => + PersistenceEnumMapping.decodeNullableReminderProfile(value)!, + ), + ).._validateProjectStatisticsDocumentMetadata(document); + }); } } -/// Convenience extension for converting a [Task] into a document map. +extension on ProjectStatistics { + void _validateProjectStatisticsDocumentMetadata( + Map document, + ) { + for (final value in _requiredList( + document, + ProjectStatisticsDocumentFields.appliedActivityIds, + )) { + if (value is! String) { + throw const DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: ProjectStatisticsDocumentFields.appliedActivityIds, + ); + } + } + } +} + +/// Document mapping for [LockedBlockRecurrence]. +abstract final class LockedBlockRecurrenceDocumentMapping { + static Map toDocument(LockedBlockRecurrence recurrence) { + return { + LockedBlockRecurrenceDocumentFields.type: 'weekly', + LockedBlockRecurrenceDocumentFields.weekdays: recurrence.weekdays + .map(PersistenceEnumMapping.encodeLockedWeekday) + .toList(growable: false), + }; + } + + static LockedBlockRecurrence fromDocument(Map document) { + return _mapInvalidValues(() { + final type = _requiredString( + document, + LockedBlockRecurrenceDocumentFields.type, + ); + if (type != 'weekly') { + throw DocumentMappingException( + code: DocumentMappingFailureCode.unknownCode, + fieldName: LockedBlockRecurrenceDocumentFields.type, + detailCode: type, + ); + } + final weekdays = _requiredList( + document, + LockedBlockRecurrenceDocumentFields.weekdays, + ).map((value) { + if (value is! String) { + throw const DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: LockedBlockRecurrenceDocumentFields.weekdays, + ); + } + return PersistenceEnumMapping.decodeLockedWeekday(value); + }).toSet(); + return LockedBlockRecurrence.weekly(weekdays: weekdays); + }); + } +} + +/// Document mapping for [LockedBlock]. +abstract final class LockedBlockDocumentMapping { + static Map toDocument( + LockedBlock block, { + required String ownerId, + int revision = 1, + }) { + return { + ..._commonFields( + id: block.id, + ownerId: ownerId, + revision: revision, + createdAt: block.createdAt, + updatedAt: block.updatedAt, + ), + LockedBlockDocumentFields.name: block.name, + LockedBlockDocumentFields.startTime: + PersistenceWallTimeConvention.toStoredString(block.startTime), + LockedBlockDocumentFields.endTime: + PersistenceWallTimeConvention.toStoredString(block.endTime), + LockedBlockDocumentFields.date: _civilDateToStored(block.date), + LockedBlockDocumentFields.recurrence: block.recurrence == null + ? null + : LockedBlockRecurrenceDocumentMapping.toDocument( + block.recurrence!, + ), + LockedBlockDocumentFields.hiddenByDefault: block.hiddenByDefault, + LockedBlockDocumentFields.projectId: block.projectId, + LockedBlockDocumentFields.archivedAt: _dateTimeToStored(block.archivedAt), + }; + } + + static LockedBlock fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + final recurrenceDocument = _requiredNullableMap( + document, + LockedBlockDocumentFields.recurrence, + ); + return LockedBlock( + id: _requiredString(document, LockedBlockDocumentFields.id), + name: _requiredString(document, LockedBlockDocumentFields.name), + startTime: _requiredWallTime( + document, + LockedBlockDocumentFields.startTime, + ), + endTime: _requiredWallTime( + document, + LockedBlockDocumentFields.endTime, + ), + date: _requiredNullableCivilDate( + document, + LockedBlockDocumentFields.date, + ), + recurrence: recurrenceDocument == null + ? null + : LockedBlockRecurrenceDocumentMapping.fromDocument( + recurrenceDocument, + ), + hiddenByDefault: _requiredBool( + document, + LockedBlockDocumentFields.hiddenByDefault, + ), + projectId: _requiredNullableString( + document, + LockedBlockDocumentFields.projectId, + ), + createdAt: + _requiredDateTime(document, LockedBlockDocumentFields.createdAt), + updatedAt: + _requiredDateTime(document, LockedBlockDocumentFields.updatedAt), + archivedAt: _requiredNullableDateTime( + document, + LockedBlockDocumentFields.archivedAt, + ), + ); + }); + } +} + +/// Document mapping for [LockedBlockOverride]. +abstract final class LockedBlockOverrideDocumentMapping { + static Map toDocument( + LockedBlockOverride override, { + required String ownerId, + int revision = 1, + }) { + return { + ..._commonFields( + id: override.id, + ownerId: ownerId, + revision: revision, + createdAt: override.createdAt, + updatedAt: override.updatedAt, + ), + LockedBlockOverrideDocumentFields.lockedBlockId: override.lockedBlockId, + LockedBlockOverrideDocumentFields.date: + PersistenceCivilDateConvention.toStoredString(override.date), + LockedBlockOverrideDocumentFields.type: + PersistenceEnumMapping.encodeLockedBlockOverrideType(override.type), + LockedBlockOverrideDocumentFields.name: override.name, + LockedBlockOverrideDocumentFields.startTime: + _wallTimeToStored(override.startTime), + LockedBlockOverrideDocumentFields.endTime: + _wallTimeToStored(override.endTime), + LockedBlockOverrideDocumentFields.hiddenByDefault: + override.hiddenByDefault, + LockedBlockOverrideDocumentFields.projectId: override.projectId, + }; + } + + static LockedBlockOverride fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + return LockedBlockOverride( + id: _requiredString(document, LockedBlockOverrideDocumentFields.id), + date: _requiredCivilDate( + document, + LockedBlockOverrideDocumentFields.date, + ), + type: PersistenceEnumMapping.decodeLockedBlockOverrideType( + _requiredString(document, LockedBlockOverrideDocumentFields.type), + ), + createdAt: _requiredDateTime( + document, + LockedBlockOverrideDocumentFields.createdAt, + ), + updatedAt: _requiredDateTime( + document, + LockedBlockOverrideDocumentFields.updatedAt, + ), + lockedBlockId: _requiredNullableString( + document, + LockedBlockOverrideDocumentFields.lockedBlockId, + ), + name: _requiredNullableString( + document, + LockedBlockOverrideDocumentFields.name, + ), + startTime: _requiredNullableWallTime( + document, + LockedBlockOverrideDocumentFields.startTime, + ), + endTime: _requiredNullableWallTime( + document, + LockedBlockOverrideDocumentFields.endTime, + ), + hiddenByDefault: _requiredBool( + document, + LockedBlockOverrideDocumentFields.hiddenByDefault, + ), + projectId: _requiredNullableString( + document, + LockedBlockOverrideDocumentFields.projectId, + ), + ); + }); + } +} + +/// Document mapping for [TaskActivity]. +abstract final class TaskActivityDocumentMapping { + static Map toDocument( + TaskActivity activity, { + required String ownerId, + int revision = 1, + DateTime? createdAt, + DateTime? updatedAt, + }) { + final created = createdAt ?? activity.occurredAt; + final updated = updatedAt ?? created; + return { + ..._commonFields( + id: activity.id, + ownerId: ownerId, + revision: revision, + createdAt: created, + updatedAt: updated, + ), + TaskActivityDocumentFields.operationId: activity.operationId, + TaskActivityDocumentFields.code: + PersistenceEnumMapping.encodeTaskActivityCode(activity.code), + TaskActivityDocumentFields.taskId: activity.taskId, + TaskActivityDocumentFields.projectId: activity.projectId, + TaskActivityDocumentFields.occurredAt: + PersistenceDateTimeConvention.toStoredString(activity.occurredAt), + TaskActivityDocumentFields.metadata: _plainDocument(activity.metadata), + }; + } + + static TaskActivity fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + return TaskActivity( + id: _requiredString(document, TaskActivityDocumentFields.id), + operationId: _requiredString( + document, + TaskActivityDocumentFields.operationId, + ), + code: PersistenceEnumMapping.decodeTaskActivityCode( + _requiredString(document, TaskActivityDocumentFields.code), + ), + taskId: _requiredString(document, TaskActivityDocumentFields.taskId), + projectId: + _requiredString(document, TaskActivityDocumentFields.projectId), + occurredAt: _requiredDateTime( + document, + TaskActivityDocumentFields.occurredAt, + ), + metadata: _requiredMap(document, TaskActivityDocumentFields.metadata), + ); + }); + } +} + +/// Document mapping for [OwnerSettings]. +abstract final class OwnerSettingsDocumentMapping { + static Map toDocument( + OwnerSettings settings, { + required DateTime createdAt, + required DateTime updatedAt, + int revision = 1, + }) { + return { + ..._commonFields( + id: settings.ownerId, + ownerId: settings.ownerId, + revision: revision, + createdAt: createdAt, + updatedAt: updatedAt, + ), + OwnerSettingsDocumentFields.timeZoneId: settings.timeZoneId, + OwnerSettingsDocumentFields.dayStart: + PersistenceWallTimeConvention.toStoredString(settings.dayStart), + OwnerSettingsDocumentFields.dayEnd: + PersistenceWallTimeConvention.toStoredString(settings.dayEnd), + OwnerSettingsDocumentFields.compactModeEnabled: + settings.compactModeEnabled, + OwnerSettingsDocumentFields.backlogStaleness: + BacklogStalenessDocumentMapping.toDocument( + settings.backlogStalenessSettings, + ), + }; + } + + static OwnerSettings fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + return OwnerSettings( + ownerId: _requiredString(document, OwnerSettingsDocumentFields.ownerId), + timeZoneId: _requiredString( + document, + OwnerSettingsDocumentFields.timeZoneId, + ), + dayStart: _requiredWallTime( + document, + OwnerSettingsDocumentFields.dayStart, + ), + dayEnd: _requiredWallTime( + document, + OwnerSettingsDocumentFields.dayEnd, + ), + compactModeEnabled: _requiredBool( + document, + OwnerSettingsDocumentFields.compactModeEnabled, + ), + backlogStalenessSettings: BacklogStalenessDocumentMapping.fromDocument( + _requiredMap(document, OwnerSettingsDocumentFields.backlogStaleness), + ), + ); + }); + } +} + +/// Document mapping for [BacklogStalenessSettings]. +abstract final class BacklogStalenessDocumentMapping { + static Map toDocument(BacklogStalenessSettings settings) { + return { + BacklogStalenessDocumentFields.greenMaxAgeDays: + settings.greenMaxAge.inDays, + BacklogStalenessDocumentFields.blueMaxAgeDays: settings.blueMaxAge.inDays, + }; + } + + static BacklogStalenessSettings fromDocument(Map document) { + return _mapInvalidValues(() { + final greenDays = _requiredInt( + document, + BacklogStalenessDocumentFields.greenMaxAgeDays, + ); + final blueDays = _requiredInt( + document, + BacklogStalenessDocumentFields.blueMaxAgeDays, + ); + if (greenDays < 0 || blueDays < 0 || blueDays < greenDays) { + throw const DocumentMappingException( + code: DocumentMappingFailureCode.invalidValue, + fieldName: OwnerSettingsDocumentFields.backlogStaleness, + detailCode: 'invalidBacklogStalenessThresholds', + ); + } + return BacklogStalenessSettings( + greenMaxAge: Duration(days: greenDays), + blueMaxAge: Duration(days: blueDays), + ); + }); + } +} + +/// Document mapping for [NoticeAcknowledgementRecord]. +abstract final class NoticeAcknowledgementDocumentMapping { + static Map toDocument( + NoticeAcknowledgementRecord record, { + int revision = 1, + }) { + return { + ..._commonFields( + id: record.noticeId, + ownerId: record.ownerId, + revision: revision, + createdAt: record.acknowledgedAt, + updatedAt: record.acknowledgedAt, + ), + NoticeAcknowledgementDocumentFields.noticeId: record.noticeId, + NoticeAcknowledgementDocumentFields.acknowledgedAt: + PersistenceDateTimeConvention.toStoredString(record.acknowledgedAt), + }; + } + + static NoticeAcknowledgementRecord fromDocument( + Map document, + ) { + return _mapInvalidValues(() { + _readCommon(document); + return NoticeAcknowledgementRecord( + noticeId: _requiredString( + document, + NoticeAcknowledgementDocumentFields.noticeId, + ), + ownerId: _requiredString( + document, + NoticeAcknowledgementDocumentFields.ownerId, + ), + acknowledgedAt: _requiredDateTime( + document, + NoticeAcknowledgementDocumentFields.acknowledgedAt, + ), + ); + }); + } +} + +/// Document mapping for [ApplicationOperationRecord]. +abstract final class ApplicationOperationDocumentMapping { + static Map toDocument( + ApplicationOperationRecord record, { + int revision = 1, + }) { + return { + ..._commonFields( + id: record.operationId, + ownerId: record.ownerId, + revision: revision, + createdAt: record.committedAt, + updatedAt: record.committedAt, + ), + ApplicationOperationDocumentFields.operationId: record.operationId, + ApplicationOperationDocumentFields.operationName: record.operationName, + ApplicationOperationDocumentFields.committedAt: + PersistenceDateTimeConvention.toStoredString(record.committedAt), + }; + } + + static ApplicationOperationRecord fromDocument( + Map document, + ) { + return _mapInvalidValues(() { + _readCommon(document); + return ApplicationOperationRecord( + operationId: _requiredString( + document, + ApplicationOperationDocumentFields.operationId, + ), + ownerId: _requiredString( + document, + ApplicationOperationDocumentFields.ownerId, + ), + operationName: _requiredString( + document, + ApplicationOperationDocumentFields.operationName, + ), + committedAt: _requiredDateTime( + document, + ApplicationOperationDocumentFields.committedAt, + ), + ); + }); + } +} + +/// Document mapping for [SchedulingStateSnapshot]. +abstract final class SchedulingSnapshotDocumentMapping { + static Map toDocument( + SchedulingStateSnapshot snapshot, { + required String ownerId, + int revision = 1, + CivilDate? sourceDate, + CivilDate? targetDate, + DateTime? retentionExpiresAt, + bool truncated = false, + }) { + return { + ..._commonFields( + id: snapshot.id, + ownerId: ownerId, + revision: revision, + createdAt: snapshot.capturedAt, + updatedAt: snapshot.capturedAt, + ), + SchedulingSnapshotDocumentFields.capturedAt: + PersistenceDateTimeConvention.toStoredString(snapshot.capturedAt), + SchedulingSnapshotDocumentFields.sourceDate: + _civilDateToStored(sourceDate), + SchedulingSnapshotDocumentFields.targetDate: + _civilDateToStored(targetDate), + SchedulingSnapshotDocumentFields.window: + SchedulingWindowDocumentMapping.toDocument(snapshot.window), + SchedulingSnapshotDocumentFields.tasks: snapshot.tasks + .map( + (task) => TaskDocumentMapping.toDocument( + task, + ownerId: ownerId, + ), + ) + .toList(growable: false), + SchedulingSnapshotDocumentFields.lockedIntervals: snapshot.lockedIntervals + .map(TimeIntervalDocumentMapping.toDocument) + .toList(growable: false), + SchedulingSnapshotDocumentFields.requiredVisibleIntervals: snapshot + .requiredVisibleIntervals + .map(TimeIntervalDocumentMapping.toDocument) + .toList(growable: false), + SchedulingSnapshotDocumentFields.notices: snapshot.notices + .map(SchedulingNoticeDocumentMapping.toDocument) + .toList(growable: false), + SchedulingSnapshotDocumentFields.changes: snapshot.changes + .map(SchedulingChangeDocumentMapping.toDocument) + .toList(growable: false), + SchedulingSnapshotDocumentFields.overlaps: snapshot.overlaps + .map(SchedulingOverlapDocumentMapping.toDocument) + .toList(growable: false), + SchedulingSnapshotDocumentFields.operationName: snapshot.operationName, + SchedulingSnapshotDocumentFields.retentionExpiresAt: + _dateTimeToStored(retentionExpiresAt), + SchedulingSnapshotDocumentFields.truncated: truncated, + }; + } + + static SchedulingStateSnapshot fromDocument(Map document) { + return _mapInvalidValues(() { + _readCommon(document); + return SchedulingStateSnapshot( + id: _requiredString(document, SchedulingSnapshotDocumentFields.id), + capturedAt: _requiredDateTime( + document, + SchedulingSnapshotDocumentFields.capturedAt, + ), + window: SchedulingWindowDocumentMapping.fromDocument( + _requiredMap(document, SchedulingSnapshotDocumentFields.window), + ), + tasks: _mappedList( + document, + SchedulingSnapshotDocumentFields.tasks, + TaskDocumentMapping.fromDocument, + ), + lockedIntervals: _mappedList( + document, + SchedulingSnapshotDocumentFields.lockedIntervals, + TimeIntervalDocumentMapping.fromDocument, + ), + requiredVisibleIntervals: _mappedList( + document, + SchedulingSnapshotDocumentFields.requiredVisibleIntervals, + TimeIntervalDocumentMapping.fromDocument, + ), + notices: _mappedList( + document, + SchedulingSnapshotDocumentFields.notices, + SchedulingNoticeDocumentMapping.fromDocument, + ), + changes: _mappedList( + document, + SchedulingSnapshotDocumentFields.changes, + SchedulingChangeDocumentMapping.fromDocument, + ), + overlaps: _mappedList( + document, + SchedulingSnapshotDocumentFields.overlaps, + SchedulingOverlapDocumentMapping.fromDocument, + ), + operationName: _requiredNullableString( + document, + SchedulingSnapshotDocumentFields.operationName, + ), + ).._validateSchedulingSnapshotDocumentMetadata(document); + }); + } +} + +extension on SchedulingStateSnapshot { + void _validateSchedulingSnapshotDocumentMetadata( + Map document, + ) { + _requiredNullableCivilDate( + document, + SchedulingSnapshotDocumentFields.sourceDate, + ); + _requiredNullableCivilDate( + document, + SchedulingSnapshotDocumentFields.targetDate, + ); + _requiredNullableDateTime( + document, + SchedulingSnapshotDocumentFields.retentionExpiresAt, + ); + _requiredBool(document, SchedulingSnapshotDocumentFields.truncated); + } +} + +/// Document mapping for [SchedulingWindow]. +abstract final class SchedulingWindowDocumentMapping { + static Map toDocument(SchedulingWindow window) { + return { + SchedulingWindowDocumentFields.start: + PersistenceDateTimeConvention.toStoredString(window.start), + SchedulingWindowDocumentFields.end: + PersistenceDateTimeConvention.toStoredString(window.end), + }; + } + + static SchedulingWindow fromDocument(Map document) { + return _mapInvalidValues(() { + return SchedulingWindow( + start: + _requiredDateTime(document, SchedulingWindowDocumentFields.start), + end: _requiredDateTime(document, SchedulingWindowDocumentFields.end), + ); + }); + } +} + +/// Document mapping for [TimeInterval]. +abstract final class TimeIntervalDocumentMapping { + static Map toDocument(TimeInterval interval) { + return { + TimeIntervalDocumentFields.start: + PersistenceDateTimeConvention.toStoredString(interval.start), + TimeIntervalDocumentFields.end: + PersistenceDateTimeConvention.toStoredString(interval.end), + TimeIntervalDocumentFields.label: interval.label, + }; + } + + static TimeInterval fromDocument(Map document) { + return _mapInvalidValues(() { + return TimeInterval( + start: _requiredDateTime(document, TimeIntervalDocumentFields.start), + end: _requiredDateTime(document, TimeIntervalDocumentFields.end), + label: _requiredNullableString( + document, + TimeIntervalDocumentFields.label, + ), + ); + }); + } +} + +/// Document mapping for [SchedulingNotice]. +abstract final class SchedulingNoticeDocumentMapping { + static Map toDocument(SchedulingNotice notice) { + return { + SchedulingNoticeDocumentFields.message: notice.message, + SchedulingNoticeDocumentFields.type: + PersistenceEnumMapping.encodeSchedulingNoticeType(notice.type), + SchedulingNoticeDocumentFields.taskId: notice.taskId, + SchedulingNoticeDocumentFields.issueCode: notice.issueCode == null + ? null + : PersistenceEnumMapping.encodeSchedulingIssueCode(notice.issueCode!), + SchedulingNoticeDocumentFields.movementCode: notice.movementCode == null + ? null + : PersistenceEnumMapping.encodeSchedulingMovementCode( + notice.movementCode!, + ), + SchedulingNoticeDocumentFields.conflictCode: notice.conflictCode == null + ? null + : PersistenceEnumMapping.encodeSchedulingConflictCode( + notice.conflictCode!, + ), + SchedulingNoticeDocumentFields.parameters: + _plainDocument(notice.parameters), + }; + } + + static SchedulingNotice fromDocument(Map document) { + return _mapInvalidValues(() { + return SchedulingNotice( + _requiredString(document, SchedulingNoticeDocumentFields.message), + type: PersistenceEnumMapping.decodeSchedulingNoticeType( + _requiredString(document, SchedulingNoticeDocumentFields.type), + ), + taskId: _requiredNullableString( + document, + SchedulingNoticeDocumentFields.taskId, + ), + issueCode: PersistenceEnumMapping.decodeNullableSchedulingIssueCode( + _requiredNullableString( + document, + SchedulingNoticeDocumentFields.issueCode, + ), + ), + movementCode: + PersistenceEnumMapping.decodeNullableSchedulingMovementCode( + _requiredNullableString( + document, + SchedulingNoticeDocumentFields.movementCode, + ), + ), + conflictCode: + PersistenceEnumMapping.decodeNullableSchedulingConflictCode( + _requiredNullableString( + document, + SchedulingNoticeDocumentFields.conflictCode, + ), + ), + parameters: _requiredMap( + document, + SchedulingNoticeDocumentFields.parameters, + ), + ); + }); + } +} + +/// Document mapping for [SchedulingChange]. +abstract final class SchedulingChangeDocumentMapping { + static Map toDocument(SchedulingChange change) { + return { + SchedulingChangeDocumentFields.taskId: change.taskId, + SchedulingChangeDocumentFields.previousStart: + _dateTimeToStored(change.previousStart), + SchedulingChangeDocumentFields.previousEnd: + _dateTimeToStored(change.previousEnd), + SchedulingChangeDocumentFields.nextStart: + _dateTimeToStored(change.nextStart), + SchedulingChangeDocumentFields.nextEnd: _dateTimeToStored(change.nextEnd), + }; + } + + static SchedulingChange fromDocument(Map document) { + return _mapInvalidValues(() { + return SchedulingChange( + taskId: + _requiredString(document, SchedulingChangeDocumentFields.taskId), + previousStart: _requiredNullableDateTime( + document, + SchedulingChangeDocumentFields.previousStart, + ), + previousEnd: _requiredNullableDateTime( + document, + SchedulingChangeDocumentFields.previousEnd, + ), + nextStart: _requiredNullableDateTime( + document, + SchedulingChangeDocumentFields.nextStart, + ), + nextEnd: _requiredNullableDateTime( + document, + SchedulingChangeDocumentFields.nextEnd, + ), + ); + }); + } +} + +/// Document mapping for [SchedulingOverlap]. +abstract final class SchedulingOverlapDocumentMapping { + static Map toDocument(SchedulingOverlap overlap) { + return { + SchedulingOverlapDocumentFields.taskId: overlap.taskId, + SchedulingOverlapDocumentFields.taskInterval: + TimeIntervalDocumentMapping.toDocument(overlap.taskInterval), + SchedulingOverlapDocumentFields.blockedInterval: + TimeIntervalDocumentMapping.toDocument(overlap.blockedInterval), + }; + } + + static SchedulingOverlap fromDocument(Map document) { + return _mapInvalidValues(() { + return SchedulingOverlap( + taskId: + _requiredString(document, SchedulingOverlapDocumentFields.taskId), + taskInterval: TimeIntervalDocumentMapping.fromDocument( + _requiredMap(document, SchedulingOverlapDocumentFields.taskInterval), + ), + blockedInterval: TimeIntervalDocumentMapping.fromDocument( + _requiredMap( + document, + SchedulingOverlapDocumentFields.blockedInterval, + ), + ), + ); + }); + } +} + +/// Convenience extension for converting a [Task] into a V1 document map. extension TaskDocumentExtension on Task { - /// Convert this task to a MongoDB-friendly document-shaped map. - Map toDocument({bool clearSchedule = false}) { - return TaskDocumentMapping.toDocument(this, clearSchedule: clearSchedule); + Map toDocument({ + required String ownerId, + int revision = 1, + bool clearSchedule = false, + DateTime? backlogEnteredAt, + String? backlogEnteredAtProvenance, + }) { + return TaskDocumentMapping.toDocument( + this, + ownerId: ownerId, + revision: revision, + clearSchedule: clearSchedule, + backlogEnteredAt: backlogEnteredAt, + backlogEnteredAtProvenance: backlogEnteredAtProvenance, + ); } } /// Convenience extension for converting [TaskStatistics] into an embedded map. extension TaskStatisticsDocumentExtension on TaskStatistics { - /// Convert this statistics value to a MongoDB-friendly embedded document. Map toDocument() { return TaskStatisticsDocumentMapping.toDocument(this); } @@ -353,9 +1561,247 @@ extension TaskStatisticsDocumentExtension on TaskStatistics { /// Convenience extension for converting [ProjectStatistics] into a document. extension ProjectStatisticsDocumentExtension on ProjectStatistics { - /// Convert this project-statistics value to a MongoDB-friendly document. - Map toDocument() { - return ProjectStatisticsDocumentMapping.toDocument(this); + Map toDocument({ + required String ownerId, + required DateTime createdAt, + required DateTime updatedAt, + int revision = 1, + Iterable appliedActivityIds = const [], + }) { + return ProjectStatisticsDocumentMapping.toDocument( + this, + ownerId: ownerId, + createdAt: createdAt, + updatedAt: updatedAt, + revision: revision, + appliedActivityIds: appliedActivityIds, + ); + } +} + +const _taskTypeByCode = { + 'flexible': TaskType.flexible, + 'inflexible': TaskType.inflexible, + 'critical': TaskType.critical, + 'locked': TaskType.locked, + 'surprise': TaskType.surprise, + 'free_slot': TaskType.freeSlot, +}; + +const _taskStatusByCode = { + 'planned': TaskStatus.planned, + 'active': TaskStatus.active, + 'completed': TaskStatus.completed, + 'missed': TaskStatus.missed, + 'cancelled': TaskStatus.cancelled, + 'no_longer_relevant': TaskStatus.noLongerRelevant, + 'backlog': TaskStatus.backlog, +}; + +const _priorityByCode = { + 'very_low': PriorityLevel.veryLow, + 'low': PriorityLevel.low, + 'medium': PriorityLevel.medium, + 'high': PriorityLevel.high, + 'very_high': PriorityLevel.veryHigh, +}; + +const _rewardByCode = { + 'not_set': RewardLevel.notSet, + 'very_low': RewardLevel.veryLow, + 'low': RewardLevel.low, + 'medium': RewardLevel.medium, + 'high': RewardLevel.high, + 'very_high': RewardLevel.veryHigh, +}; + +const _difficultyByCode = { + 'not_set': DifficultyLevel.notSet, + 'very_easy': DifficultyLevel.veryEasy, + 'easy': DifficultyLevel.easy, + 'medium': DifficultyLevel.medium, + 'hard': DifficultyLevel.hard, + 'very_hard': DifficultyLevel.veryHard, +}; + +const _reminderProfileByCode = { + 'silent': ReminderProfile.silent, + 'gentle': ReminderProfile.gentle, + 'persistent': ReminderProfile.persistent, + 'strict': ReminderProfile.strict, +}; + +const _backlogTagByCode = { + 'wishlist': BacklogTag.wishlist, +}; + +const _lockedWeekdayByCode = { + 'monday': LockedWeekday.monday, + 'tuesday': LockedWeekday.tuesday, + 'wednesday': LockedWeekday.wednesday, + 'thursday': LockedWeekday.thursday, + 'friday': LockedWeekday.friday, + 'saturday': LockedWeekday.saturday, + 'sunday': LockedWeekday.sunday, +}; + +const _lockedBlockOverrideTypeByCode = { + 'remove': LockedBlockOverrideType.remove, + 'replace': LockedBlockOverrideType.replace, + 'add': LockedBlockOverrideType.add, +}; + +const _taskActivityCodeByCode = { + 'completed': TaskActivityCode.completed, + 'missed': TaskActivityCode.missed, + 'cancelled': TaskActivityCode.cancelled, + 'no_longer_relevant': TaskActivityCode.noLongerRelevant, + 'manually_pushed': TaskActivityCode.manuallyPushed, + 'automatically_pushed': TaskActivityCode.automaticallyPushed, + 'moved_to_backlog': TaskActivityCode.movedToBacklog, + 'restored_from_backlog': TaskActivityCode.restoredFromBacklog, + 'activated': TaskActivityCode.activated, +}; + +const _schedulingNoticeTypeByCode = { + 'info': SchedulingNoticeType.info, + 'moved': SchedulingNoticeType.moved, + 'overlap': SchedulingNoticeType.overlap, + 'no_fit': SchedulingNoticeType.noFit, + 'overflow': SchedulingNoticeType.overflow, +}; + +const _schedulingIssueCodeByCode = { + 'task_not_found': SchedulingIssueCode.taskNotFound, + 'invalid_task_state': SchedulingIssueCode.invalidTaskState, + 'missing_duration': SchedulingIssueCode.missingDuration, + 'missing_scheduled_slot': SchedulingIssueCode.missingScheduledSlot, + 'non_positive_duration': SchedulingIssueCode.nonPositiveDuration, + 'no_available_slot': SchedulingIssueCode.noAvailableSlot, + 'unfinished_tasks_could_not_fit': + SchedulingIssueCode.unfinishedTasksCouldNotFit, + 'no_unfinished_flexible_tasks': SchedulingIssueCode.noUnfinishedFlexibleTasks, + 'duplicate_surprise_log': SchedulingIssueCode.duplicateSurpriseLog, +}; + +const _schedulingMovementCodeByCode = { + 'backlog_task_inserted': SchedulingMovementCode.backlogTaskInserted, + 'flexible_task_moved_to_make_room': + SchedulingMovementCode.flexibleTaskMovedToMakeRoom, + 'flexible_task_pushed_to_next_available_slot': + SchedulingMovementCode.flexibleTaskPushedToNextAvailableSlot, + 'flexible_task_moved_to_tomorrow': + SchedulingMovementCode.flexibleTaskMovedToTomorrow, + 'unfinished_flexible_tasks_rolled_over': + SchedulingMovementCode.unfinishedFlexibleTasksRolledOver, + 'flexible_task_moved_to_backlog': + SchedulingMovementCode.flexibleTaskMovedToBacklog, + 'required_commitment_scheduled': + SchedulingMovementCode.requiredCommitmentScheduled, +}; + +const _schedulingConflictCodeByCode = { + 'flexible_task_overlaps_blocked_time': + SchedulingConflictCode.flexibleTaskOverlapsBlockedTime, + 'surprise_task_overlaps_required_visible_time': + SchedulingConflictCode.surpriseTaskOverlapsRequiredVisibleTime, + 'required_commitment_overlaps_protected_free_slot': + SchedulingConflictCode.requiredCommitmentOverlapsProtectedFreeSlot, +}; + +const _projectCompletionTimeBucketByCode = + { + 'overnight': ProjectCompletionTimeBucket.overnight, + 'morning': ProjectCompletionTimeBucket.morning, + 'afternoon': ProjectCompletionTimeBucket.afternoon, + 'evening': ProjectCompletionTimeBucket.evening, + 'night': ProjectCompletionTimeBucket.night, +}; + +T _decodeCode(Map valuesByCode, String code, String fieldName) { + final value = valuesByCode[code]; + if (value == null) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.unknownCode, + fieldName: fieldName, + detailCode: code, + ); + } + return value; +} + +Map _commonFields({ + required String id, + required String ownerId, + required int revision, + required DateTime createdAt, + required DateTime updatedAt, +}) { + if (revision <= 0) { + throw const DocumentMappingException( + code: DocumentMappingFailureCode.invalidRevision, + fieldName: DocumentFields.revision, + ); + } + return { + DocumentFields.schemaVersion: v1SchemaVersion, + DocumentFields.id: id, + DocumentFields.ownerId: ownerId, + DocumentFields.revision: revision, + DocumentFields.createdAt: + PersistenceDateTimeConvention.toStoredString(createdAt), + DocumentFields.updatedAt: + PersistenceDateTimeConvention.toStoredString(updatedAt), + }; +} + +DocumentMetadata _readCommon(Map document) { + final schemaVersion = _requiredInt(document, DocumentFields.schemaVersion); + if (schemaVersion != v1SchemaVersion) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.invalidSchemaVersion, + fieldName: DocumentFields.schemaVersion, + detailCode: schemaVersion.toString(), + ); + } + final revision = _requiredInt(document, DocumentFields.revision); + if (revision <= 0) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.invalidRevision, + fieldName: DocumentFields.revision, + detailCode: revision.toString(), + ); + } + return DocumentMetadata( + id: _requiredString(document, DocumentFields.id), + ownerId: _requiredString(document, DocumentFields.ownerId), + revision: revision, + createdAt: _requiredDateTime(document, DocumentFields.createdAt), + updatedAt: _requiredDateTime(document, DocumentFields.updatedAt), + ); +} + +T _mapInvalidValues(T Function() read) { + try { + return read(); + } on DocumentMappingException { + rethrow; + } on DomainValidationException catch (error) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.invalidValue, + fieldName: error.name, + detailCode: error.code.name, + ); + } on FormatException catch (error) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.invalidValue, + detailCode: error.message, + ); + } on ArgumentError catch (error) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.invalidValue, + fieldName: error.name, + ); } } @@ -365,95 +1811,107 @@ String? _dateTimeToStored(DateTime? value) { : PersistenceDateTimeConvention.toStoredString(value); } +String? _civilDateToStored(CivilDate? value) { + return value == null + ? null + : PersistenceCivilDateConvention.toStoredString(value); +} + +String? _wallTimeToStored(WallTime? value) { + return value == null + ? null + : PersistenceWallTimeConvention.toStoredString(value); +} + String _requiredString(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } final value = document[fieldName]; if (value is String) { return value; } - throw ArgumentError.value(value, fieldName, 'Expected a string.'); + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); } -String? _nullableString(Map document, String fieldName) { +String? _requiredNullableString( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } final value = document[fieldName]; if (value == null || value is String) { return value as String?; } - throw ArgumentError.value(value, fieldName, 'Expected a string or null.'); + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); } -int? _nullableInt(Map document, String fieldName) { +int _requiredInt(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } + final value = document[fieldName]; + if (value is int) { + return value; + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +int? _requiredNullableInt( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } final value = document[fieldName]; if (value == null || value is int) { return value as int?; } - throw ArgumentError.value(value, fieldName, 'Expected an int or null.'); + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); } -int _intOrZero(Map document, String fieldName) { +bool _requiredBool(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } final value = document[fieldName]; - if (value == null) { - return 0; - } - if (value is int) { + if (value is bool) { return value; } - throw ArgumentError.value(value, fieldName, 'Expected an int.'); -} - -Map _intKeyCountMapToDocument(Map counts) { - return { - for (final entry in counts.entries) entry.key.toString(): entry.value, - }; -} - -Map _enumCountMapToDocument(Map counts) { - return { - for (final entry in counts.entries) entry.key.persistenceName: entry.value, - }; -} - -Map _intKeyCountMapFromDocument( - Map document, - String fieldName, -) { - final raw = document[fieldName]; - if (raw == null) { - return const {}; - } - if (raw is! Map) { - throw ArgumentError.value(raw, fieldName, 'Expected a count map.'); - } - - return { - for (final entry in raw.entries) - int.parse(entry.key as String): _countValue(entry.value, fieldName), - }; -} - -Map _enumCountMapFromDocument( - Map document, - String fieldName, - T Function(String value) decode, -) { - final raw = document[fieldName]; - if (raw == null) { - return {}; - } - if (raw is! Map) { - throw ArgumentError.value(raw, fieldName, 'Expected a count map.'); - } - - return { - for (final entry in raw.entries) - decode(entry.key as String): _countValue(entry.value, fieldName), - }; -} - -int _countValue(Object? value, String fieldName) { - if (value is int) { - return value; - } - throw ArgumentError.value(value, fieldName, 'Expected an int count.'); + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); } DateTime _requiredDateTime(Map document, String fieldName) { @@ -462,17 +1920,58 @@ DateTime _requiredDateTime(Map document, String fieldName) { ); } -DateTime? _nullableDateTime(Map document, String fieldName) { - final value = _nullableString(document, fieldName); +DateTime? _requiredNullableDateTime( + Map document, + String fieldName, +) { + final value = _requiredNullableString(document, fieldName); return value == null ? null : PersistenceDateTimeConvention.fromStoredString(value); } +CivilDate _requiredCivilDate(Map document, String fieldName) { + return PersistenceCivilDateConvention.fromStoredString( + _requiredString(document, fieldName), + ); +} + +CivilDate? _requiredNullableCivilDate( + Map document, + String fieldName, +) { + final value = _requiredNullableString(document, fieldName); + return value == null + ? null + : PersistenceCivilDateConvention.fromStoredString(value); +} + +WallTime _requiredWallTime(Map document, String fieldName) { + return PersistenceWallTimeConvention.fromStoredString( + _requiredString(document, fieldName), + ); +} + +WallTime? _requiredNullableWallTime( + Map document, + String fieldName, +) { + final value = _requiredNullableString(document, fieldName); + return value == null + ? null + : PersistenceWallTimeConvention.fromStoredString(value); +} + Map _requiredMap( Map document, String fieldName, ) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } final value = document[fieldName]; if (value is Map) { return value; @@ -480,5 +1979,158 @@ Map _requiredMap( if (value is Map) { return Map.from(value); } - throw ArgumentError.value(value, fieldName, 'Expected a document map.'); + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +Map? _requiredNullableMap( + Map document, + String fieldName, +) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } + final value = document[fieldName]; + if (value == null) { + return null; + } + if (value is Map) { + return value; + } + if (value is Map) { + return Map.from(value); + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +List _requiredList(Map document, String fieldName) { + if (!document.containsKey(fieldName)) { + throw DocumentMappingException( + code: DocumentMappingFailureCode.missingField, + fieldName: fieldName, + ); + } + final value = document[fieldName]; + if (value is List) { + return List.unmodifiable(value); + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +List _mappedList( + Map document, + String fieldName, + T Function(Map document) decode, +) { + return _requiredList(document, fieldName).map((value) { + if (value is Map) { + return decode(value); + } + if (value is Map) { + return decode(Map.from(value)); + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); + }).toList(growable: false); +} + +Map _intKeyCountMapToDocument(Map counts) { + return { + for (final entry in counts.entries) entry.key.toString(): entry.value, + }; +} + +Map _enumCountMapToDocument( + Map counts, + String Function(T value) encode, +) { + return { + for (final entry in counts.entries) encode(entry.key): entry.value, + }; +} + +Map _intKeyCountMapFromDocument( + Map document, + String fieldName, +) { + final raw = _requiredMap(document, fieldName); + return { + for (final entry in raw.entries) + int.parse(entry.key): _countValue(entry.value, fieldName), + }; +} + +Map _enumCountMapFromDocument( + Map document, + String fieldName, + T Function(String value) decode, +) { + final raw = _requiredMap(document, fieldName); + return { + for (final entry in raw.entries) + decode(entry.key): _countValue(entry.value, fieldName), + }; +} + +int _countValue(Object? value, String fieldName) { + if (value is int) { + return value; + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + fieldName: fieldName, + ); +} + +Map _plainDocument(Map value) { + return Map.unmodifiable( + value.map((key, value) => MapEntry(key, _plainDocumentValue(value))), + ); +} + +Object? _plainDocumentValue(Object? value) { + if (value == null || + value is String || + value is num || + value is bool || + value is DateTime) { + return value is DateTime + ? PersistenceDateTimeConvention.toStoredString(value) + : value; + } + if (value is CivilDate) { + return PersistenceCivilDateConvention.toStoredString(value); + } + if (value is WallTime) { + return PersistenceWallTimeConvention.toStoredString(value); + } + if (value is Enum) { + return PersistenceEnumMapping.encode(value); + } + if (value is List) { + return value.map(_plainDocumentValue).toList(growable: false); + } + if (value is Map) { + return _plainDocument(value); + } + if (value is Map) { + return _plainDocument(Map.from(value)); + } + throw DocumentMappingException( + code: DocumentMappingFailureCode.wrongType, + detailCode: value.runtimeType.toString(), + ); } diff --git a/lib/src/persistence_contract.dart b/lib/src/persistence_contract.dart index d40baa0..5e815d3 100644 --- a/lib/src/persistence_contract.dart +++ b/lib/src/persistence_contract.dart @@ -1,11 +1,14 @@ // MongoDB-oriented persistence contract helpers. // -// This file defines stable names and conventions that future document mapping -// code should use. It deliberately does not serialize domain models or import a -// MongoDB client. +// This file defines stable field names and encoding conventions that document +// codecs and future adapters should use. It deliberately does not serialize +// domain models or import a MongoDB client. import 'time_contracts.dart'; +/// V1 document-schema version. +const v1SchemaVersion = 1; + /// DateTime storage convention for future document persistence. abstract final class PersistenceDateTimeConvention { /// Human-readable convention kept close to the helper for tests and docs. @@ -62,9 +65,12 @@ abstract final class PersistenceWallTimeConvention { } } -/// Stable enum-name mapping plan for future document persistence. +/// Legacy enum-name extension retained for older callers. +/// +/// V1 codecs use explicit stable code maps in `document_mapping.dart` instead +/// of relying on this extension. extension PersistenceEnumName on Enum { - /// Persist enum values by their Dart enum name. + /// Legacy persisted name. String get persistenceName => name; } @@ -72,11 +78,38 @@ extension PersistenceEnumName on Enum { abstract final class DocumentFields { /// MongoDB primary id field. static const id = '_id'; + + /// Version of the document shape. + static const schemaVersion = 'schemaVersion'; + + /// Authorization-neutral owner scope. + static const ownerId = 'ownerId'; + + /// Optimistic-concurrency revision. + static const revision = 'revision'; + + /// Creation instant. + static const createdAt = 'createdAt'; + + /// Last update instant. + static const updatedAt = 'updatedAt'; + + static const common = { + schemaVersion, + id, + ownerId, + revision, + createdAt, + updatedAt, + }; } /// Document field names for [Task] documents. abstract final class TaskDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; static const title = 'title'; static const projectId = 'projectId'; static const type = 'type'; @@ -93,12 +126,14 @@ abstract final class TaskDocumentFields { static const parentTaskId = 'parentTaskId'; static const backlogTags = 'backlogTags'; static const reminderOverride = 'reminderOverride'; - static const createdAt = 'createdAt'; - static const updatedAt = 'updatedAt'; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; static const stats = 'stats'; + static const backlogEnteredAt = 'backlogEnteredAt'; + static const backlogEnteredAtProvenance = 'backlogEnteredAtProvenance'; static const all = { - id, + ...DocumentFields.common, title, projectId, type, @@ -115,15 +150,20 @@ abstract final class TaskDocumentFields { parentTaskId, backlogTags, reminderOverride, - createdAt, - updatedAt, stats, + backlogEnteredAt, + backlogEnteredAtProvenance, }; } /// Document field names for internal [TaskActivity] documents. abstract final class TaskActivityDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; static const operationId = 'operationId'; static const code = 'code'; static const taskId = 'taskId'; @@ -132,7 +172,7 @@ abstract final class TaskActivityDocumentFields { static const metadata = 'metadata'; static const all = { - id, + ...DocumentFields.common, operationId, code, taskId, @@ -179,7 +219,12 @@ abstract final class TaskStatisticsDocumentFields { /// Document field names for [ProjectProfile] documents. abstract final class ProjectDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; static const name = 'name'; static const colorKey = 'colorKey'; static const defaultPriority = 'defaultPriority'; @@ -187,9 +232,10 @@ abstract final class ProjectDocumentFields { static const defaultDifficulty = 'defaultDifficulty'; static const defaultReminderProfile = 'defaultReminderProfile'; static const defaultDurationMinutes = 'defaultDurationMinutes'; + static const archivedAt = 'archivedAt'; static const all = { - id, + ...DocumentFields.common, name, colorKey, defaultPriority, @@ -197,12 +243,19 @@ abstract final class ProjectDocumentFields { defaultDifficulty, defaultReminderProfile, defaultDurationMinutes, + archivedAt, }; } /// Document field names for [ProjectStatistics] documents. abstract final class ProjectStatisticsDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; + static const projectId = 'projectId'; static const completedTaskCount = 'completedTaskCount'; static const durationMinuteCounts = 'durationMinuteCounts'; static const completionTimeBucketCounts = 'completionTimeBucketCounts'; @@ -211,9 +264,11 @@ abstract final class ProjectStatisticsDocumentFields { static const rewardCounts = 'rewardCounts'; static const difficultyCounts = 'difficultyCounts'; static const reminderProfileCounts = 'reminderProfileCounts'; + static const appliedActivityIds = 'appliedActivityIds'; static const all = { - id, + ...DocumentFields.common, + projectId, completedTaskCount, durationMinuteCounts, completionTimeBucketCounts, @@ -222,32 +277,36 @@ abstract final class ProjectStatisticsDocumentFields { rewardCounts, difficultyCounts, reminderProfileCounts, + appliedActivityIds, }; } /// Document field names for [ClockTime] embedded documents. abstract final class ClockTimeDocumentFields { - static const hour = 'hour'; - static const minute = 'minute'; + static const value = 'value'; static const all = { - hour, - minute, + value, }; } /// Document field names for [LockedBlockRecurrence] embedded documents. abstract final class LockedBlockRecurrenceDocumentFields { + static const type = 'type'; static const weekdays = 'weekdays'; static const all = { + type, weekdays, }; } /// Document field names for [LockedBlock] documents. abstract final class LockedBlockDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; static const name = 'name'; static const startTime = 'startTime'; static const endTime = 'endTime'; @@ -255,11 +314,12 @@ abstract final class LockedBlockDocumentFields { static const recurrence = 'recurrence'; static const hiddenByDefault = 'hiddenByDefault'; static const projectId = 'projectId'; - static const createdAt = 'createdAt'; - static const updatedAt = 'updatedAt'; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; + static const archivedAt = 'archivedAt'; static const all = { - id, + ...DocumentFields.common, name, startTime, endTime, @@ -267,14 +327,16 @@ abstract final class LockedBlockDocumentFields { recurrence, hiddenByDefault, projectId, - createdAt, - updatedAt, + archivedAt, }; } /// Document field names for [LockedBlockOverride] documents. abstract final class LockedBlockOverrideDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; static const lockedBlockId = 'lockedBlockId'; static const date = 'date'; static const type = 'type'; @@ -283,11 +345,11 @@ abstract final class LockedBlockOverrideDocumentFields { static const endTime = 'endTime'; static const hiddenByDefault = 'hiddenByDefault'; static const projectId = 'projectId'; - static const createdAt = 'createdAt'; - static const updatedAt = 'updatedAt'; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; static const all = { - id, + ...DocumentFields.common, lockedBlockId, date, type, @@ -296,15 +358,93 @@ abstract final class LockedBlockOverrideDocumentFields { endTime, hiddenByDefault, projectId, - createdAt, - updatedAt, + }; +} + +/// Document field names for [OwnerSettings] documents. +abstract final class OwnerSettingsDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; + static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; + static const timeZoneId = 'timeZoneId'; + static const dayStart = 'dayStart'; + static const dayEnd = 'dayEnd'; + static const compactModeEnabled = 'compactModeEnabled'; + static const backlogStaleness = 'backlogStaleness'; + + static const all = { + ...DocumentFields.common, + timeZoneId, + dayStart, + dayEnd, + compactModeEnabled, + backlogStaleness, + }; +} + +/// Document field names for backlog staleness settings. +abstract final class BacklogStalenessDocumentFields { + static const greenMaxAgeDays = 'greenMaxAgeDays'; + static const blueMaxAgeDays = 'blueMaxAgeDays'; + + static const all = { + greenMaxAgeDays, + blueMaxAgeDays, + }; +} + +/// Document field names for notice acknowledgement documents. +abstract final class NoticeAcknowledgementDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; + static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; + static const noticeId = 'noticeId'; + static const acknowledgedAt = 'acknowledgedAt'; + + static const all = { + ...DocumentFields.common, + noticeId, + acknowledgedAt, + }; +} + +/// Document field names for idempotent operation records. +abstract final class ApplicationOperationDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; + static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; + static const operationId = 'operationId'; + static const operationName = 'operationName'; + static const committedAt = 'committedAt'; + + static const all = { + ...DocumentFields.common, + operationId, + operationName, + committedAt, }; } /// Document field names for [SchedulingStateSnapshot] documents. abstract final class SchedulingSnapshotDocumentFields { + static const schemaVersion = DocumentFields.schemaVersion; static const id = DocumentFields.id; + static const ownerId = DocumentFields.ownerId; + static const revision = DocumentFields.revision; + static const createdAt = DocumentFields.createdAt; + static const updatedAt = DocumentFields.updatedAt; static const capturedAt = 'capturedAt'; + static const sourceDate = 'sourceDate'; + static const targetDate = 'targetDate'; static const window = 'window'; static const tasks = 'tasks'; static const lockedIntervals = 'lockedIntervals'; @@ -313,10 +453,14 @@ abstract final class SchedulingSnapshotDocumentFields { static const changes = 'changes'; static const overlaps = 'overlaps'; static const operationName = 'operationName'; + static const retentionExpiresAt = 'retentionExpiresAt'; + static const truncated = 'truncated'; static const all = { - id, + ...DocumentFields.common, capturedAt, + sourceDate, + targetDate, window, tasks, lockedIntervals, @@ -325,6 +469,8 @@ abstract final class SchedulingSnapshotDocumentFields { changes, overlaps, operationName, + retentionExpiresAt, + truncated, }; } @@ -357,11 +503,19 @@ abstract final class SchedulingNoticeDocumentFields { static const message = 'message'; static const type = 'type'; static const taskId = 'taskId'; + static const issueCode = 'issueCode'; + static const movementCode = 'movementCode'; + static const conflictCode = 'conflictCode'; + static const parameters = 'parameters'; static const all = { message, type, taskId, + issueCode, + movementCode, + conflictCode, + parameters, }; } @@ -398,6 +552,7 @@ abstract final class SchedulingOverlapDocumentFields { /// Every field-name set currently committed for future document mapping. abstract final class PersistenceDocumentFieldSets { static const all = >[ + DocumentFields.common, TaskDocumentFields.all, TaskActivityDocumentFields.all, TaskStatisticsDocumentFields.all, @@ -407,6 +562,10 @@ abstract final class PersistenceDocumentFieldSets { LockedBlockRecurrenceDocumentFields.all, LockedBlockDocumentFields.all, LockedBlockOverrideDocumentFields.all, + OwnerSettingsDocumentFields.all, + BacklogStalenessDocumentFields.all, + NoticeAcknowledgementDocumentFields.all, + ApplicationOperationDocumentFields.all, SchedulingSnapshotDocumentFields.all, SchedulingWindowDocumentFields.all, TimeIntervalDocumentFields.all, diff --git a/test/document_mapping_test.dart b/test/document_mapping_test.dart index 5efed97..0c8ad5a 100644 --- a/test/document_mapping_test.dart +++ b/test/document_mapping_test.dart @@ -2,23 +2,29 @@ import 'package:adhd_scheduler_core/scheduler_core.dart'; import 'package:test/test.dart'; void main() { - group('MongoDB document mapping helpers', () { + group('MongoDB V1 document codecs', () { final createdAt = DateTime.utc(2026, 6, 23, 8); + final updatedAt = DateTime.utc(2026, 6, 23, 8, 5); - test('task documents round-trip every scheduling field', () { + test('task documents round-trip full and nullable V1 fields', () { final task = scheduledTask( id: 'task-1', createdAt: createdAt, reward: RewardLevel.high, difficulty: DifficultyLevel.hard, - ).copyWith( - stats: const TaskStatistics().incrementManualPush(), - ); + ).copyWith(stats: const TaskStatistics().incrementManualPush()); - final document = task.toDocument(); + final document = task.toDocument( + ownerId: 'owner-1', + revision: 4, + backlogEnteredAt: createdAt, + backlogEnteredAtProvenance: 'recorded', + )..['forwardCompatibleExtra'] = 'ignored'; final restored = TaskDocumentMapping.fromDocument(document); - expect(document[TaskDocumentFields.id], task.id); + expect(document[DocumentFields.schemaVersion], v1SchemaVersion); + expect(document[TaskDocumentFields.ownerId], 'owner-1'); + expect(document[TaskDocumentFields.revision], 4); expect(document[TaskDocumentFields.type], 'flexible'); expect(document[TaskDocumentFields.status], 'planned'); expect(document[TaskDocumentFields.reward], 'high'); @@ -26,68 +32,53 @@ void main() { expect(document[TaskDocumentFields.scheduledStart], '2026-06-23T09:00:00.000Z'); expect(restored.id, task.id); - expect(restored.title, task.title); - expect(restored.projectId, task.projectId); expect(restored.type, task.type); expect(restored.status, task.status); - expect(restored.priority, task.priority); expect(restored.reward, task.reward); expect(restored.difficulty, task.difficulty); - expect(restored.durationMinutes, task.durationMinutes); expect(restored.scheduledStart, task.scheduledStart); - expect(restored.scheduledEnd, task.scheduledEnd); expect(restored.actualStart, task.actualStart); - expect(restored.actualEnd, task.actualEnd); expect(restored.completedAt, task.completedAt); - expect(restored.parentTaskId, task.parentTaskId); expect(restored.backlogTags, task.backlogTags); - expect(restored.reminderOverride, task.reminderOverride); - expect(restored.createdAt, task.createdAt); - expect(restored.updatedAt, task.updatedAt); expect(restored.stats.manuallyPushedCount, 1); + + final nullableTask = Task( + id: 'task-nullable', + title: 'Nullable task', + projectId: 'inbox', + type: TaskType.flexible, + status: TaskStatus.backlog, + createdAt: createdAt, + updatedAt: updatedAt, + ); + final nullableDocument = nullableTask.toDocument(ownerId: 'owner-1'); + final nullableRestored = TaskDocumentMapping.fromDocument( + nullableDocument, + ); + + expect(nullableDocument[TaskDocumentFields.priority], isNull); + expect(nullableDocument[TaskDocumentFields.durationMinutes], isNull); + expect(nullableDocument[TaskDocumentFields.scheduledStart], isNull); + expect(nullableDocument[TaskDocumentFields.backlogEnteredAt], isNull); + expect(nullableRestored.priority, isNull); + expect(nullableRestored.durationMinutes, isNull); + expect(nullableRestored.scheduledStart, isNull); }); - test('task documents preserve not-set reward distinctly from very low', () { - final noReward = scheduledTask( - id: 'no-reward', - createdAt: createdAt, - reward: RewardLevel.notSet, - ); - final veryLowReward = scheduledTask( - id: 'very-low', - createdAt: createdAt, - reward: RewardLevel.veryLow, - ); + test('task documents preserve explicit schedule clearing', () { + final task = scheduledTask(id: 'scheduled', createdAt: createdAt); - final noRewardDocument = noReward.toDocument(); - final veryLowRewardDocument = veryLowReward.toDocument(); - - expect(noRewardDocument[TaskDocumentFields.reward], 'notSet'); - expect(veryLowRewardDocument[TaskDocumentFields.reward], 'veryLow'); - expect( - TaskDocumentMapping.fromDocument(noRewardDocument).reward, - RewardLevel.notSet, + final document = task.toDocument( + ownerId: 'owner-1', + clearSchedule: true, ); - expect( - TaskDocumentMapping.fromDocument(veryLowRewardDocument).reward, - RewardLevel.veryLow, - ); - }); + final restored = TaskDocumentMapping.fromDocument(document); - test('task documents support explicit nullable schedule clearing', () { - final task = scheduledTask( - id: 'scheduled', - createdAt: createdAt, - ); - - final clearedDocument = task.toDocument(clearSchedule: true); - final restored = TaskDocumentMapping.fromDocument(clearedDocument); - - expect(clearedDocument[TaskDocumentFields.scheduledStart], isNull); - expect(clearedDocument[TaskDocumentFields.scheduledEnd], isNull); + expect(document[TaskDocumentFields.scheduledStart], isNull); + expect(document[TaskDocumentFields.scheduledEnd], isNull); expect(restored.scheduledStart, isNull); expect(restored.scheduledEnd, isNull); - expect(restored.id, task.id); + expect(restored.actualStart, task.actualStart); }); test('task statistics document mapping preserves every counter', () { @@ -126,7 +117,39 @@ void main() { expect(restored.totalPushesBeforeCompletion, 13); }); - test('project statistics document mapping preserves aggregate fields', () { + test('project profile documents round-trip configured defaults', () { + final project = ProjectProfile( + id: 'home', + name: 'Home', + colorKey: 'home-blue', + defaultPriority: PriorityLevel.high, + defaultReward: RewardLevel.low, + defaultDifficulty: DifficultyLevel.easy, + defaultReminderProfile: ReminderProfile.persistent, + defaultDurationMinutes: 45, + archivedAt: DateTime.utc(2026, 6, 24, 9), + ); + + final document = ProjectDocumentMapping.toDocument( + project, + ownerId: 'owner-1', + createdAt: createdAt, + updatedAt: updatedAt, + revision: 2, + ); + final restored = ProjectDocumentMapping.fromDocument(document); + + expect(document.keys.toSet(), ProjectDocumentFields.all); + expect(document[ProjectDocumentFields.defaultPriority], 'high'); + expect( + document[ProjectDocumentFields.defaultReminderProfile], 'persistent'); + expect(restored.id, project.id); + expect(restored.defaultDurationMinutes, 45); + expect(restored.archivedAt, project.archivedAt); + }); + + test('project statistics documents preserve aggregates and applied ids', + () { final statistics = ProjectStatistics( projectId: 'home', completedTaskCount: 8, @@ -151,36 +174,371 @@ void main() { }, ); - final document = statistics.toDocument(); + final document = statistics.toDocument( + ownerId: 'owner-1', + createdAt: createdAt, + updatedAt: updatedAt, + appliedActivityIds: const ['activity-1'], + ); final restored = ProjectStatisticsDocumentMapping.fromDocument(document); expect(document.keys.toSet(), ProjectStatisticsDocumentFields.all); expect(document[ProjectStatisticsDocumentFields.id], 'home'); + expect(document[ProjectStatisticsDocumentFields.projectId], 'home'); expect(document[ProjectStatisticsDocumentFields.durationMinuteCounts], { '20': 3, '45': 5, }); + expect(document[ProjectStatisticsDocumentFields.rewardCounts], { + 'high': 5, + 'low': 3, + }); + expect(document[ProjectStatisticsDocumentFields.appliedActivityIds], [ + 'activity-1', + ]); expect(restored.projectId, statistics.projectId); expect(restored.completedTaskCount, 8); expect(restored.durationMinuteCounts, statistics.durationMinuteCounts); expect(restored.completionTimeBucketCounts, statistics.completionTimeBucketCounts); - expect(restored.totalPushesBeforeCompletion, 11); - expect(restored.completedAfterPushCount, 4); - expect(restored.rewardCounts, statistics.rewardCounts); - expect(restored.difficultyCounts, statistics.difficultyCounts); expect(restored.reminderProfileCounts, statistics.reminderProfileCounts); }); - test('enum document mapping rejects unknown persistence names', () { + test( + 'locked block and override documents preserve civil dates and wall time', + () { + final recurrence = LockedBlockRecurrence.weekly( + weekdays: {LockedWeekday.monday, LockedWeekday.thursday}, + ); + final recurringBlock = LockedBlock( + id: 'work', + name: 'Work', + startTime: WallTime(hour: 9, minute: 0), + endTime: WallTime(hour: 17, minute: 0), + recurrence: recurrence, + hiddenByDefault: true, + createdAt: createdAt, + updatedAt: updatedAt, + archivedAt: DateTime.utc(2026, 6, 30), + ); + final replaceOverride = LockedBlockOverride.replace( + id: 'replace-work', + lockedBlockId: 'work', + date: CivilDate(2026, 6, 25), + startTime: WallTime(hour: 10, minute: 0), + endTime: WallTime(hour: 15, minute: 0), + createdAt: createdAt, + name: 'Short work', + updatedAt: updatedAt, + ); + final addOverride = LockedBlockOverride.add( + id: 'appointment', + date: CivilDate(2026, 6, 25), + name: 'Appointment', + startTime: WallTime(hour: 18, minute: 0), + endTime: WallTime(hour: 19, minute: 0), + createdAt: createdAt, + ); + final removeOverride = LockedBlockOverride.remove( + id: 'remove-work', + lockedBlockId: 'work', + date: CivilDate(2026, 6, 26), + createdAt: createdAt, + ); + + final blockDocument = LockedBlockDocumentMapping.toDocument( + recurringBlock, + ownerId: 'owner-1', + ); + final restoredBlock = LockedBlockDocumentMapping.fromDocument( + blockDocument, + ); + final replaceDocument = LockedBlockOverrideDocumentMapping.toDocument( + replaceOverride, + ownerId: 'owner-1', + ); + final addDocument = LockedBlockOverrideDocumentMapping.toDocument( + addOverride, + ownerId: 'owner-1', + ); + final removeDocument = LockedBlockOverrideDocumentMapping.toDocument( + removeOverride, + ownerId: 'owner-1', + ); + + expect(blockDocument[LockedBlockDocumentFields.startTime], '09:00'); expect( - () => PersistenceEnumMapping.decodeTaskType('unknown'), - throwsArgumentError, + (blockDocument[LockedBlockDocumentFields.recurrence] as Map)[LockedBlockRecurrenceDocumentFields.weekdays], + ['monday', 'thursday'], + ); + expect(restoredBlock.recurrence!.weekdays, recurrence.weekdays); + expect(restoredBlock.archivedAt, recurringBlock.archivedAt); + expect(replaceDocument[LockedBlockOverrideDocumentFields.date], + '2026-06-25'); + expect( + replaceDocument[LockedBlockOverrideDocumentFields.type], 'replace'); + expect(addDocument[LockedBlockOverrideDocumentFields.type], 'add'); + expect(removeDocument[LockedBlockOverrideDocumentFields.type], 'remove'); + expect( + LockedBlockOverrideDocumentMapping.fromDocument(replaceDocument).date, + CivilDate(2026, 6, 25), + ); + expect( + LockedBlockOverrideDocumentMapping.fromDocument(removeDocument) + .startTime, + isNull, + ); + }); + + test('settings, notices, operations, and activities round-trip', () { + final settings = OwnerSettings( + ownerId: 'owner-1', + timeZoneId: 'America/Los_Angeles', + dayStart: WallTime(hour: 7, minute: 30), + dayEnd: WallTime(hour: 21, minute: 0), + compactModeEnabled: true, + backlogStalenessSettings: const BacklogStalenessSettings( + greenMaxAge: Duration(days: 3), + blueMaxAge: Duration(days: 10), + ), + ); + final activity = TaskActivity( + id: 'activity-1', + operationId: 'op-1', + code: TaskActivityCode.completed, + taskId: 'task-1', + projectId: 'home', + occurredAt: createdAt, + metadata: {'completedAt': createdAt}, + ); + final acknowledgement = NoticeAcknowledgementRecord( + noticeId: 'snapshot:0', + ownerId: 'owner-1', + acknowledgedAt: updatedAt, + ); + final operation = ApplicationOperationRecord( + operationId: 'op-1', + ownerId: 'owner-1', + operationName: 'completeTask', + committedAt: updatedAt, + ); + + final settingsDocument = OwnerSettingsDocumentMapping.toDocument( + settings, + createdAt: createdAt, + updatedAt: updatedAt, + ); + final activityDocument = TaskActivityDocumentMapping.toDocument( + activity, + ownerId: 'owner-1', + ); + final acknowledgementDocument = + NoticeAcknowledgementDocumentMapping.toDocument(acknowledgement); + final operationDocument = ApplicationOperationDocumentMapping.toDocument( + operation, + ); + + expect(settingsDocument[OwnerSettingsDocumentFields.dayStart], '07:30'); + expect( + (settingsDocument[OwnerSettingsDocumentFields.backlogStaleness] as Map< + String, Object?>)[BacklogStalenessDocumentFields.blueMaxAgeDays], + 10, + ); + expect(activityDocument[TaskActivityDocumentFields.code], 'completed'); + expect( + (activityDocument[TaskActivityDocumentFields.metadata] + as Map)['completedAt'], + '2026-06-23T08:00:00.000Z', + ); + expect(acknowledgementDocument[NoticeAcknowledgementDocumentFields.id], + 'snapshot:0'); + expect(operationDocument[ApplicationOperationDocumentFields.operationId], + 'op-1'); + + expect(OwnerSettingsDocumentMapping.fromDocument(settingsDocument).dayEnd, + settings.dayEnd); + expect(TaskActivityDocumentMapping.fromDocument(activityDocument).code, + TaskActivityCode.completed); + expect( + NoticeAcknowledgementDocumentMapping.fromDocument( + acknowledgementDocument, + ).noticeId, + 'snapshot:0', + ); + expect( + ApplicationOperationDocumentMapping.fromDocument(operationDocument) + .operationName, + 'completeTask', + ); + }); + + test('scheduling snapshot structures round-trip nested data', () { + final task = scheduledTask(id: 'task-1', createdAt: createdAt); + final snapshot = SchedulingStateSnapshot( + id: 'rollover:owner-1:2026-06-25', + capturedAt: updatedAt, + window: SchedulingWindow( + start: DateTime.utc(2026, 6, 24, 9), + end: DateTime.utc(2026, 6, 24, 17), + ), + tasks: [task], + lockedIntervals: [ + TimeInterval( + start: DateTime.utc(2026, 6, 24, 12), + end: DateTime.utc(2026, 6, 24, 13), + label: 'Hidden locked', + ), + ], + notices: [ + SchedulingNotice( + '1 unfinished flexible task was moved to today.', + type: SchedulingNoticeType.moved, + movementCode: + SchedulingMovementCode.unfinishedFlexibleTasksRolledOver, + parameters: {'count': 1}, + ), + ], + changes: [ + SchedulingChange( + taskId: task.id, + previousStart: task.scheduledStart, + previousEnd: task.scheduledEnd, + nextStart: DateTime.utc(2026, 6, 24, 9), + nextEnd: DateTime.utc(2026, 6, 24, 9, 30), + ), + ], + overlaps: [ + SchedulingOverlap( + taskId: task.id, + taskInterval: TimeInterval( + start: DateTime.utc(2026, 6, 23, 9), + end: DateTime.utc(2026, 6, 23, 9, 30), + ), + blockedInterval: TimeInterval( + start: DateTime.utc(2026, 6, 23, 9, 15), + end: DateTime.utc(2026, 6, 23, 9, 45), + ), + ), + ], + operationName: 'appOpenRecovery', + ); + + final document = SchedulingSnapshotDocumentMapping.toDocument( + snapshot, + ownerId: 'owner-1', + sourceDate: CivilDate(2026, 6, 23), + targetDate: CivilDate(2026, 6, 24), + retentionExpiresAt: DateTime.utc(2026, 7, 24), + ); + final restored = SchedulingSnapshotDocumentMapping.fromDocument(document); + + expect(document.keys.toSet(), SchedulingSnapshotDocumentFields.all); + expect( + document[SchedulingSnapshotDocumentFields.sourceDate], '2026-06-23'); + expect(restored.id, snapshot.id); + expect(restored.window.start, snapshot.window.start); + expect(restored.tasks.single.id, task.id); + expect(restored.lockedIntervals.single.label, 'Hidden locked'); + expect(restored.notices.single.movementCode, + SchedulingMovementCode.unfinishedFlexibleTasksRolledOver); + expect(restored.notices.single.parameters['count'], 1); + expect(restored.changes.single.taskId, task.id); + expect(restored.overlaps.single.taskId, task.id); + }); + + test('stable enum code tables do not depend on Dart enum names', () { + expect( + PersistenceEnumMapping.encodeReward(RewardLevel.notSet), 'not_set'); + expect( + PersistenceEnumMapping.encodeTaskStatus( + TaskStatus.noLongerRelevant, + ), + 'no_longer_relevant'); + expect(PersistenceEnumMapping.encodeTaskType(TaskType.freeSlot), + 'free_slot'); + expect( + PersistenceEnumMapping.decodeTaskStatus('no_longer_relevant'), + TaskStatus.noLongerRelevant, + ); + expect( + PersistenceEnumMapping.decodeReward('not_set'), + RewardLevel.notSet, + ); + }); + + test('malformed documents produce typed mapping failures', () { + final valid = scheduledTask(id: 'task-1', createdAt: createdAt) + .toDocument(ownerId: 'owner-1'); + + expect( + () => TaskDocumentMapping.fromDocument( + {...valid}..remove(TaskDocumentFields.title), + ), + throwsMapping( + DocumentMappingFailureCode.missingField, TaskDocumentFields.title), + ); + expect( + () => TaskDocumentMapping.fromDocument({ + ...valid, + TaskDocumentFields.reward: 'notSet', + }), + throwsMapping(DocumentMappingFailureCode.unknownCode, 'RewardLevel'), + ); + expect( + () => TaskDocumentMapping.fromDocument({ + ...valid, + TaskDocumentFields.durationMinutes: '30', + }), + throwsMapping(DocumentMappingFailureCode.wrongType, + TaskDocumentFields.durationMinutes), + ); + expect( + () => TaskDocumentMapping.fromDocument({ + ...valid, + DocumentFields.revision: 0, + }), + throwsMapping(DocumentMappingFailureCode.invalidRevision, + DocumentFields.revision), + ); + expect( + () => TaskDocumentMapping.fromDocument({ + ...valid, + DocumentFields.schemaVersion: 99, + }), + throwsMapping(DocumentMappingFailureCode.invalidSchemaVersion, + DocumentFields.schemaVersion), + ); + expect( + () => SchedulingWindowDocumentMapping.fromDocument({ + SchedulingWindowDocumentFields.start: '2026-06-23T10:00:00.000Z', + SchedulingWindowDocumentFields.end: '2026-06-23T09:00:00.000Z', + }), + throwsMapping(DocumentMappingFailureCode.invalidValue), ); }); }); } +Matcher throwsMapping( + DocumentMappingFailureCode code, [ + String? fieldName, +]) { + var matcher = isA().having( + (error) => error.code, + 'code', + code, + ); + if (fieldName != null) { + matcher = matcher.having( + (error) => error.fieldName, + 'fieldName', + fieldName, + ); + } + return throwsA(matcher); +} + Task scheduledTask({ required String id, required DateTime createdAt, diff --git a/test/persistence_edge_cases_test.dart b/test/persistence_edge_cases_test.dart index 061eccc..064e4df 100644 --- a/test/persistence_edge_cases_test.dart +++ b/test/persistence_edge_cases_test.dart @@ -204,7 +204,12 @@ void main() { test('document field sets preserve all task and statistics fields', () { expect(TaskDocumentFields.all, { + 'schemaVersion', '_id', + 'ownerId', + 'revision', + 'createdAt', + 'updatedAt', 'title', 'projectId', 'type', @@ -221,12 +226,17 @@ void main() { 'parentTaskId', 'backlogTags', 'reminderOverride', - 'createdAt', - 'updatedAt', 'stats', + 'backlogEnteredAt', + 'backlogEnteredAtProvenance', }); expect(TaskActivityDocumentFields.all, { + 'schemaVersion', '_id', + 'ownerId', + 'revision', + 'createdAt', + 'updatedAt', 'operationId', 'code', 'taskId', @@ -252,9 +262,30 @@ void main() { }); test('document field sets cover project locked and snapshot documents', () { - expect(ProjectDocumentFields.all, containsAll(['_id', 'colorKey'])); - expect(ProjectStatisticsDocumentFields.all, { + expect(ProjectDocumentFields.all, { + 'schemaVersion', '_id', + 'ownerId', + 'revision', + 'createdAt', + 'updatedAt', + 'name', + 'colorKey', + 'defaultPriority', + 'defaultReward', + 'defaultDifficulty', + 'defaultReminderProfile', + 'defaultDurationMinutes', + 'archivedAt', + }); + expect(ProjectStatisticsDocumentFields.all, { + 'schemaVersion', + '_id', + 'ownerId', + 'revision', + 'createdAt', + 'updatedAt', + 'projectId', 'completedTaskCount', 'durationMinuteCounts', 'completionTimeBucketCounts', @@ -263,20 +294,48 @@ void main() { 'rewardCounts', 'difficultyCounts', 'reminderProfileCounts', + 'appliedActivityIds', }); expect( LockedBlockDocumentFields.all, - containsAll(['_id', 'startTime', 'recurrence', 'hiddenByDefault']), + containsAll([ + 'schemaVersion', + '_id', + 'ownerId', + 'revision', + 'startTime', + 'recurrence', + 'hiddenByDefault', + 'archivedAt', + ]), ); expect( LockedBlockOverrideDocumentFields.all, - containsAll(['_id', 'lockedBlockId', 'type']), + containsAll( + ['schemaVersion', '_id', 'ownerId', 'lockedBlockId', 'type']), + ); + expect( + OwnerSettingsDocumentFields.all, + containsAll(['_id', 'ownerId', 'timeZoneId', 'backlogStaleness']), + ); + expect( + NoticeAcknowledgementDocumentFields.all, + containsAll(['_id', 'ownerId', 'noticeId', 'acknowledgedAt']), + ); + expect( + ApplicationOperationDocumentFields.all, + containsAll(['_id', 'ownerId', 'operationId', 'committedAt']), ); expect( SchedulingSnapshotDocumentFields.all, containsAll([ + 'schemaVersion', '_id', + 'ownerId', + 'revision', 'capturedAt', + 'sourceDate', + 'targetDate', 'window', 'tasks', 'lockedIntervals', @@ -285,6 +344,8 @@ void main() { 'changes', 'overlaps', 'operationName', + 'retentionExpiresAt', + 'truncated', ]), ); });