feat(data): prepare MongoDB-friendly repository interfaces

This commit is contained in:
Ashley Venn 2026-06-24 14:06:41 -07:00
parent abf069db1f
commit 305090250a
5 changed files with 508 additions and 21 deletions

View file

@ -1,8 +1,10 @@
# V1 Block 09 — Persistence Preparation
Status: Planned
Status: In progress — Chunk 9.1 complete
Purpose: Prepare the domain layer for local persistence without overbuilding sync or database implementation too early.
Purpose: Prepare the domain layer for future MongoDB-backed persistence without adding a MongoDB driver, network behavior, sync behavior, or database runtime dependency too early.
MongoDB is the committed persistence target. This block should make the core persistence-friendly and MongoDB-document-friendly while keeping the scheduling engine independent from database APIs.
## Chunk 9.1 — Repository interface
@ -19,16 +21,27 @@ Define repository interfaces for:
Rules:
- Do not couple scheduling engine directly to SQLite.
- Use interfaces that a future Drift/SQLite layer can implement.
- Keep current implementation in-memory if needed.
- Do not couple the scheduling engine directly to MongoDB APIs.
- Use interfaces that a future MongoDB adapter can implement.
- Keep the current implementation in-memory if needed.
- Repository interfaces should preserve domain rules rather than bypassing services.
- Do not introduce alternative database assumptions.
Acceptance criteria:
- Domain services can depend on interfaces.
- Tests can use fake/in-memory repositories.
- Interfaces do not import Drift, SQLite, Flutter, or platform-specific APIs.
- Interfaces do not import MongoDB, Flutter, platform-specific APIs, or network/client APIs.
- Interface names and method shapes are compatible with document-style persistence.
Completed:
- Added pure Dart repository interfaces for tasks, projects, locked blocks/overrides, and scheduling state snapshots.
- Added in-memory repository implementations for tests and early app wiring.
- Added scheduling snapshot state that can rebuild `SchedulingInput` and `SchedulingResult` without persistence APIs.
- Exported repository interfaces through the public core library.
- Added repository tests covering task/project/locked-block/snapshot fake usage.
- Verified with `dart format lib test`, `dart analyze`, and `dart test`.
BREAKPOINT: Stop here. Confirm `extra high` mode before implementing the persistence edge-case test suite.
@ -53,70 +66,87 @@ Cover these cases:
- backlog age behavior has a documented source timestamp or TODO before database work
- in-memory fake repositories can round-trip saved records without losing scheduling fields
- repository operations do not mutate input objects unexpectedly
- document-shaped maps preserve all fields needed by future MongoDB persistence
- generated document field names are stable and migration-friendly
Rules:
- This chunk may add tests and small model helpers needed to make persistence behavior testable.
- Do not add Drift/SQLite implementation yet.
- Do not add a MongoDB driver, MongoDB client, Atlas setup, connection string, local MongoDB service requirement, or database adapter yet.
- Do not add alternative database mappings or non-MongoDB terminology.
- Do not add sync, networking, cloud accounts, or background services.
- Do not mark this chunk complete unless `dart analyze` and `dart test` pass.
Acceptance criteria:
- Persistence-sensitive test group exists.
- Tests capture stable IDs, enum mapping, nullable-field behavior, and DateTime convention.
- Tests capture stable IDs, enum mapping, nullable-field behavior, DateTime convention, and document-shaped mapping expectations.
- Any unresolved persistence detail is documented as a TODO in the plan or architecture notes.
- No alternative-database references remain in the active persistence plan.
BREAKPOINT: Stop here. Confirm `high` mode before model serialization changes.
## Chunk 9.3 — Serialization-safe models
## Chunk 9.3 — MongoDB-document-safe models
Recommended Codex level: high
Tasks:
Make models persistence-friendly:
Make models persistence-friendly and MongoDB-document-friendly:
- stable IDs
- enum serialization helpers or clear mapping plan
- DateTime handling convention
- nullable fields documented
- nullable field clear helpers where needed
- migration-safe field names where possible
- map/json-like conversion helpers only if they do not over-couple the domain layer
- migration-safe document field names where possible
- document-shaped map conversion helpers only if they do not over-couple the domain layer
- clear mapping rules for nested task statistics and future child-task ownership fields
Rules:
- Keep model helpers independent from MongoDB client libraries.
- Prefer plain Dart map/document shapes that a later adapter can translate into MongoDB documents.
- Do not introduce database connection logic in this chunk.
- Do not add table assumptions, joins, or relational schema requirements.
Acceptance criteria:
- Models can round-trip through map/json-like structures or have a clear TODO for Drift mappings.
- Models can round-trip through document-shaped map/json-like structures or have a clear TODO for MongoDB document mappings.
- Tests cover at least task serialization if implemented.
- Tests cover enum persistence mapping if implemented.
- Tests cover intentional clearing of nullable fields if helper behavior is added.
- Tests confirm `not set` reward remains distinct from very low reward through mapping.
BREAKPOINT: Stop here. Confirm `low` mode before documenting the explicit non-sync boundary.
## Chunk 9.4 — Explicit non-sync boundary
## Chunk 9.4 — Explicit non-sync / no-database-runtime boundary
Recommended Codex level: low
Tasks:
Document that V1 persistence is local-first and sync is out of scope.
Document that V1 persistence work prepares for MongoDB but does not implement runtime database access, sync, accounts, or background services.
Rules:
- No network sync code.
- No cloud assumptions.
- No MongoDB connection strings.
- No Atlas setup.
- No local MongoDB server requirement.
- No background service implementation.
- No mobile notification or background reconciliation implementation in this block.
Acceptance criteria:
- README or architecture doc states sync is future work.
- Docs distinguish local persistence preparation from actual sync.
- README or architecture doc states MongoDB is the committed persistence target.
- README or architecture doc states this block does not require a running MongoDB instance.
- Docs distinguish MongoDB persistence preparation from actual sync or database adapter implementation.
- Wishlist/future notes contain sync as a later feature, not a V1 requirement.
Commit suggestion:
```text
feat(data): prepare persistence interfaces for scheduling core
feat(data): prepare MongoDB-friendly persistence interfaces
```

View file

@ -1,6 +1,6 @@
# V1 Block 09 — Persistence Preparation
Status: Planned
Status: In progress — Chunk 9.1 complete
Purpose: Prepare the domain layer for future MongoDB-backed persistence without adding a MongoDB driver, network behavior, sync behavior, or database runtime dependency too early.
@ -34,6 +34,15 @@ Acceptance criteria:
- Interfaces do not import MongoDB, Flutter, platform-specific APIs, or network/client APIs.
- Interface names and method shapes are compatible with document-style persistence.
Completed:
- Added pure Dart repository interfaces for tasks, projects, locked blocks/overrides, and scheduling state snapshots.
- Added in-memory repository implementations for tests and early app wiring.
- Added scheduling snapshot state that can rebuild `SchedulingInput` and `SchedulingResult` without persistence APIs.
- Exported repository interfaces through the public core library.
- Added repository tests covering task/project/locked-block/snapshot fake usage.
- Verified with `dart format lib test`, `dart analyze`, and `dart test`.
BREAKPOINT: Stop here. Confirm `extra high` mode before implementing the persistence edge-case test suite.
## Chunk 9.2 — Persistence edge-case regression test suite
@ -64,7 +73,7 @@ Rules:
- This chunk may add tests and small model helpers needed to make persistence behavior testable.
- Do not add a MongoDB driver, MongoDB client, Atlas setup, connection string, local MongoDB service requirement, or database adapter yet.
- Do not add alternative database mappings or SQL-specific terminology.
- Do not add alternative database mappings or non-MongoDB terminology.
- Do not add sync, networking, cloud accounts, or background services.
- Do not mark this chunk complete unless `dart analyze` and `dart test` pass.
@ -99,7 +108,7 @@ Rules:
- Keep model helpers independent from MongoDB client libraries.
- Prefer plain Dart map/document shapes that a later adapter can translate into MongoDB documents.
- Do not introduce database connection logic in this chunk.
- Do not add SQL-style table assumptions, joins, or relational schema requirements.
- Do not add table assumptions, joins, or relational schema requirements.
Acceptance criteria:

View file

@ -21,6 +21,7 @@ export 'src/backlog.dart';
export 'src/child_tasks.dart';
export 'src/locked_time.dart';
export 'src/quick_capture.dart';
export 'src/repositories.dart';
export 'src/scheduling_engine.dart';
export 'src/task_actions.dart';
export 'src/task_statistics.dart';

300
lib/src/repositories.dart Normal file
View file

@ -0,0 +1,300 @@
// Persistence boundary interfaces for the scheduling core.
//
// These interfaces are intentionally database-client-free. A future MongoDB
// adapter can implement them with document storage while the domain layer and
// tests keep using the same pure Dart contracts.
import 'locked_time.dart';
import 'models.dart';
import 'scheduling_engine.dart';
/// Repository contract for task documents.
abstract interface class TaskRepository {
/// Return a task by stable id, or null when it does not exist.
Future<Task?> findById(String id);
/// Return all tasks currently known to the repository.
Future<List<Task>> findAll();
/// Return tasks with a lifecycle status.
Future<List<Task>> findByStatus(TaskStatus status);
/// Return tasks that overlap [window] by scheduled start/end.
Future<List<Task>> findScheduledInWindow(SchedulingWindow window);
/// Save or replace [task] by stable id.
Future<void> save(Task task);
/// Save or replace every task by stable id.
Future<void> saveAll(Iterable<Task> tasks);
}
/// Repository contract for project profile documents.
abstract interface class ProjectRepository {
/// Return a project by stable id, or null when it does not exist.
Future<ProjectProfile?> findById(String id);
/// Return all projects currently known to the repository.
Future<List<ProjectProfile>> findAll();
/// Save or replace [project] by stable id.
Future<void> save(ProjectProfile project);
}
/// Repository contract for locked-block and one-day override documents.
abstract interface class LockedBlockRepository {
/// Return a locked block by stable id, or null when it does not exist.
Future<LockedBlock?> findBlockById(String id);
/// Return all locked block definitions.
Future<List<LockedBlock>> findAllBlocks();
/// Return an override by stable id, or null when it does not exist.
Future<LockedBlockOverride?> findOverrideById(String id);
/// Return all one-day overrides.
Future<List<LockedBlockOverride>> findAllOverrides();
/// Save or replace [block] by stable id.
Future<void> saveBlock(LockedBlock block);
/// Save or replace [override] by stable id.
Future<void> saveOverride(LockedBlockOverride override);
}
/// Snapshot of one scheduling operation or persisted planning state.
class SchedulingStateSnapshot {
const SchedulingStateSnapshot({
required this.id,
required this.capturedAt,
required this.window,
required this.tasks,
this.lockedIntervals = const <TimeInterval>[],
this.requiredVisibleIntervals = const <TimeInterval>[],
this.notices = const <SchedulingNotice>[],
this.changes = const <SchedulingChange>[],
this.overlaps = const <SchedulingOverlap>[],
this.operationName,
});
/// Stable snapshot id.
final String id;
/// Time this snapshot was created.
final DateTime capturedAt;
/// Planning window represented by the snapshot.
final SchedulingWindow window;
/// Task documents visible to the operation.
final List<Task> tasks;
/// Locked intervals supplied to the scheduler.
final List<TimeInterval> lockedIntervals;
/// Extra visible required intervals supplied to the scheduler.
final List<TimeInterval> requiredVisibleIntervals;
/// Human-readable scheduling notices.
final List<SchedulingNotice> notices;
/// Machine-readable task movements.
final List<SchedulingChange> changes;
/// Analysis-only overlap findings.
final List<SchedulingOverlap> overlaps;
/// Optional operation label, such as `push` or `rollover`.
final String? operationName;
/// Rebuild scheduler input from the persisted state shape.
SchedulingInput get input {
return SchedulingInput(
tasks: List<Task>.unmodifiable(tasks),
window: window,
lockedIntervals: List<TimeInterval>.unmodifiable(lockedIntervals),
requiredVisibleIntervals:
List<TimeInterval>.unmodifiable(requiredVisibleIntervals),
);
}
/// Rebuild scheduler result details from the persisted state shape.
SchedulingResult get result {
return SchedulingResult(
tasks: List<Task>.unmodifiable(tasks),
notices: List<SchedulingNotice>.unmodifiable(notices),
changes: List<SchedulingChange>.unmodifiable(changes),
overlaps: List<SchedulingOverlap>.unmodifiable(overlaps),
);
}
}
/// Repository contract for scheduling operation/state snapshot documents.
abstract interface class SchedulingSnapshotRepository {
/// Return a snapshot by stable id, or null when it does not exist.
Future<SchedulingStateSnapshot?> findById(String id);
/// Return all snapshots overlapping [window].
Future<List<SchedulingStateSnapshot>> findInWindow(SchedulingWindow window);
/// Save or replace [snapshot] by stable id.
Future<void> save(SchedulingStateSnapshot snapshot);
}
/// In-memory task repository useful for tests and early application wiring.
class InMemoryTaskRepository implements TaskRepository {
InMemoryTaskRepository([Iterable<Task> initialTasks = const []])
: _tasksById = {
for (final task in initialTasks) task.id: task,
};
final Map<String, Task> _tasksById;
@override
Future<Task?> findById(String id) async {
return _tasksById[id];
}
@override
Future<List<Task>> findAll() async {
return List<Task>.unmodifiable(_tasksById.values);
}
@override
Future<List<Task>> findByStatus(TaskStatus status) async {
return List<Task>.unmodifiable(
_tasksById.values.where((task) => task.status == status),
);
}
@override
Future<List<Task>> findScheduledInWindow(SchedulingWindow window) async {
return List<Task>.unmodifiable(
_tasksById.values.where((task) {
final start = task.scheduledStart;
final end = task.scheduledEnd;
if (start == null || end == null) {
return false;
}
return TimeInterval(start: start, end: end).overlaps(window.interval);
}),
);
}
@override
Future<void> save(Task task) async {
_tasksById[task.id] = task;
}
@override
Future<void> saveAll(Iterable<Task> tasks) async {
for (final task in tasks) {
_tasksById[task.id] = task;
}
}
}
/// In-memory project repository useful for tests and early application wiring.
class InMemoryProjectRepository implements ProjectRepository {
InMemoryProjectRepository(
[Iterable<ProjectProfile> initialProjects = const []])
: _projectsById = {
for (final project in initialProjects) project.id: project,
};
final Map<String, ProjectProfile> _projectsById;
@override
Future<ProjectProfile?> findById(String id) async {
return _projectsById[id];
}
@override
Future<List<ProjectProfile>> findAll() async {
return List<ProjectProfile>.unmodifiable(_projectsById.values);
}
@override
Future<void> save(ProjectProfile project) async {
_projectsById[project.id] = project;
}
}
/// In-memory locked-time repository useful for tests and early wiring.
class InMemoryLockedBlockRepository implements LockedBlockRepository {
InMemoryLockedBlockRepository({
Iterable<LockedBlock> initialBlocks = const [],
Iterable<LockedBlockOverride> initialOverrides = const [],
}) : _blocksById = {
for (final block in initialBlocks) block.id: block,
},
_overridesById = {
for (final override in initialOverrides) override.id: override,
};
final Map<String, LockedBlock> _blocksById;
final Map<String, LockedBlockOverride> _overridesById;
@override
Future<LockedBlock?> findBlockById(String id) async {
return _blocksById[id];
}
@override
Future<List<LockedBlock>> findAllBlocks() async {
return List<LockedBlock>.unmodifiable(_blocksById.values);
}
@override
Future<LockedBlockOverride?> findOverrideById(String id) async {
return _overridesById[id];
}
@override
Future<List<LockedBlockOverride>> findAllOverrides() async {
return List<LockedBlockOverride>.unmodifiable(_overridesById.values);
}
@override
Future<void> saveBlock(LockedBlock block) async {
_blocksById[block.id] = block;
}
@override
Future<void> saveOverride(LockedBlockOverride override) async {
_overridesById[override.id] = override;
}
}
/// In-memory snapshot repository useful for tests and early application wiring.
class InMemorySchedulingSnapshotRepository
implements SchedulingSnapshotRepository {
InMemorySchedulingSnapshotRepository([
Iterable<SchedulingStateSnapshot> initialSnapshots = const [],
]) : _snapshotsById = {
for (final snapshot in initialSnapshots) snapshot.id: snapshot,
};
final Map<String, SchedulingStateSnapshot> _snapshotsById;
@override
Future<SchedulingStateSnapshot?> findById(String id) async {
return _snapshotsById[id];
}
@override
Future<List<SchedulingStateSnapshot>> findInWindow(
SchedulingWindow window,
) async {
return List<SchedulingStateSnapshot>.unmodifiable(
_snapshotsById.values.where(
(snapshot) => snapshot.window.interval.overlaps(window.interval),
),
);
}
@override
Future<void> save(SchedulingStateSnapshot snapshot) async {
_snapshotsById[snapshot.id] = snapshot;
}
}

147
test/repositories_test.dart Normal file
View file

@ -0,0 +1,147 @@
import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('Repository interfaces', () {
final createdAt = DateTime(2026, 6, 21, 8);
final window = SchedulingWindow(
start: DateTime(2026, 6, 21, 9),
end: DateTime(2026, 6, 21, 17),
);
test('task repository supports in-memory fakes for domain services',
() async {
final backlogTask = task(
id: 'backlog',
title: 'Backlog task',
status: TaskStatus.backlog,
createdAt: createdAt,
);
final scheduledTask = task(
id: 'scheduled',
title: 'Scheduled task',
status: TaskStatus.planned,
createdAt: createdAt,
scheduledStart: DateTime(2026, 6, 21, 10),
scheduledEnd: DateTime(2026, 6, 21, 10, 30),
);
final repository = InMemoryTaskRepository([backlogTask]);
await repository.save(scheduledTask);
expect(await repository.findById('backlog'), same(backlogTask));
expect(await repository.findByStatus(TaskStatus.backlog), [backlogTask]);
expect(
(await repository.findScheduledInWindow(window)).map((task) => task.id),
['scheduled'],
);
});
test('project repository stores project profiles by stable id', () async {
const project = ProjectProfile(
id: 'home',
name: 'Home',
colorKey: 'project-home',
);
final repository = InMemoryProjectRepository();
await repository.save(project);
expect(await repository.findById('home'), same(project));
expect(await repository.findAll(), [project]);
});
test('locked block repository separates blocks from overrides', () async {
final block = LockedBlock(
id: 'work',
name: 'Work',
startTime: const ClockTime(hour: 9, minute: 0),
endTime: const ClockTime(hour: 17, minute: 0),
recurrence: const LockedBlockRecurrence.weekly(
weekdays: {LockedWeekday.monday},
),
createdAt: createdAt,
updatedAt: createdAt,
);
final override = LockedBlockOverride.remove(
id: 'holiday',
lockedBlockId: block.id,
date: DateTime(2026, 6, 22),
createdAt: createdAt,
);
final repository = InMemoryLockedBlockRepository();
await repository.saveBlock(block);
await repository.saveOverride(override);
expect(await repository.findBlockById('work'), same(block));
expect(await repository.findOverrideById('holiday'), same(override));
expect(await repository.findAllBlocks(), [block]);
expect(await repository.findAllOverrides(), [override]);
});
test('scheduling snapshot repository preserves scheduler input shape',
() async {
final scheduledTask = task(
id: 'scheduled',
title: 'Scheduled task',
status: TaskStatus.planned,
createdAt: createdAt,
scheduledStart: DateTime(2026, 6, 21, 10),
scheduledEnd: DateTime(2026, 6, 21, 10, 30),
);
final lockedInterval = TimeInterval(
start: DateTime(2026, 6, 21, 12),
end: DateTime(2026, 6, 21, 13),
label: 'Lunch',
);
final snapshot = SchedulingStateSnapshot(
id: 'snapshot-1',
capturedAt: DateTime(2026, 6, 21, 8, 30),
window: window,
tasks: [scheduledTask],
lockedIntervals: [lockedInterval],
notices: const [
SchedulingNotice('Saved schedule'),
],
operationName: 'load-day',
);
final repository = InMemorySchedulingSnapshotRepository();
await repository.save(snapshot);
final saved = await repository.findById('snapshot-1');
expect(saved, same(snapshot));
expect(saved!.input.tasks, [scheduledTask]);
expect(saved.input.lockedIntervals, [lockedInterval]);
expect(saved.result.tasks, [scheduledTask]);
expect(saved.result.notices.single.message, 'Saved schedule');
expect(
(await repository.findInWindow(window)).map((snapshot) => snapshot.id),
['snapshot-1'],
);
});
});
}
Task task({
required String id,
required String title,
required TaskStatus status,
required DateTime createdAt,
DateTime? scheduledStart,
DateTime? scheduledEnd,
}) {
return Task(
id: id,
title: title,
projectId: 'inbox',
type: TaskType.flexible,
status: status,
durationMinutes: 30,
scheduledStart: scheduledStart,
scheduledEnd: scheduledEnd,
createdAt: createdAt,
updatedAt: createdAt,
);
}