focus-flow/packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories.dart

1374 lines
44 KiB
Dart

/// SQLite repository implementations backed by [SchedulerDb].
library;
import 'dart:convert';
import 'package:drift/drift.dart' as drift;
import 'package:scheduler_core/scheduler_core.dart' as core;
import 'package:scheduler_persistence/persistence.dart';
import 'scheduler_db.dart';
const _snapshotRetention = Duration(days: 30);
final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
/// Drift-backed task repository.
final class SqliteTaskRepository implements TaskRepository {
/// Creates a task repository using [db].
SqliteTaskRepository(this.db);
/// Shared SQLite database handle.
final SchedulerDb db;
@override
Future<RepoResult<core.Task?>> findById({
required core.OwnerId ownerId,
required String taskId,
}) async {
final row = await _taskById(taskId);
if (row == null || row.ownerId != ownerId.value) {
return const Right(null);
}
return Right(_taskFromRow(row));
}
@override
Future<RepoResult<core.Page<core.Task>>> findByOwner({
required core.OwnerId ownerId,
core.PageRequest page = const core.PageRequest(),
}) async {
return Right(await _pageTaskRows(
page: page,
where: (table) => table.ownerId.equals(ownerId.value),
));
}
@override
Future<RepoResult<core.Page<core.Task>>> findByParentTaskId({
required core.OwnerId ownerId,
required String parentTaskId,
core.PageRequest page = const core.PageRequest(),
}) async {
return Right(await _pageTaskRows(
page: page,
where: (table) =>
table.ownerId.equals(ownerId.value) &
table.parentId.equals(parentTaskId),
));
}
@override
Future<RepoResult<core.Page<core.Task>>> findByProjectId({
required core.OwnerId ownerId,
required String projectId,
core.PageRequest page = const core.PageRequest(),
}) async {
return Right(await _pageTaskRows(
page: page,
where: (table) =>
table.ownerId.equals(ownerId.value) &
table.projectId.equals(projectId),
));
}
@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(await _pageTaskRows(
page: page,
where: (table) =>
table.ownerId.equals(ownerId.value) &
table.status.equals(core.PersistenceEnumMapping.encodeTaskStatus(
status,
)),
));
}
@override
Future<RepoResult<core.Revision>> insert({
required core.OwnerId ownerId,
required core.Task task,
}) async {
if (await _taskById(task.id) != null) {
return Left(RepositoryDuplicateId(entityId: task.id));
}
await db.into(db.tasks).insert(_taskCompanion(
task,
ownerId: ownerId,
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 = await _checkTaskWrite(
task.id,
ownerId,
expectedRevision,
);
if (checked is Left<RepositoryFailure, TaskRow>) {
return Left(checked.value);
}
final nextRevision = checked.right.revision.next();
final updates = _taskCompanion(
task,
ownerId: ownerId,
revision: nextRevision,
);
final updated = await (db.update(db.tasks)
..where(
(table) =>
table.id.equals(task.id) &
table.ownerId.equals(ownerId.value) &
table.revision.equals(expectedRevision.value),
))
.write(updates);
if (updated != 1) {
return Left(await _staleTaskFailure(task.id, expectedRevision));
}
return Right(nextRevision);
}
@override
Future<RepoResult<core.Revision>> delete({
required core.OwnerId ownerId,
required String taskId,
required core.Revision expectedRevision,
}) async {
final checked = await _checkTaskWrite(taskId, ownerId, expectedRevision);
if (checked is Left<RepositoryFailure, TaskRow>) {
return Left(checked.value);
}
final nextRevision = checked.right.revision.next();
final deleted = await (db.delete(db.tasks)
..where(
(table) =>
table.id.equals(taskId) &
table.ownerId.equals(ownerId.value) &
table.revision.equals(expectedRevision.value),
))
.go();
if (deleted != 1) {
return Left(await _staleTaskFailure(taskId, expectedRevision));
}
return Right(nextRevision);
}
Future<core.Page<core.Task>> _pageTaskRows({
required core.PageRequest page,
required drift.Expression<bool> Function($TasksTable table) where,
}) async {
final offset = _cursorOffset(page.cursor);
final query = db.select(db.tasks)
..where(where)
..orderBy([(table) => drift.OrderingTerm.asc(table.id)])
..limit(page.limit + 1, offset: offset);
final rows = await query.get();
final pageRows = rows.take(page.limit).toList(growable: false);
return core.Page(
items: pageRows.map(_taskFromRow),
nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null,
);
}
Future<TaskRow?> _taskById(String id) {
return (db.select(db.tasks)..where((table) => table.id.equals(id)))
.getSingleOrNull();
}
Future<Either<RepositoryFailure, TaskRow>> _checkTaskWrite(
String id,
core.OwnerId ownerId,
core.Revision expectedRevision,
) async {
if (expectedRevision.value <= 0) {
return Left(RepositoryInvalidRevision(
entityId: id,
expectedRevision: expectedRevision,
));
}
final row = await _taskById(id);
if (row == null) {
return Left(RepositoryNotFound(entityId: id));
}
if (row.ownerId != ownerId.value) {
return Left(RepositoryOwnerMismatch(entityId: id));
}
if (row.revision != expectedRevision.value) {
return Left(RepositoryStaleRevision(
entityId: id,
expectedRevision: expectedRevision,
actualRevision: core.Revision(row.revision),
));
}
return Right(row);
}
Future<RepositoryStaleRevision> _staleTaskFailure(
String id,
core.Revision expectedRevision,
) async {
final row = await _taskById(id);
return RepositoryStaleRevision(
entityId: id,
expectedRevision: expectedRevision,
actualRevision:
row == null ? expectedRevision.next() : core.Revision(row.revision),
);
}
}
/// Drift-backed project repository.
final class SqliteProjectRepository implements ProjectRepository {
/// Creates a project repository using [db].
SqliteProjectRepository(this.db);
/// Shared SQLite database handle.
final SchedulerDb db;
@override
Future<RepoResult<core.ProjectProfile?>> findById({
required core.OwnerId ownerId,
required String projectId,
}) async {
final row = await _projectById(projectId);
if (row == null || row.ownerId != ownerId.value) {
return const Right(null);
}
return Right(_projectFromRow(row));
}
@override
Future<RepoResult<core.Page<core.ProjectProfile>>> findByOwner({
required core.OwnerId ownerId,
bool includeArchived = false,
core.PageRequest page = const core.PageRequest(),
}) async {
final offset = _cursorOffset(page.cursor);
final query = db.select(db.projects)
..where((table) {
final ownerClause = table.ownerId.equals(ownerId.value);
return includeArchived
? ownerClause
: ownerClause & table.archivedAtUtc.isNull();
})
..orderBy([(table) => drift.OrderingTerm.asc(table.id)])
..limit(page.limit + 1, offset: offset);
final rows = await query.get();
final pageRows = rows.take(page.limit).toList(growable: false);
return Right(core.Page(
items: pageRows.map(_projectFromRow),
nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null,
));
}
@override
Future<RepoResult<core.Revision>> insert({
required core.OwnerId ownerId,
required core.ProjectProfile project,
}) async {
if (await _projectById(project.id) != null) {
return Left(RepositoryDuplicateId(entityId: project.id));
}
await db.into(db.projects).insert(_projectCompanion(
project,
ownerId: ownerId,
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 = await _checkProjectWrite(
project.id,
ownerId,
expectedRevision,
);
if (checked is Left<RepositoryFailure, ProjectRow>) {
return Left(checked.value);
}
final row = checked.right;
final nextRevision = core.Revision(row.revision).next();
final updated = await (db.update(db.projects)
..where(
(table) =>
table.id.equals(project.id) &
table.ownerId.equals(ownerId.value) &
table.revision.equals(expectedRevision.value),
))
.write(_projectCompanion(
project,
ownerId: ownerId,
revision: nextRevision,
createdAt: row.createdAtUtc,
updatedAt: _epoch,
));
if (updated != 1) {
return Left(await _staleProjectFailure(project.id, expectedRevision));
}
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 = await _checkProjectWrite(
projectId,
ownerId,
expectedRevision,
);
if (checked is Left<RepositoryFailure, ProjectRow>) {
return Left(checked.value);
}
final row = checked.right;
final nextRevision = core.Revision(row.revision).next();
final archived = _projectFromRow(row).copyWith(archivedAt: archivedAtUtc);
final updated = await (db.update(db.projects)
..where(
(table) =>
table.id.equals(projectId) &
table.ownerId.equals(ownerId.value) &
table.revision.equals(expectedRevision.value),
))
.write(_projectCompanion(
archived,
ownerId: ownerId,
revision: nextRevision,
createdAt: row.createdAtUtc,
updatedAt: archivedAtUtc,
));
if (updated != 1) {
return Left(await _staleProjectFailure(projectId, expectedRevision));
}
return Right(nextRevision);
}
Future<ProjectRow?> _projectById(String id) {
return (db.select(db.projects)..where((table) => table.id.equals(id)))
.getSingleOrNull();
}
Future<Either<RepositoryFailure, ProjectRow>> _checkProjectWrite(
String id,
core.OwnerId ownerId,
core.Revision expectedRevision,
) async {
if (expectedRevision.value <= 0) {
return Left(RepositoryInvalidRevision(
entityId: id,
expectedRevision: expectedRevision,
));
}
final row = await _projectById(id);
if (row == null) {
return Left(RepositoryNotFound(entityId: id));
}
if (row.ownerId != ownerId.value) {
return Left(RepositoryOwnerMismatch(entityId: id));
}
if (row.revision != expectedRevision.value) {
return Left(RepositoryStaleRevision(
entityId: id,
expectedRevision: expectedRevision,
actualRevision: core.Revision(row.revision),
));
}
return Right(row);
}
Future<RepositoryStaleRevision> _staleProjectFailure(
String id,
core.Revision expectedRevision,
) async {
final row = await _projectById(id);
return RepositoryStaleRevision(
entityId: id,
expectedRevision: expectedRevision,
actualRevision:
row == null ? expectedRevision.next() : core.Revision(row.revision),
);
}
}
/// Drift-backed locked-block and override repository.
final class SqliteLockedBlockRepository implements LockedBlockRepository {
/// Creates a locked-time repository using [db].
SqliteLockedBlockRepository(this.db);
/// Shared SQLite database handle.
final SchedulerDb db;
@override
Future<RepoResult<core.LockedBlock?>> findBlockById({
required core.OwnerId ownerId,
required String blockId,
}) async {
final row = await _blockById(blockId);
if (row == null || row.ownerId != ownerId.value) {
return const Right(null);
}
return Right(_lockedBlockFromRow(row));
}
@override
Future<RepoResult<core.Page<core.LockedBlock>>> findBlocksByOwner({
required core.OwnerId ownerId,
bool includeArchived = false,
core.PageRequest page = const core.PageRequest(),
}) async {
final offset = _cursorOffset(page.cursor);
final query = db.select(db.lockedBlocks)
..where((table) {
final ownerClause = table.ownerId.equals(ownerId.value);
return includeArchived
? ownerClause
: ownerClause & table.archivedAtUtc.isNull();
})
..orderBy([(table) => drift.OrderingTerm.asc(table.id)])
..limit(page.limit + 1, offset: offset);
final rows = await query.get();
final pageRows = rows.take(page.limit).toList(growable: false);
return Right(core.Page(
items: pageRows.map(_lockedBlockFromRow),
nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null,
));
}
@override
Future<RepoResult<core.LockedBlockOverride?>> findOverrideById({
required core.OwnerId ownerId,
required String overrideId,
}) async {
final row = await _overrideById(overrideId);
if (row == null || row.ownerId != ownerId.value) {
return const Right(null);
}
return Right(_lockedOverrideFromRow(row));
}
@override
Future<RepoResult<core.Page<core.LockedBlockOverride>>> findOverridesForDate({
required core.OwnerId ownerId,
required core.CivilDate date,
core.PageRequest page = const core.PageRequest(),
}) async {
final offset = _cursorOffset(page.cursor);
final query = db.select(db.lockedOverrides)
..where(
(table) =>
table.ownerId.equals(ownerId.value) &
table.date.equals(date.toIsoString()),
)
..orderBy([(table) => drift.OrderingTerm.asc(table.id)])
..limit(page.limit + 1, offset: offset);
final rows = await query.get();
final pageRows = rows.take(page.limit).toList(growable: false);
return Right(core.Page(
items: pageRows.map(_lockedOverrideFromRow),
nextCursor: rows.length > page.limit ? '${offset + page.limit}' : null,
));
}
@override
Future<RepoResult<core.Revision>> insertBlock({
required core.OwnerId ownerId,
required core.LockedBlock block,
}) async {
if (await _blockById(block.id) != null) {
return Left(RepositoryDuplicateId(entityId: block.id));
}
await db.into(db.lockedBlocks).insert(_lockedBlockCompanion(
block,
ownerId: ownerId,
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 = await _checkBlockWrite(
block.id,
ownerId,
expectedRevision,
);
if (checked is Left<RepositoryFailure, LockedBlockRow>) {
return Left(checked.value);
}
final nextRevision = core.Revision(checked.right.revision).next();
final updated = await (db.update(db.lockedBlocks)
..where(
(table) =>
table.id.equals(block.id) &
table.ownerId.equals(ownerId.value) &
table.revision.equals(expectedRevision.value),
))
.write(_lockedBlockCompanion(
block,
ownerId: ownerId,
revision: nextRevision,
));
if (updated != 1) {
return Left(await _staleBlockFailure(block.id, expectedRevision));
}
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 = await _checkBlockWrite(blockId, ownerId, expectedRevision);
if (checked is Left<RepositoryFailure, LockedBlockRow>) {
return Left(checked.value);
}
final row = checked.right;
final nextRevision = core.Revision(row.revision).next();
final archived =
_lockedBlockFromRow(row).copyWith(archivedAt: archivedAtUtc);
final updated = await (db.update(db.lockedBlocks)
..where(
(table) =>
table.id.equals(blockId) &
table.ownerId.equals(ownerId.value) &
table.revision.equals(expectedRevision.value),
))
.write(_lockedBlockCompanion(
archived,
ownerId: ownerId,
revision: nextRevision,
));
if (updated != 1) {
return Left(await _staleBlockFailure(blockId, expectedRevision));
}
return Right(nextRevision);
}
@override
Future<RepoResult<core.Revision>> insertOverride({
required core.OwnerId ownerId,
required core.LockedBlockOverride override,
}) async {
if (await _overrideById(override.id) != null) {
return Left(RepositoryDuplicateId(entityId: override.id));
}
await db.into(db.lockedOverrides).insert(_lockedOverrideCompanion(
override,
ownerId: ownerId,
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 = await _checkOverrideWrite(
override.id,
ownerId,
expectedRevision,
);
if (checked is Left<RepositoryFailure, LockedOverrideRow>) {
return Left(checked.value);
}
final nextRevision = core.Revision(checked.right.revision).next();
final updated = await (db.update(db.lockedOverrides)
..where(
(table) =>
table.id.equals(override.id) &
table.ownerId.equals(ownerId.value) &
table.revision.equals(expectedRevision.value),
))
.write(_lockedOverrideCompanion(
override,
ownerId: ownerId,
revision: nextRevision,
));
if (updated != 1) {
return Left(await _staleOverrideFailure(
override.id,
expectedRevision,
));
}
return Right(nextRevision);
}
Future<LockedBlockRow?> _blockById(String id) {
return (db.select(db.lockedBlocks)..where((table) => table.id.equals(id)))
.getSingleOrNull();
}
Future<LockedOverrideRow?> _overrideById(String id) {
return (db.select(db.lockedOverrides)
..where((table) => table.id.equals(id)))
.getSingleOrNull();
}
Future<Either<RepositoryFailure, LockedBlockRow>> _checkBlockWrite(
String id,
core.OwnerId ownerId,
core.Revision expectedRevision,
) async {
if (expectedRevision.value <= 0) {
return Left(RepositoryInvalidRevision(
entityId: id,
expectedRevision: expectedRevision,
));
}
final row = await _blockById(id);
if (row == null) return Left(RepositoryNotFound(entityId: id));
if (row.ownerId != ownerId.value) {
return Left(RepositoryOwnerMismatch(entityId: id));
}
if (row.revision != expectedRevision.value) {
return Left(RepositoryStaleRevision(
entityId: id,
expectedRevision: expectedRevision,
actualRevision: core.Revision(row.revision),
));
}
return Right(row);
}
Future<Either<RepositoryFailure, LockedOverrideRow>> _checkOverrideWrite(
String id,
core.OwnerId ownerId,
core.Revision expectedRevision,
) async {
if (expectedRevision.value <= 0) {
return Left(RepositoryInvalidRevision(
entityId: id,
expectedRevision: expectedRevision,
));
}
final row = await _overrideById(id);
if (row == null) return Left(RepositoryNotFound(entityId: id));
if (row.ownerId != ownerId.value) {
return Left(RepositoryOwnerMismatch(entityId: id));
}
if (row.revision != expectedRevision.value) {
return Left(RepositoryStaleRevision(
entityId: id,
expectedRevision: expectedRevision,
actualRevision: core.Revision(row.revision),
));
}
return Right(row);
}
Future<RepositoryStaleRevision> _staleBlockFailure(
String id,
core.Revision expectedRevision,
) async {
final row = await _blockById(id);
return RepositoryStaleRevision(
entityId: id,
expectedRevision: expectedRevision,
actualRevision:
row == null ? expectedRevision.next() : core.Revision(row.revision),
);
}
Future<RepositoryStaleRevision> _staleOverrideFailure(
String id,
core.Revision expectedRevision,
) async {
final row = await _overrideById(id);
return RepositoryStaleRevision(
entityId: id,
expectedRevision: expectedRevision,
actualRevision:
row == null ? expectedRevision.next() : core.Revision(row.revision),
);
}
}
/// Drift-backed owner settings repository.
final class SqliteSettingsRepository implements SettingsRepository {
/// Creates a settings repository using [db].
SqliteSettingsRepository(this.db);
/// Shared SQLite database handle.
final SchedulerDb db;
@override
Future<RepoResult<core.OwnerSettings?>> findByOwner({
required core.OwnerId ownerId,
}) async {
final row = await _settingsByOwner(ownerId.value);
if (row == null) return const Right(null);
return Right(_settingsFromRow(row));
}
@override
Future<RepoResult<core.Revision>> insert({
required core.OwnerId ownerId,
required core.OwnerSettings settings,
}) async {
if (await _settingsByOwner(ownerId.value) != null) {
return Left(RepositoryDuplicateId(entityId: ownerId.value));
}
final ownedSettings = settings.ownerId == ownerId.value
? settings
: settings.copyWith(ownerId: ownerId.value);
await db.into(db.settingsTable).insert(_settingsCompanion(
ownedSettings,
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 = await _checkSettingsWrite(ownerId, expectedRevision);
if (checked is Left<RepositoryFailure, SettingsRow>) {
return Left(checked.value);
}
final row = checked.right;
final nextRevision = core.Revision(row.revision).next();
final ownedSettings = settings.ownerId == ownerId.value
? settings
: settings.copyWith(ownerId: ownerId.value);
final updated = await (db.update(db.settingsTable)
..where(
(table) =>
table.ownerId.equals(ownerId.value) &
table.revision.equals(expectedRevision.value),
))
.write(_settingsCompanion(
ownedSettings,
revision: nextRevision,
createdAt: row.createdAtUtc,
updatedAt: _epoch,
));
if (updated != 1) {
return Left(await _staleSettingsFailure(ownerId, expectedRevision));
}
return Right(nextRevision);
}
Future<SettingsRow?> _settingsByOwner(String ownerId) {
return (db.select(db.settingsTable)
..where((table) => table.ownerId.equals(ownerId)))
.getSingleOrNull();
}
Future<Either<RepositoryFailure, SettingsRow>> _checkSettingsWrite(
core.OwnerId ownerId,
core.Revision expectedRevision,
) async {
if (expectedRevision.value <= 0) {
return Left(RepositoryInvalidRevision(
entityId: ownerId.value,
expectedRevision: expectedRevision,
));
}
final row = await _settingsByOwner(ownerId.value);
if (row == null) return Left(RepositoryNotFound(entityId: ownerId.value));
if (row.revision != expectedRevision.value) {
return Left(RepositoryStaleRevision(
entityId: ownerId.value,
expectedRevision: expectedRevision,
actualRevision: core.Revision(row.revision),
));
}
return Right(row);
}
Future<RepositoryStaleRevision> _staleSettingsFailure(
core.OwnerId ownerId,
core.Revision expectedRevision,
) async {
final row = await _settingsByOwner(ownerId.value);
return RepositoryStaleRevision(
entityId: ownerId.value,
expectedRevision: expectedRevision,
actualRevision:
row == null ? expectedRevision.next() : core.Revision(row.revision),
);
}
}
/// Drift-backed schedule snapshot repository.
final class SqliteScheduleSnapshotRepository
implements ScheduleSnapshotRepository {
/// Creates a snapshot repository using [db].
SqliteScheduleSnapshotRepository(this.db);
/// Shared SQLite database handle.
final SchedulerDb db;
@override
Future<RepoResult<core.SchedulingStateSnapshot?>> findById({
required core.OwnerId ownerId,
required String snapshotId,
}) async {
final row = await _snapshotById(snapshotId);
if (row == null || row.ownerId != ownerId.value) {
return const Right(null);
}
return Right(_snapshotFromRow(row));
}
@override
Future<RepoResult<core.Page<core.SchedulingStateSnapshot>>> findInWindow({
required core.OwnerId ownerId,
required core.SchedulingWindow window,
core.PageRequest page = const core.PageRequest(),
}) async {
final rows = await (db.select(db.snapshots)
..where((table) => table.ownerId.equals(ownerId.value))
..orderBy([(table) => drift.OrderingTerm.asc(table.id)]))
.get();
final matching = rows
.map(_snapshotFromRow)
.where((snapshot) => _windowsOverlap(snapshot.window, window))
.toList(growable: false);
final offset = _cursorOffset(page.cursor);
final pageItems = matching.skip(offset).take(page.limit).toList();
final nextOffset = offset + page.limit;
return Right(core.Page(
items: pageItems,
nextCursor: nextOffset < matching.length ? '$nextOffset' : null,
));
}
@override
Future<RepoResult<core.Revision>> insert({
required core.OwnerId ownerId,
required core.SchedulingStateSnapshot snapshot,
}) async {
if (await _snapshotById(snapshot.id) != null) {
return Left(RepositoryDuplicateId(entityId: snapshot.id));
}
await db.into(db.snapshots).insert(_snapshotCompanion(
snapshot,
ownerId: ownerId,
revision: core.Revision.initial,
));
return const Right(core.Revision.initial);
}
@override
Future<RepoResult<int>> deleteExpired({
required core.OwnerId ownerId,
required DateTime nowUtc,
}) async {
final deleted = await (db.delete(db.snapshots)
..where(
(table) =>
table.ownerId.equals(ownerId.value) &
table.retentionExpiresUtc.isSmallerThanValue(nowUtc.toUtc()),
))
.go();
return Right(deleted);
}
Future<SnapshotRow?> _snapshotById(String id) {
return (db.select(db.snapshots)..where((table) => table.id.equals(id)))
.getSingleOrNull();
}
}
TasksCompanion _taskCompanion(
core.Task task, {
required core.OwnerId ownerId,
required core.Revision revision,
}) {
final doc = core.TaskDocumentMapping.toDocument(
task,
ownerId: ownerId.value,
revision: revision.value,
);
return TasksCompanion.insert(
id: task.id,
ownerId: ownerId.value,
title: _string(doc, core.TaskDocumentFields.title),
projectId: _string(doc, core.TaskDocumentFields.projectId),
parentId: drift.Value(_nullableString(
doc,
core.TaskDocumentFields.parentTaskId,
)),
type: _string(doc, core.TaskDocumentFields.type),
status: _string(doc, core.TaskDocumentFields.status),
priority: drift.Value(_nullableString(
doc,
core.TaskDocumentFields.priority,
)),
reward: _string(doc, core.TaskDocumentFields.reward),
difficulty: _string(doc, core.TaskDocumentFields.difficulty),
durationMinutes: drift.Value(_nullableInt(
doc,
core.TaskDocumentFields.durationMinutes,
)),
scheduledStartUtc: drift.Value(_nullableDateTime(
doc,
core.TaskDocumentFields.scheduledStart,
)),
scheduledEndUtc: drift.Value(_nullableDateTime(
doc,
core.TaskDocumentFields.scheduledEnd,
)),
actualStartUtc: drift.Value(_nullableDateTime(
doc,
core.TaskDocumentFields.actualStart,
)),
actualEndUtc: drift.Value(_nullableDateTime(
doc,
core.TaskDocumentFields.actualEnd,
)),
completedAtUtc: drift.Value(_nullableDateTime(
doc,
core.TaskDocumentFields.completedAt,
)),
backlogTagsJson: jsonEncode(doc[core.TaskDocumentFields.backlogTags]),
reminderOverride: drift.Value(_nullableString(
doc,
core.TaskDocumentFields.reminderOverride,
)),
statsJson: jsonEncode(doc[core.TaskDocumentFields.stats]),
backlogEnteredAtUtc: drift.Value(_nullableDateTime(
doc,
core.TaskDocumentFields.backlogEnteredAt,
)),
backlogEnteredProvenance: drift.Value(_nullableString(
doc,
core.TaskDocumentFields.backlogEnteredAtProvenance,
)),
revision: revision.value,
createdAtUtc: task.createdAt.toUtc(),
updatedAtUtc: task.updatedAt.toUtc(),
);
}
core.Task _taskFromRow(TaskRow row) {
return core.TaskDocumentMapping.fromDocument({
..._commonDocumentFields(
row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc),
core.TaskDocumentFields.title: row.title,
core.TaskDocumentFields.projectId: row.projectId,
core.TaskDocumentFields.type: row.type,
core.TaskDocumentFields.status: row.status,
core.TaskDocumentFields.priority: row.priority,
core.TaskDocumentFields.reward: row.reward,
core.TaskDocumentFields.difficulty: row.difficulty,
core.TaskDocumentFields.durationMinutes: row.durationMinutes,
core.TaskDocumentFields.scheduledStart:
_storedDateTimeOrNull(row.scheduledStartUtc),
core.TaskDocumentFields.scheduledEnd:
_storedDateTimeOrNull(row.scheduledEndUtc),
core.TaskDocumentFields.actualStart:
_storedDateTimeOrNull(row.actualStartUtc),
core.TaskDocumentFields.actualEnd: _storedDateTimeOrNull(row.actualEndUtc),
core.TaskDocumentFields.completedAt:
_storedDateTimeOrNull(row.completedAtUtc),
core.TaskDocumentFields.parentTaskId: row.parentId,
core.TaskDocumentFields.backlogTags: _jsonList(row.backlogTagsJson),
core.TaskDocumentFields.reminderOverride: row.reminderOverride,
core.TaskDocumentFields.stats: _jsonMap(row.statsJson),
core.TaskDocumentFields.backlogEnteredAt:
_storedDateTimeOrNull(row.backlogEnteredAtUtc),
core.TaskDocumentFields.backlogEnteredAtProvenance:
row.backlogEnteredProvenance,
});
}
ProjectsCompanion _projectCompanion(
core.ProjectProfile project, {
required core.OwnerId ownerId,
required core.Revision revision,
required DateTime createdAt,
required DateTime updatedAt,
}) {
final doc = core.ProjectDocumentMapping.toDocument(
project,
ownerId: ownerId.value,
revision: revision.value,
createdAt: createdAt,
updatedAt: updatedAt,
);
return ProjectsCompanion.insert(
id: project.id,
ownerId: ownerId.value,
name: _string(doc, core.ProjectDocumentFields.name),
colorKey: _string(doc, core.ProjectDocumentFields.colorKey),
defaultPriority: _string(doc, core.ProjectDocumentFields.defaultPriority),
defaultReward: _string(doc, core.ProjectDocumentFields.defaultReward),
defaultDifficulty:
_string(doc, core.ProjectDocumentFields.defaultDifficulty),
defaultReminderProfile:
_string(doc, core.ProjectDocumentFields.defaultReminderProfile),
defaultDurationMinutes: drift.Value(_nullableInt(
doc,
core.ProjectDocumentFields.defaultDurationMinutes,
)),
archivedAtUtc: drift.Value(_nullableDateTime(
doc,
core.ProjectDocumentFields.archivedAt,
)),
revision: revision.value,
createdAtUtc: createdAt.toUtc(),
updatedAtUtc: updatedAt.toUtc(),
);
}
core.ProjectProfile _projectFromRow(ProjectRow row) {
return core.ProjectDocumentMapping.fromDocument({
..._commonDocumentFields(
row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc),
core.ProjectDocumentFields.name: row.name,
core.ProjectDocumentFields.colorKey: row.colorKey,
core.ProjectDocumentFields.defaultPriority: row.defaultPriority,
core.ProjectDocumentFields.defaultReward: row.defaultReward,
core.ProjectDocumentFields.defaultDifficulty: row.defaultDifficulty,
core.ProjectDocumentFields.defaultReminderProfile:
row.defaultReminderProfile,
core.ProjectDocumentFields.defaultDurationMinutes:
row.defaultDurationMinutes,
core.ProjectDocumentFields.archivedAt: _storedDateTimeOrNull(
row.archivedAtUtc,
),
});
}
LockedBlocksCompanion _lockedBlockCompanion(
core.LockedBlock block, {
required core.OwnerId ownerId,
required core.Revision revision,
}) {
final doc = core.LockedBlockDocumentMapping.toDocument(
block,
ownerId: ownerId.value,
revision: revision.value,
);
return LockedBlocksCompanion.insert(
id: block.id,
ownerId: ownerId.value,
name: _string(doc, core.LockedBlockDocumentFields.name),
date:
drift.Value(_nullableString(doc, core.LockedBlockDocumentFields.date)),
startTime: _string(doc, core.LockedBlockDocumentFields.startTime),
endTime: _string(doc, core.LockedBlockDocumentFields.endTime),
recurrenceJson: drift.Value(_jsonEncodeNullable(
doc[core.LockedBlockDocumentFields.recurrence],
)),
hiddenByDefault: _bool(doc, core.LockedBlockDocumentFields.hiddenByDefault),
projectId: drift.Value(_nullableString(
doc,
core.LockedBlockDocumentFields.projectId,
)),
archivedAtUtc: drift.Value(_nullableDateTime(
doc,
core.LockedBlockDocumentFields.archivedAt,
)),
revision: revision.value,
createdAtUtc: block.createdAt.toUtc(),
updatedAtUtc: block.updatedAt.toUtc(),
);
}
core.LockedBlock _lockedBlockFromRow(LockedBlockRow row) {
return core.LockedBlockDocumentMapping.fromDocument({
..._commonDocumentFields(
row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc),
core.LockedBlockDocumentFields.name: row.name,
core.LockedBlockDocumentFields.startTime: row.startTime,
core.LockedBlockDocumentFields.endTime: row.endTime,
core.LockedBlockDocumentFields.date: row.date,
core.LockedBlockDocumentFields.recurrence:
row.recurrenceJson == null ? null : _jsonMap(row.recurrenceJson!),
core.LockedBlockDocumentFields.hiddenByDefault: row.hiddenByDefault,
core.LockedBlockDocumentFields.projectId: row.projectId,
core.LockedBlockDocumentFields.archivedAt:
_storedDateTimeOrNull(row.archivedAtUtc),
});
}
LockedOverridesCompanion _lockedOverrideCompanion(
core.LockedBlockOverride override, {
required core.OwnerId ownerId,
required core.Revision revision,
}) {
final doc = core.LockedBlockOverrideDocumentMapping.toDocument(
override,
ownerId: ownerId.value,
revision: revision.value,
);
return LockedOverridesCompanion.insert(
id: override.id,
ownerId: ownerId.value,
lockedBlockId: drift.Value(_nullableString(
doc,
core.LockedBlockOverrideDocumentFields.lockedBlockId,
)),
date: _string(doc, core.LockedBlockOverrideDocumentFields.date),
type: _string(doc, core.LockedBlockOverrideDocumentFields.type),
name: drift.Value(
_nullableString(doc, core.LockedBlockOverrideDocumentFields.name),
),
startTime: drift.Value(_nullableString(
doc,
core.LockedBlockOverrideDocumentFields.startTime,
)),
endTime: drift.Value(_nullableString(
doc,
core.LockedBlockOverrideDocumentFields.endTime,
)),
hiddenByDefault:
_bool(doc, core.LockedBlockOverrideDocumentFields.hiddenByDefault),
projectId: drift.Value(_nullableString(
doc,
core.LockedBlockOverrideDocumentFields.projectId,
)),
revision: revision.value,
createdAtUtc: override.createdAt.toUtc(),
updatedAtUtc: override.updatedAt.toUtc(),
);
}
core.LockedBlockOverride _lockedOverrideFromRow(LockedOverrideRow row) {
return core.LockedBlockOverrideDocumentMapping.fromDocument({
..._commonDocumentFields(
row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc),
core.LockedBlockOverrideDocumentFields.lockedBlockId: row.lockedBlockId,
core.LockedBlockOverrideDocumentFields.date: row.date,
core.LockedBlockOverrideDocumentFields.type: row.type,
core.LockedBlockOverrideDocumentFields.name: row.name,
core.LockedBlockOverrideDocumentFields.startTime: row.startTime,
core.LockedBlockOverrideDocumentFields.endTime: row.endTime,
core.LockedBlockOverrideDocumentFields.hiddenByDefault: row.hiddenByDefault,
core.LockedBlockOverrideDocumentFields.projectId: row.projectId,
});
}
SettingsTableCompanion _settingsCompanion(
core.OwnerSettings settings, {
required core.Revision revision,
required DateTime createdAt,
required DateTime updatedAt,
}) {
final doc = core.OwnerSettingsDocumentMapping.toDocument(
settings,
revision: revision.value,
createdAt: createdAt,
updatedAt: updatedAt,
);
return SettingsTableCompanion.insert(
ownerId: settings.ownerId,
timeZoneId: _string(doc, core.OwnerSettingsDocumentFields.timeZoneId),
dayStartMinutes: settings.dayStart.minutesSinceMidnight,
dayEndMinutes: settings.dayEnd.minutesSinceMidnight,
compactMode:
_bool(doc, core.OwnerSettingsDocumentFields.compactModeEnabled),
backlogStalenessJson:
jsonEncode(doc[core.OwnerSettingsDocumentFields.backlogStaleness]),
revision: revision.value,
createdAtUtc: createdAt.toUtc(),
updatedAtUtc: updatedAt.toUtc(),
);
}
core.OwnerSettings _settingsFromRow(SettingsRow row) {
return core.OwnerSettingsDocumentMapping.fromDocument({
..._commonDocumentFields(row.ownerId, row.ownerId, row.revision,
row.createdAtUtc, row.updatedAtUtc),
core.OwnerSettingsDocumentFields.timeZoneId: row.timeZoneId,
core.OwnerSettingsDocumentFields.dayStart:
_wallTimeFromMinutes(row.dayStartMinutes).toIsoString(),
core.OwnerSettingsDocumentFields.dayEnd:
_wallTimeFromMinutes(row.dayEndMinutes).toIsoString(),
core.OwnerSettingsDocumentFields.compactModeEnabled: row.compactMode,
core.OwnerSettingsDocumentFields.backlogStaleness:
_jsonMap(row.backlogStalenessJson),
});
}
SnapshotsCompanion _snapshotCompanion(
core.SchedulingStateSnapshot snapshot, {
required core.OwnerId ownerId,
required core.Revision revision,
}) {
final retentionExpiresAt = snapshot.capturedAt.add(_snapshotRetention);
final doc = core.SchedulingSnapshotDocumentMapping.toDocument(
snapshot,
ownerId: ownerId.value,
revision: revision.value,
retentionExpiresAt: retentionExpiresAt,
);
return SnapshotsCompanion.insert(
id: snapshot.id,
ownerId: ownerId.value,
capturedAtUtc: snapshot.capturedAt.toUtc(),
operationName: drift.Value(_nullableString(
doc,
core.SchedulingSnapshotDocumentFields.operationName,
)),
sourceDate: drift.Value(_nullableString(
doc,
core.SchedulingSnapshotDocumentFields.sourceDate,
)),
targetDate: drift.Value(_nullableString(
doc,
core.SchedulingSnapshotDocumentFields.targetDate,
)),
windowJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.window]),
tasksJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.tasks]),
lockedIntervalsJson:
jsonEncode(doc[core.SchedulingSnapshotDocumentFields.lockedIntervals]),
requiredVisibleIntervalsJson: jsonEncode(
doc[core.SchedulingSnapshotDocumentFields.requiredVisibleIntervals],
),
noticesJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.notices]),
changesJson: jsonEncode(doc[core.SchedulingSnapshotDocumentFields.changes]),
overlapsJson:
jsonEncode(doc[core.SchedulingSnapshotDocumentFields.overlaps]),
retentionExpiresUtc: retentionExpiresAt.toUtc(),
truncated: _bool(doc, core.SchedulingSnapshotDocumentFields.truncated),
revision: revision.value,
createdAtUtc: snapshot.capturedAt.toUtc(),
updatedAtUtc: snapshot.capturedAt.toUtc(),
);
}
core.SchedulingStateSnapshot _snapshotFromRow(SnapshotRow row) {
return core.SchedulingSnapshotDocumentMapping.fromDocument({
..._commonDocumentFields(
row.id, row.ownerId, row.revision, row.createdAtUtc, row.updatedAtUtc),
core.SchedulingSnapshotDocumentFields.capturedAt:
core.PersistenceDateTimeConvention.toStoredString(row.capturedAtUtc),
core.SchedulingSnapshotDocumentFields.sourceDate: row.sourceDate,
core.SchedulingSnapshotDocumentFields.targetDate: row.targetDate,
core.SchedulingSnapshotDocumentFields.window: _jsonMap(row.windowJson),
core.SchedulingSnapshotDocumentFields.tasks: _jsonList(row.tasksJson),
core.SchedulingSnapshotDocumentFields.lockedIntervals:
_jsonList(row.lockedIntervalsJson),
core.SchedulingSnapshotDocumentFields.requiredVisibleIntervals:
_jsonList(row.requiredVisibleIntervalsJson),
core.SchedulingSnapshotDocumentFields.notices: _jsonList(row.noticesJson),
core.SchedulingSnapshotDocumentFields.changes: _jsonList(row.changesJson),
core.SchedulingSnapshotDocumentFields.overlaps: _jsonList(row.overlapsJson),
core.SchedulingSnapshotDocumentFields.operationName: row.operationName,
core.SchedulingSnapshotDocumentFields.retentionExpiresAt:
core.PersistenceDateTimeConvention.toStoredString(
row.retentionExpiresUtc,
),
core.SchedulingSnapshotDocumentFields.truncated: row.truncated,
});
}
Map<String, Object?> _commonDocumentFields(
String id,
String ownerId,
int revision,
DateTime createdAt,
DateTime updatedAt,
) {
return <String, Object?>{
core.DocumentFields.schemaVersion: core.v1SchemaVersion,
core.DocumentFields.id: id,
core.DocumentFields.ownerId: ownerId,
core.DocumentFields.revision: revision,
core.DocumentFields.createdAt:
core.PersistenceDateTimeConvention.toStoredString(createdAt),
core.DocumentFields.updatedAt:
core.PersistenceDateTimeConvention.toStoredString(updatedAt),
};
}
String _string(Map<String, Object?> doc, String fieldName) {
return doc[fieldName] as String;
}
String? _nullableString(Map<String, Object?> doc, String fieldName) {
return doc[fieldName] as String?;
}
int? _nullableInt(Map<String, Object?> doc, String fieldName) {
return doc[fieldName] as int?;
}
bool _bool(Map<String, Object?> doc, String fieldName) {
return doc[fieldName] as bool;
}
DateTime? _nullableDateTime(Map<String, Object?> doc, String fieldName) {
final value = doc[fieldName] as String?;
return value == null
? null
: core.PersistenceDateTimeConvention.fromStoredString(value);
}
String? _storedDateTimeOrNull(DateTime? value) {
return value == null
? null
: core.PersistenceDateTimeConvention.toStoredString(value);
}
String? _jsonEncodeNullable(Object? value) {
return value == null ? null : jsonEncode(value);
}
Map<String, Object?> _jsonMap(String value) {
return Map<String, Object?>.from(
jsonDecode(value) as Map<dynamic, dynamic>,
);
}
List<Object?> _jsonList(String value) {
return List<Object?>.from(jsonDecode(value) as List<dynamic>);
}
core.WallTime _wallTimeFromMinutes(int minutes) {
return core.WallTime(hour: minutes ~/ 60, minute: minutes % 60);
}
int _cursorOffset(String? cursor) {
if (cursor == null) return 0;
final parsed = int.tryParse(cursor);
if (parsed == null || parsed < 0) return 0;
return parsed;
}
extension on int {
core.Revision next() => core.Revision(this).next();
}
bool _windowsOverlap(core.SchedulingWindow left, core.SchedulingWindow right) {
return left.start.isBefore(right.end) && right.start.isBefore(left.end);
}