focus-flow/packages/scheduler_persistence_memory/lib/persistence_memory.dart

852 lines
24 KiB
Dart

/// In-memory implementations of the scheduler persistence repository contracts.
///
/// These repositories are intended for core tests, local integration tests, and
/// adapter conformance checks. They model optimistic compare-and-set behavior
/// but do not provide durable storage.
library;
import 'dart:convert';
import 'package:scheduler_core/scheduler_core.dart' as core;
import 'package:scheduler_persistence/persistence.dart';
const _snapshotRetention = Duration(days: 30);
final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
/// In-memory task repository with owner scoping and revision checks.
final class InMemoryTaskRepository implements TaskRepository {
final Map<String, _Record<core.Task>> _records =
<String, _Record<core.Task>>{};
@override
Future<RepoResult<core.Task?>> findById({
required core.OwnerId ownerId,
required String taskId,
}) async {
final record = _records[taskId];
if (record == null || record.ownerId != ownerId) {
return const Right(null);
}
return Right(_cloneTask(record.value, record.ownerId, record.revision));
}
@override
Future<RepoResult<core.Page<core.Task>>> findByOwner({
required core.OwnerId ownerId,
core.PageRequest page = const core.PageRequest(),
}) async {
return Right(_pageRecords(
_records.values.where((record) => record.ownerId == ownerId),
page,
(record) => _cloneTask(record.value, record.ownerId, record.revision),
));
}
@override
Future<RepoResult<core.Page<core.Task>>> findByParentTaskId({
required core.OwnerId ownerId,
required String parentTaskId,
core.PageRequest page = const core.PageRequest(),
}) async {
return Right(_pageRecords(
_records.values.where(
(record) =>
record.ownerId == ownerId &&
record.value.parentTaskId == parentTaskId,
),
page,
(record) => _cloneTask(record.value, record.ownerId, record.revision),
));
}
@override
Future<RepoResult<core.Page<core.Task>>> findByProjectId({
required core.OwnerId ownerId,
required String projectId,
core.PageRequest page = const core.PageRequest(),
}) async {
return Right(_pageRecords(
_records.values.where(
(record) =>
record.ownerId == ownerId && record.value.projectId == projectId,
),
page,
(record) => _cloneTask(record.value, record.ownerId, record.revision),
));
}
@override
Future<RepoResult<core.Page<core.Task>>> findByStatus({
required core.OwnerId ownerId,
required core.TaskStatus status,
core.PageRequest page = const core.PageRequest(),
}) async {
return Right(_pageRecords(
_records.values.where(
(record) => record.ownerId == ownerId && record.value.status == status,
),
page,
(record) => _cloneTask(record.value, record.ownerId, record.revision),
));
}
@override
Future<RepoResult<core.Revision>> insert({
required core.OwnerId ownerId,
required core.Task task,
}) async {
if (_records.containsKey(task.id)) {
return Left(RepositoryDuplicateId(entityId: task.id));
}
_records[task.id] = _Record(
id: task.id,
ownerId: ownerId,
value: _cloneTask(task, ownerId, core.Revision.initial),
revision: core.Revision.initial,
);
return const Right(core.Revision.initial);
}
@override
Future<RepoResult<core.Revision>> save({
required core.OwnerId ownerId,
required core.Task task,
required core.Revision expectedRevision,
}) async {
final checked = _checkWrite(
records: _records,
id: task.id,
ownerId: ownerId,
expectedRevision: expectedRevision,
);
if (checked is Left<RepositoryFailure, _Record<core.Task>>) {
return Left(checked.value);
}
final record = checked.right;
final nextRevision = record.revision.next();
_records[task.id] = record.copyWith(
value: _cloneTask(task, ownerId, nextRevision),
revision: nextRevision,
updatedAt: task.updatedAt,
);
return Right(nextRevision);
}
@override
Future<RepoResult<core.Revision>> delete({
required core.OwnerId ownerId,
required String taskId,
required core.Revision expectedRevision,
}) async {
final checked = _checkWrite(
records: _records,
id: taskId,
ownerId: ownerId,
expectedRevision: expectedRevision,
);
if (checked is Left<RepositoryFailure, _Record<core.Task>>) {
return Left(checked.value);
}
final nextRevision = checked.right.revision.next();
_records.remove(taskId);
return Right(nextRevision);
}
}
/// In-memory project repository with owner scoping and revision checks.
final class InMemoryProjectRepository implements ProjectRepository {
final Map<String, _Record<core.ProjectProfile>> _records =
<String, _Record<core.ProjectProfile>>{};
@override
Future<RepoResult<core.ProjectProfile?>> findById({
required core.OwnerId ownerId,
required String projectId,
}) async {
final record = _records[projectId];
if (record == null || record.ownerId != ownerId) {
return const Right(null);
}
return Right(_cloneProject(record));
}
@override
Future<RepoResult<core.Page<core.ProjectProfile>>> findByOwner({
required core.OwnerId ownerId,
bool includeArchived = false,
core.PageRequest page = const core.PageRequest(),
}) async {
return Right(_pageRecords(
_records.values.where(
(record) =>
record.ownerId == ownerId &&
(includeArchived || !record.value.isArchived),
),
page,
_cloneProject,
));
}
@override
Future<RepoResult<core.Revision>> insert({
required core.OwnerId ownerId,
required core.ProjectProfile project,
}) async {
if (_records.containsKey(project.id)) {
return Left(RepositoryDuplicateId(entityId: project.id));
}
_records[project.id] = _Record(
id: project.id,
ownerId: ownerId,
value: _cloneProjectValue(
project,
ownerId: ownerId,
revision: core.Revision.initial,
createdAt: _epoch,
updatedAt: _epoch,
),
revision: core.Revision.initial,
createdAt: _epoch,
updatedAt: _epoch,
);
return const Right(core.Revision.initial);
}
@override
Future<RepoResult<core.Revision>> save({
required core.OwnerId ownerId,
required core.ProjectProfile project,
required core.Revision expectedRevision,
}) async {
final checked = _checkWrite(
records: _records,
id: project.id,
ownerId: ownerId,
expectedRevision: expectedRevision,
);
if (checked is Left<RepositoryFailure, _Record<core.ProjectProfile>>) {
return Left(checked.value);
}
final record = checked.right;
final nextRevision = record.revision.next();
_records[project.id] = record.copyWith(
value: _cloneProjectValue(
project,
ownerId: ownerId,
revision: nextRevision,
createdAt: record.createdAt,
updatedAt: _epoch,
),
revision: nextRevision,
updatedAt: _epoch,
);
return Right(nextRevision);
}
@override
Future<RepoResult<core.Revision>> archive({
required core.OwnerId ownerId,
required String projectId,
required core.Revision expectedRevision,
required DateTime archivedAtUtc,
}) async {
final checked = _checkWrite(
records: _records,
id: projectId,
ownerId: ownerId,
expectedRevision: expectedRevision,
);
if (checked is Left<RepositoryFailure, _Record<core.ProjectProfile>>) {
return Left(checked.value);
}
final record = checked.right;
final nextRevision = record.revision.next();
final archived = record.value.copyWith(archivedAt: archivedAtUtc);
_records[projectId] = record.copyWith(
value: _cloneProjectValue(
archived,
ownerId: ownerId,
revision: nextRevision,
createdAt: record.createdAt,
updatedAt: _epoch,
),
revision: nextRevision,
updatedAt: _epoch,
);
return Right(nextRevision);
}
}
/// In-memory locked-block repository with owner scoping and revision checks.
final class InMemoryLockedBlockRepository implements LockedBlockRepository {
final Map<String, _Record<core.LockedBlock>> _blocks =
<String, _Record<core.LockedBlock>>{};
final Map<String, _Record<core.LockedBlockOverride>> _overrides =
<String, _Record<core.LockedBlockOverride>>{};
@override
Future<RepoResult<core.LockedBlock?>> findBlockById({
required core.OwnerId ownerId,
required String blockId,
}) async {
final record = _blocks[blockId];
if (record == null || record.ownerId != ownerId) {
return const Right(null);
}
return Right(
_cloneLockedBlock(record.value, record.ownerId, record.revision));
}
@override
Future<RepoResult<core.Page<core.LockedBlock>>> findBlocksByOwner({
required core.OwnerId ownerId,
bool includeArchived = false,
core.PageRequest page = const core.PageRequest(),
}) async {
return Right(_pageRecords(
_blocks.values.where(
(record) =>
record.ownerId == ownerId &&
(includeArchived || !record.value.isArchived),
),
page,
(record) => _cloneLockedBlock(
record.value,
record.ownerId,
record.revision,
),
));
}
@override
Future<RepoResult<core.LockedBlockOverride?>> findOverrideById({
required core.OwnerId ownerId,
required String overrideId,
}) async {
final record = _overrides[overrideId];
if (record == null || record.ownerId != ownerId) {
return const Right(null);
}
return Right(
_cloneLockedBlockOverride(record.value, record.ownerId, record.revision),
);
}
@override
Future<RepoResult<core.Page<core.LockedBlockOverride>>> findOverridesForDate({
required core.OwnerId ownerId,
required core.CivilDate date,
core.PageRequest page = const core.PageRequest(),
}) async {
return Right(_pageRecords(
_overrides.values.where(
(record) => record.ownerId == ownerId && record.value.date == date,
),
page,
(record) => _cloneLockedBlockOverride(
record.value,
record.ownerId,
record.revision,
),
));
}
@override
Future<RepoResult<core.Revision>> insertBlock({
required core.OwnerId ownerId,
required core.LockedBlock block,
}) async {
if (_blocks.containsKey(block.id)) {
return Left(RepositoryDuplicateId(entityId: block.id));
}
_blocks[block.id] = _Record(
id: block.id,
ownerId: ownerId,
value: _cloneLockedBlock(block, ownerId, core.Revision.initial),
revision: core.Revision.initial,
);
return const Right(core.Revision.initial);
}
@override
Future<RepoResult<core.Revision>> saveBlock({
required core.OwnerId ownerId,
required core.LockedBlock block,
required core.Revision expectedRevision,
}) async {
final checked = _checkWrite(
records: _blocks,
id: block.id,
ownerId: ownerId,
expectedRevision: expectedRevision,
);
if (checked is Left<RepositoryFailure, _Record<core.LockedBlock>>) {
return Left(checked.value);
}
final record = checked.right;
final nextRevision = record.revision.next();
_blocks[block.id] = record.copyWith(
value: _cloneLockedBlock(block, ownerId, nextRevision),
revision: nextRevision,
updatedAt: block.updatedAt,
);
return Right(nextRevision);
}
@override
Future<RepoResult<core.Revision>> archiveBlock({
required core.OwnerId ownerId,
required String blockId,
required core.Revision expectedRevision,
required DateTime archivedAtUtc,
}) async {
final checked = _checkWrite(
records: _blocks,
id: blockId,
ownerId: ownerId,
expectedRevision: expectedRevision,
);
if (checked is Left<RepositoryFailure, _Record<core.LockedBlock>>) {
return Left(checked.value);
}
final record = checked.right;
final nextRevision = record.revision.next();
final archived = record.value.copyWith(archivedAt: archivedAtUtc);
_blocks[blockId] = record.copyWith(
value: _cloneLockedBlock(archived, ownerId, nextRevision),
revision: nextRevision,
updatedAt: archived.updatedAt,
);
return Right(nextRevision);
}
@override
Future<RepoResult<core.Revision>> insertOverride({
required core.OwnerId ownerId,
required core.LockedBlockOverride override,
}) async {
if (_overrides.containsKey(override.id)) {
return Left(RepositoryDuplicateId(entityId: override.id));
}
_overrides[override.id] = _Record(
id: override.id,
ownerId: ownerId,
value: _cloneLockedBlockOverride(
override,
ownerId,
core.Revision.initial,
),
revision: core.Revision.initial,
);
return const Right(core.Revision.initial);
}
@override
Future<RepoResult<core.Revision>> saveOverride({
required core.OwnerId ownerId,
required core.LockedBlockOverride override,
required core.Revision expectedRevision,
}) async {
final checked = _checkWrite(
records: _overrides,
id: override.id,
ownerId: ownerId,
expectedRevision: expectedRevision,
);
if (checked is Left<RepositoryFailure, _Record<core.LockedBlockOverride>>) {
return Left(checked.value);
}
final record = checked.right;
final nextRevision = record.revision.next();
_overrides[override.id] = record.copyWith(
value: _cloneLockedBlockOverride(override, ownerId, nextRevision),
revision: nextRevision,
updatedAt: override.updatedAt,
);
return Right(nextRevision);
}
}
/// In-memory owner settings repository with owner scoping and revision checks.
final class InMemorySettingsRepository implements SettingsRepository {
final Map<String, _Record<core.OwnerSettings>> _records =
<String, _Record<core.OwnerSettings>>{};
@override
Future<RepoResult<core.OwnerSettings?>> findByOwner({
required core.OwnerId ownerId,
}) async {
final record = _records[ownerId.value];
if (record == null || record.ownerId != ownerId) {
return const Right(null);
}
return Right(_cloneSettings(record));
}
@override
Future<RepoResult<core.Revision>> insert({
required core.OwnerId ownerId,
required core.OwnerSettings settings,
}) async {
if (_records.containsKey(ownerId.value)) {
return Left(RepositoryDuplicateId(entityId: ownerId.value));
}
final ownedSettings = settings.ownerId == ownerId.value
? settings
: settings.copyWith(ownerId: ownerId.value);
_records[ownerId.value] = _Record(
id: ownerId.value,
ownerId: ownerId,
value: _cloneSettingsValue(
ownedSettings,
revision: core.Revision.initial,
createdAt: _epoch,
updatedAt: _epoch,
),
revision: core.Revision.initial,
createdAt: _epoch,
updatedAt: _epoch,
);
return const Right(core.Revision.initial);
}
@override
Future<RepoResult<core.Revision>> save({
required core.OwnerId ownerId,
required core.OwnerSettings settings,
required core.Revision expectedRevision,
}) async {
final checked = _checkWrite(
records: _records,
id: ownerId.value,
ownerId: ownerId,
expectedRevision: expectedRevision,
);
if (checked is Left<RepositoryFailure, _Record<core.OwnerSettings>>) {
return Left(checked.value);
}
final record = checked.right;
final nextRevision = record.revision.next();
final ownedSettings = settings.ownerId == ownerId.value
? settings
: settings.copyWith(ownerId: ownerId.value);
_records[ownerId.value] = record.copyWith(
value: _cloneSettingsValue(
ownedSettings,
revision: nextRevision,
createdAt: record.createdAt,
updatedAt: _epoch,
),
revision: nextRevision,
updatedAt: _epoch,
);
return Right(nextRevision);
}
}
/// In-memory diagnostic schedule snapshot repository.
final class InMemoryScheduleSnapshotRepository
implements ScheduleSnapshotRepository {
final Map<String, _SnapshotRecord> _records = <String, _SnapshotRecord>{};
@override
Future<RepoResult<core.SchedulingStateSnapshot?>> findById({
required core.OwnerId ownerId,
required String snapshotId,
}) async {
final record = _records[snapshotId];
if (record == null || record.ownerId != ownerId) {
return const Right(null);
}
return Right(_cloneSnapshot(record.value, record.ownerId, record.revision));
}
@override
Future<RepoResult<core.Page<core.SchedulingStateSnapshot>>> findInWindow({
required core.OwnerId ownerId,
required core.SchedulingWindow window,
core.PageRequest page = const core.PageRequest(),
}) async {
return Right(_pageRecords(
_records.values.where(
(record) =>
record.ownerId == ownerId &&
_windowsOverlap(record.value.window, window),
),
page,
(record) => _cloneSnapshot(record.value, record.ownerId, record.revision),
));
}
@override
Future<RepoResult<core.Revision>> insert({
required core.OwnerId ownerId,
required core.SchedulingStateSnapshot snapshot,
}) async {
if (_records.containsKey(snapshot.id)) {
return Left(RepositoryDuplicateId(entityId: snapshot.id));
}
_records[snapshot.id] = _SnapshotRecord(
id: snapshot.id,
ownerId: ownerId,
value: _cloneSnapshot(snapshot, ownerId, core.Revision.initial),
revision: core.Revision.initial,
retentionExpiresAt: snapshot.capturedAt.add(_snapshotRetention),
);
return const Right(core.Revision.initial);
}
@override
Future<RepoResult<int>> deleteExpired({
required core.OwnerId ownerId,
required DateTime nowUtc,
}) async {
final expiredIds = _records.values
.where(
(record) =>
record.ownerId == ownerId &&
record.retentionExpiresAt.isBefore(nowUtc),
)
.map((record) => record.id)
.toList(growable: false);
for (final id in expiredIds) {
_records.remove(id);
}
return Right(expiredIds.length);
}
}
Either<RepositoryFailure, _Record<T>> _checkWrite<T>({
required Map<String, _Record<T>> records,
required String id,
required core.OwnerId ownerId,
required core.Revision expectedRevision,
}) {
if (expectedRevision.value <= 0) {
return Left(
RepositoryInvalidRevision(
entityId: id,
expectedRevision: expectedRevision,
),
);
}
final record = records[id];
if (record == null) {
return Left(RepositoryNotFound(entityId: id));
}
if (record.ownerId != ownerId) {
return Left(RepositoryOwnerMismatch(entityId: id));
}
if (record.revision != expectedRevision) {
return Left(
RepositoryStaleRevision(
entityId: id,
expectedRevision: expectedRevision,
actualRevision: record.revision,
),
);
}
return Right(record);
}
core.Page<R> _pageRecords<T extends _IdentifiedRecord, R>(
Iterable<T> records,
core.PageRequest page,
R Function(T record) clone,
) {
final ordered = records.toList(growable: false)
..sort((left, right) => left.id.compareTo(right.id));
final start = _cursorOffset(page.cursor);
final end = (start + page.limit).clamp(0, ordered.length);
final items = ordered.sublist(start, end).map(clone).toList(growable: false);
final nextCursor = end < ordered.length ? end.toString() : null;
return core.Page<R>(items: items, nextCursor: nextCursor);
}
int _cursorOffset(String? cursor) {
if (cursor == null) {
return 0;
}
final parsed = int.tryParse(cursor);
if (parsed == null || parsed < 0) {
return 0;
}
return parsed;
}
bool _windowsOverlap(core.SchedulingWindow left, core.SchedulingWindow right) {
return left.start.isBefore(right.end) && right.start.isBefore(left.end);
}
core.Task _cloneTask(
core.Task task,
core.OwnerId ownerId,
core.Revision revision,
) {
return core.TaskDocumentMapping.fromDocument(_roundTrip(
core.TaskDocumentMapping.toDocument(
task,
ownerId: ownerId.value,
revision: revision.value,
),
));
}
core.ProjectProfile _cloneProject(_Record<core.ProjectProfile> record) {
return _cloneProjectValue(
record.value,
ownerId: record.ownerId,
revision: record.revision,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
);
}
core.ProjectProfile _cloneProjectValue(
core.ProjectProfile project, {
required core.OwnerId ownerId,
required core.Revision revision,
required DateTime createdAt,
required DateTime updatedAt,
}) {
return core.ProjectDocumentMapping.fromDocument(_roundTrip(
core.ProjectDocumentMapping.toDocument(
project,
ownerId: ownerId.value,
revision: revision.value,
createdAt: createdAt,
updatedAt: updatedAt,
),
));
}
core.LockedBlock _cloneLockedBlock(
core.LockedBlock block,
core.OwnerId ownerId,
core.Revision revision,
) {
return core.LockedBlockDocumentMapping.fromDocument(_roundTrip(
core.LockedBlockDocumentMapping.toDocument(
block,
ownerId: ownerId.value,
revision: revision.value,
),
));
}
core.LockedBlockOverride _cloneLockedBlockOverride(
core.LockedBlockOverride override,
core.OwnerId ownerId,
core.Revision revision,
) {
return core.LockedBlockOverrideDocumentMapping.fromDocument(_roundTrip(
core.LockedBlockOverrideDocumentMapping.toDocument(
override,
ownerId: ownerId.value,
revision: revision.value,
),
));
}
core.OwnerSettings _cloneSettings(_Record<core.OwnerSettings> record) {
return _cloneSettingsValue(
record.value,
revision: record.revision,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
);
}
core.OwnerSettings _cloneSettingsValue(
core.OwnerSettings settings, {
required core.Revision revision,
required DateTime createdAt,
required DateTime updatedAt,
}) {
return core.OwnerSettingsDocumentMapping.fromDocument(_roundTrip(
core.OwnerSettingsDocumentMapping.toDocument(
settings,
revision: revision.value,
createdAt: createdAt,
updatedAt: updatedAt,
),
));
}
core.SchedulingStateSnapshot _cloneSnapshot(
core.SchedulingStateSnapshot snapshot,
core.OwnerId ownerId,
core.Revision revision,
) {
return core.SchedulingSnapshotDocumentMapping.fromDocument(_roundTrip(
core.SchedulingSnapshotDocumentMapping.toDocument(
snapshot,
ownerId: ownerId.value,
revision: revision.value,
retentionExpiresAt: snapshot.capturedAt.add(_snapshotRetention),
),
));
}
Map<String, Object?> _roundTrip(Map<String, Object?> document) {
return Map<String, Object?>.from(
jsonDecode(jsonEncode(document)) as Map<dynamic, dynamic>,
);
}
abstract interface class _IdentifiedRecord {
String get id;
}
final class _Record<T> implements _IdentifiedRecord {
_Record({
required this.id,
required this.ownerId,
required this.value,
required this.revision,
DateTime? createdAt,
DateTime? updatedAt,
}) : createdAt = createdAt ?? _epoch,
updatedAt = updatedAt ?? _epoch;
@override
final String id;
final core.OwnerId ownerId;
final T value;
final core.Revision revision;
final DateTime createdAt;
final DateTime updatedAt;
_Record<T> copyWith({
T? value,
core.Revision? revision,
DateTime? updatedAt,
}) {
return _Record<T>(
id: id,
ownerId: ownerId,
value: value ?? this.value,
revision: revision ?? this.revision,
createdAt: createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}
final class _SnapshotRecord implements _IdentifiedRecord {
_SnapshotRecord({
required this.id,
required this.ownerId,
required this.value,
required this.revision,
required this.retentionExpiresAt,
});
@override
final String id;
final core.OwnerId ownerId;
final core.SchedulingStateSnapshot value;
final core.Revision revision;
final DateTime retentionExpiresAt;
}