feat(data): add persistence index contracts
This commit is contained in:
parent
4269f4d9ce
commit
394d6e6e96
7 changed files with 834 additions and 8 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = <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.
|
||||
abstract final class PersistenceDateTimeConvention {
|
||||
/// Human-readable convention kept close to the helper for tests and docs.
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
168
test/persistence_index_contract_test.dart
Normal file
168
test/persistence_index_contract_test.dart
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue