feat(data): expand repository contracts
This commit is contained in:
parent
55efa98182
commit
4269f4d9ce
10 changed files with 2332 additions and 74 deletions
|
|
@ -221,7 +221,7 @@ Verification:
|
||||||
|
|
||||||
- `dart format lib test`: passed, 0 files changed after final format
|
- `dart format lib test`: passed, 0 files changed after final format
|
||||||
- `dart analyze`: passed, no issues found
|
- `dart analyze`: passed, no issues found
|
||||||
- `dart test`: passed, 289 tests
|
- `dart test`: passed, 293 tests
|
||||||
- `git diff --check`: passed
|
- `git diff --check`: passed
|
||||||
|
|
||||||
BREAKPOINT: Stop here. Confirm `high` mode before expanding repository/query
|
BREAKPOINT: Stop here. Confirm `high` mode before expanding repository/query
|
||||||
|
|
@ -231,6 +231,8 @@ contracts and index specifications.
|
||||||
|
|
||||||
Recommended Codex level: high
|
Recommended Codex level: high
|
||||||
|
|
||||||
|
Status: Complete
|
||||||
|
|
||||||
Tasks:
|
Tasks:
|
||||||
|
|
||||||
Expand repository interfaces and in-memory implementations to support:
|
Expand repository interfaces and in-memory implementations to support:
|
||||||
|
|
@ -274,6 +276,40 @@ Acceptance criteria:
|
||||||
- Existing in-memory tests are migrated and pass.
|
- Existing in-memory tests are migrated and pass.
|
||||||
- Interfaces remain persistence-client-independent.
|
- 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
|
## Chunk 15.5 — Index and data-integrity contract tests
|
||||||
|
|
||||||
Recommended Codex level: high
|
Recommended Codex level: high
|
||||||
|
|
|
||||||
|
|
@ -370,6 +370,23 @@ Chunk 15.3 intentionally changed the public API:
|
||||||
- Legacy backlog tasks now carry explicit provenance when `backlogEnteredAt` is
|
- Legacy backlog tasks now carry explicit provenance when `backlogEnteredAt` is
|
||||||
approximated from `createdAt`.
|
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
|
## Repository contracts
|
||||||
|
|
||||||
The current repository surface is pure Dart and in-memory only:
|
The current repository surface is pure Dart and in-memory only:
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,6 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
final beforeTasks = await repositories.tasks.findAll();
|
|
||||||
final result = quickCaptureService.capture(
|
final result = quickCaptureService.capture(
|
||||||
QuickCaptureRequest(
|
QuickCaptureRequest(
|
||||||
id: context.idGenerator.nextId(),
|
id: context.idGenerator.nextId(),
|
||||||
|
|
@ -243,8 +242,8 @@ class V1ApplicationCommandUseCases {
|
||||||
repositories: repositories,
|
repositories: repositories,
|
||||||
commandCode: commandCode,
|
commandCode: commandCode,
|
||||||
context: context,
|
context: context,
|
||||||
beforeTasks: beforeTasks,
|
beforeTasks: const <Task>[],
|
||||||
afterTasks: [...beforeTasks, result.task],
|
afterTasks: [result.task],
|
||||||
activities: const <TaskActivity>[],
|
activities: const <TaskActivity>[],
|
||||||
readHint: ApplicationCommandReadHint(
|
readHint: ApplicationCommandReadHint(
|
||||||
affectedTaskIds: [result.task.id],
|
affectedTaskIds: [result.task.id],
|
||||||
|
|
@ -334,9 +333,14 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
final state =
|
var state = await _loadPlanningState(repositories, context, localDate);
|
||||||
await _loadPlanningState(repositories, context, localDate);
|
var task = _taskById(state.tasks, taskId);
|
||||||
final task = _taskById(state.tasks, taskId);
|
if (task == null) {
|
||||||
|
task = await repositories.tasks.findById(taskId);
|
||||||
|
if (task != null) {
|
||||||
|
state = state.withTask(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||||
}
|
}
|
||||||
|
|
@ -432,8 +436,7 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
final tasks = await repositories.tasks.findAll();
|
final task = await repositories.tasks.findById(taskId);
|
||||||
final task = _taskById(tasks, taskId);
|
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||||
}
|
}
|
||||||
|
|
@ -457,13 +460,12 @@ class V1ApplicationCommandUseCases {
|
||||||
detailCode: TaskTransitionOutcomeCode.invalidState.name,
|
detailCode: TaskTransitionOutcomeCode.invalidState.name,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final afterTasks = _replaceTask(tasks, actionResult.task);
|
|
||||||
return _persist(
|
return _persist(
|
||||||
repositories: repositories,
|
repositories: repositories,
|
||||||
commandCode: commandCode,
|
commandCode: commandCode,
|
||||||
context: context,
|
context: context,
|
||||||
beforeTasks: tasks,
|
beforeTasks: [task],
|
||||||
afterTasks: afterTasks,
|
afterTasks: [actionResult.task],
|
||||||
activities: actionResult.activities,
|
activities: actionResult.activities,
|
||||||
readHint: ApplicationCommandReadHint(
|
readHint: ApplicationCommandReadHint(
|
||||||
affectedTaskIds: [taskId],
|
affectedTaskIds: [taskId],
|
||||||
|
|
@ -488,9 +490,14 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
final state =
|
var state = await _loadPlanningState(repositories, context, localDate);
|
||||||
await _loadPlanningState(repositories, context, localDate);
|
var task = _taskById(state.tasks, taskId);
|
||||||
final task = _taskById(state.tasks, taskId);
|
if (task == null) {
|
||||||
|
task = await repositories.tasks.findById(taskId);
|
||||||
|
if (task != null) {
|
||||||
|
state = state.withTask(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||||
}
|
}
|
||||||
|
|
@ -548,9 +555,14 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
final state =
|
var state = await _loadPlanningState(repositories, context, localDate);
|
||||||
await _loadPlanningState(repositories, context, localDate);
|
var task = _taskById(state.tasks, taskId);
|
||||||
final task = _taskById(state.tasks, taskId);
|
if (task == null) {
|
||||||
|
task = await repositories.tasks.findById(taskId);
|
||||||
|
if (task != null) {
|
||||||
|
state = state.withTask(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||||
}
|
}
|
||||||
|
|
@ -664,7 +676,6 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
final tasks = await repositories.tasks.findAll();
|
|
||||||
final freeSlot = freeSlotService.create(
|
final freeSlot = freeSlotService.create(
|
||||||
id: context.idGenerator.nextId(),
|
id: context.idGenerator.nextId(),
|
||||||
title: title,
|
title: title,
|
||||||
|
|
@ -678,8 +689,8 @@ class V1ApplicationCommandUseCases {
|
||||||
repositories: repositories,
|
repositories: repositories,
|
||||||
commandCode: commandCode,
|
commandCode: commandCode,
|
||||||
context: context,
|
context: context,
|
||||||
beforeTasks: tasks,
|
beforeTasks: const <Task>[],
|
||||||
afterTasks: [...tasks, freeSlot],
|
afterTasks: [freeSlot],
|
||||||
activities: const <TaskActivity>[],
|
activities: const <TaskActivity>[],
|
||||||
readHint: ApplicationCommandReadHint(
|
readHint: ApplicationCommandReadHint(
|
||||||
localDate: localDate,
|
localDate: localDate,
|
||||||
|
|
@ -706,8 +717,7 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
final tasks = await repositories.tasks.findAll();
|
final task = await repositories.tasks.findById(taskId);
|
||||||
final task = _taskById(tasks, taskId);
|
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||||
}
|
}
|
||||||
|
|
@ -727,8 +737,8 @@ class V1ApplicationCommandUseCases {
|
||||||
repositories: repositories,
|
repositories: repositories,
|
||||||
commandCode: commandCode,
|
commandCode: commandCode,
|
||||||
context: context,
|
context: context,
|
||||||
beforeTasks: tasks,
|
beforeTasks: [task],
|
||||||
afterTasks: _replaceTask(tasks, updated),
|
afterTasks: [updated],
|
||||||
activities: const <TaskActivity>[],
|
activities: const <TaskActivity>[],
|
||||||
readHint: ApplicationCommandReadHint(
|
readHint: ApplicationCommandReadHint(
|
||||||
localDate: localDate,
|
localDate: localDate,
|
||||||
|
|
@ -751,8 +761,7 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
final tasks = await repositories.tasks.findAll();
|
final task = await repositories.tasks.findById(taskId);
|
||||||
final task = _taskById(tasks, taskId);
|
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||||
}
|
}
|
||||||
|
|
@ -776,8 +785,8 @@ class V1ApplicationCommandUseCases {
|
||||||
repositories: repositories,
|
repositories: repositories,
|
||||||
commandCode: commandCode,
|
commandCode: commandCode,
|
||||||
context: context,
|
context: context,
|
||||||
beforeTasks: tasks,
|
beforeTasks: [task],
|
||||||
afterTasks: _replaceTask(tasks, removed),
|
afterTasks: [removed],
|
||||||
activities: const <TaskActivity>[],
|
activities: const <TaskActivity>[],
|
||||||
readHint: ApplicationCommandReadHint(
|
readHint: ApplicationCommandReadHint(
|
||||||
localDate: localDate,
|
localDate: localDate,
|
||||||
|
|
@ -934,9 +943,14 @@ class V1ApplicationCommandUseCases {
|
||||||
context: context,
|
context: context,
|
||||||
operationName: commandCode.name,
|
operationName: commandCode.name,
|
||||||
action: (repositories) async {
|
action: (repositories) async {
|
||||||
final state =
|
var state = await _loadPlanningState(repositories, context, localDate);
|
||||||
await _loadPlanningState(repositories, context, localDate);
|
var task = _taskById(state.tasks, taskId);
|
||||||
final task = _taskById(state.tasks, taskId);
|
if (task == null) {
|
||||||
|
task = await repositories.tasks.findById(taskId);
|
||||||
|
if (task != null) {
|
||||||
|
state = state.withTask(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
return _failure(ApplicationFailureCode.notFound, entityId: taskId);
|
||||||
}
|
}
|
||||||
|
|
@ -1045,10 +1059,22 @@ class V1ApplicationCommandUseCases {
|
||||||
timeZoneId: settings.timeZoneId,
|
timeZoneId: settings.timeZoneId,
|
||||||
);
|
);
|
||||||
final window = SchedulingWindow(start: dayStart, end: dayEnd);
|
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(
|
final lockedExpansion = expandLockedBlocksForDay(
|
||||||
blocks: await repositories.lockedBlocks.findAllBlocks(),
|
blocks: (await repositories.lockedBlocks.findBlocksByOwner(
|
||||||
overrides: await repositories.lockedBlocks.findAllOverrides(),
|
ownerId: ownerId,
|
||||||
|
))
|
||||||
|
.items,
|
||||||
|
overrides: (await repositories.lockedBlocks.findOverridesForDate(
|
||||||
|
ownerId: ownerId,
|
||||||
|
date: localDate,
|
||||||
|
))
|
||||||
|
.items,
|
||||||
date: localDate,
|
date: localDate,
|
||||||
timeZoneId: settings.timeZoneId,
|
timeZoneId: settings.timeZoneId,
|
||||||
timeZoneResolver: timeZoneResolver,
|
timeZoneResolver: timeZoneResolver,
|
||||||
|
|
@ -1168,6 +1194,17 @@ class _PlanningState {
|
||||||
lockedIntervals: lockedIntervals,
|
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(
|
ApplicationResult<ApplicationCommandResult> _failure(
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -312,7 +312,11 @@ class V1ApplicationManagementUseCases {
|
||||||
) {
|
) {
|
||||||
return applicationStore.read(
|
return applicationStore.read(
|
||||||
action: (repositories) async {
|
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 resolutions = <ProjectDefaultResolution>[];
|
||||||
final sortedProjects = [...projects]
|
final sortedProjects = [...projects]
|
||||||
..sort((a, b) => a.name.compareTo(b.name));
|
..sort((a, b) => a.name.compareTo(b.name));
|
||||||
|
|
|
||||||
|
|
@ -146,8 +146,15 @@ class V1AppOpenRecoveryUseCases {
|
||||||
sourceWindow: sourceWindow,
|
sourceWindow: sourceWindow,
|
||||||
targetWindow: targetWindow,
|
targetWindow: targetWindow,
|
||||||
);
|
);
|
||||||
final blocks = await repositories.lockedBlocks.findAllBlocks();
|
final blocks = (await repositories.lockedBlocks.findBlocksByOwner(
|
||||||
final overrides = await repositories.lockedBlocks.findAllOverrides();
|
ownerId: context.ownerTimeZone.ownerId,
|
||||||
|
))
|
||||||
|
.items;
|
||||||
|
final overrides = (await repositories.lockedBlocks.findOverridesForDate(
|
||||||
|
ownerId: context.ownerTimeZone.ownerId,
|
||||||
|
date: targetDate,
|
||||||
|
))
|
||||||
|
.items;
|
||||||
final lockedExpansion = expandLockedBlocksForDay(
|
final lockedExpansion = expandLockedBlocksForDay(
|
||||||
blocks: blocks,
|
blocks: blocks,
|
||||||
overrides: overrides,
|
overrides: overrides,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -327,10 +327,26 @@ class GetTodayStateQuery {
|
||||||
);
|
);
|
||||||
final window = SchedulingWindow(start: dayStart, end: dayEnd);
|
final window = SchedulingWindow(start: dayStart, end: dayEnd);
|
||||||
|
|
||||||
final projects = await repositories.projects.findAll();
|
final projects = (await repositories.projects.findByOwner(
|
||||||
final tasks = await repositories.tasks.findScheduledInWindow(window);
|
ownerId: ownerId,
|
||||||
final blocks = await repositories.lockedBlocks.findAllBlocks();
|
includeArchived: true,
|
||||||
final overrides = await repositories.lockedBlocks.findAllOverrides();
|
))
|
||||||
|
.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 =
|
final snapshots =
|
||||||
await repositories.schedulingSnapshots.findInWindow(window);
|
await repositories.schedulingSnapshots.findInWindow(window);
|
||||||
final acknowledgedNoticeIds =
|
final acknowledgedNoticeIds =
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,123 @@ void main() {
|
||||||
result.failure!.detailCode, DomainValidationCode.blankStableId.name);
|
result.failure!.detailCode, DomainValidationCode.blankStableId.name);
|
||||||
expect(unitOfWork.currentOperations, isEmpty);
|
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', () {
|
group('Application scheduling loader', () {
|
||||||
|
|
|
||||||
|
|
@ -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({
|
Task task({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue