From 394d6e6e9636a86e8d3e295fd3b7cbca0dadafcb Mon Sep 17 00:00:00 2001 From: Ashley Venn Date: Thu, 25 Jun 2026 12:17:54 -0700 Subject: [PATCH] feat(data): add persistence index contracts --- ..._Persistence_Schema_Codecs_Repositories.md | 35 +- ...1_BLOCK_16_REPOSITORY_ADAPTER_CHECKLIST.md | 47 ++ .../V1_PUBLIC_API_BASELINE.md | 14 + lib/src/persistence_contract.dart | 479 ++++++++++++++++++ lib/src/repositories.dart | 84 ++- test/persistence_index_contract_test.dart | 168 ++++++ test/repositories_test.dart | 15 + 7 files changed, 834 insertions(+), 8 deletions(-) create mode 100644 Codex Documentation/Current Software Plan/V1_BLOCK_16_REPOSITORY_ADAPTER_CHECKLIST.md create mode 100644 test/persistence_index_contract_test.dart diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_15_Persistence_Schema_Codecs_Repositories.md b/Codex Documentation/Current Software Plan/V1_BLOCK_15_Persistence_Schema_Codecs_Repositories.md index f162c78..5da54cf 100644 --- a/Codex Documentation/Current Software Plan/V1_BLOCK_15_Persistence_Schema_Codecs_Repositories.md +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_15_Persistence_Schema_Codecs_Repositories.md @@ -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, 293 tests +- `dart test`: passed, 289 tests - `git diff --check`: passed 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 analyze`: passed, no issues found -- `dart test`: passed, 289 tests +- `dart test`: passed, 293 tests - `git diff --check`: passed ## Chunk 15.5 — Index and data-integrity contract tests Recommended Codex level: high +Status: Complete + Tasks: - Represent required MongoDB indexes as declarative adapter-neutral @@ -345,6 +347,35 @@ Acceptance criteria: - Block 16 can create indexes from the published contract. - 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 the MongoDB runtime boundary. diff --git a/Codex Documentation/Current Software Plan/V1_BLOCK_16_REPOSITORY_ADAPTER_CHECKLIST.md b/Codex Documentation/Current Software Plan/V1_BLOCK_16_REPOSITORY_ADAPTER_CHECKLIST.md new file mode 100644 index 0000000..ef9dbc7 --- /dev/null +++ b/Codex Documentation/Current Software Plan/V1_BLOCK_16_REPOSITORY_ADAPTER_CHECKLIST.md @@ -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. diff --git a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md index 59699be..0fc6b91 100644 --- a/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md +++ b/Codex Documentation/Current Software Plan/V1_PUBLIC_API_BASELINE.md @@ -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 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 The current repository surface is pure Dart and in-memory only: diff --git a/lib/src/persistence_contract.dart b/lib/src/persistence_contract.dart index 5e815d3..37cf970 100644 --- a/lib/src/persistence_contract.dart +++ b/lib/src/persistence_contract.dart @@ -9,6 +9,485 @@ import 'time_contracts.dart'; /// V1 document-schema version. 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 = { + tasks, + projects, + projectStatistics, + lockedBlocks, + lockedOverrides, + ownerSettings, + }; + + static const internalRetained = { + 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 {}, + this.supportsQueries = const {}, + }); + + final String name; + final String collection; + final List keys; + final bool unique; + final Set partialFilterFields; + final Set 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( + 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 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 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 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. abstract final class PersistenceDateTimeConvention { /// Human-readable convention kept close to the helper for tests and docs. diff --git a/lib/src/repositories.dart b/lib/src/repositories.dart index 70e601d..4cb12dc 100644 --- a/lib/src/repositories.dart +++ b/lib/src/repositories.dart @@ -13,6 +13,8 @@ import 'time_contracts.dart'; enum RepositoryFailureCode { notFound, staleRevision, + invalidRevision, + ownerMismatch, duplicateId, duplicateOperation, } @@ -584,11 +586,30 @@ class InMemoryTaskRepository implements TaskRepository { required String ownerId, required int expectedRevision, }) async { - final current = _tasksById[task.id]; - if (current == null || _ownerFor(task.id) != ownerId) { + if (expectedRevision <= 0) { return RepositoryMutationResult.failure( 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); @@ -707,8 +728,17 @@ class InMemoryProjectRepository implements ProjectRepository { required String ownerId, required int expectedRevision, }) async { + if (expectedRevision <= 0) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.invalidRevision, + entityId: project.id, + expectedRevision: expectedRevision, + ), + ); + } final current = _projectsById[project.id]; - if (current == null || _ownerFor(project.id) != ownerId) { + if (current == null) { return RepositoryMutationResult.failure( RepositoryFailure( 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); if (actualRevision != expectedRevision) { return RepositoryMutationResult.failure( @@ -917,8 +955,17 @@ class InMemoryLockedBlockRepository implements LockedBlockRepository { required String ownerId, required int expectedRevision, }) async { + if (expectedRevision <= 0) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.invalidRevision, + entityId: block.id, + expectedRevision: expectedRevision, + ), + ); + } final current = _blocksById[block.id]; - if (current == null || _blockOwnerFor(block.id) != ownerId) { + if (current == null) { return RepositoryMutationResult.failure( RepositoryFailure( 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); if (actualRevision != expectedRevision) { return RepositoryMutationResult.failure( @@ -992,8 +1047,17 @@ class InMemoryLockedBlockRepository implements LockedBlockRepository { required String ownerId, required int expectedRevision, }) async { + if (expectedRevision <= 0) { + return RepositoryMutationResult.failure( + RepositoryFailure( + code: RepositoryFailureCode.invalidRevision, + entityId: override.id, + expectedRevision: expectedRevision, + ), + ); + } final current = _overridesById[override.id]; - if (current == null || _overrideOwnerFor(override.id) != ownerId) { + if (current == null) { return RepositoryMutationResult.failure( RepositoryFailure( 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); if (actualRevision != expectedRevision) { return RepositoryMutationResult.failure( diff --git a/test/persistence_index_contract_test.dart b/test/persistence_index_contract_test.dart new file mode 100644 index 0000000..c08597d --- /dev/null +++ b/test/persistence_index_contract_test.dart @@ -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 = {}; + + 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.generate( + PersistencePayloadLimits.maxSchedulingSnapshotTasks + 1, + (index) => 'task-$index', + ); + final appliedActivityIds = List.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 [], + limit: PersistencePayloadLimits.maxSchedulingSnapshotNotices, + ).isValid, + isTrue, + ); + }); + }); +} + +Set 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); +} diff --git a/test/repositories_test.dart b/test/repositories_test.dart index cf43b53..0100d8e 100644 --- a/test/repositories_test.dart +++ b/test/repositories_test.dart @@ -243,8 +243,23 @@ void runCoreRepositoryConformance({ ownerId: ownerId, 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!.actualRevision, 1); + expect( + invalidRevision.failure!.code, + RepositoryFailureCode.invalidRevision, + ); + expect(wrongOwner.failure!.code, RepositoryFailureCode.ownerMismatch); final saved = await repository.saveIfRevision( task: updated,