feat(data): add persistence index contracts

This commit is contained in:
Ashley Venn 2026-06-25 12:17:54 -07:00
parent 4269f4d9ce
commit 394d6e6e96
7 changed files with 834 additions and 8 deletions

View file

@ -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, 293 tests - `dart test`: passed, 289 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
@ -307,13 +307,15 @@ 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
## 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
Status: Complete
Tasks: Tasks:
- Represent required MongoDB indexes as declarative adapter-neutral - Represent required MongoDB indexes as declarative adapter-neutral
@ -345,6 +347,35 @@ Acceptance criteria:
- Block 16 can create indexes from the published contract. - Block 16 can create indexes from the published contract.
- All persistence and repository tests pass. - All persistence and repository tests pass.
Completed implementation:
- Added adapter-neutral V1 collection names, index-field specs, index specs, and
repository query names in `persistence_contract.dart`.
- Added `PersistenceIndexCatalog.all` with stable names, key order, uniqueness,
partial-filter expectations, archive-aware indexes, and supported query
coverage.
- Added indexes for owner/document uniqueness, idempotent operations, Today
windows, backlog/status/project lookups, parent ownership, date-scoped locked
overrides, notice acknowledgement lookup, activity aggregation, and bounded
scheduling snapshots.
- Added bounded payload and retention contracts through
`PersistencePayloadLimits`, `PersistenceGuardResult`, and
`PersistencePayloadGuard`.
- Added schema-integrity repository tests for duplicate IDs, invalid revisions,
stale revisions, and mismatched owner scopes at the repository boundary.
- Added index contract tests that verify every V1 repository query has a
declared access path, stable index names/key order, explicit uniqueness,
archive/partial-index coverage, and bounded payload rejection.
- Published `V1_BLOCK_16_REPOSITORY_ADAPTER_CHECKLIST.md` for the future MongoDB
adapter implementation.
Verification:
- `dart format lib test`: passed, 0 files changed after final format
- `dart analyze`: passed, no issues found
- `dart test`: passed, 298 tests
- `git diff --check`: passed
BREAKPOINT: Stop here. Confirm `extra high` mode before selecting and implementing BREAKPOINT: Stop here. Confirm `extra high` mode before selecting and implementing
the MongoDB runtime boundary. the MongoDB runtime boundary.

View file

@ -0,0 +1,47 @@
# V1 Block 16 — Repository Adapter Checklist
Use this checklist before implementing a MongoDB runtime adapter.
## Index bootstrap
- Create every `PersistenceIndexCatalog.all` index by stable `name`.
- Treat index creation as idempotent; do not rename indexes casually.
- Enforce unique indexes before accepting writes for owner/document IDs,
owner/project statistics, owner/notice IDs, and owner/operation IDs.
- Preserve key order exactly as declared in the catalog.
- Apply partial filter expectations where `partialFilterFields` is non-empty.
## Repository behavior
- Keep MongoDB driver types behind adapter classes; repository interfaces remain
pure Dart.
- Scope every query by `ownerId`.
- Return deterministic ordering that matches the in-memory conformance tests.
- Implement cursor pagination without exposing raw driver cursors in public
repository interfaces.
- Return typed `RepositoryMutationResult` failures for stale revisions,
invalid revisions, owner mismatches, duplicate IDs, and duplicate operation
IDs.
- Use compare-and-set writes for revisioned updates.
- Do not hard-delete authoritative task, project, locked, or settings documents
unless a future plan explicitly adds that operation.
## Payload and retention
- Apply `PersistencePayloadLimits` before writing internal activity, operation,
project-statistics applied IDs, and scheduling snapshot payloads.
- Retain authoritative collections indefinitely unless a later plan changes the
product data policy.
- Apply bounded retention only to internal retained collections:
task activities, notice acknowledgements, operation records, and scheduling
snapshots.
- Do not denormalize hidden locked-block details into unrelated documents.
## Migration and validation
- Run V0-to-V1 migrations through `V1DocumentMigrationService` in dry-run/report
mode before writing replacements.
- Refuse unsupported future schema versions.
- Decode all writes through V1 document codecs in tests before persisting.
- Run the repository conformance tests against the adapter before enabling it in
application code.

View file

@ -387,6 +387,20 @@ Chunk 15.4 intentionally changed the public API:
- In-memory unit-of-work repositories now track owner/revision metadata in the - In-memory unit-of-work repositories now track owner/revision metadata in the
staged transaction state. staged transaction state.
Chunk 15.5 intentionally changed the public API:
- Added V1 collection/index contract types and constants:
`PersistenceCollections`, `PersistenceIndexDirection`,
`PersistenceIndexField`, `PersistenceIndexSpec`, `RepositoryQueryNames`, and
`PersistenceIndexCatalog`.
- Added bounded payload/retention guard surface:
`PersistencePayloadLimits`, `PersistenceGuardResult`, and
`PersistencePayloadGuard`.
- Added repository integrity failure codes for invalid revisions and owner
mismatches.
- Added `V1_BLOCK_16_REPOSITORY_ADAPTER_CHECKLIST.md` for MongoDB adapter
implementation prerequisites.
## 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

@ -9,6 +9,485 @@ import 'time_contracts.dart';
/// V1 document-schema version. /// V1 document-schema version.
const v1SchemaVersion = 1; const v1SchemaVersion = 1;
/// Stable V1 collection names for future MongoDB adapters.
abstract final class PersistenceCollections {
static const tasks = 'tasks';
static const projects = 'projects';
static const projectStatistics = 'project_statistics';
static const lockedBlocks = 'locked_blocks';
static const lockedOverrides = 'locked_overrides';
static const taskActivities = 'task_activities';
static const ownerSettings = 'owner_settings';
static const noticeAcknowledgements = 'notice_acknowledgements';
static const applicationOperations = 'application_operations';
static const schedulingSnapshots = 'scheduling_snapshots';
static const authoritative = <String>{
tasks,
projects,
projectStatistics,
lockedBlocks,
lockedOverrides,
ownerSettings,
};
static const internalRetained = <String>{
taskActivities,
noticeAcknowledgements,
applicationOperations,
schedulingSnapshots,
};
}
/// Sort direction in an adapter-neutral index specification.
enum PersistenceIndexDirection {
ascending,
descending,
}
/// One field in a declarative index key.
class PersistenceIndexField {
const PersistenceIndexField(
this.fieldName, {
this.direction = PersistenceIndexDirection.ascending,
});
final String fieldName;
final PersistenceIndexDirection direction;
}
/// A stable index plan future adapters can translate to driver-specific calls.
class PersistenceIndexSpec {
const PersistenceIndexSpec({
required this.name,
required this.collection,
required this.keys,
this.unique = false,
this.partialFilterFields = const <String>{},
this.supportsQueries = const <String>{},
});
final String name;
final String collection;
final List<PersistenceIndexField> keys;
final bool unique;
final Set<String> partialFilterFields;
final Set<String> supportsQueries;
}
/// Stable repository query identifiers for index coverage tests.
abstract final class RepositoryQueryNames {
static const taskById = 'TaskRepository.findById';
static const taskByOwner = 'TaskRepository.findByOwner';
static const taskByStatus = 'TaskRepository.findByStatus';
static const taskByProject = 'TaskRepository.findByProjectId';
static const taskByParent = 'TaskRepository.findByParentTaskId';
static const taskBacklogCandidates = 'TaskRepository.findBacklogCandidates';
static const taskScheduledWindow =
'TaskRepository.findScheduledInWindowForOwner';
static const taskLocalDay = 'TaskRepository.findForLocalDay';
static const projectByOwner = 'ProjectRepository.findByOwner';
static const projectById = 'ProjectRepository.findById';
static const lockedBlockByOwner = 'LockedBlockRepository.findBlocksByOwner';
static const lockedBlockById = 'LockedBlockRepository.findBlockById';
static const lockedOverrideByDate =
'LockedBlockRepository.findOverridesForDate';
static const taskActivityByOwner = 'TaskActivityRepository.findByOwner';
static const taskActivityByOperation =
'TaskActivityRepository.findByOperationId';
static const taskActivityByTask = 'TaskActivityRepository.findByTaskId';
static const taskActivityByProject = 'TaskActivityRepository.findByProjectId';
static const projectStatisticsByProject =
'ProjectStatisticsRepository.findByProjectId';
static const ownerSettingsByOwner = 'OwnerSettingsRepository.findByOwnerId';
static const noticeAcknowledgementByOwner =
'NoticeAcknowledgementRepository.findByOwner';
static const noticeAcknowledgementById =
'NoticeAcknowledgementRepository.findById';
static const operationByIdempotencyKey =
'ApplicationOperationRepository.findByIdempotencyKey';
static const schedulingSnapshotById = 'SchedulingSnapshotRepository.findById';
static const schedulingSnapshotWindow =
'SchedulingSnapshotRepository.findInWindow';
}
/// Required V1 index plan.
abstract final class PersistenceIndexCatalog {
static const all = <PersistenceIndexSpec>[
PersistenceIndexSpec(
name: 'tasks_owner_id_unique',
collection: PersistenceCollections.tasks,
unique: true,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(DocumentFields.id),
],
supportsQueries: {
RepositoryQueryNames.taskById,
RepositoryQueryNames.taskByOwner,
},
),
PersistenceIndexSpec(
name: 'tasks_owner_status_backlog_age',
collection: PersistenceCollections.tasks,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(TaskDocumentFields.status),
PersistenceIndexField(TaskDocumentFields.backlogEnteredAt),
PersistenceIndexField(TaskDocumentFields.createdAt),
PersistenceIndexField(DocumentFields.id),
],
supportsQueries: {
RepositoryQueryNames.taskByStatus,
RepositoryQueryNames.taskBacklogCandidates,
},
),
PersistenceIndexSpec(
name: 'tasks_owner_scheduled_window',
collection: PersistenceCollections.tasks,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(TaskDocumentFields.scheduledStart),
PersistenceIndexField(TaskDocumentFields.scheduledEnd),
PersistenceIndexField(DocumentFields.id),
],
partialFilterFields: {
TaskDocumentFields.scheduledStart,
TaskDocumentFields.scheduledEnd,
},
supportsQueries: {
RepositoryQueryNames.taskScheduledWindow,
RepositoryQueryNames.taskLocalDay,
},
),
PersistenceIndexSpec(
name: 'tasks_owner_project_status',
collection: PersistenceCollections.tasks,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(TaskDocumentFields.projectId),
PersistenceIndexField(TaskDocumentFields.status),
PersistenceIndexField(DocumentFields.id),
],
supportsQueries: {
RepositoryQueryNames.taskByProject,
RepositoryQueryNames.taskBacklogCandidates,
},
),
PersistenceIndexSpec(
name: 'tasks_owner_parent_status',
collection: PersistenceCollections.tasks,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(TaskDocumentFields.parentTaskId),
PersistenceIndexField(TaskDocumentFields.status),
PersistenceIndexField(DocumentFields.id),
],
partialFilterFields: {
TaskDocumentFields.parentTaskId,
},
supportsQueries: {
RepositoryQueryNames.taskByParent,
},
),
PersistenceIndexSpec(
name: 'projects_owner_id_unique',
collection: PersistenceCollections.projects,
unique: true,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(DocumentFields.id),
],
supportsQueries: {
RepositoryQueryNames.projectById,
},
),
PersistenceIndexSpec(
name: 'projects_owner_archived_name',
collection: PersistenceCollections.projects,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(ProjectDocumentFields.archivedAt),
PersistenceIndexField(ProjectDocumentFields.name),
PersistenceIndexField(DocumentFields.id),
],
supportsQueries: {
RepositoryQueryNames.projectByOwner,
},
),
PersistenceIndexSpec(
name: 'project_stats_owner_project_unique',
collection: PersistenceCollections.projectStatistics,
unique: true,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(ProjectStatisticsDocumentFields.projectId),
],
supportsQueries: {
RepositoryQueryNames.projectStatisticsByProject,
},
),
PersistenceIndexSpec(
name: 'locked_blocks_owner_id_unique',
collection: PersistenceCollections.lockedBlocks,
unique: true,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(DocumentFields.id),
],
supportsQueries: {
RepositoryQueryNames.lockedBlockById,
},
),
PersistenceIndexSpec(
name: 'locked_blocks_owner_archived_name',
collection: PersistenceCollections.lockedBlocks,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(LockedBlockDocumentFields.archivedAt),
PersistenceIndexField(LockedBlockDocumentFields.name),
PersistenceIndexField(DocumentFields.id),
],
supportsQueries: {
RepositoryQueryNames.lockedBlockByOwner,
},
),
PersistenceIndexSpec(
name: 'locked_overrides_owner_id_unique',
collection: PersistenceCollections.lockedOverrides,
unique: true,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(DocumentFields.id),
],
),
PersistenceIndexSpec(
name: 'locked_overrides_owner_date_block',
collection: PersistenceCollections.lockedOverrides,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(LockedBlockOverrideDocumentFields.date),
PersistenceIndexField(LockedBlockOverrideDocumentFields.lockedBlockId),
PersistenceIndexField(DocumentFields.id),
],
supportsQueries: {
RepositoryQueryNames.lockedOverrideByDate,
},
),
PersistenceIndexSpec(
name: 'task_activities_owner_id_unique',
collection: PersistenceCollections.taskActivities,
unique: true,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(DocumentFields.id),
],
),
PersistenceIndexSpec(
name: 'task_activities_owner_operation',
collection: PersistenceCollections.taskActivities,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(TaskActivityDocumentFields.operationId),
PersistenceIndexField(DocumentFields.id),
],
supportsQueries: {
RepositoryQueryNames.taskActivityByOperation,
},
),
PersistenceIndexSpec(
name: 'task_activities_owner_task_time',
collection: PersistenceCollections.taskActivities,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(TaskActivityDocumentFields.taskId),
PersistenceIndexField(TaskActivityDocumentFields.occurredAt),
PersistenceIndexField(DocumentFields.id),
],
supportsQueries: {
RepositoryQueryNames.taskActivityByOwner,
RepositoryQueryNames.taskActivityByTask,
},
),
PersistenceIndexSpec(
name: 'task_activities_owner_project_code_time',
collection: PersistenceCollections.taskActivities,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(TaskActivityDocumentFields.projectId),
PersistenceIndexField(TaskActivityDocumentFields.code),
PersistenceIndexField(TaskActivityDocumentFields.occurredAt),
],
supportsQueries: {
RepositoryQueryNames.taskActivityByProject,
},
),
PersistenceIndexSpec(
name: 'owner_settings_owner_unique',
collection: PersistenceCollections.ownerSettings,
unique: true,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(DocumentFields.id),
],
supportsQueries: {
RepositoryQueryNames.ownerSettingsByOwner,
},
),
PersistenceIndexSpec(
name: 'notice_ack_owner_notice_unique',
collection: PersistenceCollections.noticeAcknowledgements,
unique: true,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(NoticeAcknowledgementDocumentFields.noticeId),
],
supportsQueries: {
RepositoryQueryNames.noticeAcknowledgementById,
},
),
PersistenceIndexSpec(
name: 'notice_ack_owner_acknowledged',
collection: PersistenceCollections.noticeAcknowledgements,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(
NoticeAcknowledgementDocumentFields.acknowledgedAt,
),
PersistenceIndexField(NoticeAcknowledgementDocumentFields.noticeId),
],
supportsQueries: {
RepositoryQueryNames.noticeAcknowledgementByOwner,
},
),
PersistenceIndexSpec(
name: 'operations_owner_operation_unique',
collection: PersistenceCollections.applicationOperations,
unique: true,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(
ApplicationOperationDocumentFields.operationId,
),
],
supportsQueries: {
RepositoryQueryNames.operationByIdempotencyKey,
},
),
PersistenceIndexSpec(
name: 'snapshots_owner_id_unique',
collection: PersistenceCollections.schedulingSnapshots,
unique: true,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(DocumentFields.id),
],
supportsQueries: {
RepositoryQueryNames.schedulingSnapshotById,
},
),
PersistenceIndexSpec(
name: 'snapshots_owner_dates_retention',
collection: PersistenceCollections.schedulingSnapshots,
keys: [
PersistenceIndexField(DocumentFields.ownerId),
PersistenceIndexField(SchedulingSnapshotDocumentFields.sourceDate),
PersistenceIndexField(SchedulingSnapshotDocumentFields.targetDate),
PersistenceIndexField(
SchedulingSnapshotDocumentFields.retentionExpiresAt,
),
],
supportsQueries: {
RepositoryQueryNames.schedulingSnapshotWindow,
},
),
];
static Set<String> get supportedQueries {
return {
for (final spec in all) ...spec.supportsQueries,
};
}
}
/// Bounded payload and retention policy for non-authoritative documents.
abstract final class PersistencePayloadLimits {
static const maxTaskActivityMetadataEntries = 50;
static const maxSchedulingSnapshotTasks = 250;
static const maxSchedulingSnapshotNotices = 100;
static const maxSchedulingSnapshotChanges = 250;
static const maxAppliedActivityIds = 1000;
static const taskActivityRetention = Duration(days: 365);
static const applicationOperationRetention = Duration(days: 90);
static const schedulingSnapshotRetention = Duration(days: 30);
}
/// Guard result for bounded document payload checks.
class PersistenceGuardResult {
const PersistenceGuardResult._({
required this.isValid,
this.fieldName,
this.limit,
this.actual,
});
factory PersistenceGuardResult.valid() {
return const PersistenceGuardResult._(isValid: true);
}
factory PersistenceGuardResult.tooLarge({
required String fieldName,
required int limit,
required int actual,
}) {
return PersistenceGuardResult._(
isValid: false,
fieldName: fieldName,
limit: limit,
actual: actual,
);
}
final bool isValid;
final String? fieldName;
final int? limit;
final int? actual;
}
/// Pure guard helpers for bounded embedded document payloads.
abstract final class PersistencePayloadGuard {
static PersistenceGuardResult mapEntries({
required String fieldName,
required Map<Object?, Object?> value,
required int limit,
}) {
if (value.length > limit) {
return PersistenceGuardResult.tooLarge(
fieldName: fieldName,
limit: limit,
actual: value.length,
);
}
return PersistenceGuardResult.valid();
}
static PersistenceGuardResult listLength({
required String fieldName,
required Iterable<Object?> value,
required int limit,
}) {
final actual = value.length;
if (actual > limit) {
return PersistenceGuardResult.tooLarge(
fieldName: fieldName,
limit: limit,
actual: actual,
);
}
return PersistenceGuardResult.valid();
}
}
/// DateTime storage convention for future document persistence. /// DateTime storage convention for future document persistence.
abstract final class PersistenceDateTimeConvention { abstract final class PersistenceDateTimeConvention {
/// Human-readable convention kept close to the helper for tests and docs. /// Human-readable convention kept close to the helper for tests and docs.

View file

@ -13,6 +13,8 @@ import 'time_contracts.dart';
enum RepositoryFailureCode { enum RepositoryFailureCode {
notFound, notFound,
staleRevision, staleRevision,
invalidRevision,
ownerMismatch,
duplicateId, duplicateId,
duplicateOperation, duplicateOperation,
} }
@ -584,11 +586,30 @@ class InMemoryTaskRepository implements TaskRepository {
required String ownerId, required String ownerId,
required int expectedRevision, required int expectedRevision,
}) async { }) async {
final current = _tasksById[task.id]; if (expectedRevision <= 0) {
if (current == null || _ownerFor(task.id) != ownerId) {
return RepositoryMutationResult.failure( return RepositoryMutationResult.failure(
RepositoryFailure( RepositoryFailure(
code: RepositoryFailureCode.notFound, entityId: task.id), code: RepositoryFailureCode.invalidRevision,
entityId: task.id,
expectedRevision: expectedRevision,
),
);
}
final current = _tasksById[task.id];
if (current == null) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.notFound,
entityId: task.id,
),
);
}
if (_ownerFor(task.id) != ownerId) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.ownerMismatch,
entityId: task.id,
),
); );
} }
final actualRevision = _revisionFor(task.id); final actualRevision = _revisionFor(task.id);
@ -707,8 +728,17 @@ class InMemoryProjectRepository implements ProjectRepository {
required String ownerId, required String ownerId,
required int expectedRevision, required int expectedRevision,
}) async { }) async {
if (expectedRevision <= 0) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.invalidRevision,
entityId: project.id,
expectedRevision: expectedRevision,
),
);
}
final current = _projectsById[project.id]; final current = _projectsById[project.id];
if (current == null || _ownerFor(project.id) != ownerId) { if (current == null) {
return RepositoryMutationResult.failure( return RepositoryMutationResult.failure(
RepositoryFailure( RepositoryFailure(
code: RepositoryFailureCode.notFound, code: RepositoryFailureCode.notFound,
@ -716,6 +746,14 @@ class InMemoryProjectRepository implements ProjectRepository {
), ),
); );
} }
if (_ownerFor(project.id) != ownerId) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.ownerMismatch,
entityId: project.id,
),
);
}
final actualRevision = _revisionFor(project.id); final actualRevision = _revisionFor(project.id);
if (actualRevision != expectedRevision) { if (actualRevision != expectedRevision) {
return RepositoryMutationResult.failure( return RepositoryMutationResult.failure(
@ -917,8 +955,17 @@ class InMemoryLockedBlockRepository implements LockedBlockRepository {
required String ownerId, required String ownerId,
required int expectedRevision, required int expectedRevision,
}) async { }) async {
if (expectedRevision <= 0) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.invalidRevision,
entityId: block.id,
expectedRevision: expectedRevision,
),
);
}
final current = _blocksById[block.id]; final current = _blocksById[block.id];
if (current == null || _blockOwnerFor(block.id) != ownerId) { if (current == null) {
return RepositoryMutationResult.failure( return RepositoryMutationResult.failure(
RepositoryFailure( RepositoryFailure(
code: RepositoryFailureCode.notFound, code: RepositoryFailureCode.notFound,
@ -926,6 +973,14 @@ class InMemoryLockedBlockRepository implements LockedBlockRepository {
), ),
); );
} }
if (_blockOwnerFor(block.id) != ownerId) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.ownerMismatch,
entityId: block.id,
),
);
}
final actualRevision = _blockRevisionFor(block.id); final actualRevision = _blockRevisionFor(block.id);
if (actualRevision != expectedRevision) { if (actualRevision != expectedRevision) {
return RepositoryMutationResult.failure( return RepositoryMutationResult.failure(
@ -992,8 +1047,17 @@ class InMemoryLockedBlockRepository implements LockedBlockRepository {
required String ownerId, required String ownerId,
required int expectedRevision, required int expectedRevision,
}) async { }) async {
if (expectedRevision <= 0) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.invalidRevision,
entityId: override.id,
expectedRevision: expectedRevision,
),
);
}
final current = _overridesById[override.id]; final current = _overridesById[override.id];
if (current == null || _overrideOwnerFor(override.id) != ownerId) { if (current == null) {
return RepositoryMutationResult.failure( return RepositoryMutationResult.failure(
RepositoryFailure( RepositoryFailure(
code: RepositoryFailureCode.notFound, code: RepositoryFailureCode.notFound,
@ -1001,6 +1065,14 @@ class InMemoryLockedBlockRepository implements LockedBlockRepository {
), ),
); );
} }
if (_overrideOwnerFor(override.id) != ownerId) {
return RepositoryMutationResult.failure(
RepositoryFailure(
code: RepositoryFailureCode.ownerMismatch,
entityId: override.id,
),
);
}
final actualRevision = _overrideRevisionFor(override.id); final actualRevision = _overrideRevisionFor(override.id);
if (actualRevision != expectedRevision) { if (actualRevision != expectedRevision) {
return RepositoryMutationResult.failure( return RepositoryMutationResult.failure(

View file

@ -0,0 +1,168 @@
import 'package:adhd_scheduler_core/scheduler_core.dart';
import 'package:test/test.dart';
void main() {
group('Persistence index contract', () {
test('index names and key fields are stable and adapter-neutral', () {
final names = <String>{};
for (final spec in PersistenceIndexCatalog.all) {
expect(spec.name, isNotEmpty);
expect(spec.name.trim(), spec.name);
expect(spec.name.contains(' '), isFalse);
expect(names.add(spec.name), isTrue, reason: spec.name);
expect(spec.collection, isNotEmpty);
expect(spec.keys, isNotEmpty);
expect(spec.keys.first.fieldName, DocumentFields.ownerId);
for (final key in spec.keys) {
expect(key.fieldName, isNotEmpty);
expect(key.fieldName.contains('.'), isFalse);
}
}
});
test('every repository query has an index-backed access path', () {
const requiredQueries = {
RepositoryQueryNames.taskById,
RepositoryQueryNames.taskByOwner,
RepositoryQueryNames.taskByStatus,
RepositoryQueryNames.taskByProject,
RepositoryQueryNames.taskByParent,
RepositoryQueryNames.taskBacklogCandidates,
RepositoryQueryNames.taskScheduledWindow,
RepositoryQueryNames.taskLocalDay,
RepositoryQueryNames.projectByOwner,
RepositoryQueryNames.projectById,
RepositoryQueryNames.lockedBlockByOwner,
RepositoryQueryNames.lockedBlockById,
RepositoryQueryNames.lockedOverrideByDate,
RepositoryQueryNames.taskActivityByOwner,
RepositoryQueryNames.taskActivityByOperation,
RepositoryQueryNames.taskActivityByTask,
RepositoryQueryNames.taskActivityByProject,
RepositoryQueryNames.projectStatisticsByProject,
RepositoryQueryNames.ownerSettingsByOwner,
RepositoryQueryNames.noticeAcknowledgementByOwner,
RepositoryQueryNames.noticeAcknowledgementById,
RepositoryQueryNames.operationByIdempotencyKey,
RepositoryQueryNames.schedulingSnapshotById,
RepositoryQueryNames.schedulingSnapshotWindow,
};
expect(PersistenceIndexCatalog.supportedQueries,
containsAll(requiredQueries));
});
test('uniqueness and archive/partial index requirements are explicit', () {
expect(
uniqueIndexNames,
containsAll({
'tasks_owner_id_unique',
'projects_owner_id_unique',
'project_stats_owner_project_unique',
'locked_blocks_owner_id_unique',
'locked_overrides_owner_id_unique',
'task_activities_owner_id_unique',
'owner_settings_owner_unique',
'notice_ack_owner_notice_unique',
'operations_owner_operation_unique',
'snapshots_owner_id_unique',
}),
);
expect(
indexByName('projects_owner_archived_name').keys.map(
(key) => key.fieldName,
),
contains(ProjectDocumentFields.archivedAt));
expect(
indexByName('locked_blocks_owner_archived_name').keys.map(
(key) => key.fieldName,
),
contains(LockedBlockDocumentFields.archivedAt));
expect(
indexByName('tasks_owner_scheduled_window').partialFilterFields,
containsAll({
TaskDocumentFields.scheduledStart,
TaskDocumentFields.scheduledEnd,
}),
);
expect(
indexByName('tasks_owner_parent_status').partialFilterFields,
contains(TaskDocumentFields.parentTaskId),
);
});
test('collection retention does not target authoritative state', () {
expect(
PersistenceCollections.authoritative.intersection(
PersistenceCollections.internalRetained,
),
isEmpty,
);
expect(PersistencePayloadLimits.taskActivityRetention.inDays, 365);
expect(PersistencePayloadLimits.applicationOperationRetention.inDays, 90);
expect(PersistencePayloadLimits.schedulingSnapshotRetention.inDays, 30);
});
test('payload guards reject unbounded embedded growth', () {
final metadata = {
for (var index = 0;
index <= PersistencePayloadLimits.maxTaskActivityMetadataEntries;
index++)
'key-$index': index,
};
final taskIds = List<Object?>.generate(
PersistencePayloadLimits.maxSchedulingSnapshotTasks + 1,
(index) => 'task-$index',
);
final appliedActivityIds = List<Object?>.generate(
PersistencePayloadLimits.maxAppliedActivityIds + 1,
(index) => 'activity-$index',
);
final metadataResult = PersistencePayloadGuard.mapEntries(
fieldName: TaskActivityDocumentFields.metadata,
value: metadata,
limit: PersistencePayloadLimits.maxTaskActivityMetadataEntries,
);
final snapshotResult = PersistencePayloadGuard.listLength(
fieldName: SchedulingSnapshotDocumentFields.tasks,
value: taskIds,
limit: PersistencePayloadLimits.maxSchedulingSnapshotTasks,
);
final activityIdsResult = PersistencePayloadGuard.listLength(
fieldName: ProjectStatisticsDocumentFields.appliedActivityIds,
value: appliedActivityIds,
limit: PersistencePayloadLimits.maxAppliedActivityIds,
);
expect(metadataResult.isValid, isFalse);
expect(metadataResult.actual, metadata.length);
expect(snapshotResult.isValid, isFalse);
expect(snapshotResult.fieldName, SchedulingSnapshotDocumentFields.tasks);
expect(activityIdsResult.isValid, isFalse);
expect(activityIdsResult.limit,
PersistencePayloadLimits.maxAppliedActivityIds);
expect(
PersistencePayloadGuard.listLength(
fieldName: SchedulingSnapshotDocumentFields.notices,
value: const <Object?>[],
limit: PersistencePayloadLimits.maxSchedulingSnapshotNotices,
).isValid,
isTrue,
);
});
});
}
Set<String> get uniqueIndexNames {
return {
for (final spec in PersistenceIndexCatalog.all)
if (spec.unique) spec.name,
};
}
PersistenceIndexSpec indexByName(String name) {
return PersistenceIndexCatalog.all.singleWhere((spec) => spec.name == name);
}

View file

@ -243,8 +243,23 @@ void runCoreRepositoryConformance({
ownerId: ownerId, ownerId: ownerId,
expectedRevision: 99, expectedRevision: 99,
); );
final invalidRevision = await repository.saveIfRevision(
task: updated,
ownerId: ownerId,
expectedRevision: 0,
);
final wrongOwner = await repository.saveIfRevision(
task: updated,
ownerId: 'other-owner',
expectedRevision: 1,
);
expect(stale.failure!.code, RepositoryFailureCode.staleRevision); expect(stale.failure!.code, RepositoryFailureCode.staleRevision);
expect(stale.failure!.actualRevision, 1); expect(stale.failure!.actualRevision, 1);
expect(
invalidRevision.failure!.code,
RepositoryFailureCode.invalidRevision,
);
expect(wrongOwner.failure!.code, RepositoryFailureCode.ownerMismatch);
final saved = await repository.saveIfRevision( final saved = await repository.saveIfRevision(
task: updated, task: updated,