feat(data): add v1 document codecs

This commit is contained in:
Ashley Venn 2026-06-25 11:43:28 -07:00
parent dcd049c549
commit 3dc73e8d43
6 changed files with 2694 additions and 382 deletions

View file

@ -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

View file

@ -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:

File diff suppressed because it is too large Load diff

View file

@ -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 = <String>{
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 = <String>{
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 = <String>{
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 = <String>{
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 = <String>{
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 = <String>{
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 = <String>{
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 = <String>{
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 = <String>{
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 = <String>{
...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 = <String>{
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 = <String>{
...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 = <String>{
...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 = <String>{
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 = <String>{
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 = <Set<String>>[
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,

View file

@ -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<String,
Object?>)[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<String, Object?>)['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<DocumentMappingException>().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,

View file

@ -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',
]),
);
});