feat(data): expand repository contracts

This commit is contained in:
Ashley Venn 2026-06-25 12:11:04 -07:00
parent 55efa98182
commit 4269f4d9ce
10 changed files with 2332 additions and 74 deletions

View file

@ -221,7 +221,7 @@ Verification:
- `dart format lib test`: passed, 0 files changed after final format
- `dart analyze`: passed, no issues found
- `dart test`: passed, 289 tests
- `dart test`: passed, 293 tests
- `git diff --check`: passed
BREAKPOINT: Stop here. Confirm `high` mode before expanding repository/query
@ -231,6 +231,8 @@ contracts and index specifications.
Recommended Codex level: high
Status: Complete
Tasks:
Expand repository interfaces and in-memory implementations to support:
@ -274,6 +276,40 @@ Acceptance criteria:
- Existing in-memory tests are migrated and pass.
- Interfaces remain persistence-client-independent.
Completed implementation:
- Added adapter-neutral repository mutation/result contracts:
`RepositoryFailureCode`, `RepositoryFailure`, `RepositoryMutationResult`,
`RepositoryRecord`, `RepositoryPageRequest`, `RepositoryPage`, and
`BacklogCandidateQuery`.
- Expanded task repository contracts and in-memory implementations for owner,
project, parent, backlog-candidate, local-day/window, record/revision,
insert, and compare-and-set save behavior.
- Expanded project and locked-time repositories with owner-scoped paged queries,
revision records, compare-and-set saves, duplicate insert failures, archive
behavior, and date-scoped locked override queries.
- Expanded application-layer repositories for task activity append/query,
project statistics/settings revision saves, notice acknowledgement insert and
paging, and idempotent operation lookup/insert.
- Updated the in-memory application unit of work to stage/copy owner and
revision metadata across transactions.
- Shifted Today, project-default, recovery, and planning-state loading to
owner/date-scoped repository queries where those paths do not need wider task
family context.
- Added reusable core repository conformance tests covering query semantics,
deterministic pagination, archive behavior, optimistic revisions, duplicate
IDs, and immutable result pages.
- Added application unit-of-work contract tests for activity append conflicts,
owner settings stale revisions, notice duplicate inserts, and duplicate
operation IDs.
Verification:
- `dart format lib test`: passed, 0 files changed after final format
- `dart analyze`: passed, no issues found
- `dart test`: passed, 289 tests
- `git diff --check`: passed
## Chunk 15.5 — Index and data-integrity contract tests
Recommended Codex level: high

View file

@ -370,6 +370,23 @@ Chunk 15.3 intentionally changed the public API:
- Legacy backlog tasks now carry explicit provenance when `backlogEnteredAt` is
approximated from `createdAt`.
Chunk 15.4 intentionally changed the public API:
- Added adapter-neutral repository conflict/paging/record contracts:
`RepositoryFailureCode`, `RepositoryFailure`, `RepositoryMutationResult`,
`RepositoryRecord`, `RepositoryPageRequest`, `RepositoryPage`, and
`BacklogCandidateQuery`.
- Expanded `TaskRepository` with owner, project, parent, backlog-candidate,
local-day/window, record/revision, insert, and compare-and-set save methods.
- Expanded `ProjectRepository` and `LockedBlockRepository` with owner-scoped
paged queries, revision records, compare-and-set saves, duplicate insert
failures, archive methods, and date-scoped locked override lookup.
- Expanded application-layer repository interfaces for task activity owner
queries/appends, project statistics/settings revision saves, notice
acknowledgement insert/paging, and idempotent operation lookup/insert.
- In-memory unit-of-work repositories now track owner/revision metadata in the
staged transaction state.
## Repository contracts
The current repository surface is pure Dart and in-memory only:

View file

@ -215,7 +215,6 @@ class V1ApplicationCommandUseCases {
context: context,
operationName: commandCode.name,
action: (repositories) async {
final beforeTasks = await repositories.tasks.findAll();
final result = quickCaptureService.capture(
QuickCaptureRequest(
id: context.idGenerator.nextId(),
@ -243,8 +242,8 @@ class V1ApplicationCommandUseCases {
repositories: repositories,
commandCode: commandCode,
context: context,
beforeTasks: beforeTasks,
afterTasks: [...beforeTasks, result.task],
beforeTasks: const <Task>[],
afterTasks: [result.task],
activities: const <TaskActivity>[],
readHint: ApplicationCommandReadHint(
affectedTaskIds: [result.task.id],
@ -334,9 +333,14 @@ class V1ApplicationCommandUseCases {
context: context,
operationName: commandCode.name,
action: (repositories) async {
final state =
await _loadPlanningState(repositories, context, localDate);
final task = _taskById(state.tasks, taskId);
var state = await _loadPlanningState(repositories, context, localDate);
var task = _taskById(state.tasks, taskId);
if (task == null) {
task = await repositories.tasks.findById(taskId);
if (task != null) {
state = state.withTask(task);
}
}
if (task == null) {
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
}
@ -432,8 +436,7 @@ class V1ApplicationCommandUseCases {
context: context,
operationName: commandCode.name,
action: (repositories) async {
final tasks = await repositories.tasks.findAll();
final task = _taskById(tasks, taskId);
final task = await repositories.tasks.findById(taskId);
if (task == null) {
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
}
@ -457,13 +460,12 @@ class V1ApplicationCommandUseCases {
detailCode: TaskTransitionOutcomeCode.invalidState.name,
);
}
final afterTasks = _replaceTask(tasks, actionResult.task);
return _persist(
repositories: repositories,
commandCode: commandCode,
context: context,
beforeTasks: tasks,
afterTasks: afterTasks,
beforeTasks: [task],
afterTasks: [actionResult.task],
activities: actionResult.activities,
readHint: ApplicationCommandReadHint(
affectedTaskIds: [taskId],
@ -488,9 +490,14 @@ class V1ApplicationCommandUseCases {
context: context,
operationName: commandCode.name,
action: (repositories) async {
final state =
await _loadPlanningState(repositories, context, localDate);
final task = _taskById(state.tasks, taskId);
var state = await _loadPlanningState(repositories, context, localDate);
var task = _taskById(state.tasks, taskId);
if (task == null) {
task = await repositories.tasks.findById(taskId);
if (task != null) {
state = state.withTask(task);
}
}
if (task == null) {
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
}
@ -548,9 +555,14 @@ class V1ApplicationCommandUseCases {
context: context,
operationName: commandCode.name,
action: (repositories) async {
final state =
await _loadPlanningState(repositories, context, localDate);
final task = _taskById(state.tasks, taskId);
var state = await _loadPlanningState(repositories, context, localDate);
var task = _taskById(state.tasks, taskId);
if (task == null) {
task = await repositories.tasks.findById(taskId);
if (task != null) {
state = state.withTask(task);
}
}
if (task == null) {
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
}
@ -664,7 +676,6 @@ class V1ApplicationCommandUseCases {
context: context,
operationName: commandCode.name,
action: (repositories) async {
final tasks = await repositories.tasks.findAll();
final freeSlot = freeSlotService.create(
id: context.idGenerator.nextId(),
title: title,
@ -678,8 +689,8 @@ class V1ApplicationCommandUseCases {
repositories: repositories,
commandCode: commandCode,
context: context,
beforeTasks: tasks,
afterTasks: [...tasks, freeSlot],
beforeTasks: const <Task>[],
afterTasks: [freeSlot],
activities: const <TaskActivity>[],
readHint: ApplicationCommandReadHint(
localDate: localDate,
@ -706,8 +717,7 @@ class V1ApplicationCommandUseCases {
context: context,
operationName: commandCode.name,
action: (repositories) async {
final tasks = await repositories.tasks.findAll();
final task = _taskById(tasks, taskId);
final task = await repositories.tasks.findById(taskId);
if (task == null) {
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
}
@ -727,8 +737,8 @@ class V1ApplicationCommandUseCases {
repositories: repositories,
commandCode: commandCode,
context: context,
beforeTasks: tasks,
afterTasks: _replaceTask(tasks, updated),
beforeTasks: [task],
afterTasks: [updated],
activities: const <TaskActivity>[],
readHint: ApplicationCommandReadHint(
localDate: localDate,
@ -751,8 +761,7 @@ class V1ApplicationCommandUseCases {
context: context,
operationName: commandCode.name,
action: (repositories) async {
final tasks = await repositories.tasks.findAll();
final task = _taskById(tasks, taskId);
final task = await repositories.tasks.findById(taskId);
if (task == null) {
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
}
@ -776,8 +785,8 @@ class V1ApplicationCommandUseCases {
repositories: repositories,
commandCode: commandCode,
context: context,
beforeTasks: tasks,
afterTasks: _replaceTask(tasks, removed),
beforeTasks: [task],
afterTasks: [removed],
activities: const <TaskActivity>[],
readHint: ApplicationCommandReadHint(
localDate: localDate,
@ -934,9 +943,14 @@ class V1ApplicationCommandUseCases {
context: context,
operationName: commandCode.name,
action: (repositories) async {
final state =
await _loadPlanningState(repositories, context, localDate);
final task = _taskById(state.tasks, taskId);
var state = await _loadPlanningState(repositories, context, localDate);
var task = _taskById(state.tasks, taskId);
if (task == null) {
task = await repositories.tasks.findById(taskId);
if (task != null) {
state = state.withTask(task);
}
}
if (task == null) {
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
}
@ -1045,10 +1059,22 @@ class V1ApplicationCommandUseCases {
timeZoneId: settings.timeZoneId,
);
final window = SchedulingWindow(start: dayStart, end: dayEnd);
final tasks = await repositories.tasks.findAll();
final tasks = (await repositories.tasks.findForLocalDay(
ownerId: ownerId,
localDate: localDate,
window: window,
))
.items;
final lockedExpansion = expandLockedBlocksForDay(
blocks: await repositories.lockedBlocks.findAllBlocks(),
overrides: await repositories.lockedBlocks.findAllOverrides(),
blocks: (await repositories.lockedBlocks.findBlocksByOwner(
ownerId: ownerId,
))
.items,
overrides: (await repositories.lockedBlocks.findOverridesForDate(
ownerId: ownerId,
date: localDate,
))
.items,
date: localDate,
timeZoneId: settings.timeZoneId,
timeZoneResolver: timeZoneResolver,
@ -1168,6 +1194,17 @@ class _PlanningState {
lockedIntervals: lockedIntervals,
);
}
_PlanningState withTask(Task task) {
if (tasks.any((existing) => existing.id == task.id)) {
return this;
}
return _PlanningState(
tasks: [task, ...tasks],
window: window,
lockedIntervals: lockedIntervals,
);
}
}
ApplicationResult<ApplicationCommandResult> _failure(

File diff suppressed because it is too large Load diff

View file

@ -312,7 +312,11 @@ class V1ApplicationManagementUseCases {
) {
return applicationStore.read(
action: (repositories) async {
final projects = await repositories.projects.findAll();
final projects = (await repositories.projects.findByOwner(
ownerId: request.context.ownerTimeZone.ownerId,
includeArchived: request.includeArchived,
))
.items;
final resolutions = <ProjectDefaultResolution>[];
final sortedProjects = [...projects]
..sort((a, b) => a.name.compareTo(b.name));

View file

@ -146,8 +146,15 @@ class V1AppOpenRecoveryUseCases {
sourceWindow: sourceWindow,
targetWindow: targetWindow,
);
final blocks = await repositories.lockedBlocks.findAllBlocks();
final overrides = await repositories.lockedBlocks.findAllOverrides();
final blocks = (await repositories.lockedBlocks.findBlocksByOwner(
ownerId: context.ownerTimeZone.ownerId,
))
.items;
final overrides = (await repositories.lockedBlocks.findOverridesForDate(
ownerId: context.ownerTimeZone.ownerId,
date: targetDate,
))
.items;
final lockedExpansion = expandLockedBlocksForDay(
blocks: blocks,
overrides: overrides,

File diff suppressed because it is too large Load diff

View file

@ -327,10 +327,26 @@ class GetTodayStateQuery {
);
final window = SchedulingWindow(start: dayStart, end: dayEnd);
final projects = await repositories.projects.findAll();
final tasks = await repositories.tasks.findScheduledInWindow(window);
final blocks = await repositories.lockedBlocks.findAllBlocks();
final overrides = await repositories.lockedBlocks.findAllOverrides();
final projects = (await repositories.projects.findByOwner(
ownerId: ownerId,
includeArchived: true,
))
.items;
final tasks = (await repositories.tasks.findForLocalDay(
ownerId: ownerId,
localDate: request.date,
window: window,
))
.items;
final blocks = (await repositories.lockedBlocks.findBlocksByOwner(
ownerId: ownerId,
))
.items;
final overrides = (await repositories.lockedBlocks.findOverridesForDate(
ownerId: ownerId,
date: request.date,
))
.items;
final snapshots =
await repositories.schedulingSnapshots.findInWindow(window);
final acknowledgedNoticeIds =

View file

@ -188,6 +188,123 @@ void main() {
result.failure!.detailCode, DomainValidationCode.blankStableId.name);
expect(unitOfWork.currentOperations, isEmpty);
});
test(
'repository contracts expose activity settings notice and operation '
'conflicts', () async {
final unitOfWork = InMemoryApplicationUnitOfWork(
initialOwnerSettings: [
OwnerSettings(ownerId: 'owner-1', timeZoneId: 'America/Los_Angeles'),
],
);
final result = await unitOfWork.run<void>(
context: appContext(operationId: 'repo-contracts', now: now),
operationName: 'repo-contracts',
action: (repositories) async {
final activity = activityFor(
id: 'activity-1',
operationId: 'manual-op',
task: task(
id: 'task-1',
status: TaskStatus.completed,
createdAt: now,
),
occurredAt: now,
);
final appended = await repositories.taskActivities.append(
activity: activity,
ownerId: 'owner-1',
);
final duplicateActivity = await repositories.taskActivities.append(
activity: activity,
ownerId: 'owner-1',
);
final activities = await repositories.taskActivities.findByOwner(
ownerId: 'owner-1',
);
final settingsRecord =
await repositories.ownerSettings.findRecordByOwnerId('owner-1');
final staleSettings = await repositories.ownerSettings.saveIfRevision(
settings: settingsRecord!.value.copyWith(compactModeEnabled: true),
expectedRevision: 99,
);
final savedSettings = await repositories.ownerSettings.saveIfRevision(
settings: settingsRecord.value.copyWith(compactModeEnabled: true),
expectedRevision: settingsRecord.revision,
);
final acknowledgement = NoticeAcknowledgementRecord(
noticeId: 'snapshot:0',
ownerId: 'owner-1',
acknowledgedAt: now,
);
final insertedNotice =
await repositories.noticeAcknowledgements.insert(acknowledgement);
final duplicateNotice =
await repositories.noticeAcknowledgements.insert(acknowledgement);
final notices = await repositories.noticeAcknowledgements.findByOwner(
ownerId: 'owner-1',
);
final operation = ApplicationOperationRecord(
operationId: 'manual-op',
ownerId: 'owner-1',
operationName: 'manual',
committedAt: now,
);
final insertedOperation =
await repositories.operations.insertIfAbsent(operation);
final duplicateOperation =
await repositories.operations.insertIfAbsent(operation);
final idempotentOperation =
await repositories.operations.findByIdempotencyKey(
ownerId: 'owner-1',
operationId: 'manual-op',
);
expect(appended.isSuccess, isTrue);
expect(
duplicateActivity.failure!.code,
RepositoryFailureCode.duplicateId,
);
expect(activities.items.map((activity) => activity.id), [
'activity-1',
]);
expect(
staleSettings.failure!.code,
RepositoryFailureCode.staleRevision,
);
expect(savedSettings.revision, 2);
expect(insertedNotice.isSuccess, isTrue);
expect(
duplicateNotice.failure!.code,
RepositoryFailureCode.duplicateId,
);
expect(notices.items.map((notice) => notice.noticeId), [
'snapshot:0',
]);
expect(insertedOperation.isSuccess, isTrue);
expect(
duplicateOperation.failure!.code,
RepositoryFailureCode.duplicateOperation,
);
expect(idempotentOperation!.operationName, 'manual');
return ApplicationResult.success(null);
},
);
expect(result.isSuccess, isTrue);
expect(unitOfWork.currentTaskActivities.single.id, 'activity-1');
expect(unitOfWork.currentOwnerSettings.single.compactModeEnabled, isTrue);
expect(unitOfWork.currentNoticeAcknowledgements.single.noticeId,
'snapshot:0');
expect(
unitOfWork.currentOperations.map((operation) => operation.operationId),
containsAll(['manual-op', 'repo-contracts']),
);
});
});
group('Application scheduling loader', () {

View file

@ -122,6 +122,214 @@ void main() {
);
});
});
runCoreRepositoryConformance(
createTaskRepository: () => InMemoryTaskRepository(
[
task(
id: 'backlog-a',
title: 'Backlog A',
status: TaskStatus.backlog,
createdAt: DateTime.utc(2026, 6, 20),
),
task(
id: 'backlog-b',
title: 'Backlog B',
status: TaskStatus.backlog,
createdAt: DateTime.utc(2026, 6, 21),
).copyWith(projectId: 'home', parentTaskId: 'parent'),
task(
id: 'scheduled',
title: 'Scheduled',
status: TaskStatus.planned,
createdAt: DateTime.utc(2026, 6, 21),
scheduledStart: DateTime.utc(2026, 6, 22, 10),
scheduledEnd: DateTime.utc(2026, 6, 22, 10, 30),
).copyWith(projectId: 'home'),
],
),
createProjectRepository: () => InMemoryProjectRepository([
ProjectProfile(id: 'home', name: 'Home', colorKey: 'home-blue'),
ProjectProfile(id: 'work', name: 'Work', colorKey: 'work-green'),
]),
createLockedBlockRepository: () => InMemoryLockedBlockRepository(
initialBlocks: [
LockedBlock(
id: 'work',
name: 'Work',
startTime: ClockTime(hour: 9, minute: 0),
endTime: ClockTime(hour: 17, minute: 0),
recurrence: LockedBlockRecurrence.weekly(
weekdays: {LockedWeekday.monday},
),
createdAt: DateTime.utc(2026, 6, 20),
updatedAt: DateTime.utc(2026, 6, 20),
),
],
initialOverrides: [
LockedBlockOverride.remove(
id: 'holiday',
lockedBlockId: 'work',
date: CivilDate(2026, 6, 22),
createdAt: DateTime.utc(2026, 6, 20),
),
],
),
);
}
void runCoreRepositoryConformance({
required TaskRepository Function() createTaskRepository,
required ProjectRepository Function() createProjectRepository,
required LockedBlockRepository Function() createLockedBlockRepository,
}) {
group('Core repository conformance', () {
const ownerId = 'owner-1';
test('task queries are owner-scoped, ordered, paged, and revisioned',
() async {
final repository = createTaskRepository();
final firstPage = await repository.findByOwner(
ownerId: ownerId,
page: const RepositoryPageRequest(limit: 2),
);
final secondPage = await repository.findByOwner(
ownerId: ownerId,
page: RepositoryPageRequest(cursor: firstPage.nextCursor, limit: 2),
);
final homeTasks = await repository.findByProjectId(
ownerId: ownerId,
projectId: 'home',
);
final children = await repository.findByParentTaskId(
ownerId: ownerId,
parentTaskId: 'parent',
);
final backlog = await repository.findBacklogCandidates(
const BacklogCandidateQuery(ownerId: ownerId, projectId: 'home'),
);
final localDay = await repository.findForLocalDay(
ownerId: ownerId,
localDate: CivilDate(2026, 6, 22),
window: SchedulingWindow(
start: DateTime.utc(2026, 6, 22),
end: DateTime.utc(2026, 6, 23),
),
);
final record = await repository.findRecordById('scheduled');
final updated = record!.value.copyWith(
title: 'Updated scheduled',
updatedAt: DateTime.utc(2026, 6, 22, 12),
);
expect(firstPage.items.map((task) => task.id), [
'backlog-a',
'backlog-b',
]);
expect(firstPage.hasMore, isTrue);
expect(secondPage.items.map((task) => task.id), ['scheduled']);
expect(homeTasks.items.map((task) => task.id), [
'backlog-b',
'scheduled',
]);
expect(children.items.map((task) => task.id), ['backlog-b']);
expect(backlog.items.map((task) => task.id), ['backlog-b']);
expect(localDay.items.map((task) => task.id), ['scheduled']);
expect(record.ownerId, ownerId);
expect(record.revision, 1);
final stale = await repository.saveIfRevision(
task: updated,
ownerId: ownerId,
expectedRevision: 99,
);
expect(stale.failure!.code, RepositoryFailureCode.staleRevision);
expect(stale.failure!.actualRevision, 1);
final saved = await repository.saveIfRevision(
task: updated,
ownerId: ownerId,
expectedRevision: 1,
);
expect(saved.isSuccess, isTrue);
expect(saved.revision, 2);
expect((await repository.findRecordById('scheduled'))!.revision, 2);
expect(
() => firstPage.items.add(record.value),
throwsUnsupportedError,
);
});
test('project queries hide archived records by default and honor revisions',
() async {
final repository = createProjectRepository();
final record = await repository.findRecordById('home');
final archived = await repository.archive(
projectId: 'home',
ownerId: ownerId,
expectedRevision: record!.revision,
archivedAt: DateTime.utc(2026, 6, 25),
);
final visible = await repository.findByOwner(ownerId: ownerId);
final includingArchived = await repository.findByOwner(
ownerId: ownerId,
includeArchived: true,
);
expect(archived.isSuccess, isTrue);
expect(visible.items.map((project) => project.id), ['work']);
expect(includingArchived.items.map((project) => project.id), [
'home',
'work',
]);
expect(
(await repository.findRecordById('home'))!.archivedAt,
DateTime.utc(2026, 6, 25),
);
expect(
(await repository.insert(
project: ProjectProfile(id: 'home', name: 'Home', colorKey: 'again'),
ownerId: ownerId,
))
.failure!
.code,
RepositoryFailureCode.duplicateId,
);
});
test('locked queries find date-scoped overrides and archive by revision',
() async {
final repository = createLockedBlockRepository();
final blockRecord = await repository.findBlockRecordById('work');
final overrides = await repository.findOverridesForDate(
ownerId: ownerId,
date: CivilDate(2026, 6, 22),
);
final archived = await repository.archiveBlock(
blockId: 'work',
ownerId: ownerId,
expectedRevision: blockRecord!.revision,
archivedAt: DateTime.utc(2026, 6, 25),
);
final visible = await repository.findBlocksByOwner(ownerId: ownerId);
final includingArchived = await repository.findBlocksByOwner(
ownerId: ownerId,
includeArchived: true,
);
expect(overrides.items.map((override) => override.id), ['holiday']);
expect(archived.isSuccess, isTrue);
expect(visible.items, isEmpty);
expect(includingArchived.items.map((block) => block.id), ['work']);
expect(
(await repository.findBlockRecordById('work'))!.archivedAt,
DateTime.utc(2026, 6, 25),
);
});
});
}
Task task({