feat(data): add legacy document migrations

This commit is contained in:
Ashley Venn 2026-06-25 11:55:17 -07:00
parent 3dc73e8d43
commit 55efa98182
13 changed files with 1626 additions and 0 deletions

View file

@ -158,6 +158,8 @@ Verification:
Recommended Codex level: extra high Recommended Codex level: extra high
Status: Complete
Tasks: Tasks:
- Treat the existing task/task-statistics mapping as legacy schema version 0. - Treat the existing task/task-statistics mapping as legacy schema version 0.
@ -192,6 +194,36 @@ Acceptance criteria:
- Date/backlog approximations carry provenance. - Date/backlog approximations carry provenance.
- Migration fixtures are part of the automated suite. - Migration fixtures are part of the automated suite.
Completed implementation:
- Added `document_migration.dart` with a pure Dart `V1DocumentMigrationService`
for V0 task, project, locked block, and locked override documents.
- Treated missing `schemaVersion` as schema V0 and validated already-current V1
documents through the V1 codecs without rewriting them.
- Added typed migration reports/results, non-fatal notes, and `shouldWrite`
semantics so adapters can dry-run migrations and avoid overwriting unreadable
source documents.
- Migrated legacy enum `.name` values to stable V1 codes for task, project, and
locked-time fields.
- Added conservative backlog provenance: legacy backlog tasks set
`backlogEnteredAt` from `createdAt` with
`approximated_from_created_at`; non-backlog tasks keep the metadata null.
- Preserved date-only locked override values as civil-date strings and rejected
ambiguous legacy date-time values rather than guessing a timezone.
- Added checked-in JSON fixtures for minimum/full V0 task documents, manually
produced project/locked documents, corrupt/malformed V0 documents, and an
unsupported future schema document.
- Added automated migration tests for V0→V1, already-V1 no-op, repeated
migration idempotency, partial corruption, ambiguous date-times, and future
schema failure.
Verification:
- `dart format lib test`: passed, 0 files changed after final format
- `dart analyze`: passed, no issues found
- `dart test`: passed, 289 tests
- `git diff --check`: passed
BREAKPOINT: Stop here. Confirm `high` mode before expanding repository/query BREAKPOINT: Stop here. Confirm `high` mode before expanding repository/query
contracts and index specifications. contracts and index specifications.

View file

@ -354,6 +354,22 @@ Chunk 15.2 intentionally changed the public API:
- Expanded `persistence_contract.dart` with `v1SchemaVersion`, common document - Expanded `persistence_contract.dart` with `v1SchemaVersion`, common document
fields, and V1 field sets for every persisted repository entity. fields, and V1 field sets for every persisted repository entity.
Chunk 15.3 intentionally changed the public API:
- Added `src/document_migration.dart` and exported it from `scheduler_core.dart`.
- Added `V1DocumentMigrationService` for pure Dart V0-to-V1 migration of task,
project, locked block, and locked override documents.
- Added typed migration surface area:
`DocumentMigrationEntity`, `DocumentMigrationStatus`,
`DocumentMigrationFailureCode`, `DocumentMigrationNoteCode`,
`DocumentMigrationProvenance`, `DocumentMigrationNote`,
`DocumentMigrationReport`, and `DocumentMigrationResult`.
- Migration results expose `document`, `isSuccess`, and `shouldWrite` so
persistence adapters can dry-run migrations and refuse to overwrite unreadable
source documents.
- Legacy backlog tasks now carry explicit provenance when `backlogEnteredAt` is
approximated from `createdAt`.
## Repository contracts ## Repository contracts
The current repository surface is pure Dart and in-memory only: The current repository surface is pure Dart and in-memory only:

View file

@ -24,6 +24,7 @@ export 'src/application_recovery.dart';
export 'src/backlog.dart'; export 'src/backlog.dart';
export 'src/child_tasks.dart'; export 'src/child_tasks.dart';
export 'src/document_mapping.dart'; export 'src/document_mapping.dart';
export 'src/document_migration.dart';
export 'src/free_slots.dart'; export 'src/free_slots.dart';
export 'src/locked_time.dart'; export 'src/locked_time.dart';
export 'src/occupancy_policy.dart'; export 'src/occupancy_policy.dart';

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,237 @@
import 'dart:convert';
import 'dart:io';
import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('V0 to V1 document migrations', () {
final migratedAt = DateTime.utc(2026, 6, 25, 12);
const ownerId = 'owner-1';
final service = V1DocumentMigrationService(
ownerId: ownerId,
migratedAt: migratedAt,
);
test('legacy minimum task migrates with backlog provenance', () {
final legacy = fixture('task_v0_minimum.json');
final result = service.migrateTask(legacy);
final document = result.document!;
final task = TaskDocumentMapping.fromDocument(document);
expect(result.isSuccess, isTrue);
expect(result.shouldWrite, isTrue);
expect(result.report.status, DocumentMigrationStatus.migrated);
expect(result.report.fromSchemaVersion, 0);
expect(result.report.documentId, 'task-v0-minimum');
expect(result.report.notes.single.code,
DocumentMigrationNoteCode.approximatedBacklogEnteredAt);
expect(legacy.containsKey(DocumentFields.schemaVersion), isFalse);
expect(document, containsPair(DocumentFields.schemaVersion, 1));
expect(document, containsPair(DocumentFields.ownerId, ownerId));
expect(document, containsPair(DocumentFields.revision, 1));
expect(document, containsPair(TaskDocumentFields.reward, 'not_set'));
expect(document, containsPair(TaskDocumentFields.difficulty, 'not_set'));
expect(
document,
containsPair(
TaskDocumentFields.backlogEnteredAt,
'2026-06-20T09:15:00.000Z',
),
);
expect(
document,
containsPair(
TaskDocumentFields.backlogEnteredAtProvenance,
DocumentMigrationProvenance.approximatedFromCreatedAt,
),
);
expect(task.id, 'task-v0-minimum');
expect(task.status, TaskStatus.backlog);
expect(task.stats.manuallyPushedCount, 0);
});
test('legacy full task migrates enum names to stable codes', () {
final legacy = fixture('task_v0_full.json');
final result = service.migrateTask(legacy);
final document = result.document!;
final task = TaskDocumentMapping.fromDocument(document);
expect(result.isSuccess, isTrue);
expect(document[TaskDocumentFields.type], 'free_slot');
expect(document[TaskDocumentFields.status], 'no_longer_relevant');
expect(document[TaskDocumentFields.priority], 'very_high');
expect(document[TaskDocumentFields.reward], 'very_low');
expect(document[TaskDocumentFields.difficulty], 'very_hard');
expect(document[TaskDocumentFields.backlogTags], ['wishlist']);
expect(document[TaskDocumentFields.backlogEnteredAt], isNull);
expect(document[TaskDocumentFields.backlogEnteredAtProvenance], isNull);
expect(task.type, TaskType.freeSlot);
expect(task.status, TaskStatus.noLongerRelevant);
expect(task.priority, PriorityLevel.veryHigh);
expect(task.reward, RewardLevel.veryLow);
expect(task.difficulty, DifficultyLevel.veryHard);
expect(task.stats.totalPushesBeforeCompletion, 13);
});
test('legacy project and locked documents migrate to V1 shapes', () {
final projectResult = service.migrateProject(fixture('project_v0.json'));
final blockResult =
service.migrateLockedBlock(fixture('locked_block_v0.json'));
final overrideResult = service.migrateLockedBlockOverride(
fixture('locked_override_v0.json'),
);
expect(projectResult.isSuccess, isTrue);
expect(
projectResult.report.notes.single.code,
DocumentMigrationNoteCode.synthesizedMetadataTimestamp,
);
expect(
projectResult.document![ProjectDocumentFields.defaultPriority],
'very_high',
);
expect(
projectResult.document![ProjectDocumentFields.defaultReward],
'not_set',
);
expect(
projectResult.document![ProjectDocumentFields.defaultDifficulty],
'very_easy',
);
expect(
projectResult.document![DocumentFields.createdAt],
'2026-06-25T12:00:00.000Z',
);
expect(
ProjectDocumentMapping.fromDocument(projectResult.document!).id,
'project-v0-home',
);
final recurrence =
blockResult.document![LockedBlockDocumentFields.recurrence]
as Map<String, Object?>;
expect(blockResult.isSuccess, isTrue);
expect(
blockResult.document![LockedBlockDocumentFields.startTime], '09:00');
expect(blockResult.document![LockedBlockDocumentFields.endTime], '17:00');
expect(
recurrence[LockedBlockRecurrenceDocumentFields.type],
'weekly',
);
expect(
recurrence[LockedBlockRecurrenceDocumentFields.weekdays],
['monday', 'thursday'],
);
expect(
LockedBlockDocumentMapping.fromDocument(blockResult.document!)
.recurrence!
.weekdays,
{LockedWeekday.monday, LockedWeekday.thursday},
);
expect(overrideResult.isSuccess, isTrue);
expect(
overrideResult.document![LockedBlockOverrideDocumentFields.date],
'2026-06-25',
);
expect(
overrideResult.document![LockedBlockOverrideDocumentFields.startTime],
'10:00',
);
expect(
overrideResult.document![LockedBlockOverrideDocumentFields.endTime],
'15:00',
);
expect(
LockedBlockOverrideDocumentMapping.fromDocument(
overrideResult.document!,
).date,
CivilDate(2026, 6, 25),
);
});
test('already-current V1 documents are validated without rewrite', () {
final migrated = service
.migrateTask(
fixture('task_v0_minimum.json'),
)
.document!;
final result = service.migrateTask(migrated);
expect(result.isSuccess, isTrue);
expect(result.shouldWrite, isFalse);
expect(result.report.status, DocumentMigrationStatus.alreadyCurrent);
expect(result.report.fromSchemaVersion, 1);
expect(result.document, migrated);
});
test('repeated migration is idempotent', () {
final first = service.migrateTask(fixture('task_v0_full.json'));
final second = service.migrateTask(first.document!);
expect(first.isSuccess, isTrue);
expect(second.isSuccess, isTrue);
expect(second.report.status, DocumentMigrationStatus.alreadyCurrent);
expect(second.document, first.document);
});
test('partial corruption produces a typed failure report before writes',
() {
final legacy = fixture('task_v0_malformed_missing_title.json');
final result = service.migrateTask(legacy);
expect(result.isSuccess, isFalse);
expect(result.shouldWrite, isFalse);
expect(result.document, isNull);
expect(result.report.status, DocumentMigrationStatus.failed);
expect(
result.report.failureCode,
DocumentMigrationFailureCode.legacyDocumentFailure,
);
expect(result.report.fieldName, TaskDocumentFields.title);
expect(result.report.detailCode, 'missingField');
expect(legacy.containsKey(DocumentFields.schemaVersion), isFalse);
});
test('ambiguous legacy date-times fail instead of guessing a zone', () {
final result = service.migrateTask(
fixture('task_v0_ambiguous_datetime.json'),
);
expect(result.isSuccess, isFalse);
expect(result.shouldWrite, isFalse);
expect(result.document, isNull);
expect(
result.report.failureCode,
DocumentMigrationFailureCode.legacyDocumentFailure,
);
expect(result.report.fieldName, TaskDocumentFields.createdAt);
expect(result.report.detailCode, 'ambiguousDateTime');
});
test('unsupported future schema versions fail closed', () {
final result = service.migrateTask(fixture('task_future_schema.json'));
expect(result.isSuccess, isFalse);
expect(result.shouldWrite, isFalse);
expect(result.document, isNull);
expect(
result.report.failureCode,
DocumentMigrationFailureCode.unsupportedSchemaVersion,
);
expect(result.report.fromSchemaVersion, 99);
expect(result.report.fieldName, DocumentFields.schemaVersion);
});
});
}
Map<String, Object?> fixture(String name) {
final file = File('test/fixtures/migration/$name');
final decoded = jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
return Map<String, Object?>.from(decoded);
}

View file

@ -0,0 +1,23 @@
{
"_id": "locked-work",
"name": "Work",
"startTime": {
"hour": 9,
"minute": 0
},
"endTime": {
"hour": 17,
"minute": 0
},
"date": null,
"recurrence": {
"weekdays": [
"monday",
"thursday"
]
},
"hiddenByDefault": true,
"projectId": "project-v0-home",
"createdAt": "2026-06-01T12:00:00.000Z",
"updatedAt": "2026-06-02T12:00:00.000Z"
}

View file

@ -0,0 +1,18 @@
{
"_id": "locked-override-short-work",
"lockedBlockId": "locked-work",
"date": "2026-06-25",
"type": "replace",
"name": "Short work",
"startTime": {
"value": "10:00"
},
"endTime": {
"hour": 15,
"minute": 0
},
"hiddenByDefault": true,
"projectId": "project-v0-home",
"createdAt": "2026-06-20T12:00:00.000Z",
"updatedAt": "2026-06-20T12:05:00.000Z"
}

10
test/fixtures/migration/project_v0.json vendored Normal file
View file

@ -0,0 +1,10 @@
{
"_id": "project-v0-home",
"name": "Home",
"colorKey": "home-blue",
"defaultPriority": "veryHigh",
"defaultReward": "notSet",
"defaultDifficulty": "veryEasy",
"defaultReminderProfile": "gentle",
"defaultDurationMinutes": 25
}

View file

@ -0,0 +1,6 @@
{
"schemaVersion": 99,
"_id": "task-future",
"ownerId": "owner-1",
"revision": 1
}

View file

@ -0,0 +1,36 @@
{
"_id": "task-v0-ambiguous-datetime",
"title": "Ambiguous datetime task",
"projectId": "inbox",
"type": "flexible",
"status": "backlog",
"priority": null,
"reward": "notSet",
"difficulty": "notSet",
"durationMinutes": null,
"scheduledStart": null,
"scheduledEnd": null,
"actualStart": null,
"actualEnd": null,
"completedAt": null,
"parentTaskId": null,
"backlogTags": [],
"reminderOverride": null,
"createdAt": "2026-06-20T09:15:00",
"updatedAt": "2026-06-20T09:15:00.000Z",
"stats": {
"skippedDuringBurnoutCount": 0,
"manuallyPushedCount": 0,
"autoPushedCount": 0,
"movedToBacklogCount": 0,
"restoredFromBacklogCount": 0,
"missedCount": 0,
"cancelledCount": 0,
"completedLateCount": 0,
"completedDuringLockedHoursCount": 0,
"completedDuringLockedHoursMinutes": 0,
"completedAfterShieldCount": 0,
"completedAfterPushCount": 0,
"totalPushesBeforeCompletion": 0
}
}

View file

@ -0,0 +1,38 @@
{
"_id": "task-v0-full",
"title": "Legacy full task",
"projectId": "project-v0-home",
"type": "freeSlot",
"status": "noLongerRelevant",
"priority": "veryHigh",
"reward": "veryLow",
"difficulty": "veryHard",
"durationMinutes": 45,
"scheduledStart": "2026-06-23T16:00:00.000Z",
"scheduledEnd": "2026-06-23T16:45:00.000Z",
"actualStart": "2026-06-23T16:05:00.000Z",
"actualEnd": "2026-06-23T16:35:00.000Z",
"completedAt": "2026-06-23T16:35:00.000Z",
"parentTaskId": "parent-task",
"backlogTags": [
"wishlist"
],
"reminderOverride": "persistent",
"createdAt": "2026-06-21T08:00:00.000Z",
"updatedAt": "2026-06-23T17:00:00.000Z",
"stats": {
"skippedDuringBurnoutCount": 1,
"manuallyPushedCount": 2,
"autoPushedCount": 3,
"movedToBacklogCount": 4,
"restoredFromBacklogCount": 5,
"missedCount": 6,
"cancelledCount": 7,
"completedLateCount": 8,
"completedDuringLockedHoursCount": 9,
"completedDuringLockedHoursMinutes": 10,
"completedAfterShieldCount": 11,
"completedAfterPushCount": 12,
"totalPushesBeforeCompletion": 13
}
}

View file

@ -0,0 +1,35 @@
{
"_id": "task-v0-missing-title",
"projectId": "inbox",
"type": "flexible",
"status": "backlog",
"priority": null,
"reward": "notSet",
"difficulty": "notSet",
"durationMinutes": null,
"scheduledStart": null,
"scheduledEnd": null,
"actualStart": null,
"actualEnd": null,
"completedAt": null,
"parentTaskId": null,
"backlogTags": [],
"reminderOverride": null,
"createdAt": "2026-06-20T09:15:00.000Z",
"updatedAt": "2026-06-20T09:15:00.000Z",
"stats": {
"skippedDuringBurnoutCount": 0,
"manuallyPushedCount": 0,
"autoPushedCount": 0,
"movedToBacklogCount": 0,
"restoredFromBacklogCount": 0,
"missedCount": 0,
"cancelledCount": 0,
"completedLateCount": 0,
"completedDuringLockedHoursCount": 0,
"completedDuringLockedHoursMinutes": 0,
"completedAfterShieldCount": 0,
"completedAfterPushCount": 0,
"totalPushesBeforeCompletion": 0
}
}

View file

@ -0,0 +1,36 @@
{
"_id": "task-v0-minimum",
"title": "Review inbox note",
"projectId": "inbox",
"type": "flexible",
"status": "backlog",
"priority": null,
"reward": "notSet",
"difficulty": "notSet",
"durationMinutes": null,
"scheduledStart": null,
"scheduledEnd": null,
"actualStart": null,
"actualEnd": null,
"completedAt": null,
"parentTaskId": null,
"backlogTags": [],
"reminderOverride": null,
"createdAt": "2026-06-20T09:15:00.000Z",
"updatedAt": "2026-06-20T09:15:00.000Z",
"stats": {
"skippedDuringBurnoutCount": 0,
"manuallyPushedCount": 0,
"autoPushedCount": 0,
"movedToBacklogCount": 0,
"restoredFromBacklogCount": 0,
"missedCount": 0,
"cancelledCount": 0,
"completedLateCount": 0,
"completedDuringLockedHoursCount": 0,
"completedDuringLockedHoursMinutes": 0,
"completedAfterShieldCount": 0,
"completedAfterPushCount": 0,
"totalPushesBeforeCompletion": 0
}
}