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 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
- 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:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
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,
|
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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue