forked from eva/focus-flow
feat: complete sqlite runtime persistence plan
This commit is contained in:
parent
f29fb3779e
commit
ce8ebe1c1e
53 changed files with 8718 additions and 60 deletions
|
|
@ -0,0 +1,228 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Persistence Plan 1 Block 01 — Repo and Scope Guardrails
|
||||
|
||||
**Status:** Complete on 2026-07-02.
|
||||
**Goal:** Establish the exact implementation boundary before replacing the
|
||||
seeded in-memory runtime.
|
||||
|
||||
---
|
||||
|
||||
## Block outcome
|
||||
|
||||
After this block, the executor should know exactly which existing files are
|
||||
runtime truth, which demo files are temporary, which SQLite schema path to use,
|
||||
and what package-boundary rules must remain enforced.
|
||||
|
||||
No app behavior should change in this block unless a documentation correction is
|
||||
needed.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1.1 — Reconcile current repo state
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Read `AGENTS.md` before coding.
|
||||
2. Read these current plan/context files:
|
||||
- `Codex Documentation/Current Software Plan/README.md`
|
||||
- `Codex Documentation/Completed Plans/V1_BLOCK_20_SQLite_Persistence_Adapter_Package.md`
|
||||
- `Codex Documentation/Completed Plans/V1_BLOCK_29_Flutter_UI_Foundation.md`
|
||||
- `Codex Documentation/Completed Plans/UI Plan 1 - Compact Today Timeline Mockup/README.md`
|
||||
3. Inspect current Flutter startup and composition files:
|
||||
- `apps/focus_flow_flutter/lib/main.dart`
|
||||
- `apps/focus_flow_flutter/lib/app/demo_scheduler_composition.dart`
|
||||
- `apps/focus_flow_flutter/lib/app/focus_flow_app.dart`
|
||||
- `apps/focus_flow_flutter/lib/controllers/scheduler_command_controller.dart`
|
||||
- `apps/focus_flow_flutter/lib/controllers/today_screen_controller.dart`
|
||||
4. Inspect current SQLite adapter files:
|
||||
- `packages/scheduler_persistence_sqlite/lib/sqlite.dart`
|
||||
- `packages/scheduler_persistence_sqlite/lib/src/scheduler_db.dart`
|
||||
- `packages/scheduler_persistence_sqlite/lib/src/sqlite_repositories.dart`
|
||||
5. Inspect current application-layer repository interfaces exported by
|
||||
`scheduler_core`:
|
||||
- `ApplicationUnitOfWork`
|
||||
- `ApplicationUnitOfWorkRepositories`
|
||||
- task, project, locked-time, snapshot, owner-settings, task-activity,
|
||||
project-statistics, notice-acknowledgement, and operation repository
|
||||
surfaces.
|
||||
6. Note any discrepancy between the existing SQLite schema and the repository
|
||||
methods invoked by `GetTodayStateQuery`, `V1ApplicationManagementUseCases`,
|
||||
`V1ApplicationCommandUseCases`, and `V1AppOpenRecoveryUseCases`.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- The executor can name every current in-memory/demo entry point.
|
||||
- The executor can name every current SQLite persistence package entry point.
|
||||
- Any missing table/repository required by current app commands is listed before
|
||||
implementation begins.
|
||||
- No code-level architecture assumptions are carried forward from older MongoDB
|
||||
docs.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1.2 — Define the persistence slice precisely
|
||||
|
||||
### User-visible target
|
||||
|
||||
Implement only this runtime proof:
|
||||
|
||||
```text
|
||||
start app -> capture task -> schedule task if needed -> mark done/not done
|
||||
-> close app -> reopen app -> same task/state appears
|
||||
```
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Keep the current compact Today UI layout intact.
|
||||
2. Keep sidebar/date/toggle/settings/no-op controls out of scope unless they are
|
||||
already wired.
|
||||
3. Keep task creation limited to the current quick-capture path unless the
|
||||
current app has a real task editor by the time this block executes.
|
||||
4. Keep persisted runtime owner scope fixed to the existing owner id until a
|
||||
future account/settings plan changes it.
|
||||
5. Persist the minimum real state required for the current command paths:
|
||||
- owner settings,
|
||||
- default project,
|
||||
- tasks,
|
||||
- scheduled task placement,
|
||||
- completion/uncompletion state,
|
||||
- command operation records,
|
||||
- task activities emitted by completion flows,
|
||||
- project statistics touched by completion flows,
|
||||
- snapshots/notices only where current command/recovery flows already save
|
||||
them.
|
||||
6. Do not persist static mockup tasks during normal app startup.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Scope explicitly excludes broad UI editing and future surfaces.
|
||||
- The first persistence proof does not depend on manually seeding fake timeline
|
||||
tasks.
|
||||
- Any temporary fallback path is test-only or opt-in demo-only.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1.3 — Decide schema migration strategy
|
||||
|
||||
### Context
|
||||
|
||||
The accepted SQLite V1 documentation mentions app-layer rows such as activities,
|
||||
but the current generated Drift database may not include every row required by
|
||||
application-layer use cases. This must be resolved before the Flutter app is
|
||||
allowed to use SQLite as runtime truth.
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Compare actual Drift tables against application-layer write/read needs.
|
||||
2. If no real user database must be preserved yet, either:
|
||||
- patch schema version 1 before persistent runtime launch, or
|
||||
- still bump to schema version 2 to exercise migration discipline.
|
||||
3. If any dev/user database may already exist and should survive this plan, bump
|
||||
Drift `schemaVersion` and add an explicit migration.
|
||||
4. Document the chosen strategy in the block completion notes.
|
||||
5. Do not leave `scripts/bootstrap_dev.sh` creating an empty placeholder file
|
||||
that prevents Drift from creating its schema correctly.
|
||||
|
||||
### Recommended default
|
||||
|
||||
Use a Drift migration to schema version 2 if new tables are needed. This is more
|
||||
work, but it keeps the plan safe for existing local development databases and
|
||||
aligns with the migration-test guardrail.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- The chosen schema strategy is documented before table changes.
|
||||
- Migration tests are planned if `schemaVersion` changes.
|
||||
- No executor silently adds tables without either patching generated schema
|
||||
consistently or writing a migration.
|
||||
|
||||
BREAKPOINT: Stop if schema drift requires choosing between patching V1 and
|
||||
migrating to V2 and the repo docs do not clearly permit one path.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1.4 — Lock package-boundary rules
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Keep `scheduler_core` free of Flutter, Drift, SQLite, `dart:io`, platform
|
||||
notifications, and backup/export implementation imports.
|
||||
2. Keep widgets and controllers free of Drift/SQL APIs and scheduler `src/`
|
||||
imports.
|
||||
3. Choose one of these composition-boundary options:
|
||||
- **Option A:** expose `SqliteApplicationUnitOfWork` from
|
||||
`scheduler_persistence_sqlite`, and allow only the Flutter app composition
|
||||
root to import `package:scheduler_persistence_sqlite/sqlite.dart`.
|
||||
- **Option B:** add a tiny pure-Dart runtime package, for example
|
||||
`scheduler_runtime`, that opens SQLite and exposes a scheduler composition;
|
||||
Flutter imports only the runtime package.
|
||||
4. Update `apps/focus_flow_flutter/test/forbidden_imports_test.dart` to enforce
|
||||
the chosen boundary precisely.
|
||||
5. Do not loosen the forbidden import scan globally.
|
||||
|
||||
### Recommended default
|
||||
|
||||
Use Option B if the current app test should keep banning direct SQLite adapter
|
||||
imports from the Flutter app. Use Option A if a small app composition file is
|
||||
accepted as the local embedded runtime boundary.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Boundary tests describe the intended rule after this plan, not the old UI Plan
|
||||
1 rule.
|
||||
- Flutter widgets still cannot import Drift, scheduler internals, or direct OS
|
||||
file APIs.
|
||||
- Any allowed persistence import is restricted to the composition root or runtime
|
||||
package.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1.5 — Establish validation commands
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Run or prepare to run the current backend gate:
|
||||
|
||||
```sh
|
||||
scripts/test.sh
|
||||
```
|
||||
|
||||
2. Run or prepare to run Flutter app gates:
|
||||
|
||||
```sh
|
||||
cd apps/focus_flow_flutter
|
||||
flutter analyze
|
||||
flutter test
|
||||
```
|
||||
|
||||
3. If Flutter is unavailable in the execution environment, document that as
|
||||
unverified and still run all Dart package tests that are available.
|
||||
4. Ensure future block notes include exact commands run and failures.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Baseline validation status is known before code changes.
|
||||
- The executor does not claim Flutter validation passed unless it actually ran.
|
||||
|
||||
---
|
||||
|
||||
## Block acceptance criteria
|
||||
|
||||
- The repo has been inspected against the current SQLite-first architecture.
|
||||
- The persistence slice target is narrow and testable.
|
||||
- Demo data retirement is explicitly scoped.
|
||||
- Schema migration strategy is decided or paused at the breakpoint.
|
||||
- Package-boundary rule is chosen and ready to enforce.
|
||||
- Baseline validation commands are known.
|
||||
|
||||
## Validation after block
|
||||
|
||||
Documentation-only block. Run at least:
|
||||
|
||||
```sh
|
||||
dart analyze
|
||||
```
|
||||
|
||||
Run broader gates if the environment supports them.
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Persistence Plan 1 Block 02 — SQLite Schema for App-Layer Records
|
||||
|
||||
**Status:** Complete on 2026-07-02.
|
||||
**Goal:** Make the SQLite database capable of storing every record touched by the
|
||||
current application read/command flows.
|
||||
|
||||
---
|
||||
|
||||
## Block outcome
|
||||
|
||||
After this block, `SchedulerDb` should expose all tables required for a durable
|
||||
`ApplicationUnitOfWork`. Drift generation and migration/schema tests should prove
|
||||
that a database can be created or upgraded and can store the current app-layer
|
||||
record set.
|
||||
|
||||
This block does not yet wire Flutter to SQLite.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2.1 — Audit existing Drift tables against app repository needs
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Confirm existing tables for:
|
||||
- `tasks`,
|
||||
- `projects`,
|
||||
- `locked_blocks`,
|
||||
- `locked_overrides`,
|
||||
- `settings`,
|
||||
- `snapshots`.
|
||||
2. Compare with `ApplicationUnitOfWorkRepositories`, which also requires:
|
||||
- task activity records,
|
||||
- project statistics records,
|
||||
- owner settings records,
|
||||
- notice acknowledgement records,
|
||||
- operation/idempotency records.
|
||||
3. Compare with current command use cases:
|
||||
- quick capture writes tasks and operation records,
|
||||
- schedule-from-backlog writes tasks, activities, possibly snapshots/notices,
|
||||
- complete/uncomplete writes tasks, task activities, project statistics, and
|
||||
operation records,
|
||||
- app-open recovery can read/write snapshots and operation-like state.
|
||||
4. Write a short implementation note in the eventual block completion section
|
||||
listing which tables were missing and which were already present.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Missing app-layer tables are identified before implementation.
|
||||
- No application repository method is ignored because it is not needed by the
|
||||
first manual happy path; use cases should remain semantically durable.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2.2 — Add application operation table
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Add a Drift table for committed application operations.
|
||||
2. Include fields needed by `ApplicationOperationRecord` and idempotency lookup:
|
||||
- `operation_id TEXT PRIMARY KEY`,
|
||||
- `owner_id TEXT`,
|
||||
- `operation_name TEXT`,
|
||||
- `committed_at_utc DATETIME`,
|
||||
- optional `idempotency_key` only if the current core record/interface needs
|
||||
it,
|
||||
- `revision INT` only if the repository convention requires it for this row.
|
||||
3. Add an index for owner + operation id/idempotency lookup if the table does not
|
||||
use a composite key.
|
||||
4. Map through existing core document mapping helpers where available.
|
||||
5. Do not expose raw Drift rows outside the SQLite adapter/runtime boundary.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- A committed operation can be inserted once and found on a later app run.
|
||||
- Duplicate operation inserts are detected consistently.
|
||||
- Operation records are owner-scoped.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2.3 — Add task activity table
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Add a Drift table for append-only task activity records.
|
||||
2. Include fields needed by `TaskActivity` and app queries:
|
||||
- `id TEXT PRIMARY KEY`,
|
||||
- `owner_id TEXT`,
|
||||
- `task_id TEXT`,
|
||||
- `project_id TEXT`,
|
||||
- `operation_id TEXT`,
|
||||
- `code TEXT`,
|
||||
- `occurred_at_utc DATETIME`,
|
||||
- metadata/details JSON if represented by the current document mapping,
|
||||
- `created_at_utc` / `updated_at_utc` only if mapping/common fields require
|
||||
them.
|
||||
3. Add indexes for owner, task, project, and operation lookups.
|
||||
4. Preserve append-only semantics; do not design an update/delete flow unless the
|
||||
current application repository requires it.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Completion-generated activities survive close/reopen.
|
||||
- `findByTaskId`, `findByProjectId`, `findByOperationId`, `findByOwner`, and
|
||||
`findAll` can be implemented deterministically.
|
||||
- Activity rows do not require Flutter or UI metadata.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2.4 — Add project statistics table
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Add a Drift table for project statistics aggregates.
|
||||
2. Use `project_id` as the stable aggregate key, owner scoped.
|
||||
3. Store counters and applied activity ids using explicit JSON columns if the
|
||||
current domain object is map/list heavy.
|
||||
4. Include `revision INT` because current repository semantics use optimistic
|
||||
save for aggregates.
|
||||
5. Add index/unique constraint for `owner_id + project_id`.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Project statistics can be read, saved, and saved with expected revision.
|
||||
- Completion after restart does not double-apply already recorded activities.
|
||||
- JSON fields round-trip through core document mapping helpers.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2.5 — Add notice acknowledgement table if needed by current app layer
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Add a Drift table for acknowledged notices if current application management
|
||||
or recovery use cases read/write notice acknowledgements.
|
||||
2. Key by owner + notice id.
|
||||
3. Store acknowledged/created timestamp fields required by the domain record.
|
||||
4. Add deterministic owner paging.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Notice acknowledgement reads/writes can be implemented without in-memory
|
||||
fallback.
|
||||
- Acknowledged notices remain acknowledged after app restart.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2.6 — Update Drift database wiring and generation
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Add table files under descriptive `scheduler_db/tables/...` subfolders.
|
||||
2. Add DAO/accessor files only where useful; do not add empty accessors if direct
|
||||
queries are clearer and consistent with existing code.
|
||||
3. Update `SchedulerDb` table list.
|
||||
4. Apply the schema strategy chosen in Block 01:
|
||||
- patch version 1 only if explicitly permitted, or
|
||||
- bump `schemaVersion` and write migration steps.
|
||||
5. Run Drift generation:
|
||||
|
||||
```sh
|
||||
cd packages/scheduler_persistence_sqlite
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
6. Format generated and handwritten files.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Drift generated files are updated.
|
||||
- `SchedulerDb` opens a new database with all expected tables.
|
||||
- Existing table names and columns remain compatible unless a migration test
|
||||
covers the change.
|
||||
|
||||
BREAKPOINT: Stop if Drift generation would require Flutter SDK dependencies in a
|
||||
pure Dart package.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2.7 — Add schema and migration tests
|
||||
|
||||
### Required tests
|
||||
|
||||
1. New-database schema smoke test includes all current tables.
|
||||
2. Migration test from the previous schema creates the new tables and preserves
|
||||
existing task/project/settings rows.
|
||||
3. Date/time persistence test confirms UTC values remain UTC after round-trip.
|
||||
4. JSON-column smoke test confirms non-empty task activity/project statistics
|
||||
payloads round-trip.
|
||||
5. Generated schema/table-name test stays deterministic across platforms.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `cd packages/scheduler_persistence_sqlite && dart test` passes.
|
||||
- `cd packages/scheduler_persistence_sqlite && dart analyze` passes.
|
||||
- Root `dart test` still passes.
|
||||
|
||||
---
|
||||
|
||||
## Block acceptance criteria
|
||||
|
||||
- SQLite schema includes every table needed by current application read/command
|
||||
flows.
|
||||
- Drift generation is current.
|
||||
- Migration behavior is tested if `schemaVersion` changed.
|
||||
- No Flutter or widget code imports Drift or SQL.
|
||||
- No command path must fall back to in-memory-only app records.
|
||||
|
||||
## Validation after block
|
||||
|
||||
```sh
|
||||
cd packages/scheduler_persistence_sqlite
|
||||
dart analyze
|
||||
dart test
|
||||
cd ../..
|
||||
dart analyze
|
||||
dart test
|
||||
```
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Persistence Plan 1 Block 03 — SQLite Application Unit of Work
|
||||
|
||||
**Status:** Complete on 2026-07-02.
|
||||
**Goal:** Implement a SQLite-backed `ApplicationUnitOfWork` that current
|
||||
scheduler application use cases can use without knowing about Drift.
|
||||
|
||||
---
|
||||
|
||||
## Block outcome
|
||||
|
||||
After this block, scheduler application read/command use cases should run
|
||||
against SQLite in tests. The adapter must support current reads and writes for
|
||||
Today, Backlog, quick capture, schedule-from-backlog, done, and uncomplete.
|
||||
|
||||
Flutter still remains unwired until Block 04.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3.1 — Choose implementation location and public API
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Implement the application-unit-of-work adapter in the SQLite adapter layer or
|
||||
a tiny runtime package, as chosen in Block 01.
|
||||
2. Preferred direct adapter names if implemented in
|
||||
`scheduler_persistence_sqlite`:
|
||||
- `SqliteApplicationUnitOfWork`,
|
||||
- `SqliteApplicationRepositories`,
|
||||
- `SqliteApplicationRuntime` only if it owns file opening/lifecycle.
|
||||
3. Export the public entry point from `packages/scheduler_persistence_sqlite/lib/sqlite.dart`
|
||||
or from the chosen runtime package.
|
||||
4. Keep raw Drift database classes available for tests but do not require
|
||||
Flutter widgets to instantiate them.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Application code can create a SQLite-backed `ApplicationUnitOfWork` through a
|
||||
public package API.
|
||||
- `scheduler_core` remains storage-free.
|
||||
- Existing repository conformance APIs remain intact.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3.2 — Implement transaction semantics
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Implement `ApplicationUnitOfWork.run<T>` using a Drift transaction.
|
||||
2. Implement `ApplicationUnitOfWork.read<T>` as a read-only repository view.
|
||||
3. Preserve current in-memory semantics:
|
||||
- duplicate `operationId` returns duplicate-operation failure,
|
||||
- domain validation exceptions map to validation failures,
|
||||
- application persistence exceptions map to persistence failure,
|
||||
- unexpected errors map to unexpected failure,
|
||||
- operation records are written only after the action succeeds.
|
||||
4. Ensure operation record commit and all repository writes happen atomically.
|
||||
5. Ensure failed actions do not partially save tasks or activities.
|
||||
6. Ensure nested transaction behavior is either not used or explicitly safe.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- A command failure leaves the database unchanged.
|
||||
- A successful command writes changed tasks, activities/statistics, and operation
|
||||
record in one transaction.
|
||||
- Duplicate operation id is rejected across app restarts.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3.3 — Implement SQLite app task repository
|
||||
|
||||
### Required methods
|
||||
|
||||
Implement the `scheduler_core` application-layer task repository methods used by
|
||||
current use cases, including at minimum:
|
||||
|
||||
1. `findById`
|
||||
2. `findRecordById`
|
||||
3. `findAll`
|
||||
4. `findByStatus`
|
||||
5. `findByOwner`
|
||||
6. `findByProjectId`
|
||||
7. `findByParentTaskId`
|
||||
8. `findBacklogCandidates`
|
||||
9. `findScheduledInWindow`
|
||||
10. `findScheduledInWindowForOwner`
|
||||
11. `findForLocalDay`
|
||||
12. `save`
|
||||
13. `saveAll`
|
||||
14. `insert`
|
||||
15. `saveIfRevision`
|
||||
|
||||
### Implementation notes
|
||||
|
||||
- Reuse existing task document/row mapping helpers where possible.
|
||||
- Preserve owner scoping.
|
||||
- Preserve deterministic ordering used by Today and Backlog read models.
|
||||
- Treat UTC task instants as stored UTC values.
|
||||
- `save`/`saveAll` should insert if absent only if current in-memory semantics
|
||||
require that behavior; otherwise use explicit repository mutation failures.
|
||||
Document the chosen behavior because current command code calls `saveAll` for
|
||||
changed tasks.
|
||||
- If `saveAll` needs compare-and-set behavior, load current records first and
|
||||
write each changed row in the enclosing transaction.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Quick-captured tasks are stored as backlog tasks.
|
||||
- Scheduled tasks reload with schedule placement.
|
||||
- Completed tasks reload with `TaskStatus.completed`, actual times, and
|
||||
`completedAt` where present.
|
||||
- Stale revision tests cover `saveIfRevision`.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3.4 — Implement SQLite app project/settings/locked/snapshot repositories
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Implement app-layer `ProjectRepository` using existing project SQLite rows.
|
||||
2. Implement app-layer `OwnerSettingsRepository` using existing settings rows.
|
||||
3. Implement app-layer `LockedBlockRepository` using existing locked block and
|
||||
override rows.
|
||||
4. Implement app-layer `SchedulingSnapshotRepository` using existing snapshot
|
||||
rows.
|
||||
5. Preserve domain object mapping and owner scoping.
|
||||
6. Support repository page semantics where the app-layer interface requires
|
||||
pages.
|
||||
7. Convert lower-level repository failures into application-layer repository
|
||||
failures consistently.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `GetTodayStateQuery` can run against an empty SQLite database plus bootstrap
|
||||
owner settings/project.
|
||||
- Locked time and snapshots do not crash current app reads.
|
||||
- Snapshot retention/delete behavior remains consistent with ADR policy.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3.5 — Implement SQLite app activity/statistics/notice/operation repositories
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Implement `TaskActivityRepository` over the new task activity table.
|
||||
2. Implement `ProjectStatisticsRepository` over the new project statistics table.
|
||||
3. Implement `NoticeAcknowledgementRepository` over the new notice table, if it
|
||||
exists after Block 02.
|
||||
4. Implement `ApplicationOperationRepository` over the new operation table.
|
||||
5. Make append-only writes idempotent where current interfaces require it.
|
||||
6. Add owner filtering to every owner-scoped query.
|
||||
7. Avoid storing derived UI-only strings.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Completing a task writes activity records durably.
|
||||
- Project statistics survive close/reopen.
|
||||
- Operation records survive close/reopen and prevent duplicate operation replay.
|
||||
- Notice acknowledgement behavior, if used, survives close/reopen.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3.6 — Add adapter-level tests for current command/read flows
|
||||
|
||||
### Required tests
|
||||
|
||||
1. Empty database read:
|
||||
- bootstrap owner settings/project if using bootstrap helper,
|
||||
- `GetTodayStateQuery` returns empty Today without failure.
|
||||
2. Quick capture persistence:
|
||||
- run `V1ApplicationCommandUseCases.quickCaptureToBacklog`,
|
||||
- close the first database handle,
|
||||
- reopen the same file,
|
||||
- read backlog and find the task.
|
||||
3. Schedule persistence:
|
||||
- quick capture or insert a backlog task,
|
||||
- schedule it to next available slot,
|
||||
- reopen,
|
||||
- Today state contains the scheduled task and Backlog does not.
|
||||
4. Completion persistence:
|
||||
- schedule a task,
|
||||
- complete it,
|
||||
- reopen,
|
||||
- Today state shows completed/done state.
|
||||
5. Uncomplete persistence:
|
||||
- complete then uncomplete,
|
||||
- reopen,
|
||||
- Today state shows planned/not-done state.
|
||||
6. Atomic failure:
|
||||
- trigger a validation or stale write failure,
|
||||
- assert no partial task/activity/statistics/operation writes persisted.
|
||||
7. Duplicate operation:
|
||||
- run one command,
|
||||
- attempt same operation id again after reopen,
|
||||
- assert duplicate-operation failure.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Tests use temporary file SQLite databases, not only `NativeDatabase.memory()`.
|
||||
- Tests explicitly close database handles before reopen assertions.
|
||||
- Tests do not depend on Flutter.
|
||||
|
||||
---
|
||||
|
||||
## Block acceptance criteria
|
||||
|
||||
- A SQLite-backed `ApplicationUnitOfWork` exists and is exported through the
|
||||
chosen adapter/runtime boundary.
|
||||
- Current scheduler reads and commands run against SQLite in package tests.
|
||||
- Transactions prevent partial persistence.
|
||||
- Close/reopen behavior is proven outside Flutter.
|
||||
- Existing repository conformance tests still pass.
|
||||
|
||||
## Validation after block
|
||||
|
||||
```sh
|
||||
cd packages/scheduler_persistence_sqlite
|
||||
dart analyze
|
||||
dart test
|
||||
cd ../..
|
||||
scripts/test.sh
|
||||
```
|
||||
|
||||
If `scripts/test.sh` is unavailable, run `dart analyze` and `dart test` from the
|
||||
workspace root and document the gap.
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Persistence Plan 1 Block 04 — Flutter Runtime Composition
|
||||
|
||||
**Status:** Complete on 2026-07-02.
|
||||
**Goal:** Build a production-like Flutter composition root that opens SQLite,
|
||||
bootstraps required owner state, creates scheduler use cases, and disposes the
|
||||
database cleanly.
|
||||
|
||||
---
|
||||
|
||||
## Block outcome
|
||||
|
||||
After this block, the Flutter app should be able to choose between a persistent
|
||||
SQLite composition for normal runtime and a deterministic demo/test composition
|
||||
for widget tests. Normal `main()` should be ready to use the persistent
|
||||
composition, but the final demo-seed retirement happens in Block 05.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4.1 — Add runtime dependencies without polluting widgets
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Add only the dependencies needed by the chosen boundary:
|
||||
- If using direct app composition, add app path dependency on
|
||||
`scheduler_persistence_sqlite` and any SQLite native support package needed
|
||||
by Flutter desktop.
|
||||
- If using a runtime package, add app path dependency on that runtime package
|
||||
only.
|
||||
2. Keep widgets/controllers importing public scheduler app/controller APIs only.
|
||||
3. Keep `package:drift/`, `package:scheduler_persistence_sqlite/`, and `dart:io`
|
||||
out of widgets/controllers.
|
||||
4. Update forbidden import tests to enforce the exact allowed boundary.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `flutter pub get` resolves.
|
||||
- Forbidden import test still prevents SQL/Drift imports outside the approved
|
||||
composition/runtime boundary.
|
||||
- Backend packages remain in the root Dart workspace; Flutter app remains outside
|
||||
the root workspace unless a separate plan changes that.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4.2 — Read SQLite path from configuration
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Read the configured SQLite path from:
|
||||
|
||||
```dart
|
||||
const String.fromEnvironment('SCHEDULER_SQLITE_PATH')
|
||||
```
|
||||
|
||||
2. If no dart define is present, use the existing default scheduler SQLite path
|
||||
convention, but keep path resolution in the runtime/composition layer.
|
||||
3. In tests, allow injecting a temporary path or in-memory executor.
|
||||
4. Do not call `DateTime.now()` or path APIs inside widgets.
|
||||
5. Ensure parent directories are created before opening the database.
|
||||
6. Ensure an empty placeholder file created by `scripts/bootstrap_dev.sh` does
|
||||
not break Drift schema creation.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `scripts/dev.dart --sqlite /tmp/scheduler.sqlite` controls the app DB path.
|
||||
- Tests can provide isolated temp database files.
|
||||
- Runtime path resolution is not duplicated in UI widgets.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4.3 — Build persistent scheduler composition
|
||||
|
||||
### Suggested files
|
||||
|
||||
Use names that fit the chosen boundary. Possible direct-app layout:
|
||||
|
||||
```text
|
||||
apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart
|
||||
apps/focus_flow_flutter/lib/app/runtime/sqlite_path_config.dart
|
||||
apps/focus_flow_flutter/lib/app/runtime/scheduler_runtime_lifecycle.dart
|
||||
```
|
||||
|
||||
Possible runtime-package layout:
|
||||
|
||||
```text
|
||||
packages/scheduler_runtime/lib/scheduler_runtime.dart
|
||||
packages/scheduler_runtime/lib/src/sqlite_scheduler_runtime.dart
|
||||
apps/focus_flow_flutter/lib/app/persistent_scheduler_composition.dart
|
||||
```
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Create a persistent composition that provides the same high-level factory
|
||||
surface currently expected by `FocusFlowApp`/`FocusFlowHome`:
|
||||
- today screen controller,
|
||||
- command controller,
|
||||
- operation context factory,
|
||||
- selected date/date label,
|
||||
- read refresh callbacks.
|
||||
2. Internally use:
|
||||
- SQLite-backed `ApplicationUnitOfWork`,
|
||||
- `GetTodayStateQuery`,
|
||||
- `V1ApplicationManagementUseCases`,
|
||||
- `V1ApplicationCommandUseCases`,
|
||||
- existing timezone resolver policy.
|
||||
3. Preserve a fixed V1 owner id until a settings/account plan changes it.
|
||||
4. Keep the current app interface small. Prefer defining an abstract composition
|
||||
protocol if both demo and persistent compositions need to satisfy it.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `FocusFlowApp` no longer needs to know whether its composition is in-memory or
|
||||
SQLite-backed.
|
||||
- The persistent composition can load Today state from SQLite without seeded
|
||||
tasks.
|
||||
- Widget code still consumes controllers/read models.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4.4 — Bootstrap required owner state
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Add a startup bootstrap step that runs before first read.
|
||||
2. If owner settings do not exist, insert defaults:
|
||||
- owner id matching current app owner,
|
||||
- timezone id matching current runtime resolver,
|
||||
- compact mode enabled if this remains the current UI mode.
|
||||
3. If no default project exists, create a stable default project.
|
||||
4. Decide the default project id for V1 quick capture:
|
||||
- Preferred: move current UI hardcoded `home` project id into the composition
|
||||
or settings/defaults boundary.
|
||||
- Alternative: use `inbox` if that is the canonical app-layer default, and
|
||||
update quick capture wiring and tests consistently.
|
||||
5. Do not bootstrap demo timeline tasks.
|
||||
6. Make bootstrap idempotent across app restarts.
|
||||
7. Run bootstrap inside the SQLite unit-of-work transaction boundary.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- First app launch on an empty DB shows an empty real Today state, not a failure.
|
||||
- Quick capture has a valid project id without hardcoded widget knowledge.
|
||||
- Reopening the app does not duplicate default projects/settings.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4.5 — Add lifecycle disposal
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Add a `dispose` or `close` method to the persistent composition/runtime.
|
||||
2. Ensure `FocusFlowHome` or the app root disposes controllers first and DB last.
|
||||
3. Ensure tests can close the database deterministically before reopening the
|
||||
same path.
|
||||
4. Do not leave long-lived SQLite handles open across tests.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- App shutdown closes the database cleanly.
|
||||
- Close/reopen tests do not flake due to locked files.
|
||||
- No widget owns a raw database handle.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 4.6 — Add composition-level tests
|
||||
|
||||
### Required tests
|
||||
|
||||
1. Persistent composition opens an empty temp DB and loads an empty Today state.
|
||||
2. Persistent composition bootstraps exactly one settings row and one default
|
||||
project row.
|
||||
3. Persistent composition quick captures a task and refreshes read controllers.
|
||||
4. Persistent composition can be disposed and reopened against the same file.
|
||||
5. Demo composition remains available for deterministic visual tests, if kept.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Tests run under Flutter only if they need Flutter bindings; otherwise prefer
|
||||
pure Dart package tests.
|
||||
- No tests rely on static mockup tasks for persistence proof.
|
||||
|
||||
---
|
||||
|
||||
## Block acceptance criteria
|
||||
|
||||
- Persistent composition exists and can open/configure SQLite.
|
||||
- Owner settings/default project bootstrap is idempotent.
|
||||
- Runtime DB path is controlled by `SCHEDULER_SQLITE_PATH` or injected test path.
|
||||
- Composition lifecycle closes DB resources.
|
||||
- Widget/controller package boundaries remain enforced.
|
||||
|
||||
## Validation after block
|
||||
|
||||
```sh
|
||||
cd apps/focus_flow_flutter
|
||||
flutter pub get
|
||||
flutter analyze
|
||||
flutter test
|
||||
cd ../..
|
||||
scripts/test.sh
|
||||
```
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Persistence Plan 1 Block 05 — Retire Demo Seed and Wire Persistent Commands
|
||||
|
||||
**Status:** Complete on 2026-07-02.
|
||||
**Goal:** Make normal app startup use persisted SQLite state and prove the
|
||||
current capture/schedule/done controls mutate durable state.
|
||||
|
||||
---
|
||||
|
||||
## Block outcome
|
||||
|
||||
After this block, launching the app normally should no longer recreate arbitrary
|
||||
demo tasks. The app should load real persisted tasks, allow the current command
|
||||
paths to change them, and refresh Today from SQLite-backed read models.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5.1 — Introduce an app composition protocol if needed
|
||||
|
||||
### Context
|
||||
|
||||
`FocusFlowApp` currently depends on `DemoSchedulerComposition`. To switch normal
|
||||
runtime to SQLite without throwing away deterministic widget tests, the app
|
||||
should depend on a small composition surface instead of one concrete demo class.
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Define a protocol/interface for the composition used by the app shell if the
|
||||
current class shape makes that useful. It should expose only:
|
||||
- owner/date label data needed by UI,
|
||||
- Today screen controller factory,
|
||||
- command controller factory,
|
||||
- operation context factory if still needed externally,
|
||||
- dispose/close if the runtime owns resources.
|
||||
2. Make the demo composition implement the protocol or adapt it behind a wrapper.
|
||||
3. Make the persistent composition implement the protocol.
|
||||
4. Keep the protocol free of Drift, SQL, and row types.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- `FocusFlowApp` can be given either demo or persistent composition.
|
||||
- Widget tests can still use deterministic demo/fake state where visual coverage
|
||||
needs it.
|
||||
- Normal `main()` can switch to persistent composition without altering widgets.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5.2 — Change normal app startup to persistent SQLite
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Update `apps/focus_flow_flutter/lib/main.dart` so normal startup opens the
|
||||
persistent SQLite composition.
|
||||
2. Keep an explicit demo entry point only if needed, for example:
|
||||
- `main_demo.dart`,
|
||||
- test-only factory,
|
||||
- `--dart-define=FOCUS_FLOW_DEMO_SEED=true` if an opt-in demo mode is useful.
|
||||
3. Do not keep static demo task insertion on the normal code path.
|
||||
4. Show a calm failure state if the DB cannot open instead of silently falling
|
||||
back to fake data.
|
||||
5. Keep startup asynchronous if required, using a small loading shell before the
|
||||
persistent composition is ready.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Running the app with no demo flags starts from the SQLite file.
|
||||
- A database-open failure is visible and diagnosable.
|
||||
- No arbitrary UI mockup tasks are inserted into user data.
|
||||
|
||||
BREAKPOINT: Stop if the app cannot initialize asynchronous composition without a
|
||||
small root-shell refactor. Keep the refactor minimal and do not redesign the UI.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5.3 — Remove hardcoded demo assumptions from command wiring
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Replace hardcoded quick-capture project id `'home'` in
|
||||
`SchedulerCommandController` with a composition-provided/default project id.
|
||||
2. Keep owner id/timezone id in the operation context factory, not widgets.
|
||||
3. Ensure scheduled commands pass the expected updated timestamp/revision-related
|
||||
metadata currently available from read models.
|
||||
4. If read models do not expose enough information for stale-write protection,
|
||||
add the smallest read-model field needed rather than bypassing stale checks.
|
||||
5. Keep command labels/messages calm and stable.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Quick capture works on a fresh SQLite DB with bootstrapped project defaults.
|
||||
- Scheduling from Backlog does not depend on demo project ids.
|
||||
- Completion/uncompletion commands target real persisted task ids.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5.4 — Ensure Today and Backlog refresh from SQLite after commands
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Verify the current refresh callback reloads all read controllers that should
|
||||
reflect command changes.
|
||||
2. If the compact Today screen currently only refreshes one controller, ensure
|
||||
Backlog/TODAY surfaces used in this slice refresh together or derive from a
|
||||
shared reload boundary.
|
||||
3. After quick capture, Backlog reflects the new row.
|
||||
4. After scheduling, Today reflects the planned placement and Backlog no longer
|
||||
shows the task.
|
||||
5. After done/uncomplete, Today reflects the new status.
|
||||
6. Avoid optimistic UI as canonical state for this plan. It may show command
|
||||
running/success, but the rendered task state must come from SQLite-backed
|
||||
read models after refresh.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- UI state after every command is read back from SQLite-backed application state.
|
||||
- No widget directly mutates task status or schedule placement as durable truth.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5.5 — Preserve demo composition only where useful
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Keep deterministic seeded demo data only for:
|
||||
- visual/widget tests,
|
||||
- mockup comparison tests,
|
||||
- explicit demo mode if retained.
|
||||
2. Rename files/classes if needed to clarify they are not normal runtime, for
|
||||
example `SeededDemoSchedulerComposition`.
|
||||
3. Update comments and README text that still say all controls are no-op if the
|
||||
current code now supports command flows.
|
||||
4. Do not delete tests that protect UI Plan 1 visuals unless they are replaced
|
||||
with equal coverage.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Normal runtime and demo/test runtime are visibly separate in code.
|
||||
- Future contributors cannot accidentally reintroduce demo seed into user data.
|
||||
- README status matches actual command wiring.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 5.6 — Add focused Flutter tests for persistent command wiring
|
||||
|
||||
### Required tests
|
||||
|
||||
1. Fresh persistent app starts empty or with only bootstrapped non-task state.
|
||||
2. Quick capture through the UI writes a backlog task to temp-file SQLite.
|
||||
3. Reopening the app against the same temp file still shows the captured backlog
|
||||
task.
|
||||
4. Scheduling through the UI writes planned schedule placement.
|
||||
5. Reopening shows the scheduled task in Today.
|
||||
6. Tapping done writes completed state.
|
||||
7. Reopening shows the task as completed/done.
|
||||
8. Tapping done/status again uncompletes if that interaction is currently wired.
|
||||
9. Reopening shows planned/not-done state.
|
||||
10. Demo visual tests still run with deterministic seeded data.
|
||||
|
||||
### Test constraints
|
||||
|
||||
- Use temporary SQLite files.
|
||||
- Close/dispose every persistent composition before reopening.
|
||||
- Avoid relying on wall-clock current date; inject fixed clock/date where the
|
||||
app composition allows it.
|
||||
- Keep tests resilient to UI text changes by using existing value keys where
|
||||
practical.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Flutter tests prove the exact user lifecycle requested by the plan.
|
||||
- Failures identify whether the bug is read, write, scheduling, or lifecycle.
|
||||
|
||||
---
|
||||
|
||||
## Block acceptance criteria
|
||||
|
||||
- Normal app startup uses SQLite-backed persisted state.
|
||||
- Static demo task insertion is removed from normal runtime.
|
||||
- Existing quick capture/schedule/done/uncomplete commands operate on persisted
|
||||
application state.
|
||||
- Today/Backlog render from post-command SQLite reads.
|
||||
- Persistent Flutter tests cover close/reopen task state.
|
||||
- Demo/test seed remains isolated if retained.
|
||||
|
||||
## Validation after block
|
||||
|
||||
```sh
|
||||
cd apps/focus_flow_flutter
|
||||
flutter analyze
|
||||
flutter test
|
||||
cd ../..
|
||||
scripts/test.sh
|
||||
```
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Persistence Plan 1 Block 06 — Lifecycle Validation and Handoff
|
||||
|
||||
**Status:** Complete on 2026-07-02.
|
||||
**Goal:** Prove the requested close/reopen persistence behavior, run validation,
|
||||
and document what changed for the next functional plan.
|
||||
|
||||
---
|
||||
|
||||
## Block outcome
|
||||
|
||||
After this block, the repo should have a validated SQLite-backed runtime slice
|
||||
with clear handoff notes. The next plan can safely build richer task add/edit
|
||||
flows or wire more modal controls without first solving persistence.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 6.1 — Add pure Dart lifecycle integration tests
|
||||
|
||||
### Required tests
|
||||
|
||||
Create or extend package-level integration tests so the persistence proof does
|
||||
not depend only on Flutter widgets.
|
||||
|
||||
1. Open temp-file SQLite database.
|
||||
2. Bootstrap owner settings and default project.
|
||||
3. Run `quickCaptureToBacklog` through `V1ApplicationCommandUseCases`.
|
||||
4. Close DB.
|
||||
5. Reopen DB.
|
||||
6. Read Backlog and assert the task exists.
|
||||
7. Schedule task to next available slot.
|
||||
8. Close/reopen.
|
||||
9. Read Today and assert schedule placement exists.
|
||||
10. Complete task.
|
||||
11. Close/reopen.
|
||||
12. Read Today and assert completed state exists.
|
||||
13. Uncomplete task.
|
||||
14. Close/reopen.
|
||||
15. Read Today and assert planned/not-done state exists.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Tests exercise the same application use cases used by Flutter.
|
||||
- Tests do not inspect raw rows except for targeted diagnostics.
|
||||
- Tests close every database handle before reopen assertions.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 6.2 — Add Flutter lifecycle smoke tests
|
||||
|
||||
### Required tests
|
||||
|
||||
1. Pump app with persistent temp-file composition.
|
||||
2. Verify empty first-run state.
|
||||
3. Capture a task through the visible quick-capture UI.
|
||||
4. Dispose app/composition and pump a new app with the same DB path.
|
||||
5. Verify the task appears.
|
||||
6. Schedule the task if current UI exposes the action.
|
||||
7. Dispose/reopen.
|
||||
8. Verify planned Today card appears.
|
||||
9. Mark done.
|
||||
10. Dispose/reopen.
|
||||
11. Verify done/completed visual state appears.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- The requested user path is proven at widget level.
|
||||
- Test names describe the close/reopen behavior explicitly.
|
||||
- Demo widget tests remain separate from persistent lifecycle tests.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 6.3 — Manual verification script/checklist
|
||||
|
||||
### Tasks
|
||||
|
||||
1. Add a short manual checklist to app README or a plan handoff file:
|
||||
|
||||
```sh
|
||||
scripts/bootstrap_dev.sh
|
||||
scripts/dev.sh --sqlite /tmp/focus_flow_persistence_plan_1.sqlite
|
||||
```
|
||||
|
||||
2. Manual path:
|
||||
- start app,
|
||||
- capture a task,
|
||||
- schedule it if visible,
|
||||
- mark it done,
|
||||
- close app,
|
||||
- restart with the same `--sqlite` path,
|
||||
- confirm task and done state persist.
|
||||
3. Include how to reset the local dev DB safely.
|
||||
4. Include the actual default DB path used by scripts.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- A developer can manually verify persistence without reading code.
|
||||
- Reset instructions distinguish deleting dev data from backup/restore.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 6.4 — Update documentation to match reality
|
||||
|
||||
### Files to review/update
|
||||
|
||||
1. `apps/focus_flow_flutter/README.md`
|
||||
2. Root `README.md` only if commands or behavior changed.
|
||||
3. `Codex Documentation/Current Software Plan/README.md`
|
||||
4. This plan's block files after execution, moving completed files if that is the
|
||||
current project convention.
|
||||
5. Any completed-plan index if the project archives completed plans after the
|
||||
implementation is done.
|
||||
|
||||
### Documentation updates
|
||||
|
||||
1. Remove or revise claims that all visible controls are intentionally no-op.
|
||||
2. Document that normal runtime uses SQLite.
|
||||
3. Document demo/test seed boundaries.
|
||||
4. Document current persistence proof and known limits.
|
||||
5. Document validation commands and results.
|
||||
6. Keep backup/restore and export/import boundaries unchanged.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Docs do not imply demo seed is normal runtime.
|
||||
- Docs identify exactly what persists now and what still does not.
|
||||
- Docs do not promise future UI/control behavior that is still unwired.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 6.5 — Run full validation gates
|
||||
|
||||
### Required commands
|
||||
|
||||
Run the broadest available set:
|
||||
|
||||
```sh
|
||||
scripts/bootstrap_dev.sh
|
||||
scripts/test.sh
|
||||
cd apps/focus_flow_flutter
|
||||
flutter pub get
|
||||
flutter analyze
|
||||
flutter test
|
||||
```
|
||||
|
||||
If Flutter is unavailable in the execution environment, run all available Dart
|
||||
validation and document Flutter as unverified.
|
||||
|
||||
### Extra focused commands
|
||||
|
||||
```sh
|
||||
cd packages/scheduler_persistence_sqlite
|
||||
dart analyze
|
||||
dart test
|
||||
```
|
||||
|
||||
If a new runtime package was added:
|
||||
|
||||
```sh
|
||||
cd packages/scheduler_runtime
|
||||
dart analyze
|
||||
dart test
|
||||
```
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Every changed package has package-local analyze/test results.
|
||||
- Root backend gate passes.
|
||||
- Flutter gate passes when the Flutter SDK is available.
|
||||
- Coverage remains above the current project threshold or any gap is documented
|
||||
and fixed before handoff.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 6.6 — Final handoff notes
|
||||
|
||||
### Required content
|
||||
|
||||
At the end of implementation, add a concise handoff note with:
|
||||
|
||||
1. Files changed.
|
||||
2. New runtime/composition entry points.
|
||||
3. SQLite schema/migration version decision.
|
||||
4. What state now persists.
|
||||
5. What state is still intentionally not in scope.
|
||||
6. Commands run and results.
|
||||
7. Known risks or follow-up plan suggestions.
|
||||
8. Manual close/reopen verification result if it was run.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- Handoff is specific enough for the next assistant/developer to continue
|
||||
without re-discovering the persistence boundary.
|
||||
- No unverified claim is written as passed validation.
|
||||
|
||||
---
|
||||
|
||||
## Block acceptance criteria
|
||||
|
||||
- Pure Dart lifecycle tests prove add/schedule/done/uncomplete close/reopen.
|
||||
- Flutter lifecycle tests prove the visible user path.
|
||||
- Manual verification instructions exist.
|
||||
- Docs match the new SQLite runtime behavior.
|
||||
- Validation results are recorded honestly.
|
||||
|
||||
## Validation after block
|
||||
|
||||
```sh
|
||||
scripts/test.sh
|
||||
cd apps/focus_flow_flutter
|
||||
flutter analyze
|
||||
flutter test
|
||||
```
|
||||
|
||||
## Completion statement template
|
||||
|
||||
Use this shape in the final implementation handoff:
|
||||
|
||||
```text
|
||||
Persistence Plan 1 is complete.
|
||||
|
||||
Changed:
|
||||
- ...
|
||||
|
||||
Now persists:
|
||||
- tasks captured from the UI
|
||||
- schedule placement
|
||||
- done/not-done state
|
||||
- owner settings/default project bootstrap
|
||||
- application operation/activity/statistics records needed by current commands
|
||||
|
||||
Validation:
|
||||
- scripts/test.sh: passed/failed/unavailable
|
||||
- flutter analyze: passed/failed/unavailable
|
||||
- flutter test: passed/failed/unavailable
|
||||
|
||||
Not in scope:
|
||||
- full task editor
|
||||
- backup/restore UI
|
||||
- export/import UI
|
||||
- sync/calendar/notifications
|
||||
```
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Persistence Plan 1 Execution Order
|
||||
|
||||
**Status:** Complete on 2026-07-02.
|
||||
|
||||
Run these files in order. All chunks are intended for `xhigh` execution unless a
|
||||
chunk explicitly says otherwise.
|
||||
|
||||
1. `PERSISTENCE_PLAN_1_BLOCK_01_REPO_AND_SCOPE_GUARDRAILS.md`
|
||||
- Reconcile current repo state.
|
||||
- Confirm demo seed retirement scope.
|
||||
- Confirm schema migration strategy.
|
||||
- Lock package-boundary rules for this persistence slice.
|
||||
|
||||
2. `PERSISTENCE_PLAN_1_BLOCK_02_SQLITE_SCHEMA_APP_RECORDS.md`
|
||||
- Add or verify missing SQLite tables for application-layer records.
|
||||
- Add Drift migration/schema tests.
|
||||
- Keep table rows behind adapter mapping helpers.
|
||||
|
||||
3. `PERSISTENCE_PLAN_1_BLOCK_03_SQLITE_APPLICATION_UNIT_OF_WORK.md`
|
||||
- Implement a SQLite-backed `ApplicationUnitOfWork`.
|
||||
- Implement application repository adapters needed by current read/command
|
||||
use cases.
|
||||
- Add transaction, idempotency, and close/reopen tests.
|
||||
|
||||
4. `PERSISTENCE_PLAN_1_BLOCK_04_FLUTTER_RUNTIME_COMPOSITION.md`
|
||||
- Build persistent runtime composition.
|
||||
- Open the configured SQLite file.
|
||||
- Bootstrap owner settings and the default project.
|
||||
- Dispose the database cleanly.
|
||||
|
||||
5. `PERSISTENCE_PLAN_1_BLOCK_05_RETIRE_DEMO_SEED_AND_WIRE_COMMANDS.md`
|
||||
- Stop normal app startup from inserting arbitrary demo tasks.
|
||||
- Preserve demo/test composition only for tests or explicit demo mode.
|
||||
- Wire quick capture, schedule, done, and uncomplete against SQLite state.
|
||||
|
||||
6. `PERSISTENCE_PLAN_1_BLOCK_06_LIFECYCLE_VALIDATION_HANDOFF.md`
|
||||
- Add temp-file lifecycle tests for add/schedule/done/reopen.
|
||||
- Run backend and Flutter validation gates.
|
||||
- Update handoff docs and README status.
|
||||
|
||||
Do not jump ahead into broader task editing, backup UI, export UI, navigation,
|
||||
calendar sync, notifications, or Shield/Recovery screens while executing this
|
||||
plan.
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Persistence Plan 1 Summary — SQLite Runtime Persistence
|
||||
|
||||
**Status:** Complete on 2026-07-02.
|
||||
**Scope level:** XHIGH implementation plan.
|
||||
**Primary outcome:** The Flutter desktop app persists real task state to an
|
||||
on-disk SQLite database instead of recreating arbitrary demo seed data at launch.
|
||||
|
||||
---
|
||||
|
||||
## Current repo facts this plan is based on
|
||||
|
||||
1. The project is SQLite-first and V1 has no MongoDB runtime.
|
||||
2. `packages/scheduler_persistence_sqlite` already contains a Drift database and
|
||||
repository implementations for task/project/locked-time/settings/snapshot
|
||||
persistence.
|
||||
3. The Flutter app still starts through `DemoSchedulerComposition.seeded()` and
|
||||
an `InMemoryApplicationUnitOfWork`.
|
||||
4. The dev runner already passes `SCHEDULER_SQLITE_PATH` into Flutter as a Dart
|
||||
define, but the Flutter app does not yet consume that path.
|
||||
5. `V1ApplicationCommandUseCases` and `GetTodayStateQuery` already operate
|
||||
against the public `ApplicationUnitOfWork` boundary.
|
||||
6. Existing command flows write more than tasks: completion can also write task
|
||||
activities, project statistics, and operation records.
|
||||
7. Current SQLite schema files do not yet expose all app-layer repository data
|
||||
needed for durable application semantics across restarts.
|
||||
|
||||
---
|
||||
|
||||
## Definition of done
|
||||
|
||||
Persistence Plan 1 is complete when all of the following are true:
|
||||
|
||||
1. `main.dart` no longer boots the normal app through static seeded demo data.
|
||||
2. App startup opens an on-disk SQLite database at the configured path.
|
||||
3. The composition root builds scheduler queries/use cases with a SQLite-backed
|
||||
`ApplicationUnitOfWork`.
|
||||
4. The app creates or loads required owner bootstrap data, including owner
|
||||
settings and a default Inbox/Home project, without inserting arbitrary demo
|
||||
timeline tasks.
|
||||
5. Quick capture persists a task to SQLite.
|
||||
6. Scheduling a backlog task persists `status`, `durationMinutes`,
|
||||
`scheduledStart`, and `scheduledEnd`.
|
||||
7. Completing and uncompleting a task persist `status`, actual/completion
|
||||
timestamps, and updated metadata.
|
||||
8. Closing and reopening the app against the same database reloads the task,
|
||||
schedule placement, and done/not-done state.
|
||||
9. Widgets/controllers still consume read models/controllers and do not contain
|
||||
scheduling, Drift, SQL, or OS path logic.
|
||||
10. Temp-file lifecycle tests prove close/reopen persistence.
|
||||
11. Existing backend gates and Flutter gates pass, or every unavailable command
|
||||
is documented with the reason.
|
||||
|
||||
---
|
||||
|
||||
## Non-goals
|
||||
|
||||
1. No UI redesign beyond empty/loading/error text needed for real persisted data.
|
||||
2. No new scheduling algorithm behavior.
|
||||
3. No manual SQL in Flutter widgets.
|
||||
4. No backup/restore UI.
|
||||
5. No JSON/CSV export UI.
|
||||
6. No calendar sync.
|
||||
7. No notifications wiring.
|
||||
8. No week/month views.
|
||||
9. No Shield/Recovery UX beyond preserving existing backend recovery contracts.
|
||||
|
||||
---
|
||||
|
||||
## Architecture rule
|
||||
|
||||
The SQLite runtime must sit behind the existing application boundaries:
|
||||
|
||||
```text
|
||||
Flutter widgets/controllers
|
||||
-> app composition/root runtime object
|
||||
-> V1ApplicationCommandUseCases / GetTodayStateQuery
|
||||
-> ApplicationUnitOfWork
|
||||
-> SQLite application repositories / Drift database
|
||||
```
|
||||
|
||||
The UI may request capture/schedule/done actions. The scheduler core remains the
|
||||
source of truth for schedule state and task transitions.
|
||||
|
||||
---
|
||||
|
||||
## Important implementation decision
|
||||
|
||||
The current `scheduler_persistence_sqlite` adapter package implements
|
||||
repository-conformance contracts, but the app command layer needs the richer
|
||||
`scheduler_core` application repository surface. This plan therefore adds a
|
||||
SQLite-backed `ApplicationUnitOfWork` adapter and the missing app-layer rows it
|
||||
requires instead of wiring Flutter directly to low-level persistence
|
||||
repositories.
|
||||
|
||||
Recommended default: implement the application unit of work in
|
||||
`packages/scheduler_persistence_sqlite` and expose it from `sqlite.dart`.
|
||||
|
||||
Optional stricter boundary: create a small pure-Dart runtime package that owns
|
||||
SQLite file opening and depends on `scheduler_persistence_sqlite`; Flutter then
|
||||
imports only that runtime package. Use this option only if keeping all SQLite
|
||||
package imports out of `apps/focus_flow_flutter/lib/` remains a hard boundary.
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Persistence Plan 1 - SQLite Runtime Persistence
|
||||
|
||||
**Status:** Complete on 2026-07-02.
|
||||
|
||||
Start with `PERSISTENCE_PLAN_1_SUMMARY.md`, then execute the block files in
|
||||
`PERSISTENCE_PLAN_1_EXECUTION_ORDER.md` order.
|
||||
|
||||
This plan replaces the current seeded/in-memory Flutter runtime with a real
|
||||
SQLite-backed runtime slice. The target user-visible proof is narrow:
|
||||
|
||||
1. Start the desktop app.
|
||||
2. Add/capture a task.
|
||||
3. Optionally schedule it into Today.
|
||||
4. Mark it done or not done.
|
||||
5. Close the app.
|
||||
6. Reopen the app against the same SQLite file.
|
||||
7. See the task, placement, and completion state exactly as persisted.
|
||||
|
||||
The plan does **not** redesign the UI, add sync, add week/month views, add
|
||||
Shield/Recovery surfaces, or move scheduling rules into Flutter widgets.
|
||||
|
||||
## Completion Handoff
|
||||
|
||||
Changed:
|
||||
|
||||
- Added Drift schema version 2 app-layer tables for application operations,
|
||||
task activities, project statistics, and notice acknowledgements.
|
||||
- Added `SqliteApplicationUnitOfWork` and `SqliteApplicationRuntime` in
|
||||
`packages/scheduler_persistence_sqlite`.
|
||||
- Switched normal Flutter startup to `PersistentSchedulerComposition`.
|
||||
- Kept `DemoSchedulerComposition` for deterministic widget and visual tests.
|
||||
- Updated boundary tests so only the persistent composition root can import the
|
||||
SQLite adapter and `dart:io`.
|
||||
|
||||
Now persists:
|
||||
|
||||
- tasks captured through current command flows,
|
||||
- planned schedule placement,
|
||||
- completed and reopened/not-done state,
|
||||
- owner settings and default Home project bootstrap,
|
||||
- operation, activity, and project statistics records needed by current
|
||||
commands.
|
||||
|
||||
Validation recorded during implementation:
|
||||
|
||||
- `cd packages/scheduler_persistence_sqlite && dart analyze`: passed.
|
||||
- `cd packages/scheduler_persistence_sqlite && dart test`: passed.
|
||||
- `cd apps/focus_flow_flutter && flutter pub get`: passed.
|
||||
- `cd apps/focus_flow_flutter && flutter analyze`: passed.
|
||||
- `cd apps/focus_flow_flutter && flutter test`: passed.
|
||||
|
||||
Not in scope:
|
||||
|
||||
- full task editor,
|
||||
- visible Backlog navigation in the compact shell,
|
||||
- backup/restore UI,
|
||||
- export/import UI,
|
||||
- sync/calendar/notifications,
|
||||
- week/month views,
|
||||
- Shield/Recovery UX.
|
||||
|
|
@ -12,6 +12,8 @@ Included documents cover:
|
|||
ADRs, public API baseline, and requirements traceability matrix.
|
||||
- UI Plan 1, the implemented compact Today timeline mockup plan, including its
|
||||
source mockups and completed block handoff notes.
|
||||
- Persistence Plan 1, the implemented SQLite runtime persistence slice for
|
||||
normal Flutter startup and close/reopen task lifecycle proof.
|
||||
|
||||
Excluded documents include superseded originals, MongoDB-targeted persistence or
|
||||
runtime plans, planned/blocked UI handoff plans, old archive indexes, and review
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
|
||||
# Current Software Plan
|
||||
|
||||
No active implementation plans are queued here after UI Plan 1 completed on
|
||||
2026-06-30.
|
||||
Ordered implementation queue:
|
||||
|
||||
Completed implementation plans live in `../Archived plans/`. Curated copies
|
||||
that still match the app's current state live in `../Completed Plans/`.
|
||||
1. `Date Selection 01 - Day Navigation and Date Picker/`
|
||||
2. `Task Pushing 01 - Past Task Push Controls/`
|
||||
|
||||
Execute only the first incomplete plan. Do not implement Task Pushing 01 until
|
||||
Date Selection 01 is complete.
|
||||
|
||||
Start each plan with its `*_SUMMARY.md`, then follow its `*_EXECUTION_ORDER.md`.
|
||||
Completed implementation plans live in `../Archived plans/`. Curated copies that
|
||||
still match the app's current state live in `../Completed Plans/`.
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ dart run scripts/build.dart --platform current --output build/releases
|
|||
|
||||
The dev script prints the SQLite path, starts `dart run build_runner watch`, and
|
||||
launches Flutter desktop with `SCHEDULER_SQLITE_PATH` passed as a dart define.
|
||||
Normal Flutter startup uses that SQLite file for scheduler state rather than
|
||||
inserting seeded demo tasks.
|
||||
|
||||
Prerequisites:
|
||||
|
||||
|
|
|
|||
|
|
@ -7,18 +7,28 @@ Flutter desktop app target for the FocusFlow UI work.
|
|||
|
||||
## Current Scope
|
||||
|
||||
UI Plan 1 implements a compact Today timeline mockup and selected-task modal.
|
||||
The app target already existed before UI Plan 1 Block 1, so this package keeps
|
||||
the existing Flutter desktop scaffolding and local scheduler dependency.
|
||||
The app launches into the compact Today timeline. Normal startup now opens an
|
||||
on-disk SQLite database and bootstraps owner settings plus the default Home
|
||||
project when they are missing.
|
||||
|
||||
The app launches into the compact Today screen. All visible controls are
|
||||
intentionally no-op except selecting a task card and closing the modal.
|
||||
The compact Today screen can toggle completion for currently rendered task
|
||||
cards. Backlog widgets and quick-capture controls exist for tests and focused
|
||||
composition paths, but the sidebar Backlog navigation remains out of scope for
|
||||
this screen.
|
||||
|
||||
## Seed Data
|
||||
## Runtime Data
|
||||
|
||||
Static demo data lives in `lib/app/demo_scheduler_composition.dart`. It creates
|
||||
domain `ProjectProfile`, `OwnerSettings`, and `Task` records in an in-memory
|
||||
application store, then reads `TodayState` through `GetTodayStateQuery`.
|
||||
Normal runtime uses `lib/app/persistent_scheduler_composition.dart`, which reads
|
||||
`SCHEDULER_SQLITE_PATH` from a Dart define. If no define is present, it uses the
|
||||
local dev convention:
|
||||
|
||||
```text
|
||||
~/ADHD_Scheduler/scheduler.sqlite
|
||||
```
|
||||
|
||||
Static demo data still lives in `lib/app/demo_scheduler_composition.dart`, but
|
||||
it is for deterministic widget/visual tests and explicit demo composition only.
|
||||
It is not inserted during normal startup.
|
||||
|
||||
Widgets consume `TodayScreenData` from `lib/models/today_screen_models.dart`.
|
||||
Scheduler core remains the source of truth for task state and Today ordering.
|
||||
|
|
@ -31,9 +41,12 @@ Allowed app imports include public scheduler APIs such as:
|
|||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
```
|
||||
|
||||
The app must not import scheduler `src/` files, SQLite/Drift adapters,
|
||||
desktop notification implementations, backup/export packages, or direct OS APIs
|
||||
for this UI slice. `test/forbidden_imports_test.dart` enforces that boundary.
|
||||
The persistent composition root may import
|
||||
`package:scheduler_persistence_sqlite/sqlite.dart` and `dart:io` for runtime
|
||||
path resolution. Widgets/controllers must not import scheduler `src/` files,
|
||||
Drift, SQLite adapters, desktop notification implementations, backup/export
|
||||
packages, or direct OS APIs. `test/forbidden_imports_test.dart` enforces that
|
||||
boundary.
|
||||
|
||||
## Intentionally No-op
|
||||
|
||||
|
|
@ -43,7 +56,28 @@ for this UI slice. `test/forbidden_imports_test.dart` enforces that boundary.
|
|||
- Settings button.
|
||||
- Show upcoming.
|
||||
- Timeline card action icons.
|
||||
- Modal Done, Push, Move to Backlog, and Break up buttons.
|
||||
- Modal Push, Move to Backlog, and Break up buttons.
|
||||
|
||||
## Persistence Proof
|
||||
|
||||
Current focused tests prove:
|
||||
|
||||
- first run opens an empty SQLite file without seeded task rows,
|
||||
- quick capture writes a backlog task,
|
||||
- scheduling writes planned Today placement,
|
||||
- done and not-done state persists,
|
||||
- closing and reopening the same SQLite file reloads the task state.
|
||||
|
||||
Manual verification:
|
||||
|
||||
```sh
|
||||
scripts/bootstrap_dev.sh
|
||||
scripts/dev.sh --sqlite /tmp/focus_flow_persistence_plan_1.sqlite
|
||||
```
|
||||
|
||||
Use the same `--sqlite` path after closing the app to confirm state persists. To
|
||||
reset local dev data, close the app and delete the chosen `.sqlite` file. This is
|
||||
only a dev reset; backup/restore UI remains out of scope.
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -57,8 +91,7 @@ Run these from `apps/focus_flow_flutter/`.
|
|||
|
||||
## Next Plans
|
||||
|
||||
- Wire functional Done, Push, Move to Backlog, and Break up actions.
|
||||
- Add Backlog and quick-capture UI.
|
||||
- Add persistent SQLite-backed runtime composition.
|
||||
- Wire visible Backlog navigation and quick-capture UI into the main shell.
|
||||
- Wire functional Push, Move to Backlog, and Break up actions.
|
||||
- Add real navigation/settings.
|
||||
- Add Shield/Recovery only in a later scope.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
part of '../demo_scheduler_composition.dart';
|
||||
|
||||
/// Formats a civil date as a long month/day/year label.
|
||||
String _formatDateLabel(CivilDate date) {
|
||||
String formatSchedulerDateLabel(CivilDate date) {
|
||||
const months = [
|
||||
'January',
|
||||
'February',
|
||||
|
|
|
|||
|
|
@ -9,12 +9,13 @@ import 'package:scheduler_core/scheduler_core.dart';
|
|||
import '../controllers/scheduler_command_controller.dart';
|
||||
import '../controllers/scheduler_read_controller.dart';
|
||||
import '../controllers/today_screen_controller.dart';
|
||||
import 'scheduler_app_composition.dart';
|
||||
|
||||
part 'demo/demo_date_formatter.dart';
|
||||
part 'demo/demo_seed_tasks.dart';
|
||||
|
||||
/// Wires the Flutter demo UI to in-memory scheduler application use cases.
|
||||
class DemoSchedulerComposition {
|
||||
class DemoSchedulerComposition implements SchedulerAppComposition {
|
||||
/// Creates a `DemoSchedulerComposition._` instance with the values required by its invariants.
|
||||
/// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
|
||||
DemoSchedulerComposition._({
|
||||
|
|
@ -54,7 +55,12 @@ class DemoSchedulerComposition {
|
|||
final DateTime readAt;
|
||||
|
||||
/// Human-readable label for [date].
|
||||
String get dateLabel => _formatDateLabel(date);
|
||||
@override
|
||||
String get dateLabel => formatSchedulerDateLabel(date);
|
||||
|
||||
/// Default project id used by quick capture.
|
||||
@override
|
||||
String get defaultProjectId => projectId;
|
||||
|
||||
/// Creates a deterministic seeded composition for the demo app and tests.
|
||||
factory DemoSchedulerComposition.seeded({
|
||||
|
|
@ -115,6 +121,7 @@ class DemoSchedulerComposition {
|
|||
}
|
||||
|
||||
/// Creates the controller used by the compact Today screen mockup.
|
||||
@override
|
||||
TodayScreenController createTodayScreenController() {
|
||||
return TodayScreenController(
|
||||
read: () {
|
||||
|
|
@ -163,6 +170,7 @@ class DemoSchedulerComposition {
|
|||
}
|
||||
|
||||
/// Creates the command controller used by interactive scheduler controls.
|
||||
@override
|
||||
SchedulerCommandController createCommandController({
|
||||
required ReadRefresh refreshReads,
|
||||
}) {
|
||||
|
|
@ -171,6 +179,7 @@ class DemoSchedulerComposition {
|
|||
contextFor: context,
|
||||
localDate: date,
|
||||
refreshReads: refreshReads,
|
||||
quickCaptureProjectId: defaultProjectId,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -186,4 +195,8 @@ class DemoSchedulerComposition {
|
|||
idGenerator: SequentialIdGenerator(prefix: operationId),
|
||||
);
|
||||
}
|
||||
|
||||
/// Releases demo composition resources.
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
/// Composes the FocusFlow Flutter application around Focus Flow App.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../controllers/scheduler_command_controller.dart';
|
||||
|
|
@ -17,7 +19,7 @@ import '../widgets/sidebar.dart';
|
|||
import '../widgets/task_selection_modal.dart';
|
||||
import '../widgets/timeline/timeline_view.dart';
|
||||
import '../widgets/top_bar.dart';
|
||||
import 'demo_scheduler_composition.dart';
|
||||
import 'scheduler_app_composition.dart';
|
||||
|
||||
part 'home/focus_flow_home.dart';
|
||||
part 'home/focus_flow_home_state.dart';
|
||||
|
|
@ -31,7 +33,7 @@ class FocusFlowApp extends StatelessWidget {
|
|||
const FocusFlowApp({required this.composition, super.key});
|
||||
|
||||
/// Factory and controller bundle used by the app tree.
|
||||
final DemoSchedulerComposition composition;
|
||||
final SchedulerAppComposition composition;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class FocusFlowHome extends StatefulWidget {
|
|||
const FocusFlowHome({required this.composition, super.key});
|
||||
|
||||
/// Factory and controller bundle used by the home screen.
|
||||
final DemoSchedulerComposition composition;
|
||||
final SchedulerAppComposition composition;
|
||||
|
||||
/// Creates the mutable state object used by this stateful widget.
|
||||
/// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
|
|||
void dispose() {
|
||||
commandController.dispose();
|
||||
controller.dispose();
|
||||
unawaited(widget.composition.dispose());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Composes the FocusFlow app around persistent SQLite scheduler state.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_persistence_sqlite/sqlite.dart';
|
||||
|
||||
import '../controllers/scheduler_command_controller.dart';
|
||||
import '../controllers/today_screen_controller.dart';
|
||||
import 'demo_scheduler_composition.dart' show formatSchedulerDateLabel;
|
||||
import 'scheduler_app_composition.dart';
|
||||
|
||||
/// Persistent SQLite-backed scheduler composition for normal app runtime.
|
||||
final class PersistentSchedulerComposition implements SchedulerAppComposition {
|
||||
/// Creates a persistent composition from already-open collaborators.
|
||||
PersistentSchedulerComposition._({
|
||||
required this.runtime,
|
||||
required this.todayQuery,
|
||||
required this.managementUseCases,
|
||||
required this.commandUseCases,
|
||||
required this.date,
|
||||
required this.readAt,
|
||||
});
|
||||
|
||||
/// Opens the configured SQLite runtime and bootstraps required owner state.
|
||||
static Future<PersistentSchedulerComposition> open({
|
||||
String? sqlitePath,
|
||||
Clock? clock,
|
||||
CivilDate? selectedDate,
|
||||
}) async {
|
||||
final runtime = await SqliteApplicationRuntime.open(
|
||||
path: sqlitePath ?? sqlitePathFromEnvironment(),
|
||||
);
|
||||
final bootstrap = await runtime.bootstrap();
|
||||
if (bootstrap.isFailure) {
|
||||
await runtime.close();
|
||||
throw StateError(
|
||||
'SQLite bootstrap failed: ${bootstrap.failure!.code.name}',
|
||||
);
|
||||
}
|
||||
final resolvedClock = clock ?? const SystemClock();
|
||||
final date = selectedDate ?? CivilDate.fromDateTime(resolvedClock.now());
|
||||
final readAt = resolvedClock.now().toUtc();
|
||||
|
||||
return PersistentSchedulerComposition._(
|
||||
runtime: runtime,
|
||||
todayQuery: GetTodayStateQuery(
|
||||
applicationStore: runtime.applicationStore,
|
||||
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
|
||||
),
|
||||
managementUseCases: V1ApplicationManagementUseCases(
|
||||
applicationStore: runtime.applicationStore,
|
||||
),
|
||||
commandUseCases: V1ApplicationCommandUseCases(
|
||||
applicationStore: runtime.applicationStore,
|
||||
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
|
||||
),
|
||||
date: date,
|
||||
readAt: readAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Resolves the SQLite path from Dart defines or the local dev default.
|
||||
static String sqlitePathFromEnvironment() {
|
||||
const configured = String.fromEnvironment('SCHEDULER_SQLITE_PATH');
|
||||
final trimmed = configured.trim();
|
||||
if (trimmed.isNotEmpty) {
|
||||
return trimmed;
|
||||
}
|
||||
final home =
|
||||
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
|
||||
if (home == null || home.trim().isEmpty) {
|
||||
return 'scheduler.sqlite';
|
||||
}
|
||||
return '$home/ADHD_Scheduler/scheduler.sqlite';
|
||||
}
|
||||
|
||||
/// SQLite runtime owning the database handle.
|
||||
final SqliteApplicationRuntime runtime;
|
||||
|
||||
/// Query object used to read the Today state.
|
||||
final GetTodayStateQuery todayQuery;
|
||||
|
||||
/// Management use cases used by backlog-facing UI surfaces.
|
||||
final V1ApplicationManagementUseCases managementUseCases;
|
||||
|
||||
/// Command use cases used by interactive UI controls.
|
||||
final V1ApplicationCommandUseCases commandUseCases;
|
||||
|
||||
/// Local date represented by the app.
|
||||
final CivilDate date;
|
||||
|
||||
/// Stable read clock instant for operation contexts.
|
||||
final DateTime readAt;
|
||||
|
||||
/// Pending close operation, if disposal has started.
|
||||
Future<void>? _disposeFuture;
|
||||
|
||||
/// Default project id used by quick capture.
|
||||
@override
|
||||
String get defaultProjectId => SqliteApplicationRuntime.defaultV1ProjectId;
|
||||
|
||||
/// Human-readable label for [date].
|
||||
@override
|
||||
String get dateLabel => formatSchedulerDateLabel(date);
|
||||
|
||||
/// Creates the controller used by the compact Today screen mockup.
|
||||
@override
|
||||
TodayScreenController createTodayScreenController() {
|
||||
return TodayScreenController(
|
||||
read: () {
|
||||
return todayQuery.execute(
|
||||
GetTodayStateRequest(
|
||||
context: context('ui-plan-1-read-today'),
|
||||
date: date,
|
||||
includeNextFlexibleItem: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates the command controller used by interactive scheduler controls.
|
||||
@override
|
||||
SchedulerCommandController createCommandController({
|
||||
required ReadRefresh refreshReads,
|
||||
}) {
|
||||
return SchedulerCommandController(
|
||||
commands: commandUseCases,
|
||||
contextFor: context,
|
||||
localDate: date,
|
||||
refreshReads: refreshReads,
|
||||
quickCaptureProjectId: defaultProjectId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates an application operation context for [operationId].
|
||||
ApplicationOperationContext context(String operationId, {DateTime? now}) {
|
||||
return ApplicationOperationContext.start(
|
||||
operationId: operationId,
|
||||
ownerTimeZone: OwnerTimeZoneContext(
|
||||
ownerId: SqliteApplicationRuntime.defaultV1OwnerId,
|
||||
timeZoneId: SqliteApplicationRuntime.defaultV1TimeZoneId,
|
||||
),
|
||||
clock: FixedClock(now ?? readAt),
|
||||
idGenerator: SequentialIdGenerator(prefix: operationId),
|
||||
);
|
||||
}
|
||||
|
||||
/// Closes the persistent runtime.
|
||||
@override
|
||||
Future<void> dispose() {
|
||||
return _disposeFuture ??= runtime.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Defines the app composition protocol used by FocusFlow widgets.
|
||||
library;
|
||||
|
||||
import '../controllers/scheduler_command_controller.dart';
|
||||
import '../controllers/today_screen_controller.dart';
|
||||
|
||||
/// Runtime-independent composition surface consumed by the app shell.
|
||||
abstract interface class SchedulerAppComposition {
|
||||
/// Human-readable label for the selected local date.
|
||||
String get dateLabel;
|
||||
|
||||
/// Default project id used by quick-capture commands.
|
||||
String get defaultProjectId;
|
||||
|
||||
/// Creates the controller used by the compact Today screen.
|
||||
TodayScreenController createTodayScreenController();
|
||||
|
||||
/// Creates the command controller used by interactive scheduler controls.
|
||||
SchedulerCommandController createCommandController({
|
||||
required ReadRefresh refreshReads,
|
||||
});
|
||||
|
||||
/// Releases resources owned by the composition.
|
||||
Future<void> dispose();
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
required this.contextFor,
|
||||
required this.localDate,
|
||||
required this.refreshReads,
|
||||
required this.quickCaptureProjectId,
|
||||
DateTime Function()? now,
|
||||
}) : now = now ?? _utcNow;
|
||||
|
||||
|
|
@ -26,6 +27,9 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
/// Refresh callback invoked after commands that mutate read state.
|
||||
final ReadRefresh refreshReads;
|
||||
|
||||
/// Project id used by quick-capture commands.
|
||||
final String quickCaptureProjectId;
|
||||
|
||||
/// Clock used by direct UI commands such as marking a task complete.
|
||||
final DateTime Function() now;
|
||||
|
||||
|
|
@ -50,7 +54,7 @@ class SchedulerCommandController extends ChangeNotifier {
|
|||
return commands.quickCaptureToBacklog(
|
||||
context: contextFor(operationId),
|
||||
title: title,
|
||||
projectId: 'home',
|
||||
projectId: quickCaptureProjectId,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,13 +6,47 @@ library;
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app/demo_scheduler_composition.dart';
|
||||
import 'app/focus_flow_app.dart';
|
||||
import 'app/persistent_scheduler_composition.dart';
|
||||
|
||||
export 'app/demo_scheduler_composition.dart';
|
||||
export 'app/focus_flow_app.dart';
|
||||
export 'app/persistent_scheduler_composition.dart';
|
||||
|
||||
/// Starts the FocusFlow app with the seeded demo scheduler composition.
|
||||
void main() {
|
||||
runApp(FocusFlowApp(composition: DemoSchedulerComposition.seeded()));
|
||||
/// Starts the FocusFlow app with the persistent SQLite scheduler composition.
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
try {
|
||||
final composition = await PersistentSchedulerComposition.open();
|
||||
runApp(FocusFlowApp(composition: composition));
|
||||
} on Object catch (error) {
|
||||
runApp(_StartupFailureApp(message: error.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal failure app shown when persistent startup cannot finish.
|
||||
class _StartupFailureApp extends StatelessWidget {
|
||||
/// Creates a startup failure app with [message].
|
||||
const _StartupFailureApp({required this.message});
|
||||
|
||||
/// Diagnostic startup failure message.
|
||||
final String message;
|
||||
|
||||
/// Builds the widget subtree for this component.
|
||||
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'FocusFlow',
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text('Unable to open local schedule data.\n$message'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -41,6 +49,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -49,6 +73,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
drift:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: drift
|
||||
sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.34.0"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -57,6 +89,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
|
|
@ -75,6 +123,22 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -107,6 +171,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -131,6 +203,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.19.2"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -139,6 +219,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
scheduler_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -146,6 +242,20 @@ packages:
|
|||
relative: true
|
||||
source: path
|
||||
version: "0.1.0"
|
||||
scheduler_persistence:
|
||||
dependency: "direct overridden"
|
||||
description:
|
||||
path: "../../packages/scheduler_persistence"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.1.0"
|
||||
scheduler_persistence_sqlite:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../../packages/scheduler_persistence_sqlite"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.1.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
|
@ -159,6 +269,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "752d9d746052359a2022f588bb979f2e7c4e0f9e4b6a1c3121f7626a1574974b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.4"
|
||||
sqlite3_flutter_libs:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3_flutter_libs
|
||||
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.42"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -199,6 +325,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -215,6 +349,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.2.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.12.2 <4.0.0"
|
||||
flutter: ">=3.18.0-18.0.pre.54"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ dependencies:
|
|||
sdk: flutter
|
||||
scheduler_core:
|
||||
path: ../../packages/scheduler_core
|
||||
scheduler_persistence_sqlite:
|
||||
path: ../../packages/scheduler_persistence_sqlite
|
||||
sqlite3_flutter_libs: ^0.5.24
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
dev_dependencies:
|
||||
|
|
@ -21,5 +24,11 @@ dev_dependencies:
|
|||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
dependency_overrides:
|
||||
scheduler_core:
|
||||
path: ../../packages/scheduler_core
|
||||
scheduler_persistence:
|
||||
path: ../../packages/scheduler_persistence
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import 'package:flutter_test/flutter_test.dart';
|
|||
|
||||
/// Runs app package-boundary tests.
|
||||
void main() {
|
||||
test('Flutter UI imports stay inside the UI Plan 1 package boundary', () {
|
||||
test(
|
||||
'Flutter UI imports stay inside the SQLite runtime package boundary',
|
||||
() {
|
||||
final appRoot = Directory.current;
|
||||
final dartFiles = <File>[
|
||||
..._dartFiles(Directory('${appRoot.path}/lib')),
|
||||
|
|
@ -33,7 +35,8 @@ void main() {
|
|||
isEmpty,
|
||||
reason: 'Forbidden imports found:\n${violations.join('\n')}',
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Top-level helper that performs the `_dartFiles` operation for this file.
|
||||
|
|
@ -56,6 +59,7 @@ List<_ForbiddenImport> _forbiddenImportsFor(String relativePath) {
|
|||
"package:scheduler_core/src/",
|
||||
'scheduler_core internals',
|
||||
),
|
||||
if (!_isPersistentComposition(relativePath))
|
||||
const _ForbiddenImport(
|
||||
"package:scheduler_persistence_sqlite/",
|
||||
'SQLite persistence adapter',
|
||||
|
|
@ -71,11 +75,18 @@ List<_ForbiddenImport> _forbiddenImportsFor(String relativePath) {
|
|||
"package:scheduler_export_json/",
|
||||
'JSON export package',
|
||||
),
|
||||
if (relativePath != 'test/forbidden_imports_test.dart')
|
||||
if (relativePath != 'test/forbidden_imports_test.dart' &&
|
||||
relativePath != 'test/persistent_composition_test.dart' &&
|
||||
!_isPersistentComposition(relativePath))
|
||||
const _ForbiddenImport("dart:io", 'dart:io'),
|
||||
];
|
||||
}
|
||||
|
||||
/// Whether [relativePath] is the approved persistent runtime composition root.
|
||||
bool _isPersistentComposition(String relativePath) {
|
||||
return relativePath == 'lib/app/persistent_scheduler_composition.dart';
|
||||
}
|
||||
|
||||
/// Private implementation type for `_ForbiddenImport` in this library.
|
||||
/// It keeps supporting state and behavior scoped to the owning file so the public API stays focused on the scheduler concepts that callers use directly.
|
||||
class _ForbiddenImport {
|
||||
|
|
|
|||
111
apps/focus_flow_flutter/test/persistent_composition_test.dart
Normal file
111
apps/focus_flow_flutter/test/persistent_composition_test.dart
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Tests persistent SQLite composition behavior in the Flutter app.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
|
||||
import 'package:focus_flow_flutter/app/focus_flow_app.dart';
|
||||
import 'package:focus_flow_flutter/app/persistent_scheduler_composition.dart';
|
||||
import 'package:focus_flow_flutter/controllers/scheduler_command_controller.dart';
|
||||
|
||||
/// Runs persistent composition smoke tests.
|
||||
void main() {
|
||||
testWidgets(
|
||||
'persistent app starts without seeded demo tasks and reopens capture',
|
||||
(tester) async {
|
||||
final path = await _runAsync(tester, _tempDatabasePath);
|
||||
final composition = await _runAsync(
|
||||
tester,
|
||||
() => PersistentSchedulerComposition.open(
|
||||
sqlitePath: path,
|
||||
selectedDate: _date,
|
||||
clock: FixedClock(_instant(8)),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(FocusFlowApp(composition: composition));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(find.text('January 2, 2026'), findsOneWidget);
|
||||
expect(find.text('Clean coffee maker'), findsNothing);
|
||||
expect(find.text('Water plants'), findsNothing);
|
||||
|
||||
final commandController = composition.createCommandController(
|
||||
refreshReads: () async {},
|
||||
);
|
||||
addTearDown(commandController.dispose);
|
||||
await tester.runAsync(
|
||||
() => commandController.quickCaptureToBacklog(
|
||||
'Captured from Flutter test',
|
||||
),
|
||||
);
|
||||
expect(commandController.state, isA<SchedulerCommandSuccess>());
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pump();
|
||||
await tester.runAsync(composition.dispose);
|
||||
|
||||
final reopened = await _runAsync(
|
||||
tester,
|
||||
() => PersistentSchedulerComposition.open(
|
||||
sqlitePath: path,
|
||||
selectedDate: _date,
|
||||
clock: FixedClock(_instant(8, 10)),
|
||||
),
|
||||
);
|
||||
addTearDown(() => tester.runAsync(reopened.dispose));
|
||||
final backlog = await _runAsync(
|
||||
tester,
|
||||
() => reopened.managementUseCases.getBacklog(
|
||||
GetBacklogRequest(
|
||||
context: reopened.context('flutter-read-backlog'),
|
||||
sortKey: BacklogSortKey.age,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(backlog.requireValue.items, hasLength(1));
|
||||
expect(
|
||||
backlog.requireValue.items.single.task.title,
|
||||
'Captured from Flutter test',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Fixed date used by persistent Flutter tests.
|
||||
final _date = CivilDate(2026, 1, 2);
|
||||
|
||||
/// Returns an instant on the fixed lifecycle date.
|
||||
DateTime _instant(int hour, [int minute = 0]) {
|
||||
return DateTime.utc(_date.year, _date.month, _date.day, hour, minute);
|
||||
}
|
||||
|
||||
/// Returns a fresh temporary SQLite path.
|
||||
Future<String> _tempDatabasePath() async {
|
||||
final directory = await Directory.systemTemp.createTemp(
|
||||
'focus-flow-flutter-persistence-',
|
||||
);
|
||||
addTearDown(() => directory.delete(recursive: true));
|
||||
return '${directory.path}/scheduler.sqlite';
|
||||
}
|
||||
|
||||
/// Runs real async work outside the widget-test fake async zone.
|
||||
Future<T> _runAsync<T>(
|
||||
WidgetTester tester,
|
||||
Future<T> Function() callback,
|
||||
) async {
|
||||
final result = await tester.runAsync(callback);
|
||||
if (result == null) {
|
||||
throw StateError('runAsync returned null.');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -6,6 +6,9 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
sqlite3_flutter_libs
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
|
|
|||
|
|
@ -11,11 +11,15 @@ import 'package:drift/drift.dart';
|
|||
|
||||
part 'scheduler_db.g.dart';
|
||||
part 'scheduler_db/tables/tasks/tasks.dart';
|
||||
part 'scheduler_db/tables/tasks/task_activities.dart';
|
||||
part 'scheduler_db/tables/projects/projects.dart';
|
||||
part 'scheduler_db/tables/projects/project_statistics.dart';
|
||||
part 'scheduler_db/tables/locked_time/locked_blocks.dart';
|
||||
part 'scheduler_db/tables/locked_time/locked_overrides.dart';
|
||||
part 'scheduler_db/tables/settings/settings_table.dart';
|
||||
part 'scheduler_db/tables/snapshots/snapshots.dart';
|
||||
part 'scheduler_db/tables/application/application_operations.dart';
|
||||
part 'scheduler_db/tables/application/notice_acknowledgements.dart';
|
||||
part 'scheduler_db/daos/task_dao.dart';
|
||||
part 'scheduler_db/daos/project_dao.dart';
|
||||
part 'scheduler_db/daos/locked_block_dao.dart';
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -7,11 +7,15 @@ part of '../../scheduler_db.dart';
|
|||
@DriftDatabase(
|
||||
tables: [
|
||||
Tasks,
|
||||
TaskActivities,
|
||||
Projects,
|
||||
ProjectStatisticsTable,
|
||||
LockedBlocks,
|
||||
LockedOverrides,
|
||||
SettingsTable,
|
||||
Snapshots,
|
||||
ApplicationOperations,
|
||||
NoticeAcknowledgements,
|
||||
],
|
||||
daos: [
|
||||
TaskDao,
|
||||
|
|
@ -29,7 +33,7 @@ class SchedulerDb extends _$SchedulerDb {
|
|||
/// Returns the derived `schemaVersion` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
int get schemaVersion => 2;
|
||||
|
||||
/// Runs the `migration` operation and completes after its asynchronous work finishes.
|
||||
/// Callers should await the returned future so repository writes, UI refreshes, and failure handling happen in a predictable order.
|
||||
|
|
@ -37,6 +41,48 @@ class SchedulerDb extends _$SchedulerDb {
|
|||
MigrationStrategy get migration => MigrationStrategy(
|
||||
onCreate: (Migrator migrator) async {
|
||||
await migrator.createAll();
|
||||
await _createApplicationIndexes();
|
||||
},
|
||||
onUpgrade: (Migrator migrator, int from, int to) async {
|
||||
if (from < 2) {
|
||||
await migrator.createTable(taskActivities);
|
||||
await migrator.createTable(projectStatisticsTable);
|
||||
await migrator.createTable(applicationOperations);
|
||||
await migrator.createTable(noticeAcknowledgements);
|
||||
await _createApplicationIndexes();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/// Creates secondary indexes for app-layer tables.
|
||||
Future<void> _createApplicationIndexes() async {
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_application_operations_owner '
|
||||
'ON application_operations(owner_id)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_task_activities_owner '
|
||||
'ON task_activities(owner_id)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_task_activities_task '
|
||||
'ON task_activities(task_id)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_task_activities_project '
|
||||
'ON task_activities(project_id)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_task_activities_operation '
|
||||
'ON task_activities(operation_id)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS idx_project_statistics_owner_project '
|
||||
'ON project_statistics(owner_id, project_id)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_notice_acknowledgements_owner '
|
||||
'ON notice_acknowledgements(owner_id)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../../scheduler_db.dart';
|
||||
|
||||
/// Committed application operation rows used for idempotency.
|
||||
@DataClassName('ApplicationOperationRow')
|
||||
class ApplicationOperations extends Table {
|
||||
/// Returns the derived `tableName` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
@override
|
||||
String get tableName => 'application_operations';
|
||||
|
||||
/// Returns the derived `operationId` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
TextColumn get operationId => text().named('operation_id')();
|
||||
|
||||
/// Returns the derived `ownerId` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
TextColumn get ownerId => text().named('owner_id')();
|
||||
|
||||
/// Returns the derived `operationName` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
TextColumn get operationName => text().named('operation_name')();
|
||||
|
||||
/// Returns the derived `committedAtUtc` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
DateTimeColumn get committedAtUtc => dateTime().named('committed_at_utc')();
|
||||
|
||||
/// Returns the derived `primaryKey` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
@override
|
||||
Set<Column<Object>> get primaryKey => {operationId};
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../../scheduler_db.dart';
|
||||
|
||||
/// Owner-scoped acknowledged notice rows.
|
||||
@DataClassName('NoticeAcknowledgementRow')
|
||||
class NoticeAcknowledgements extends Table {
|
||||
/// Returns the derived `tableName` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
@override
|
||||
String get tableName => 'notice_acknowledgements';
|
||||
|
||||
/// Returns the derived `ownerId` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
TextColumn get ownerId => text().named('owner_id')();
|
||||
|
||||
/// Returns the derived `noticeId` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
TextColumn get noticeId => text().named('notice_id')();
|
||||
|
||||
/// Returns the derived `acknowledgedAtUtc` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
DateTimeColumn get acknowledgedAtUtc =>
|
||||
dateTime().named('acknowledged_at_utc')();
|
||||
|
||||
/// Returns the derived `revision` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
IntColumn get revision => integer()();
|
||||
|
||||
/// Returns the derived `createdAtUtc` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')();
|
||||
|
||||
/// Returns the derived `updatedAtUtc` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')();
|
||||
|
||||
/// Returns the derived `primaryKey` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
@override
|
||||
Set<Column<Object>> get primaryKey => {ownerId, noticeId};
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../../scheduler_db.dart';
|
||||
|
||||
/// Project-level aggregate statistics rows.
|
||||
@DataClassName('ProjectStatisticsRow')
|
||||
class ProjectStatisticsTable extends Table {
|
||||
/// Returns the derived `tableName` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
@override
|
||||
String get tableName => 'project_statistics';
|
||||
|
||||
/// Returns the derived `projectId` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
TextColumn get projectId => text().named('project_id')();
|
||||
|
||||
/// Returns the derived `ownerId` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
TextColumn get ownerId => text().named('owner_id')();
|
||||
|
||||
/// Returns the derived `completedTaskCount` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
IntColumn get completedTaskCount => integer().named('completed_task_count')();
|
||||
|
||||
/// Converts scheduler data for `durationMinuteCountsJson` without changing the source object.
|
||||
/// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details.
|
||||
TextColumn get durationMinuteCountsJson =>
|
||||
text().named('duration_minute_counts_json')();
|
||||
|
||||
/// Converts scheduler data for `completionTimeBucketCountsJson` without changing the source object.
|
||||
/// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details.
|
||||
TextColumn get completionTimeBucketCountsJson =>
|
||||
text().named('completion_time_bucket_counts_json')();
|
||||
|
||||
/// Returns the derived `totalPushesBeforeCompletion` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
IntColumn get totalPushesBeforeCompletion =>
|
||||
integer().named('total_pushes_before_completion')();
|
||||
|
||||
/// Returns the derived `completedAfterPushCount` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
IntColumn get completedAfterPushCount =>
|
||||
integer().named('completed_after_push_count')();
|
||||
|
||||
/// Converts scheduler data for `rewardCountsJson` without changing the source object.
|
||||
/// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details.
|
||||
TextColumn get rewardCountsJson => text().named('reward_counts_json')();
|
||||
|
||||
/// Converts scheduler data for `difficultyCountsJson` without changing the source object.
|
||||
/// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details.
|
||||
TextColumn get difficultyCountsJson =>
|
||||
text().named('difficulty_counts_json')();
|
||||
|
||||
/// Converts scheduler data for `reminderProfileCountsJson` without changing the source object.
|
||||
/// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details.
|
||||
TextColumn get reminderProfileCountsJson =>
|
||||
text().named('reminder_profile_counts_json')();
|
||||
|
||||
/// Returns the derived `revision` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
IntColumn get revision => integer()();
|
||||
|
||||
/// Returns the derived `createdAtUtc` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
DateTimeColumn get createdAtUtc => dateTime().named('created_at_utc')();
|
||||
|
||||
/// Returns the derived `updatedAtUtc` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
DateTimeColumn get updatedAtUtc => dateTime().named('updated_at_utc')();
|
||||
|
||||
/// Returns the derived `primaryKey` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
@override
|
||||
Set<Column<Object>> get primaryKey => {projectId};
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../../../scheduler_db.dart';
|
||||
|
||||
/// Append-only task activity fact rows.
|
||||
@DataClassName('TaskActivityRow')
|
||||
class TaskActivities extends Table {
|
||||
/// Returns the derived `tableName` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
@override
|
||||
String get tableName => 'task_activities';
|
||||
|
||||
/// Returns the derived `id` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
TextColumn get id => text()();
|
||||
|
||||
/// Returns the derived `ownerId` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
TextColumn get ownerId => text().named('owner_id')();
|
||||
|
||||
/// Returns the derived `taskId` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
TextColumn get taskId => text().named('task_id')();
|
||||
|
||||
/// Returns the derived `projectId` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
TextColumn get projectId => text().named('project_id')();
|
||||
|
||||
/// Returns the derived `operationId` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
TextColumn get operationId => text().named('operation_id')();
|
||||
|
||||
/// Returns the derived `code` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
TextColumn get code => text()();
|
||||
|
||||
/// Returns the derived `occurredAtUtc` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
DateTimeColumn get occurredAtUtc => dateTime().named('occurred_at_utc')();
|
||||
|
||||
/// Converts scheduler data for `metadataJson` without changing the source object.
|
||||
/// Conversion helpers keep persistence and export shapes explicit so domain models do not depend on storage-specific details.
|
||||
TextColumn get metadataJson => text().named('metadata_json')();
|
||||
|
||||
/// Returns the derived `primaryKey` value for this object.
|
||||
/// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
|
||||
@override
|
||||
Set<Column<Object>> get primaryKey => {id};
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../sqlite_repositories.dart';
|
||||
|
||||
/// SQLite implementation of the app-layer task activity repository.
|
||||
final class _SqliteApplicationTaskActivityRepository
|
||||
implements core.TaskActivityRepository {
|
||||
/// Creates a task activity repository for [db].
|
||||
_SqliteApplicationTaskActivityRepository(
|
||||
this.db, {
|
||||
required this.defaultOwnerId,
|
||||
});
|
||||
|
||||
/// Shared SQLite database handle.
|
||||
final SchedulerDb db;
|
||||
|
||||
/// Owner used for save methods without an owner parameter.
|
||||
final String defaultOwnerId;
|
||||
|
||||
/// Returns an activity by stable id.
|
||||
@override
|
||||
Future<core.TaskActivity?> findById(String id) async {
|
||||
final row = await _activityById(id);
|
||||
return row == null ? null : _taskActivityFromRow(row);
|
||||
}
|
||||
|
||||
/// Returns activities emitted by [operationId].
|
||||
@override
|
||||
Future<List<core.TaskActivity>> findByOperationId(String operationId) {
|
||||
return _activitiesWhere(
|
||||
(table) => table.operationId.equals(operationId),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns activities for [taskId].
|
||||
@override
|
||||
Future<List<core.TaskActivity>> findByTaskId(String taskId) {
|
||||
return _activitiesWhere((table) => table.taskId.equals(taskId));
|
||||
}
|
||||
|
||||
/// Returns activities for [projectId].
|
||||
@override
|
||||
Future<List<core.TaskActivity>> findByProjectId(String projectId) {
|
||||
return _activitiesWhere((table) => table.projectId.equals(projectId));
|
||||
}
|
||||
|
||||
/// Returns every activity.
|
||||
@override
|
||||
Future<List<core.TaskActivity>> findAll() {
|
||||
return _activitiesWhere((table) => const drift.Constant(true));
|
||||
}
|
||||
|
||||
/// Returns owner-scoped activities.
|
||||
@override
|
||||
Future<core.RepositoryPage<core.TaskActivity>> findByOwner({
|
||||
required String ownerId,
|
||||
core.RepositoryPageRequest page = const core.RepositoryPageRequest(),
|
||||
}) async {
|
||||
final rows = await (db.select(db.taskActivities)
|
||||
..where((table) => table.ownerId.equals(ownerId))
|
||||
..orderBy([
|
||||
(table) => drift.OrderingTerm.asc(table.occurredAtUtc),
|
||||
(table) => drift.OrderingTerm.asc(table.id),
|
||||
])
|
||||
..limit(
|
||||
page.limit + 1,
|
||||
offset: _applicationCursorOffset(page.cursor),
|
||||
))
|
||||
.get();
|
||||
return _applicationPageFromRows(rows, page, _taskActivityFromRow);
|
||||
}
|
||||
|
||||
/// Saves [activity], inserting it when absent.
|
||||
@override
|
||||
Future<void> save(core.TaskActivity activity) async {
|
||||
final row = await _activityById(activity.id);
|
||||
final ownerId = row?.ownerId ?? defaultOwnerId;
|
||||
await db.into(db.taskActivities).insertOnConflictUpdate(
|
||||
_taskActivityCompanion(activity, ownerId: ownerId),
|
||||
);
|
||||
}
|
||||
|
||||
/// Saves all [activities].
|
||||
@override
|
||||
Future<void> saveAll(Iterable<core.TaskActivity> activities) async {
|
||||
for (final activity in activities) {
|
||||
await save(activity);
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends [activity] for [ownerId] when its id is unused.
|
||||
@override
|
||||
Future<core.RepositoryMutationResult> append({
|
||||
required core.TaskActivity activity,
|
||||
required String ownerId,
|
||||
}) async {
|
||||
if (await _activityById(activity.id) != null) {
|
||||
return _mutationFailure(
|
||||
core.RepositoryFailureCode.duplicateId,
|
||||
entityId: activity.id,
|
||||
);
|
||||
}
|
||||
await db.into(db.taskActivities).insert(
|
||||
_taskActivityCompanion(activity, ownerId: ownerId),
|
||||
);
|
||||
return core.RepositoryMutationResult.success(revision: 1);
|
||||
}
|
||||
|
||||
/// Returns activities matching [where].
|
||||
Future<List<core.TaskActivity>> _activitiesWhere(
|
||||
drift.Expression<bool> Function($TaskActivitiesTable table) where,
|
||||
) async {
|
||||
final rows = await (db.select(db.taskActivities)
|
||||
..where(where)
|
||||
..orderBy([
|
||||
(table) => drift.OrderingTerm.asc(table.occurredAtUtc),
|
||||
(table) => drift.OrderingTerm.asc(table.id),
|
||||
]))
|
||||
.get();
|
||||
return List<core.TaskActivity>.unmodifiable(
|
||||
rows.map(_taskActivityFromRow),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a raw activity row by id.
|
||||
Future<TaskActivityRow?> _activityById(String id) {
|
||||
return (db.select(db.taskActivities)..where((table) => table.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a Drift companion for [activity].
|
||||
TaskActivitiesCompanion _taskActivityCompanion(
|
||||
core.TaskActivity activity, {
|
||||
required String ownerId,
|
||||
}) {
|
||||
return TaskActivitiesCompanion.insert(
|
||||
id: activity.id,
|
||||
ownerId: ownerId,
|
||||
taskId: activity.taskId,
|
||||
projectId: activity.projectId,
|
||||
operationId: activity.operationId,
|
||||
code: activity.code.name,
|
||||
occurredAtUtc: activity.occurredAt.toUtc(),
|
||||
metadataJson: _metadataJson(activity.metadata),
|
||||
);
|
||||
}
|
||||
|
||||
/// Converts a task activity row to a core activity.
|
||||
core.TaskActivity _taskActivityFromRow(TaskActivityRow row) {
|
||||
return core.TaskActivity(
|
||||
id: row.id,
|
||||
operationId: row.operationId,
|
||||
code: _enumByName(core.TaskActivityCode.values, row.code),
|
||||
taskId: row.taskId,
|
||||
projectId: row.projectId,
|
||||
occurredAt: row.occurredAtUtc.toUtc(),
|
||||
metadata: _metadataFromJson(row.metadataJson),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../sqlite_repositories.dart';
|
||||
|
||||
/// SQLite implementation of the app-layer locked-time repository.
|
||||
final class _SqliteApplicationLockedBlockRepository
|
||||
implements core.LockedBlockRepository {
|
||||
/// Creates a locked-time repository for [db].
|
||||
_SqliteApplicationLockedBlockRepository(
|
||||
this.db, {
|
||||
required this.defaultOwnerId,
|
||||
});
|
||||
|
||||
/// Shared SQLite database handle.
|
||||
final SchedulerDb db;
|
||||
|
||||
/// Owner used for save methods without an owner parameter.
|
||||
final String defaultOwnerId;
|
||||
|
||||
/// Returns a locked block by stable id.
|
||||
@override
|
||||
Future<core.LockedBlock?> findBlockById(String id) async {
|
||||
final row = await _blockById(id);
|
||||
return row == null ? null : _lockedBlockFromRow(row);
|
||||
}
|
||||
|
||||
/// Returns a locked block plus owner/revision metadata.
|
||||
@override
|
||||
Future<core.RepositoryRecord<core.LockedBlock>?> findBlockRecordById(
|
||||
String id,
|
||||
) async {
|
||||
final row = await _blockById(id);
|
||||
if (row == null) {
|
||||
return null;
|
||||
}
|
||||
return core.RepositoryRecord(
|
||||
value: _lockedBlockFromRow(row),
|
||||
ownerId: row.ownerId,
|
||||
revision: row.revision,
|
||||
archivedAt: _storedDateTime(row.archivedAtUtc),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns every locked block.
|
||||
@override
|
||||
Future<List<core.LockedBlock>> findAllBlocks() async {
|
||||
final rows = await (db.select(db.lockedBlocks)
|
||||
..orderBy([
|
||||
(table) => drift.OrderingTerm.asc(table.name),
|
||||
(table) => drift.OrderingTerm.asc(table.id),
|
||||
]))
|
||||
.get();
|
||||
return List<core.LockedBlock>.unmodifiable(rows.map(_lockedBlockFromRow));
|
||||
}
|
||||
|
||||
/// Returns owner-scoped locked blocks.
|
||||
@override
|
||||
Future<core.RepositoryPage<core.LockedBlock>> findBlocksByOwner({
|
||||
required String ownerId,
|
||||
bool includeArchived = false,
|
||||
core.RepositoryPageRequest page = const core.RepositoryPageRequest(),
|
||||
}) async {
|
||||
final rows = await (db.select(db.lockedBlocks)
|
||||
..where((table) {
|
||||
final ownerClause = table.ownerId.equals(ownerId);
|
||||
return includeArchived
|
||||
? ownerClause
|
||||
: ownerClause & table.archivedAtUtc.isNull();
|
||||
})
|
||||
..orderBy([
|
||||
(table) => drift.OrderingTerm.asc(table.name),
|
||||
(table) => drift.OrderingTerm.asc(table.id),
|
||||
])
|
||||
..limit(
|
||||
page.limit + 1,
|
||||
offset: _applicationCursorOffset(page.cursor),
|
||||
))
|
||||
.get();
|
||||
return _applicationPageFromRows(rows, page, _lockedBlockFromRow);
|
||||
}
|
||||
|
||||
/// Returns a locked override by stable id.
|
||||
@override
|
||||
Future<core.LockedBlockOverride?> findOverrideById(String id) async {
|
||||
final row = await _overrideById(id);
|
||||
return row == null ? null : _lockedOverrideFromRow(row);
|
||||
}
|
||||
|
||||
/// Returns a locked override plus owner/revision metadata.
|
||||
@override
|
||||
Future<core.RepositoryRecord<core.LockedBlockOverride>?>
|
||||
findOverrideRecordById(String id) async {
|
||||
final row = await _overrideById(id);
|
||||
if (row == null) {
|
||||
return null;
|
||||
}
|
||||
return core.RepositoryRecord(
|
||||
value: _lockedOverrideFromRow(row),
|
||||
ownerId: row.ownerId,
|
||||
revision: row.revision,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns every one-day override.
|
||||
@override
|
||||
Future<List<core.LockedBlockOverride>> findAllOverrides() async {
|
||||
final rows = await (db.select(db.lockedOverrides)
|
||||
..orderBy([
|
||||
(table) => drift.OrderingTerm.asc(table.date),
|
||||
(table) => drift.OrderingTerm.asc(table.createdAtUtc),
|
||||
(table) => drift.OrderingTerm.asc(table.id),
|
||||
]))
|
||||
.get();
|
||||
return List<core.LockedBlockOverride>.unmodifiable(
|
||||
rows.map(_lockedOverrideFromRow),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns owner-scoped overrides for [date].
|
||||
@override
|
||||
Future<core.RepositoryPage<core.LockedBlockOverride>> findOverridesForDate({
|
||||
required String ownerId,
|
||||
required core.CivilDate date,
|
||||
core.RepositoryPageRequest page = const core.RepositoryPageRequest(),
|
||||
}) async {
|
||||
final rows = await (db.select(db.lockedOverrides)
|
||||
..where(
|
||||
(table) =>
|
||||
table.ownerId.equals(ownerId) &
|
||||
table.date.equals(date.toIsoString()),
|
||||
)
|
||||
..orderBy([
|
||||
(table) => drift.OrderingTerm.asc(table.createdAtUtc),
|
||||
(table) => drift.OrderingTerm.asc(table.id),
|
||||
])
|
||||
..limit(
|
||||
page.limit + 1,
|
||||
offset: _applicationCursorOffset(page.cursor),
|
||||
))
|
||||
.get();
|
||||
return _applicationPageFromRows(rows, page, _lockedOverrideFromRow);
|
||||
}
|
||||
|
||||
/// Saves [block], inserting it when absent.
|
||||
@override
|
||||
Future<void> saveBlock(core.LockedBlock block) async {
|
||||
final row = await _blockById(block.id);
|
||||
if (row == null) {
|
||||
await db.into(db.lockedBlocks).insert(
|
||||
_lockedBlockCompanion(
|
||||
block,
|
||||
ownerId: core.OwnerId(defaultOwnerId),
|
||||
revision: core.Revision.initial,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await (db.update(db.lockedBlocks)
|
||||
..where((table) => table.id.equals(block.id)))
|
||||
.write(
|
||||
_lockedBlockCompanion(
|
||||
block,
|
||||
ownerId: core.OwnerId(row.ownerId),
|
||||
revision: core.Revision(row.revision).next(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Saves [override], inserting it when absent.
|
||||
@override
|
||||
Future<void> saveOverride(core.LockedBlockOverride override) async {
|
||||
final row = await _overrideById(override.id);
|
||||
if (row == null) {
|
||||
await db.into(db.lockedOverrides).insert(
|
||||
_lockedOverrideCompanion(
|
||||
override,
|
||||
ownerId: core.OwnerId(defaultOwnerId),
|
||||
revision: core.Revision.initial,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await (db.update(db.lockedOverrides)
|
||||
..where((table) => table.id.equals(override.id)))
|
||||
.write(
|
||||
_lockedOverrideCompanion(
|
||||
override,
|
||||
ownerId: core.OwnerId(row.ownerId),
|
||||
revision: core.Revision(row.revision).next(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Inserts [block] for [ownerId] when its id is unused.
|
||||
@override
|
||||
Future<core.RepositoryMutationResult> insertBlock({
|
||||
required core.LockedBlock block,
|
||||
required String ownerId,
|
||||
}) async {
|
||||
if (await _blockById(block.id) != null) {
|
||||
return _mutationFailure(
|
||||
core.RepositoryFailureCode.duplicateId,
|
||||
entityId: block.id,
|
||||
);
|
||||
}
|
||||
await db.into(db.lockedBlocks).insert(
|
||||
_lockedBlockCompanion(
|
||||
block,
|
||||
ownerId: core.OwnerId(ownerId),
|
||||
revision: core.Revision.initial,
|
||||
),
|
||||
);
|
||||
return core.RepositoryMutationResult.success(revision: 1);
|
||||
}
|
||||
|
||||
/// Saves [block] only when the repository revision matches [expectedRevision].
|
||||
@override
|
||||
Future<core.RepositoryMutationResult> saveBlockIfRevision({
|
||||
required core.LockedBlock block,
|
||||
required String ownerId,
|
||||
required int expectedRevision,
|
||||
}) async {
|
||||
final row = await _blockById(block.id);
|
||||
final failure = _compareForWrite(
|
||||
row: row,
|
||||
ownerId: ownerId,
|
||||
expectedRevision: expectedRevision,
|
||||
entityId: block.id,
|
||||
rowOwnerId: row?.ownerId,
|
||||
rowRevision: row?.revision,
|
||||
);
|
||||
if (failure != null) {
|
||||
return failure;
|
||||
}
|
||||
final nextRevision = core.Revision(expectedRevision).next();
|
||||
await (db.update(db.lockedBlocks)
|
||||
..where(
|
||||
(table) =>
|
||||
table.id.equals(block.id) &
|
||||
table.ownerId.equals(ownerId) &
|
||||
table.revision.equals(expectedRevision),
|
||||
))
|
||||
.write(
|
||||
_lockedBlockCompanion(
|
||||
block,
|
||||
ownerId: core.OwnerId(ownerId),
|
||||
revision: nextRevision,
|
||||
),
|
||||
);
|
||||
return core.RepositoryMutationResult.success(revision: nextRevision.value);
|
||||
}
|
||||
|
||||
/// Archives [blockId] without deleting history.
|
||||
@override
|
||||
Future<core.RepositoryMutationResult> archiveBlock({
|
||||
required String blockId,
|
||||
required String ownerId,
|
||||
required int expectedRevision,
|
||||
required DateTime archivedAt,
|
||||
}) async {
|
||||
final block = await findBlockById(blockId);
|
||||
if (block == null) {
|
||||
return _mutationFailure(
|
||||
core.RepositoryFailureCode.notFound,
|
||||
entityId: blockId,
|
||||
);
|
||||
}
|
||||
return saveBlockIfRevision(
|
||||
block: block.copyWith(updatedAt: archivedAt, archivedAt: archivedAt),
|
||||
ownerId: ownerId,
|
||||
expectedRevision: expectedRevision,
|
||||
);
|
||||
}
|
||||
|
||||
/// Inserts [override] for [ownerId] when its id is unused.
|
||||
@override
|
||||
Future<core.RepositoryMutationResult> insertOverride({
|
||||
required core.LockedBlockOverride override,
|
||||
required String ownerId,
|
||||
}) async {
|
||||
if (await _overrideById(override.id) != null) {
|
||||
return _mutationFailure(
|
||||
core.RepositoryFailureCode.duplicateId,
|
||||
entityId: override.id,
|
||||
);
|
||||
}
|
||||
await db.into(db.lockedOverrides).insert(
|
||||
_lockedOverrideCompanion(
|
||||
override,
|
||||
ownerId: core.OwnerId(ownerId),
|
||||
revision: core.Revision.initial,
|
||||
),
|
||||
);
|
||||
return core.RepositoryMutationResult.success(revision: 1);
|
||||
}
|
||||
|
||||
/// Saves [override] only when the repository revision matches [expectedRevision].
|
||||
@override
|
||||
Future<core.RepositoryMutationResult> saveOverrideIfRevision({
|
||||
required core.LockedBlockOverride override,
|
||||
required String ownerId,
|
||||
required int expectedRevision,
|
||||
}) async {
|
||||
final row = await _overrideById(override.id);
|
||||
final failure = _compareForWrite(
|
||||
row: row,
|
||||
ownerId: ownerId,
|
||||
expectedRevision: expectedRevision,
|
||||
entityId: override.id,
|
||||
rowOwnerId: row?.ownerId,
|
||||
rowRevision: row?.revision,
|
||||
);
|
||||
if (failure != null) {
|
||||
return failure;
|
||||
}
|
||||
final nextRevision = core.Revision(expectedRevision).next();
|
||||
await (db.update(db.lockedOverrides)
|
||||
..where(
|
||||
(table) =>
|
||||
table.id.equals(override.id) &
|
||||
table.ownerId.equals(ownerId) &
|
||||
table.revision.equals(expectedRevision),
|
||||
))
|
||||
.write(
|
||||
_lockedOverrideCompanion(
|
||||
override,
|
||||
ownerId: core.OwnerId(ownerId),
|
||||
revision: nextRevision,
|
||||
),
|
||||
);
|
||||
return core.RepositoryMutationResult.success(revision: nextRevision.value);
|
||||
}
|
||||
|
||||
/// Returns a raw block row by id.
|
||||
Future<LockedBlockRow?> _blockById(String id) {
|
||||
return (db.select(db.lockedBlocks)..where((table) => table.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Returns a raw override row by id.
|
||||
Future<LockedOverrideRow?> _overrideById(String id) {
|
||||
return (db.select(db.lockedOverrides)
|
||||
..where((table) => table.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../sqlite_repositories.dart';
|
||||
|
||||
/// Returns a typed mutation failure.
|
||||
core.RepositoryMutationResult _mutationFailure(
|
||||
core.RepositoryFailureCode code, {
|
||||
String? entityId,
|
||||
int? expectedRevision,
|
||||
int? actualRevision,
|
||||
}) {
|
||||
return core.RepositoryMutationResult.failure(
|
||||
core.RepositoryFailure(
|
||||
code: code,
|
||||
entityId: entityId,
|
||||
expectedRevision: expectedRevision,
|
||||
actualRevision: actualRevision,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Compares row ownership and revision before a write.
|
||||
core.RepositoryMutationResult? _compareForWrite({
|
||||
required Object? row,
|
||||
required String ownerId,
|
||||
required int expectedRevision,
|
||||
required String entityId,
|
||||
required String? rowOwnerId,
|
||||
required int? rowRevision,
|
||||
}) {
|
||||
if (expectedRevision <= 0) {
|
||||
return _mutationFailure(
|
||||
core.RepositoryFailureCode.invalidRevision,
|
||||
entityId: entityId,
|
||||
expectedRevision: expectedRevision,
|
||||
);
|
||||
}
|
||||
if (row == null) {
|
||||
return _mutationFailure(
|
||||
core.RepositoryFailureCode.notFound,
|
||||
entityId: entityId,
|
||||
);
|
||||
}
|
||||
if (rowOwnerId != ownerId) {
|
||||
return _mutationFailure(
|
||||
core.RepositoryFailureCode.ownerMismatch,
|
||||
entityId: entityId,
|
||||
);
|
||||
}
|
||||
if (rowRevision != expectedRevision) {
|
||||
return _mutationFailure(
|
||||
core.RepositoryFailureCode.staleRevision,
|
||||
entityId: entityId,
|
||||
expectedRevision: expectedRevision,
|
||||
actualRevision: rowRevision,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Converts a cursor string to a bounded integer offset.
|
||||
int _applicationCursorOffset(String? cursor) {
|
||||
if (cursor == null) {
|
||||
return 0;
|
||||
}
|
||||
final parsed = int.tryParse(cursor);
|
||||
if (parsed == null || parsed < 0) {
|
||||
return 0;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/// Builds a repository page from already sorted [items].
|
||||
core.RepositoryPage<T> _applicationPage<T>(
|
||||
List<T> items,
|
||||
core.RepositoryPageRequest request,
|
||||
) {
|
||||
final offset = _applicationCursorOffset(request.cursor);
|
||||
final safeOffset = offset.clamp(0, items.length);
|
||||
final end = (safeOffset + request.limit).clamp(0, items.length);
|
||||
final pageItems = items.sublist(safeOffset, end);
|
||||
return core.RepositoryPage<T>(
|
||||
items: List<T>.unmodifiable(pageItems),
|
||||
nextCursor: end < items.length ? '$end' : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a repository page from Drift [rows] fetched with one extra row.
|
||||
core.RepositoryPage<T> _applicationPageFromRows<R, T>(
|
||||
List<R> rows,
|
||||
core.RepositoryPageRequest request,
|
||||
T Function(R row) convert,
|
||||
) {
|
||||
final pageRows = rows.take(request.limit).toList(growable: false);
|
||||
return core.RepositoryPage<T>(
|
||||
items: List<T>.unmodifiable(pageRows.map(convert)),
|
||||
nextCursor: rows.length > request.limit
|
||||
? '${_applicationCursorOffset(request.cursor) + request.limit}'
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a stored UTC instant as a [DateTime], if present.
|
||||
DateTime? _storedDateTime(DateTime? value) {
|
||||
return value?.toUtc();
|
||||
}
|
||||
|
||||
/// Encodes metadata values to JSON-compatible values.
|
||||
String _metadataJson(Map<String, Object?> metadata) {
|
||||
return jsonEncode(_jsonSafe(metadata));
|
||||
}
|
||||
|
||||
/// Decodes JSON metadata from SQLite.
|
||||
Map<String, Object?> _metadataFromJson(String value) {
|
||||
return Map<String, Object?>.from(
|
||||
jsonDecode(value) as Map<dynamic, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
/// Converts [value] to values supported by `jsonEncode`.
|
||||
Object? _jsonSafe(Object? value) {
|
||||
if (value == null || value is String || value is num || value is bool) {
|
||||
return value;
|
||||
}
|
||||
if (value is DateTime) {
|
||||
return core.PersistenceDateTimeConvention.toStoredString(value);
|
||||
}
|
||||
if (value is Enum) {
|
||||
return value.name;
|
||||
}
|
||||
if (value is Map) {
|
||||
return {
|
||||
for (final entry in value.entries)
|
||||
entry.key.toString(): _jsonSafe(entry.value as Object?),
|
||||
};
|
||||
}
|
||||
if (value is Iterable) {
|
||||
return [for (final item in value) _jsonSafe(item as Object?)];
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
/// Encodes integer-keyed counts.
|
||||
String _intCountMapJson(Map<int, int> counts) {
|
||||
return jsonEncode({
|
||||
for (final entry in counts.entries) '${entry.key}': entry.value,
|
||||
});
|
||||
}
|
||||
|
||||
/// Decodes integer-keyed counts.
|
||||
Map<int, int> _intCountMap(String value) {
|
||||
final decoded = jsonDecode(value) as Map<dynamic, dynamic>;
|
||||
return {
|
||||
for (final entry in decoded.entries)
|
||||
int.parse(entry.key as String): entry.value as int,
|
||||
};
|
||||
}
|
||||
|
||||
/// Encodes enum-keyed counts.
|
||||
String _enumCountMapJson<T extends Enum>(Map<T, int> counts) {
|
||||
return jsonEncode({
|
||||
for (final entry in counts.entries) entry.key.name: entry.value,
|
||||
});
|
||||
}
|
||||
|
||||
/// Decodes enum-keyed counts.
|
||||
Map<T, int> _enumCountMap<T extends Enum>(
|
||||
String value,
|
||||
List<T> values,
|
||||
) {
|
||||
final decoded = jsonDecode(value) as Map<dynamic, dynamic>;
|
||||
return {
|
||||
for (final entry in decoded.entries)
|
||||
_enumByName(values, entry.key as String): entry.value as int,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns an enum value by stable [name].
|
||||
T _enumByName<T extends Enum>(List<T> values, String name) {
|
||||
return values.firstWhere((value) => value.name == name);
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../sqlite_repositories.dart';
|
||||
|
||||
/// SQLite implementation of the app-layer notice acknowledgement repository.
|
||||
final class _SqliteApplicationNoticeAcknowledgementRepository
|
||||
implements core.NoticeAcknowledgementRepository {
|
||||
/// Creates a notice acknowledgement repository for [db].
|
||||
_SqliteApplicationNoticeAcknowledgementRepository(this.db);
|
||||
|
||||
/// Shared SQLite database handle.
|
||||
final SchedulerDb db;
|
||||
|
||||
/// Returns a notice acknowledgement by owner and notice id.
|
||||
@override
|
||||
Future<core.NoticeAcknowledgementRecord?> findById({
|
||||
required String ownerId,
|
||||
required String noticeId,
|
||||
}) async {
|
||||
final row = await _noticeById(ownerId: ownerId, noticeId: noticeId);
|
||||
return row == null ? null : _noticeFromRow(row);
|
||||
}
|
||||
|
||||
/// Returns acknowledgements for [ownerId].
|
||||
@override
|
||||
Future<List<core.NoticeAcknowledgementRecord>> findByOwnerId(
|
||||
String ownerId,
|
||||
) async {
|
||||
final rows = await (db.select(db.noticeAcknowledgements)
|
||||
..where((table) => table.ownerId.equals(ownerId))
|
||||
..orderBy([
|
||||
(table) => drift.OrderingTerm.asc(table.acknowledgedAtUtc),
|
||||
(table) => drift.OrderingTerm.asc(table.noticeId),
|
||||
]))
|
||||
.get();
|
||||
return List<core.NoticeAcknowledgementRecord>.unmodifiable(
|
||||
rows.map(_noticeFromRow),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns owner-scoped acknowledgements.
|
||||
@override
|
||||
Future<core.RepositoryPage<core.NoticeAcknowledgementRecord>> findByOwner({
|
||||
required String ownerId,
|
||||
core.RepositoryPageRequest page = const core.RepositoryPageRequest(),
|
||||
}) async {
|
||||
final rows = await (db.select(db.noticeAcknowledgements)
|
||||
..where((table) => table.ownerId.equals(ownerId))
|
||||
..orderBy([
|
||||
(table) => drift.OrderingTerm.asc(table.acknowledgedAtUtc),
|
||||
(table) => drift.OrderingTerm.asc(table.noticeId),
|
||||
])
|
||||
..limit(
|
||||
page.limit + 1,
|
||||
offset: _applicationCursorOffset(page.cursor),
|
||||
))
|
||||
.get();
|
||||
return _applicationPageFromRows(rows, page, _noticeFromRow);
|
||||
}
|
||||
|
||||
/// Saves [record], inserting it when absent.
|
||||
@override
|
||||
Future<void> save(core.NoticeAcknowledgementRecord record) async {
|
||||
final row = await _noticeById(
|
||||
ownerId: record.ownerId,
|
||||
noticeId: record.noticeId,
|
||||
);
|
||||
if (row == null) {
|
||||
await db.into(db.noticeAcknowledgements).insert(
|
||||
_noticeCompanion(
|
||||
record,
|
||||
revision: 1,
|
||||
createdAt: record.acknowledgedAt,
|
||||
updatedAt: record.acknowledgedAt,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await (db.update(db.noticeAcknowledgements)
|
||||
..where(
|
||||
(table) =>
|
||||
table.ownerId.equals(record.ownerId) &
|
||||
table.noticeId.equals(record.noticeId),
|
||||
))
|
||||
.write(
|
||||
_noticeCompanion(
|
||||
record,
|
||||
revision: row.revision + 1,
|
||||
createdAt: row.createdAtUtc,
|
||||
updatedAt: record.acknowledgedAt,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Inserts [record] when absent.
|
||||
@override
|
||||
Future<core.RepositoryMutationResult> insert(
|
||||
core.NoticeAcknowledgementRecord record,
|
||||
) async {
|
||||
if (await _noticeById(
|
||||
ownerId: record.ownerId,
|
||||
noticeId: record.noticeId,
|
||||
) !=
|
||||
null) {
|
||||
return _mutationFailure(
|
||||
core.RepositoryFailureCode.duplicateId,
|
||||
entityId: record.noticeId,
|
||||
);
|
||||
}
|
||||
await db.into(db.noticeAcknowledgements).insert(
|
||||
_noticeCompanion(
|
||||
record,
|
||||
revision: 1,
|
||||
createdAt: record.acknowledgedAt,
|
||||
updatedAt: record.acknowledgedAt,
|
||||
),
|
||||
);
|
||||
return core.RepositoryMutationResult.success(revision: 1);
|
||||
}
|
||||
|
||||
/// Returns a raw notice row by owner and notice id.
|
||||
Future<NoticeAcknowledgementRow?> _noticeById({
|
||||
required String ownerId,
|
||||
required String noticeId,
|
||||
}) {
|
||||
return (db.select(db.noticeAcknowledgements)
|
||||
..where(
|
||||
(table) =>
|
||||
table.ownerId.equals(ownerId) & table.noticeId.equals(noticeId),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a Drift companion for [record].
|
||||
NoticeAcknowledgementsCompanion _noticeCompanion(
|
||||
core.NoticeAcknowledgementRecord record, {
|
||||
required int revision,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
}) {
|
||||
return NoticeAcknowledgementsCompanion.insert(
|
||||
ownerId: record.ownerId,
|
||||
noticeId: record.noticeId,
|
||||
acknowledgedAtUtc: record.acknowledgedAt.toUtc(),
|
||||
revision: revision,
|
||||
createdAtUtc: createdAt.toUtc(),
|
||||
updatedAtUtc: updatedAt.toUtc(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Converts a notice acknowledgement row to a core record.
|
||||
core.NoticeAcknowledgementRecord _noticeFromRow(NoticeAcknowledgementRow row) {
|
||||
return core.NoticeAcknowledgementRecord(
|
||||
noticeId: row.noticeId,
|
||||
ownerId: row.ownerId,
|
||||
acknowledgedAt: row.acknowledgedAtUtc.toUtc(),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../sqlite_repositories.dart';
|
||||
|
||||
/// SQLite implementation of the app-layer operation repository.
|
||||
final class _SqliteApplicationOperationRepository
|
||||
implements core.ApplicationOperationRepository {
|
||||
/// Creates an operation repository for [db].
|
||||
_SqliteApplicationOperationRepository(this.db);
|
||||
|
||||
/// Shared SQLite database handle.
|
||||
final SchedulerDb db;
|
||||
|
||||
/// Returns an operation by stable operation id.
|
||||
@override
|
||||
Future<core.ApplicationOperationRecord?> findById(String operationId) async {
|
||||
final row = await _operationById(operationId);
|
||||
return row == null ? null : _operationFromRow(row);
|
||||
}
|
||||
|
||||
/// Returns an owner-scoped operation by operation id.
|
||||
@override
|
||||
Future<core.ApplicationOperationRecord?> findByIdempotencyKey({
|
||||
required String ownerId,
|
||||
required String operationId,
|
||||
}) async {
|
||||
final row = await _operationById(operationId);
|
||||
if (row == null || row.ownerId != ownerId) {
|
||||
return null;
|
||||
}
|
||||
return _operationFromRow(row);
|
||||
}
|
||||
|
||||
/// Saves [operation], replacing an existing row with the same id.
|
||||
@override
|
||||
Future<void> save(core.ApplicationOperationRecord operation) async {
|
||||
await db.applicationOperations.insertOnConflictUpdate(
|
||||
_operationCompanion(operation),
|
||||
);
|
||||
}
|
||||
|
||||
/// Inserts [operation] when absent.
|
||||
@override
|
||||
Future<core.RepositoryMutationResult> insertIfAbsent(
|
||||
core.ApplicationOperationRecord operation,
|
||||
) async {
|
||||
if (await _operationById(operation.operationId) != null) {
|
||||
return _mutationFailure(
|
||||
core.RepositoryFailureCode.duplicateOperation,
|
||||
entityId: operation.operationId,
|
||||
);
|
||||
}
|
||||
await db
|
||||
.into(db.applicationOperations)
|
||||
.insert(_operationCompanion(operation));
|
||||
return core.RepositoryMutationResult.success(revision: 1);
|
||||
}
|
||||
|
||||
/// Returns a raw operation row by operation id.
|
||||
Future<ApplicationOperationRow?> _operationById(String operationId) {
|
||||
return (db.select(db.applicationOperations)
|
||||
..where((table) => table.operationId.equals(operationId)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a Drift companion for [operation].
|
||||
ApplicationOperationsCompanion _operationCompanion(
|
||||
core.ApplicationOperationRecord operation,
|
||||
) {
|
||||
return ApplicationOperationsCompanion.insert(
|
||||
operationId: operation.operationId,
|
||||
ownerId: operation.ownerId,
|
||||
operationName: operation.operationName,
|
||||
committedAtUtc: operation.committedAt.toUtc(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Converts an operation row to a core record.
|
||||
core.ApplicationOperationRecord _operationFromRow(ApplicationOperationRow row) {
|
||||
return core.ApplicationOperationRecord(
|
||||
operationId: row.operationId,
|
||||
ownerId: row.ownerId,
|
||||
operationName: row.operationName,
|
||||
committedAt: row.committedAtUtc.toUtc(),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../sqlite_repositories.dart';
|
||||
|
||||
/// SQLite implementation of the app-layer owner settings repository.
|
||||
final class _SqliteApplicationOwnerSettingsRepository
|
||||
implements core.OwnerSettingsRepository {
|
||||
/// Creates an owner settings repository for [db].
|
||||
_SqliteApplicationOwnerSettingsRepository(this.db);
|
||||
|
||||
/// Shared SQLite database handle.
|
||||
final SchedulerDb db;
|
||||
|
||||
/// Returns settings for [ownerId].
|
||||
@override
|
||||
Future<core.OwnerSettings?> findByOwnerId(String ownerId) async {
|
||||
final row = await _settingsByOwnerId(ownerId);
|
||||
return row == null ? null : _settingsFromRow(row);
|
||||
}
|
||||
|
||||
/// Returns settings plus owner/revision metadata.
|
||||
@override
|
||||
Future<core.RepositoryRecord<core.OwnerSettings>?> findRecordByOwnerId(
|
||||
String ownerId,
|
||||
) async {
|
||||
final row = await _settingsByOwnerId(ownerId);
|
||||
if (row == null) {
|
||||
return null;
|
||||
}
|
||||
return core.RepositoryRecord(
|
||||
value: _settingsFromRow(row),
|
||||
ownerId: row.ownerId,
|
||||
revision: row.revision,
|
||||
);
|
||||
}
|
||||
|
||||
/// Saves [settings], inserting it when absent.
|
||||
@override
|
||||
Future<void> save(core.OwnerSettings settings) async {
|
||||
final row = await _settingsByOwnerId(settings.ownerId);
|
||||
if (row == null) {
|
||||
await db.into(db.settingsTable).insert(
|
||||
_settingsCompanion(
|
||||
settings,
|
||||
revision: core.Revision.initial,
|
||||
createdAt: _epoch,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await (db.update(db.settingsTable)
|
||||
..where((table) => table.ownerId.equals(settings.ownerId)))
|
||||
.write(
|
||||
_settingsCompanion(
|
||||
settings,
|
||||
revision: core.Revision(row.revision).next(),
|
||||
createdAt: row.createdAtUtc,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Saves [settings] only when the repository revision matches [expectedRevision].
|
||||
@override
|
||||
Future<core.RepositoryMutationResult> saveIfRevision({
|
||||
required core.OwnerSettings settings,
|
||||
required int expectedRevision,
|
||||
}) async {
|
||||
final row = await _settingsByOwnerId(settings.ownerId);
|
||||
final failure = _compareForWrite(
|
||||
row: row,
|
||||
ownerId: settings.ownerId,
|
||||
expectedRevision: expectedRevision,
|
||||
entityId: settings.ownerId,
|
||||
rowOwnerId: row?.ownerId,
|
||||
rowRevision: row?.revision,
|
||||
);
|
||||
if (failure != null) {
|
||||
return failure;
|
||||
}
|
||||
final nextRevision = core.Revision(expectedRevision).next();
|
||||
await (db.update(db.settingsTable)
|
||||
..where(
|
||||
(table) =>
|
||||
table.ownerId.equals(settings.ownerId) &
|
||||
table.revision.equals(expectedRevision),
|
||||
))
|
||||
.write(
|
||||
_settingsCompanion(
|
||||
settings,
|
||||
revision: nextRevision,
|
||||
createdAt: row!.createdAtUtc,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
);
|
||||
return core.RepositoryMutationResult.success(revision: nextRevision.value);
|
||||
}
|
||||
|
||||
/// Returns a raw settings row by owner id.
|
||||
Future<SettingsRow?> _settingsByOwnerId(String ownerId) {
|
||||
return (db.select(db.settingsTable)
|
||||
..where((table) => table.ownerId.equals(ownerId)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../sqlite_repositories.dart';
|
||||
|
||||
/// SQLite implementation of the app-layer project repository.
|
||||
final class _SqliteApplicationProjectRepository
|
||||
implements core.ProjectRepository {
|
||||
/// Creates a project repository for [db].
|
||||
_SqliteApplicationProjectRepository(
|
||||
this.db, {
|
||||
required this.defaultOwnerId,
|
||||
});
|
||||
|
||||
/// Shared SQLite database handle.
|
||||
final SchedulerDb db;
|
||||
|
||||
/// Owner used for save methods without an owner parameter.
|
||||
final String defaultOwnerId;
|
||||
|
||||
/// Returns a project by stable id.
|
||||
@override
|
||||
Future<core.ProjectProfile?> findById(String id) async {
|
||||
final row = await _projectById(id);
|
||||
return row == null ? null : _projectFromRow(row);
|
||||
}
|
||||
|
||||
/// Returns a project plus owner/revision metadata.
|
||||
@override
|
||||
Future<core.RepositoryRecord<core.ProjectProfile>?> findRecordById(
|
||||
String id,
|
||||
) async {
|
||||
final row = await _projectById(id);
|
||||
if (row == null) {
|
||||
return null;
|
||||
}
|
||||
return core.RepositoryRecord(
|
||||
value: _projectFromRow(row),
|
||||
ownerId: row.ownerId,
|
||||
revision: row.revision,
|
||||
archivedAt: _storedDateTime(row.archivedAtUtc),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns every project in deterministic name/id order.
|
||||
@override
|
||||
Future<List<core.ProjectProfile>> findAll() async {
|
||||
final rows = await (db.select(db.projects)
|
||||
..orderBy([
|
||||
(table) => drift.OrderingTerm.asc(table.name),
|
||||
(table) => drift.OrderingTerm.asc(table.id),
|
||||
]))
|
||||
.get();
|
||||
return List<core.ProjectProfile>.unmodifiable(rows.map(_projectFromRow));
|
||||
}
|
||||
|
||||
/// Returns owner-scoped projects.
|
||||
@override
|
||||
Future<core.RepositoryPage<core.ProjectProfile>> findByOwner({
|
||||
required String ownerId,
|
||||
bool includeArchived = false,
|
||||
core.RepositoryPageRequest page = const core.RepositoryPageRequest(),
|
||||
}) async {
|
||||
final rows = await (db.select(db.projects)
|
||||
..where((table) {
|
||||
final ownerClause = table.ownerId.equals(ownerId);
|
||||
return includeArchived
|
||||
? ownerClause
|
||||
: ownerClause & table.archivedAtUtc.isNull();
|
||||
})
|
||||
..orderBy([
|
||||
(table) => drift.OrderingTerm.asc(table.name),
|
||||
(table) => drift.OrderingTerm.asc(table.id),
|
||||
])
|
||||
..limit(
|
||||
page.limit + 1,
|
||||
offset: _applicationCursorOffset(page.cursor),
|
||||
))
|
||||
.get();
|
||||
return _applicationPageFromRows(rows, page, _projectFromRow);
|
||||
}
|
||||
|
||||
/// Saves [project], inserting it when absent.
|
||||
@override
|
||||
Future<void> save(core.ProjectProfile project) async {
|
||||
final row = await _projectById(project.id);
|
||||
if (row == null) {
|
||||
await db.into(db.projects).insert(
|
||||
_projectCompanion(
|
||||
project,
|
||||
ownerId: core.OwnerId(defaultOwnerId),
|
||||
revision: core.Revision.initial,
|
||||
createdAt: _epoch,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final nextRevision = core.Revision(row.revision).next();
|
||||
await (db.update(db.projects)
|
||||
..where((table) => table.id.equals(project.id)))
|
||||
.write(
|
||||
_projectCompanion(
|
||||
project,
|
||||
ownerId: core.OwnerId(row.ownerId),
|
||||
revision: nextRevision,
|
||||
createdAt: row.createdAtUtc,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Inserts [project] for [ownerId] when its id is unused.
|
||||
@override
|
||||
Future<core.RepositoryMutationResult> insert({
|
||||
required core.ProjectProfile project,
|
||||
required String ownerId,
|
||||
}) async {
|
||||
if (await _projectById(project.id) != null) {
|
||||
return _mutationFailure(
|
||||
core.RepositoryFailureCode.duplicateId,
|
||||
entityId: project.id,
|
||||
);
|
||||
}
|
||||
await db.into(db.projects).insert(
|
||||
_projectCompanion(
|
||||
project,
|
||||
ownerId: core.OwnerId(ownerId),
|
||||
revision: core.Revision.initial,
|
||||
createdAt: _epoch,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
);
|
||||
return core.RepositoryMutationResult.success(revision: 1);
|
||||
}
|
||||
|
||||
/// Saves [project] only when the repository revision matches [expectedRevision].
|
||||
@override
|
||||
Future<core.RepositoryMutationResult> saveIfRevision({
|
||||
required core.ProjectProfile project,
|
||||
required String ownerId,
|
||||
required int expectedRevision,
|
||||
}) async {
|
||||
final row = await _projectById(project.id);
|
||||
final failure = _compareForWrite(
|
||||
row: row,
|
||||
ownerId: ownerId,
|
||||
expectedRevision: expectedRevision,
|
||||
entityId: project.id,
|
||||
rowOwnerId: row?.ownerId,
|
||||
rowRevision: row?.revision,
|
||||
);
|
||||
if (failure != null) {
|
||||
return failure;
|
||||
}
|
||||
final nextRevision = core.Revision(expectedRevision).next();
|
||||
final updated = await (db.update(db.projects)
|
||||
..where(
|
||||
(table) =>
|
||||
table.id.equals(project.id) &
|
||||
table.ownerId.equals(ownerId) &
|
||||
table.revision.equals(expectedRevision),
|
||||
))
|
||||
.write(
|
||||
_projectCompanion(
|
||||
project,
|
||||
ownerId: core.OwnerId(ownerId),
|
||||
revision: nextRevision,
|
||||
createdAt: row!.createdAtUtc,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
);
|
||||
if (updated != 1) {
|
||||
return _mutationFailure(
|
||||
core.RepositoryFailureCode.staleRevision,
|
||||
entityId: project.id,
|
||||
expectedRevision: expectedRevision,
|
||||
actualRevision: row.revision,
|
||||
);
|
||||
}
|
||||
return core.RepositoryMutationResult.success(revision: nextRevision.value);
|
||||
}
|
||||
|
||||
/// Archives [projectId] without deleting task history.
|
||||
@override
|
||||
Future<core.RepositoryMutationResult> archive({
|
||||
required String projectId,
|
||||
required String ownerId,
|
||||
required int expectedRevision,
|
||||
required DateTime archivedAt,
|
||||
}) async {
|
||||
final project = await findById(projectId);
|
||||
if (project == null) {
|
||||
return _mutationFailure(
|
||||
core.RepositoryFailureCode.notFound,
|
||||
entityId: projectId,
|
||||
);
|
||||
}
|
||||
return saveIfRevision(
|
||||
project: project.copyWith(archivedAt: archivedAt),
|
||||
ownerId: ownerId,
|
||||
expectedRevision: expectedRevision,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a raw project row by id.
|
||||
Future<ProjectRow?> _projectById(String id) {
|
||||
return (db.select(db.projects)..where((table) => table.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../sqlite_repositories.dart';
|
||||
|
||||
/// SQLite implementation of the app-layer project statistics repository.
|
||||
final class _SqliteApplicationProjectStatisticsRepository
|
||||
implements core.ProjectStatisticsRepository {
|
||||
/// Creates a project statistics repository for [db].
|
||||
_SqliteApplicationProjectStatisticsRepository(
|
||||
this.db, {
|
||||
required this.defaultOwnerId,
|
||||
});
|
||||
|
||||
/// Shared SQLite database handle.
|
||||
final SchedulerDb db;
|
||||
|
||||
/// Owner used for save methods without an owner parameter.
|
||||
final String defaultOwnerId;
|
||||
|
||||
/// Returns statistics by project id.
|
||||
@override
|
||||
Future<core.ProjectStatistics?> findByProjectId(String projectId) async {
|
||||
final row = await _statisticsByProjectId(projectId);
|
||||
return row == null ? null : _projectStatisticsFromRow(row);
|
||||
}
|
||||
|
||||
/// Returns statistics plus owner/revision metadata.
|
||||
@override
|
||||
Future<core.RepositoryRecord<core.ProjectStatistics>?> findRecordByProjectId(
|
||||
String projectId) async {
|
||||
final row = await _statisticsByProjectId(projectId);
|
||||
if (row == null) {
|
||||
return null;
|
||||
}
|
||||
return core.RepositoryRecord(
|
||||
value: _projectStatisticsFromRow(row),
|
||||
ownerId: row.ownerId,
|
||||
revision: row.revision,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns every project statistics aggregate.
|
||||
@override
|
||||
Future<List<core.ProjectStatistics>> findAll() async {
|
||||
final rows = await (db.select(db.projectStatisticsTable)
|
||||
..orderBy([(table) => drift.OrderingTerm.asc(table.projectId)]))
|
||||
.get();
|
||||
return List<core.ProjectStatistics>.unmodifiable(
|
||||
rows.map(_projectStatisticsFromRow),
|
||||
);
|
||||
}
|
||||
|
||||
/// Saves [statistics], inserting it when absent.
|
||||
@override
|
||||
Future<void> save(core.ProjectStatistics statistics) async {
|
||||
final row = await _statisticsByProjectId(statistics.projectId);
|
||||
if (row == null) {
|
||||
await db.into(db.projectStatisticsTable).insert(
|
||||
_projectStatisticsCompanion(
|
||||
statistics,
|
||||
ownerId: defaultOwnerId,
|
||||
revision: core.Revision.initial.value,
|
||||
createdAt: _epoch,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await (db.update(db.projectStatisticsTable)
|
||||
..where((table) => table.projectId.equals(statistics.projectId)))
|
||||
.write(
|
||||
_projectStatisticsCompanion(
|
||||
statistics,
|
||||
ownerId: row.ownerId,
|
||||
revision: core.Revision(row.revision).next().value,
|
||||
createdAt: row.createdAtUtc,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Saves [statistics] only when the repository revision matches [expectedRevision].
|
||||
@override
|
||||
Future<core.RepositoryMutationResult> saveIfRevision({
|
||||
required core.ProjectStatistics statistics,
|
||||
required String ownerId,
|
||||
required int expectedRevision,
|
||||
}) async {
|
||||
final row = await _statisticsByProjectId(statistics.projectId);
|
||||
final failure = _compareForWrite(
|
||||
row: row,
|
||||
ownerId: ownerId,
|
||||
expectedRevision: expectedRevision,
|
||||
entityId: statistics.projectId,
|
||||
rowOwnerId: row?.ownerId,
|
||||
rowRevision: row?.revision,
|
||||
);
|
||||
if (failure != null) {
|
||||
return failure;
|
||||
}
|
||||
final nextRevision = core.Revision(expectedRevision).next().value;
|
||||
await (db.update(db.projectStatisticsTable)
|
||||
..where(
|
||||
(table) =>
|
||||
table.projectId.equals(statistics.projectId) &
|
||||
table.ownerId.equals(ownerId) &
|
||||
table.revision.equals(expectedRevision),
|
||||
))
|
||||
.write(
|
||||
_projectStatisticsCompanion(
|
||||
statistics,
|
||||
ownerId: ownerId,
|
||||
revision: nextRevision,
|
||||
createdAt: row!.createdAtUtc,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
);
|
||||
return core.RepositoryMutationResult.success(revision: nextRevision);
|
||||
}
|
||||
|
||||
/// Returns a raw project statistics row by project id.
|
||||
Future<ProjectStatisticsRow?> _statisticsByProjectId(String projectId) {
|
||||
return (db.select(db.projectStatisticsTable)
|
||||
..where((table) => table.projectId.equals(projectId)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a Drift companion for [statistics].
|
||||
ProjectStatisticsTableCompanion _projectStatisticsCompanion(
|
||||
core.ProjectStatistics statistics, {
|
||||
required String ownerId,
|
||||
required int revision,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
}) {
|
||||
return ProjectStatisticsTableCompanion.insert(
|
||||
projectId: statistics.projectId,
|
||||
ownerId: ownerId,
|
||||
completedTaskCount: statistics.completedTaskCount,
|
||||
durationMinuteCountsJson: _intCountMapJson(
|
||||
statistics.durationMinuteCounts,
|
||||
),
|
||||
completionTimeBucketCountsJson: _enumCountMapJson(
|
||||
statistics.completionTimeBucketCounts,
|
||||
),
|
||||
totalPushesBeforeCompletion: statistics.totalPushesBeforeCompletion,
|
||||
completedAfterPushCount: statistics.completedAfterPushCount,
|
||||
rewardCountsJson: _enumCountMapJson(statistics.rewardCounts),
|
||||
difficultyCountsJson: _enumCountMapJson(statistics.difficultyCounts),
|
||||
reminderProfileCountsJson: _enumCountMapJson(
|
||||
statistics.reminderProfileCounts,
|
||||
),
|
||||
revision: revision,
|
||||
createdAtUtc: createdAt.toUtc(),
|
||||
updatedAtUtc: updatedAt.toUtc(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Converts a project statistics row to a core aggregate.
|
||||
core.ProjectStatistics _projectStatisticsFromRow(ProjectStatisticsRow row) {
|
||||
return core.ProjectStatistics(
|
||||
projectId: row.projectId,
|
||||
completedTaskCount: row.completedTaskCount,
|
||||
durationMinuteCounts: _intCountMap(row.durationMinuteCountsJson),
|
||||
completionTimeBucketCounts: _enumCountMap(
|
||||
row.completionTimeBucketCountsJson,
|
||||
core.ProjectCompletionTimeBucket.values,
|
||||
),
|
||||
totalPushesBeforeCompletion: row.totalPushesBeforeCompletion,
|
||||
completedAfterPushCount: row.completedAfterPushCount,
|
||||
rewardCounts: _enumCountMap(row.rewardCountsJson, core.RewardLevel.values),
|
||||
difficultyCounts: _enumCountMap(
|
||||
row.difficultyCountsJson,
|
||||
core.DifficultyLevel.values,
|
||||
),
|
||||
reminderProfileCounts: _enumCountMap(
|
||||
row.reminderProfileCountsJson,
|
||||
core.ReminderProfile.values,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../sqlite_repositories.dart';
|
||||
|
||||
/// SQLite repository set exposed inside one application unit of work.
|
||||
final class _SqliteApplicationRepositories
|
||||
implements core.ApplicationUnitOfWorkRepositories {
|
||||
/// Creates a repository set for [db].
|
||||
_SqliteApplicationRepositories(
|
||||
SchedulerDb db, {
|
||||
required String defaultOwnerId,
|
||||
}) : tasks = _SqliteApplicationTaskRepository(
|
||||
db,
|
||||
defaultOwnerId: defaultOwnerId,
|
||||
),
|
||||
projects = _SqliteApplicationProjectRepository(
|
||||
db,
|
||||
defaultOwnerId: defaultOwnerId,
|
||||
),
|
||||
lockedBlocks = _SqliteApplicationLockedBlockRepository(
|
||||
db,
|
||||
defaultOwnerId: defaultOwnerId,
|
||||
),
|
||||
schedulingSnapshots = _SqliteApplicationSnapshotRepository(
|
||||
db,
|
||||
defaultOwnerId: defaultOwnerId,
|
||||
),
|
||||
taskActivities = _SqliteApplicationTaskActivityRepository(
|
||||
db,
|
||||
defaultOwnerId: defaultOwnerId,
|
||||
),
|
||||
projectStatistics = _SqliteApplicationProjectStatisticsRepository(
|
||||
db,
|
||||
defaultOwnerId: defaultOwnerId,
|
||||
),
|
||||
ownerSettings = _SqliteApplicationOwnerSettingsRepository(db),
|
||||
noticeAcknowledgements =
|
||||
_SqliteApplicationNoticeAcknowledgementRepository(db),
|
||||
operations = _SqliteApplicationOperationRepository(db);
|
||||
|
||||
/// Repository for task documents.
|
||||
@override
|
||||
final core.TaskRepository tasks;
|
||||
|
||||
/// Repository for project profiles.
|
||||
@override
|
||||
final core.ProjectRepository projects;
|
||||
|
||||
/// Repository for locked blocks and overrides.
|
||||
@override
|
||||
final core.LockedBlockRepository lockedBlocks;
|
||||
|
||||
/// Repository for scheduling snapshots.
|
||||
@override
|
||||
final core.SchedulingSnapshotRepository schedulingSnapshots;
|
||||
|
||||
/// Repository for task activity facts.
|
||||
@override
|
||||
final core.TaskActivityRepository taskActivities;
|
||||
|
||||
/// Repository for project statistics aggregates.
|
||||
@override
|
||||
final core.ProjectStatisticsRepository projectStatistics;
|
||||
|
||||
/// Repository for owner settings.
|
||||
@override
|
||||
final core.OwnerSettingsRepository ownerSettings;
|
||||
|
||||
/// Repository for notice acknowledgements.
|
||||
@override
|
||||
final core.NoticeAcknowledgementRepository noticeAcknowledgements;
|
||||
|
||||
/// Repository for committed application operations.
|
||||
@override
|
||||
final core.ApplicationOperationRepository operations;
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../sqlite_repositories.dart';
|
||||
|
||||
/// Owns a SQLite database handle and its application unit-of-work adapter.
|
||||
final class SqliteApplicationRuntime {
|
||||
/// Creates a runtime from an already-open [db].
|
||||
SqliteApplicationRuntime({
|
||||
required this.db,
|
||||
String defaultOwnerId = defaultV1OwnerId,
|
||||
}) : applicationStore = SqliteApplicationUnitOfWork(
|
||||
db,
|
||||
defaultOwnerId: defaultOwnerId,
|
||||
);
|
||||
|
||||
/// Default fixed owner used by the V1 local runtime.
|
||||
static const defaultV1OwnerId = 'owner-1';
|
||||
|
||||
/// Default fixed owner time zone used by the V1 local runtime.
|
||||
static const defaultV1TimeZoneId = 'UTC';
|
||||
|
||||
/// Default project id used for V1 quick capture.
|
||||
static const defaultV1ProjectId = 'home';
|
||||
|
||||
/// Open scheduler runtime state from the SQLite file at [path].
|
||||
static Future<SqliteApplicationRuntime> open({
|
||||
required String path,
|
||||
String defaultOwnerId = defaultV1OwnerId,
|
||||
}) async {
|
||||
final file = File(path);
|
||||
await file.parent.create(recursive: true);
|
||||
return SqliteApplicationRuntime(
|
||||
db: SchedulerDb(NativeDatabase(file)),
|
||||
defaultOwnerId: defaultOwnerId,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared SQLite database handle.
|
||||
final SchedulerDb db;
|
||||
|
||||
/// Application transaction boundary backed by [db].
|
||||
final SqliteApplicationUnitOfWork applicationStore;
|
||||
|
||||
/// Inserts required owner settings and default project rows when absent.
|
||||
Future<core.ApplicationResult<void>> bootstrap({
|
||||
String ownerId = defaultV1OwnerId,
|
||||
String timeZoneId = defaultV1TimeZoneId,
|
||||
String defaultProjectId = defaultV1ProjectId,
|
||||
}) {
|
||||
return _guardApplicationErrors(() {
|
||||
return db.transaction(() async {
|
||||
final repositories = _SqliteApplicationRepositories(
|
||||
db,
|
||||
defaultOwnerId: ownerId,
|
||||
);
|
||||
final settings = await repositories.ownerSettings.findByOwnerId(
|
||||
ownerId,
|
||||
);
|
||||
if (settings == null) {
|
||||
await repositories.ownerSettings.save(
|
||||
core.OwnerSettings(
|
||||
ownerId: ownerId,
|
||||
timeZoneId: timeZoneId,
|
||||
compactModeEnabled: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final project = await repositories.projects.findById(defaultProjectId);
|
||||
if (project == null) {
|
||||
await repositories.projects.save(
|
||||
core.ProjectProfile(
|
||||
id: defaultProjectId,
|
||||
name: 'Home',
|
||||
colorKey: 'project-home',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return core.ApplicationResult.success(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Closes the underlying SQLite database.
|
||||
Future<void> close() {
|
||||
return db.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../sqlite_repositories.dart';
|
||||
|
||||
/// SQLite implementation of the app-layer scheduling snapshot repository.
|
||||
final class _SqliteApplicationSnapshotRepository
|
||||
implements core.SchedulingSnapshotRepository {
|
||||
/// Creates a snapshot repository for [db].
|
||||
_SqliteApplicationSnapshotRepository(
|
||||
this.db, {
|
||||
required this.defaultOwnerId,
|
||||
});
|
||||
|
||||
/// Shared SQLite database handle.
|
||||
final SchedulerDb db;
|
||||
|
||||
/// Owner used for save methods without an owner parameter.
|
||||
final String defaultOwnerId;
|
||||
|
||||
/// Returns a snapshot by stable id.
|
||||
@override
|
||||
Future<core.SchedulingStateSnapshot?> findById(String id) async {
|
||||
final row = await _snapshotById(id);
|
||||
return row == null ? null : _snapshotFromRow(row);
|
||||
}
|
||||
|
||||
/// Returns snapshots overlapping [window].
|
||||
@override
|
||||
Future<List<core.SchedulingStateSnapshot>> findInWindow(
|
||||
core.SchedulingWindow window,
|
||||
) async {
|
||||
final rows = await (db.select(db.snapshots)
|
||||
..orderBy([(table) => drift.OrderingTerm.asc(table.id)]))
|
||||
.get();
|
||||
final snapshots = rows
|
||||
.map(_snapshotFromRow)
|
||||
.where((snapshot) => _windowsOverlap(snapshot.window, window))
|
||||
.toList(growable: false);
|
||||
return List<core.SchedulingStateSnapshot>.unmodifiable(snapshots);
|
||||
}
|
||||
|
||||
/// Saves [snapshot], inserting it when absent.
|
||||
@override
|
||||
Future<void> save(core.SchedulingStateSnapshot snapshot) async {
|
||||
final row = await _snapshotById(snapshot.id);
|
||||
if (row == null) {
|
||||
await db.into(db.snapshots).insert(
|
||||
_snapshotCompanion(
|
||||
snapshot,
|
||||
ownerId: core.OwnerId(defaultOwnerId),
|
||||
revision: core.Revision.initial,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await (db.update(db.snapshots)
|
||||
..where((table) => table.id.equals(snapshot.id)))
|
||||
.write(
|
||||
_snapshotCompanion(
|
||||
snapshot,
|
||||
ownerId: core.OwnerId(row.ownerId),
|
||||
revision: core.Revision(row.revision).next(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a raw snapshot row by id.
|
||||
Future<SnapshotRow?> _snapshotById(String id) {
|
||||
return (db.select(db.snapshots)..where((table) => table.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../sqlite_repositories.dart';
|
||||
|
||||
/// SQLite implementation of the app-layer task repository.
|
||||
final class _SqliteApplicationTaskRepository implements core.TaskRepository {
|
||||
/// Creates a task repository for [db].
|
||||
_SqliteApplicationTaskRepository(
|
||||
this.db, {
|
||||
required this.defaultOwnerId,
|
||||
});
|
||||
|
||||
/// Shared SQLite database handle.
|
||||
final SchedulerDb db;
|
||||
|
||||
/// Owner used for save methods without an owner parameter.
|
||||
final String defaultOwnerId;
|
||||
|
||||
/// Returns a task by stable id.
|
||||
@override
|
||||
Future<core.Task?> findById(String id) async {
|
||||
final row = await _taskById(id);
|
||||
return row == null ? null : _taskFromRow(row);
|
||||
}
|
||||
|
||||
/// Returns a task plus owner/revision metadata.
|
||||
@override
|
||||
Future<core.RepositoryRecord<core.Task>?> findRecordById(String id) async {
|
||||
final row = await _taskById(id);
|
||||
if (row == null) {
|
||||
return null;
|
||||
}
|
||||
return core.RepositoryRecord(
|
||||
value: _taskFromRow(row),
|
||||
ownerId: row.ownerId,
|
||||
revision: row.revision,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns every task in deterministic id order.
|
||||
@override
|
||||
Future<List<core.Task>> findAll() async {
|
||||
final rows = await (db.select(db.tasks)
|
||||
..orderBy([(table) => drift.OrderingTerm.asc(table.id)]))
|
||||
.get();
|
||||
return List<core.Task>.unmodifiable(rows.map(_taskFromRow));
|
||||
}
|
||||
|
||||
/// Returns tasks with [status].
|
||||
@override
|
||||
Future<List<core.Task>> findByStatus(core.TaskStatus status) async {
|
||||
final rows = await (db.select(db.tasks)
|
||||
..where(
|
||||
(table) => table.status.equals(
|
||||
core.PersistenceEnumMapping.encodeTaskStatus(status),
|
||||
),
|
||||
)
|
||||
..orderBy([(table) => drift.OrderingTerm.asc(table.id)]))
|
||||
.get();
|
||||
return List<core.Task>.unmodifiable(rows.map(_taskFromRow));
|
||||
}
|
||||
|
||||
/// Returns owner-scoped tasks in deterministic id order.
|
||||
@override
|
||||
Future<core.RepositoryPage<core.Task>> findByOwner({
|
||||
required String ownerId,
|
||||
core.RepositoryPageRequest page = const core.RepositoryPageRequest(),
|
||||
}) async {
|
||||
final rows = await _pagedTaskRows(
|
||||
page: page,
|
||||
where: (table) => table.ownerId.equals(ownerId),
|
||||
orderBy: [(table) => drift.OrderingTerm.asc(table.id)],
|
||||
);
|
||||
return _applicationPageFromRows(rows, page, _taskFromRow);
|
||||
}
|
||||
|
||||
/// Returns owner-scoped tasks assigned to [projectId].
|
||||
@override
|
||||
Future<core.RepositoryPage<core.Task>> findByProjectId({
|
||||
required String ownerId,
|
||||
required String projectId,
|
||||
core.RepositoryPageRequest page = const core.RepositoryPageRequest(),
|
||||
}) async {
|
||||
final rows = await _pagedTaskRows(
|
||||
page: page,
|
||||
where: (table) =>
|
||||
table.ownerId.equals(ownerId) & table.projectId.equals(projectId),
|
||||
orderBy: [(table) => drift.OrderingTerm.asc(table.id)],
|
||||
);
|
||||
return _applicationPageFromRows(rows, page, _taskFromRow);
|
||||
}
|
||||
|
||||
/// Returns owner-scoped children of [parentTaskId].
|
||||
@override
|
||||
Future<core.RepositoryPage<core.Task>> findByParentTaskId({
|
||||
required String ownerId,
|
||||
required String parentTaskId,
|
||||
core.RepositoryPageRequest page = const core.RepositoryPageRequest(),
|
||||
}) async {
|
||||
final rows = await _pagedTaskRows(
|
||||
page: page,
|
||||
where: (table) =>
|
||||
table.ownerId.equals(ownerId) & table.parentId.equals(parentTaskId),
|
||||
orderBy: [(table) => drift.OrderingTerm.asc(table.id)],
|
||||
);
|
||||
return _applicationPageFromRows(rows, page, _taskFromRow);
|
||||
}
|
||||
|
||||
/// Returns backlog candidates matching [query].
|
||||
@override
|
||||
Future<core.RepositoryPage<core.Task>> findBacklogCandidates(
|
||||
core.BacklogCandidateQuery query,
|
||||
) async {
|
||||
final status = core.PersistenceEnumMapping.encodeTaskStatus(
|
||||
core.TaskStatus.backlog,
|
||||
);
|
||||
final rows = await (db.select(db.tasks)
|
||||
..where((table) {
|
||||
final projectClause = query.projectId == null
|
||||
? const drift.Constant(true)
|
||||
: table.projectId.equals(query.projectId!);
|
||||
return table.ownerId.equals(query.ownerId) &
|
||||
table.status.equals(status) &
|
||||
projectClause;
|
||||
})
|
||||
..orderBy([
|
||||
(table) => drift.OrderingTerm.asc(table.createdAtUtc),
|
||||
(table) => drift.OrderingTerm.asc(table.id),
|
||||
]))
|
||||
.get();
|
||||
final tasks = rows
|
||||
.map(_taskFromRow)
|
||||
.where((task) => query.tags.every(task.backlogTags.contains))
|
||||
.toList(growable: false);
|
||||
return _applicationPage(tasks, query.page);
|
||||
}
|
||||
|
||||
/// Returns tasks scheduled in [window].
|
||||
@override
|
||||
Future<List<core.Task>> findScheduledInWindow(
|
||||
core.SchedulingWindow window,
|
||||
) async {
|
||||
final rows = await _scheduledRowsInWindow(window);
|
||||
return List<core.Task>.unmodifiable(rows.map(_taskFromRow));
|
||||
}
|
||||
|
||||
/// Returns owner-scoped tasks scheduled in [window].
|
||||
@override
|
||||
Future<core.RepositoryPage<core.Task>> findScheduledInWindowForOwner({
|
||||
required String ownerId,
|
||||
required core.SchedulingWindow window,
|
||||
core.RepositoryPageRequest page = const core.RepositoryPageRequest(),
|
||||
}) async {
|
||||
final rows = await _scheduledRowsInWindow(window, ownerId: ownerId);
|
||||
final tasks = rows.map(_taskFromRow).toList(growable: false);
|
||||
return _applicationPage(tasks, page);
|
||||
}
|
||||
|
||||
/// Returns owner-scoped tasks for [localDate] in [window].
|
||||
@override
|
||||
Future<core.RepositoryPage<core.Task>> findForLocalDay({
|
||||
required String ownerId,
|
||||
required core.CivilDate localDate,
|
||||
required core.SchedulingWindow window,
|
||||
core.RepositoryPageRequest page = const core.RepositoryPageRequest(),
|
||||
}) {
|
||||
return findScheduledInWindowForOwner(
|
||||
ownerId: ownerId,
|
||||
window: window,
|
||||
page: page,
|
||||
);
|
||||
}
|
||||
|
||||
/// Saves [task], inserting it when absent.
|
||||
@override
|
||||
Future<void> save(core.Task task) async {
|
||||
final row = await _taskById(task.id);
|
||||
if (row == null) {
|
||||
await db.into(db.tasks).insert(
|
||||
_taskCompanion(
|
||||
task,
|
||||
ownerId: core.OwnerId(defaultOwnerId),
|
||||
revision: core.Revision.initial,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final nextRevision = core.Revision(row.revision).next();
|
||||
await (db.update(db.tasks)..where((table) => table.id.equals(task.id)))
|
||||
.write(
|
||||
_taskCompanion(
|
||||
task,
|
||||
ownerId: core.OwnerId(row.ownerId),
|
||||
revision: nextRevision,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Saves every task in [tasks].
|
||||
@override
|
||||
Future<void> saveAll(Iterable<core.Task> tasks) async {
|
||||
for (final task in tasks) {
|
||||
await save(task);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts [task] for [ownerId] when its id is unused.
|
||||
@override
|
||||
Future<core.RepositoryMutationResult> insert({
|
||||
required core.Task task,
|
||||
required String ownerId,
|
||||
}) async {
|
||||
if (await _taskById(task.id) != null) {
|
||||
return _mutationFailure(
|
||||
core.RepositoryFailureCode.duplicateId,
|
||||
entityId: task.id,
|
||||
);
|
||||
}
|
||||
await db.into(db.tasks).insert(
|
||||
_taskCompanion(
|
||||
task,
|
||||
ownerId: core.OwnerId(ownerId),
|
||||
revision: core.Revision.initial,
|
||||
),
|
||||
);
|
||||
return core.RepositoryMutationResult.success(
|
||||
revision: core.Revision.initial.value,
|
||||
);
|
||||
}
|
||||
|
||||
/// Saves [task] only when the repository revision matches [expectedRevision].
|
||||
@override
|
||||
Future<core.RepositoryMutationResult> saveIfRevision({
|
||||
required core.Task task,
|
||||
required String ownerId,
|
||||
required int expectedRevision,
|
||||
}) async {
|
||||
final row = await _taskById(task.id);
|
||||
final failure = _compareForWrite(
|
||||
row: row,
|
||||
ownerId: ownerId,
|
||||
expectedRevision: expectedRevision,
|
||||
entityId: task.id,
|
||||
rowOwnerId: row?.ownerId,
|
||||
rowRevision: row?.revision,
|
||||
);
|
||||
if (failure != null) {
|
||||
return failure;
|
||||
}
|
||||
final nextRevision = core.Revision(expectedRevision).next();
|
||||
final updated = await (db.update(db.tasks)
|
||||
..where(
|
||||
(table) =>
|
||||
table.id.equals(task.id) &
|
||||
table.ownerId.equals(ownerId) &
|
||||
table.revision.equals(expectedRevision),
|
||||
))
|
||||
.write(
|
||||
_taskCompanion(
|
||||
task,
|
||||
ownerId: core.OwnerId(ownerId),
|
||||
revision: nextRevision,
|
||||
),
|
||||
);
|
||||
if (updated != 1) {
|
||||
return _mutationFailure(
|
||||
core.RepositoryFailureCode.staleRevision,
|
||||
entityId: task.id,
|
||||
expectedRevision: expectedRevision,
|
||||
actualRevision: row?.revision,
|
||||
);
|
||||
}
|
||||
return core.RepositoryMutationResult.success(revision: nextRevision.value);
|
||||
}
|
||||
|
||||
/// Returns a raw task row by id.
|
||||
Future<TaskRow?> _taskById(String id) {
|
||||
return (db.select(db.tasks)..where((table) => table.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Returns paged task rows for the supplied [where] clause.
|
||||
Future<List<TaskRow>> _pagedTaskRows({
|
||||
required core.RepositoryPageRequest page,
|
||||
required drift.Expression<bool> Function($TasksTable table) where,
|
||||
required List<drift.OrderingTerm Function($TasksTable table)> orderBy,
|
||||
}) {
|
||||
final offset = _applicationCursorOffset(page.cursor);
|
||||
return (db.select(db.tasks)
|
||||
..where(where)
|
||||
..orderBy(orderBy)
|
||||
..limit(page.limit + 1, offset: offset))
|
||||
.get();
|
||||
}
|
||||
|
||||
/// Returns raw scheduled task rows overlapping [window].
|
||||
Future<List<TaskRow>> _scheduledRowsInWindow(
|
||||
core.SchedulingWindow window, {
|
||||
String? ownerId,
|
||||
}) {
|
||||
final start = window.start.toUtc();
|
||||
final end = window.end.toUtc();
|
||||
return (db.select(db.tasks)
|
||||
..where((table) {
|
||||
final ownerClause = ownerId == null
|
||||
? const drift.Constant(true)
|
||||
: table.ownerId.equals(ownerId);
|
||||
return ownerClause &
|
||||
table.scheduledStartUtc.isNotNull() &
|
||||
table.scheduledEndUtc.isNotNull() &
|
||||
table.scheduledStartUtc.isSmallerThanValue(end) &
|
||||
table.scheduledEndUtc.isBiggerThanValue(start);
|
||||
})
|
||||
..orderBy([
|
||||
(table) => drift.OrderingTerm.asc(table.scheduledStartUtc),
|
||||
(table) => drift.OrderingTerm.asc(table.id),
|
||||
]))
|
||||
.get();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
part of '../sqlite_repositories.dart';
|
||||
|
||||
/// SQLite-backed application transaction boundary.
|
||||
final class SqliteApplicationUnitOfWork implements core.ApplicationUnitOfWork {
|
||||
/// Creates an application unit of work backed by [db].
|
||||
SqliteApplicationUnitOfWork(
|
||||
this.db, {
|
||||
this.defaultOwnerId = SqliteApplicationRuntime.defaultV1OwnerId,
|
||||
});
|
||||
|
||||
/// Shared SQLite database handle.
|
||||
final SchedulerDb db;
|
||||
|
||||
/// Owner used by app-layer save methods that do not carry owner explicitly.
|
||||
final String defaultOwnerId;
|
||||
|
||||
/// Runs an application command atomically in a Drift transaction.
|
||||
@override
|
||||
Future<core.ApplicationResult<T>> run<T>({
|
||||
required core.ApplicationOperationContext context,
|
||||
required String operationName,
|
||||
required Future<core.ApplicationResult<T>> Function(
|
||||
core.ApplicationUnitOfWorkRepositories repositories,
|
||||
) action,
|
||||
}) {
|
||||
return _guardApplicationErrors(() {
|
||||
return db.transaction(() async {
|
||||
final repositories = _SqliteApplicationRepositories(
|
||||
db,
|
||||
defaultOwnerId: context.ownerTimeZone.ownerId,
|
||||
);
|
||||
final existing = await repositories.operations.findById(
|
||||
context.operationId,
|
||||
);
|
||||
if (existing != null) {
|
||||
return core.ApplicationResult.failure(
|
||||
core.ApplicationFailure(
|
||||
code: core.ApplicationFailureCode.duplicateOperation,
|
||||
entityId: context.operationId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final result = await action(repositories);
|
||||
if (result.isFailure) {
|
||||
return result;
|
||||
}
|
||||
|
||||
final operationResult = await repositories.operations.insertIfAbsent(
|
||||
core.ApplicationOperationRecord(
|
||||
operationId: context.operationId,
|
||||
ownerId: context.ownerTimeZone.ownerId,
|
||||
operationName: operationName,
|
||||
committedAt: context.now,
|
||||
),
|
||||
);
|
||||
if (!operationResult.isSuccess) {
|
||||
return core.ApplicationResult.failure(
|
||||
core.ApplicationFailure(
|
||||
code: core.ApplicationFailureCode.duplicateOperation,
|
||||
entityId: context.operationId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Runs an application read against SQLite repositories.
|
||||
@override
|
||||
Future<core.ApplicationResult<T>> read<T>({
|
||||
required Future<core.ApplicationResult<T>> Function(
|
||||
core.ApplicationUnitOfWorkRepositories repositories,
|
||||
) action,
|
||||
}) {
|
||||
return _guardApplicationErrors(() {
|
||||
return action(
|
||||
_SqliteApplicationRepositories(
|
||||
db,
|
||||
defaultOwnerId: defaultOwnerId,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts expected application exceptions into typed application failures.
|
||||
Future<core.ApplicationResult<T>> _guardApplicationErrors<T>(
|
||||
Future<core.ApplicationResult<T>> Function() action,
|
||||
) async {
|
||||
try {
|
||||
return await action();
|
||||
} on core.ApplicationFailureException catch (error) {
|
||||
return core.ApplicationResult.failure(error.failure);
|
||||
} on core.DomainValidationException catch (error) {
|
||||
return core.ApplicationResult.failure(
|
||||
core.ApplicationFailure(
|
||||
code: core.ApplicationFailureCode.validation,
|
||||
detailCode: error.code.name,
|
||||
),
|
||||
);
|
||||
} on ArgumentError catch (error) {
|
||||
return core.ApplicationResult.failure(
|
||||
core.ApplicationFailure(
|
||||
code: core.ApplicationFailureCode.validation,
|
||||
detailCode: error.name,
|
||||
),
|
||||
);
|
||||
} on core.ApplicationPersistenceException {
|
||||
return core.ApplicationResult.failure(
|
||||
const core.ApplicationFailure(
|
||||
code: core.ApplicationFailureCode.persistenceFailure,
|
||||
),
|
||||
);
|
||||
} on sqlite.SqliteException catch (_) {
|
||||
return core.ApplicationResult.failure(
|
||||
const core.ApplicationFailure(
|
||||
code: core.ApplicationFailureCode.persistenceFailure,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
return core.ApplicationResult.failure(
|
||||
const core.ApplicationFailure(
|
||||
code: core.ApplicationFailureCode.unexpected),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,10 +5,13 @@
|
|||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:scheduler_core/scheduler_core.dart' as core;
|
||||
import 'package:scheduler_persistence/persistence.dart';
|
||||
import 'package:sqlite3/sqlite3.dart' as sqlite;
|
||||
|
||||
import 'scheduler_db.dart';
|
||||
part 'sqlite_repositories/sqlite_task_repository.dart';
|
||||
|
|
@ -16,6 +19,19 @@ part 'sqlite_repositories/sqlite_project_repository.dart';
|
|||
part 'sqlite_repositories/sqlite_locked_block_repository.dart';
|
||||
part 'sqlite_repositories/sqlite_settings_repository.dart';
|
||||
part 'sqlite_repositories/sqlite_schedule_snapshot_repository.dart';
|
||||
part 'sqlite_application/sqlite_application_runtime.dart';
|
||||
part 'sqlite_application/sqlite_application_unit_of_work.dart';
|
||||
part 'sqlite_application/sqlite_application_repositories.dart';
|
||||
part 'sqlite_application/sqlite_application_task_repository.dart';
|
||||
part 'sqlite_application/sqlite_application_project_repository.dart';
|
||||
part 'sqlite_application/sqlite_application_locked_block_repository.dart';
|
||||
part 'sqlite_application/sqlite_application_snapshot_repository.dart';
|
||||
part 'sqlite_application/sqlite_application_activity_repository.dart';
|
||||
part 'sqlite_application/sqlite_application_project_statistics_repository.dart';
|
||||
part 'sqlite_application/sqlite_application_owner_settings_repository.dart';
|
||||
part 'sqlite_application/sqlite_application_notice_repository.dart';
|
||||
part 'sqlite_application/sqlite_application_operation_repository.dart';
|
||||
part 'sqlite_application/sqlite_application_mapping.dart';
|
||||
|
||||
/// Private file-level value for `_snapshotRetention`.
|
||||
/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals.
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ dependencies:
|
|||
drift: ^2.20.0
|
||||
scheduler_core: any
|
||||
scheduler_persistence: any
|
||||
sqlite3: ^3.3.3
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.4.13
|
||||
|
|
|
|||
|
|
@ -4,18 +4,21 @@
|
|||
/// Tests Scheduler Db behavior for the Scheduler Persistence Sqlite package.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:scheduler_persistence_sqlite/sqlite.dart';
|
||||
import 'package:sqlite3/sqlite3.dart' as sqlite;
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Entry point for this executable Dart file.
|
||||
/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API.
|
||||
void main() {
|
||||
test('opens schema version 1 with expected tables', () async {
|
||||
test('opens schema version 2 with expected tables', () async {
|
||||
final db = SchedulerDb(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
|
||||
expect(db.schemaVersion, 1);
|
||||
expect(db.schemaVersion, 2);
|
||||
|
||||
final rows = await db
|
||||
.customSelect(
|
||||
|
|
@ -29,13 +32,207 @@ void main() {
|
|||
expect(
|
||||
tableNames,
|
||||
containsAll(<String>[
|
||||
'application_operations',
|
||||
'locked_blocks',
|
||||
'locked_overrides',
|
||||
'notice_acknowledgements',
|
||||
'projects',
|
||||
'project_statistics',
|
||||
'settings',
|
||||
'snapshots',
|
||||
'task_activities',
|
||||
'tasks',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('migrates schema version 1 file and preserves existing rows', () async {
|
||||
final directory = await Directory.systemTemp.createTemp(
|
||||
'focus-flow-sqlite-migration-',
|
||||
);
|
||||
addTearDown(() => directory.delete(recursive: true));
|
||||
final file = File('${directory.path}/scheduler.sqlite');
|
||||
_createVersionOneDatabase(file);
|
||||
|
||||
final db = SchedulerDb(NativeDatabase(file));
|
||||
addTearDown(db.close);
|
||||
|
||||
final rows = await db
|
||||
.customSelect(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type = 'table' AND name NOT LIKE 'sqlite_%' "
|
||||
'ORDER BY name',
|
||||
)
|
||||
.get();
|
||||
final tableNames = rows.map((row) => row.data['name']).toList();
|
||||
expect(tableNames, contains('application_operations'));
|
||||
expect(tableNames, contains('task_activities'));
|
||||
expect(tableNames, contains('project_statistics'));
|
||||
expect(tableNames, contains('notice_acknowledgements'));
|
||||
|
||||
final taskRows = await db
|
||||
.customSelect("SELECT title FROM tasks WHERE id = 'v1-task'")
|
||||
.get();
|
||||
final projectRows = await db
|
||||
.customSelect("SELECT name FROM projects WHERE id = 'home'")
|
||||
.get();
|
||||
final settingsRows = await db
|
||||
.customSelect(
|
||||
"SELECT timezone_id FROM settings WHERE owner_id = 'owner-1'")
|
||||
.get();
|
||||
|
||||
expect(taskRows.single.data['title'], 'Existing V1 task');
|
||||
expect(projectRows.single.data['name'], 'Home');
|
||||
expect(settingsRows.single.data['timezone_id'], 'UTC');
|
||||
});
|
||||
}
|
||||
|
||||
/// Creates a minimal version-1 SQLite database file.
|
||||
void _createVersionOneDatabase(File file) {
|
||||
final database = sqlite.sqlite3.open(file.path);
|
||||
try {
|
||||
database
|
||||
..execute('''
|
||||
CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
parent_id TEXT NULL,
|
||||
type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
priority TEXT NULL,
|
||||
reward TEXT NOT NULL,
|
||||
difficulty TEXT NOT NULL,
|
||||
duration_minutes INTEGER NULL,
|
||||
scheduled_start_utc INTEGER NULL,
|
||||
scheduled_end_utc INTEGER NULL,
|
||||
actual_start_utc INTEGER NULL,
|
||||
actual_end_utc INTEGER NULL,
|
||||
completed_at_utc INTEGER NULL,
|
||||
backlog_tags_json TEXT NOT NULL,
|
||||
reminder_override TEXT NULL,
|
||||
stats_json TEXT NOT NULL,
|
||||
backlog_entered_at_utc INTEGER NULL,
|
||||
backlog_entered_provenance TEXT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
created_at_utc INTEGER NOT NULL,
|
||||
updated_at_utc INTEGER NOT NULL
|
||||
)
|
||||
''')
|
||||
..execute('''
|
||||
CREATE TABLE projects (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color_key TEXT NOT NULL,
|
||||
default_priority TEXT NOT NULL,
|
||||
default_reward TEXT NOT NULL,
|
||||
default_difficulty TEXT NOT NULL,
|
||||
default_reminder_profile TEXT NOT NULL,
|
||||
default_duration_minutes INTEGER NULL,
|
||||
archived_at_utc INTEGER NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
created_at_utc INTEGER NOT NULL,
|
||||
updated_at_utc INTEGER NOT NULL
|
||||
)
|
||||
''')
|
||||
..execute('''
|
||||
CREATE TABLE settings (
|
||||
owner_id TEXT PRIMARY KEY NOT NULL,
|
||||
timezone_id TEXT NOT NULL,
|
||||
day_start_minutes INTEGER NOT NULL,
|
||||
day_end_minutes INTEGER NOT NULL,
|
||||
compact_mode INTEGER NOT NULL,
|
||||
backlog_staleness_json TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
created_at_utc INTEGER NOT NULL,
|
||||
updated_at_utc INTEGER NOT NULL
|
||||
)
|
||||
''')
|
||||
..execute('''
|
||||
INSERT INTO tasks (
|
||||
id,
|
||||
owner_id,
|
||||
title,
|
||||
project_id,
|
||||
type,
|
||||
status,
|
||||
reward,
|
||||
difficulty,
|
||||
backlog_tags_json,
|
||||
stats_json,
|
||||
revision,
|
||||
created_at_utc,
|
||||
updated_at_utc
|
||||
) VALUES (
|
||||
'v1-task',
|
||||
'owner-1',
|
||||
'Existing V1 task',
|
||||
'home',
|
||||
'flexible',
|
||||
'backlog',
|
||||
'notSet',
|
||||
'notSet',
|
||||
'[]',
|
||||
'{}',
|
||||
1,
|
||||
0,
|
||||
0
|
||||
)
|
||||
''')
|
||||
..execute('''
|
||||
INSERT INTO projects (
|
||||
id,
|
||||
owner_id,
|
||||
name,
|
||||
color_key,
|
||||
default_priority,
|
||||
default_reward,
|
||||
default_difficulty,
|
||||
default_reminder_profile,
|
||||
revision,
|
||||
created_at_utc,
|
||||
updated_at_utc
|
||||
) VALUES (
|
||||
'home',
|
||||
'owner-1',
|
||||
'Home',
|
||||
'project-home',
|
||||
'medium',
|
||||
'notSet',
|
||||
'notSet',
|
||||
'none',
|
||||
1,
|
||||
0,
|
||||
0
|
||||
)
|
||||
''')
|
||||
..execute('''
|
||||
INSERT INTO settings (
|
||||
owner_id,
|
||||
timezone_id,
|
||||
day_start_minutes,
|
||||
day_end_minutes,
|
||||
compact_mode,
|
||||
backlog_staleness_json,
|
||||
revision,
|
||||
created_at_utc,
|
||||
updated_at_utc
|
||||
) VALUES (
|
||||
'owner-1',
|
||||
'UTC',
|
||||
0,
|
||||
1440,
|
||||
1,
|
||||
'{}',
|
||||
1,
|
||||
0,
|
||||
0
|
||||
)
|
||||
''')
|
||||
..execute('PRAGMA user_version = 1');
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,828 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Tests SQLite application unit-of-work behavior.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:scheduler_core/scheduler_core.dart';
|
||||
import 'package:scheduler_persistence_sqlite/sqlite.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Runs SQLite application unit-of-work tests.
|
||||
void main() {
|
||||
test('bootstrap is idempotent across close and reopen', () async {
|
||||
final path = await _tempDatabasePath();
|
||||
var runtime = await _openBootstrappedRuntime(path);
|
||||
|
||||
var settingsRows = await runtime.db.select(runtime.db.settingsTable).get();
|
||||
var projectRows = await runtime.db.select(runtime.db.projects).get();
|
||||
expect(settingsRows, hasLength(1));
|
||||
expect(projectRows, hasLength(1));
|
||||
await runtime.close();
|
||||
|
||||
runtime = await _openBootstrappedRuntime(path);
|
||||
addTearDown(runtime.close);
|
||||
settingsRows = await runtime.db.select(runtime.db.settingsTable).get();
|
||||
projectRows = await runtime.db.select(runtime.db.projects).get();
|
||||
expect(settingsRows, hasLength(1));
|
||||
expect(projectRows, hasLength(1));
|
||||
});
|
||||
|
||||
test('quick capture schedule complete and uncomplete survive reopen',
|
||||
() async {
|
||||
final path = await _tempDatabasePath();
|
||||
var runtime = await _openBootstrappedRuntime(path);
|
||||
var commands = _commands(runtime);
|
||||
var management = _management(runtime);
|
||||
var todayQuery = _todayQuery(runtime);
|
||||
|
||||
final capture = await commands.quickCaptureToBacklog(
|
||||
context: _context('capture-task', _instant(8)),
|
||||
title: 'Persist close reopen task',
|
||||
projectId: SqliteApplicationRuntime.defaultV1ProjectId,
|
||||
);
|
||||
expect(capture.isSuccess, isTrue);
|
||||
await runtime.close();
|
||||
|
||||
runtime = await _openBootstrappedRuntime(path);
|
||||
commands = _commands(runtime);
|
||||
management = _management(runtime);
|
||||
todayQuery = _todayQuery(runtime);
|
||||
final backlog = await management.getBacklog(
|
||||
GetBacklogRequest(
|
||||
context: _context('read-backlog-after-capture', _instant(8, 5)),
|
||||
sortKey: BacklogSortKey.age,
|
||||
),
|
||||
);
|
||||
expect(backlog.requireValue.items, hasLength(1));
|
||||
final task = backlog.requireValue.items.single.task;
|
||||
expect(task.title, 'Persist close reopen task');
|
||||
|
||||
final scheduled = await commands.scheduleBacklogItemToNextAvailableSlot(
|
||||
context: _context('schedule-task', _instant(8, 10)),
|
||||
localDate: _date,
|
||||
taskId: task.id,
|
||||
durationMinutes: 30,
|
||||
expectedUpdatedAt: task.updatedAt,
|
||||
);
|
||||
expect(scheduled.isSuccess, isTrue);
|
||||
await runtime.close();
|
||||
|
||||
runtime = await _openBootstrappedRuntime(path);
|
||||
commands = _commands(runtime);
|
||||
todayQuery = _todayQuery(runtime);
|
||||
var today = await todayQuery.execute(
|
||||
GetTodayStateRequest(
|
||||
context: _context('read-today-after-schedule', _instant(8, 15)),
|
||||
date: _date,
|
||||
),
|
||||
);
|
||||
var scheduledTask = today.requireValue.timelineItems
|
||||
.singleWhere((item) => item.taskId == task.id);
|
||||
expect(scheduledTask.taskStatus, TaskStatus.planned);
|
||||
|
||||
final completed = await commands.completeFlexibleTask(
|
||||
context: _context('complete-task', _instant(8, 20)),
|
||||
localDate: _date,
|
||||
taskId: task.id,
|
||||
);
|
||||
expect(completed.isSuccess, isTrue);
|
||||
await runtime.close();
|
||||
|
||||
runtime = await _openBootstrappedRuntime(path);
|
||||
commands = _commands(runtime);
|
||||
todayQuery = _todayQuery(runtime);
|
||||
today = await todayQuery.execute(
|
||||
GetTodayStateRequest(
|
||||
context: _context('read-today-after-complete', _instant(8, 25)),
|
||||
date: _date,
|
||||
),
|
||||
);
|
||||
scheduledTask = today.requireValue.timelineItems
|
||||
.singleWhere((item) => item.taskId == task.id);
|
||||
expect(scheduledTask.taskStatus, TaskStatus.completed);
|
||||
|
||||
final reopened = await commands.uncompleteTask(
|
||||
context: _context('uncomplete-task', _instant(8, 30)),
|
||||
localDate: _date,
|
||||
taskId: task.id,
|
||||
);
|
||||
expect(reopened.isSuccess, isTrue);
|
||||
await runtime.close();
|
||||
|
||||
runtime = await _openBootstrappedRuntime(path);
|
||||
addTearDown(runtime.close);
|
||||
todayQuery = _todayQuery(runtime);
|
||||
today = await todayQuery.execute(
|
||||
GetTodayStateRequest(
|
||||
context: _context('read-today-after-uncomplete', _instant(8, 35)),
|
||||
date: _date,
|
||||
),
|
||||
);
|
||||
scheduledTask = today.requireValue.timelineItems
|
||||
.singleWhere((item) => item.taskId == task.id);
|
||||
expect(scheduledTask.taskStatus, TaskStatus.planned);
|
||||
|
||||
final activityRows =
|
||||
await runtime.db.select(runtime.db.taskActivities).get();
|
||||
final operationRows =
|
||||
await runtime.db.select(runtime.db.applicationOperations).get();
|
||||
expect(activityRows, isNotEmpty);
|
||||
expect(
|
||||
operationRows.map((row) => row.operationId), contains('capture-task'));
|
||||
});
|
||||
|
||||
test('failed command rolls back task and operation writes', () async {
|
||||
final path = await _tempDatabasePath();
|
||||
final runtime = await _openBootstrappedRuntime(path);
|
||||
addTearDown(runtime.close);
|
||||
final commands = _commands(runtime);
|
||||
|
||||
final result = await commands.quickCaptureToBacklog(
|
||||
context: _context('invalid-capture', _instant(9)),
|
||||
title: '',
|
||||
projectId: SqliteApplicationRuntime.defaultV1ProjectId,
|
||||
);
|
||||
|
||||
expect(result.isFailure, isTrue);
|
||||
final taskRows = await runtime.db.select(runtime.db.tasks).get();
|
||||
final operationRows =
|
||||
await runtime.db.select(runtime.db.applicationOperations).get();
|
||||
expect(taskRows, isEmpty);
|
||||
expect(operationRows, isEmpty);
|
||||
});
|
||||
|
||||
test('duplicate operation is rejected after reopen', () async {
|
||||
final path = await _tempDatabasePath();
|
||||
var runtime = await _openBootstrappedRuntime(path);
|
||||
var commands = _commands(runtime);
|
||||
|
||||
final first = await commands.quickCaptureToBacklog(
|
||||
context: _context('duplicate-capture', _instant(10)),
|
||||
title: 'Only once',
|
||||
projectId: SqliteApplicationRuntime.defaultV1ProjectId,
|
||||
);
|
||||
expect(first.isSuccess, isTrue);
|
||||
await runtime.close();
|
||||
|
||||
runtime = await _openBootstrappedRuntime(path);
|
||||
addTearDown(runtime.close);
|
||||
commands = _commands(runtime);
|
||||
final second = await commands.quickCaptureToBacklog(
|
||||
context: _context('duplicate-capture', _instant(10, 5)),
|
||||
title: 'Should not persist',
|
||||
projectId: SqliteApplicationRuntime.defaultV1ProjectId,
|
||||
);
|
||||
|
||||
expect(second.failure?.code, ApplicationFailureCode.duplicateOperation);
|
||||
final taskRows = await runtime.db.select(runtime.db.tasks).get();
|
||||
expect(taskRows, hasLength(1));
|
||||
expect(taskRows.single.title, 'Only once');
|
||||
});
|
||||
|
||||
test('application repositories persist owner scoped records and conflicts',
|
||||
() async {
|
||||
final path = await _tempDatabasePath();
|
||||
final runtime = await SqliteApplicationRuntime.open(path: path);
|
||||
addTearDown(runtime.close);
|
||||
const ownerId = SqliteApplicationRuntime.defaultV1OwnerId;
|
||||
final now = _instant(11);
|
||||
final project = _project('project-alpha', 'Alpha');
|
||||
final scheduledTask = _task(
|
||||
id: 'task-scheduled',
|
||||
title: 'Scheduled task',
|
||||
status: TaskStatus.planned,
|
||||
scheduledStart: _instant(8),
|
||||
scheduledEnd: _instant(8, 30),
|
||||
);
|
||||
final backlogTask = _task(
|
||||
id: 'task-backlog',
|
||||
title: 'Backlog task',
|
||||
status: TaskStatus.backlog,
|
||||
backlogTags: {BacklogTag.wishlist},
|
||||
);
|
||||
final childTask = _task(
|
||||
id: 'task-child',
|
||||
title: 'Child task',
|
||||
status: TaskStatus.backlog,
|
||||
parentTaskId: backlogTask.id,
|
||||
);
|
||||
final block = LockedBlock(
|
||||
id: 'block-work',
|
||||
name: 'Work block',
|
||||
date: _date,
|
||||
startTime: WallTime(hour: 9, minute: 0),
|
||||
endTime: WallTime(hour: 10, minute: 0),
|
||||
hiddenByDefault: false,
|
||||
projectId: project.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
);
|
||||
final override = LockedBlockOverride.add(
|
||||
id: 'override-errand',
|
||||
date: _date,
|
||||
name: 'Errand',
|
||||
startTime: WallTime(hour: 13, minute: 0),
|
||||
endTime: WallTime(hour: 13, minute: 30),
|
||||
projectId: project.id,
|
||||
createdAt: now,
|
||||
);
|
||||
final snapshot = SchedulingStateSnapshot(
|
||||
id: 'snapshot-1',
|
||||
capturedAt: now,
|
||||
window: _window(8, 12),
|
||||
tasks: [scheduledTask],
|
||||
lockedIntervals: [
|
||||
TimeInterval(
|
||||
start: _instant(9),
|
||||
end: _instant(10),
|
||||
label: block.name,
|
||||
),
|
||||
],
|
||||
operationName: 'coverage-snapshot',
|
||||
);
|
||||
final activity = TaskActivity(
|
||||
id: 'activity-1',
|
||||
operationId: 'repository-surface',
|
||||
code: TaskActivityCode.completed,
|
||||
taskId: scheduledTask.id,
|
||||
projectId: project.id,
|
||||
occurredAt: now,
|
||||
metadata: {
|
||||
'completedAt': now,
|
||||
'reward': RewardLevel.high,
|
||||
'labels': ['done', 1],
|
||||
'nested': {'ok': true},
|
||||
},
|
||||
);
|
||||
final statistics = ProjectStatistics(
|
||||
projectId: project.id,
|
||||
completedTaskCount: 2,
|
||||
durationMinuteCounts: {30: 2},
|
||||
completionTimeBucketCounts: {
|
||||
ProjectCompletionTimeBucket.morning: 2,
|
||||
},
|
||||
totalPushesBeforeCompletion: 3,
|
||||
completedAfterPushCount: 1,
|
||||
rewardCounts: {RewardLevel.high: 2},
|
||||
difficultyCounts: {DifficultyLevel.easy: 1},
|
||||
reminderProfileCounts: {ReminderProfile.gentle: 2},
|
||||
);
|
||||
final settings = OwnerSettings(
|
||||
ownerId: ownerId,
|
||||
timeZoneId: 'UTC',
|
||||
dayStart: WallTime(hour: 7, minute: 30),
|
||||
dayEnd: WallTime(hour: 22, minute: 0),
|
||||
compactModeEnabled: true,
|
||||
backlogStalenessSettings: const BacklogStalenessSettings(
|
||||
greenMaxAge: Duration(days: 3),
|
||||
blueMaxAge: Duration(days: 10),
|
||||
),
|
||||
);
|
||||
final notice = NoticeAcknowledgementRecord(
|
||||
ownerId: ownerId,
|
||||
noticeId: 'notice-rollover',
|
||||
acknowledgedAt: now,
|
||||
);
|
||||
|
||||
final write = await runtime.applicationStore.run<Map<String, Object?>>(
|
||||
context: _context('repository-surface', now),
|
||||
operationName: 'repository-surface',
|
||||
action: (repositories) async {
|
||||
await repositories.ownerSettings.save(settings);
|
||||
final settingsUpdate = await repositories.ownerSettings.saveIfRevision(
|
||||
settings: settings.copyWith(compactModeEnabled: false),
|
||||
expectedRevision: 1,
|
||||
);
|
||||
final settingsStale = await repositories.ownerSettings.saveIfRevision(
|
||||
settings: settings,
|
||||
expectedRevision: 1,
|
||||
);
|
||||
|
||||
final projectInsert = await repositories.projects.insert(
|
||||
project: project,
|
||||
ownerId: ownerId,
|
||||
);
|
||||
final projectDuplicate = await repositories.projects.insert(
|
||||
project: project,
|
||||
ownerId: ownerId,
|
||||
);
|
||||
final projectUpdate = await repositories.projects.saveIfRevision(
|
||||
project: project.copyWith(name: 'Alpha Renamed'),
|
||||
ownerId: ownerId,
|
||||
expectedRevision: projectInsert.revision!,
|
||||
);
|
||||
final projectArchive = await repositories.projects.archive(
|
||||
projectId: project.id,
|
||||
ownerId: ownerId,
|
||||
expectedRevision: projectUpdate.revision!,
|
||||
archivedAt: _instant(11, 5),
|
||||
);
|
||||
final projectMissingArchive = await repositories.projects.archive(
|
||||
projectId: 'missing-project',
|
||||
ownerId: ownerId,
|
||||
expectedRevision: 1,
|
||||
archivedAt: _instant(11, 5),
|
||||
);
|
||||
|
||||
final taskInsert = await repositories.tasks.insert(
|
||||
task: scheduledTask,
|
||||
ownerId: ownerId,
|
||||
);
|
||||
final taskDuplicate = await repositories.tasks.insert(
|
||||
task: scheduledTask,
|
||||
ownerId: ownerId,
|
||||
);
|
||||
final taskUpdate = await repositories.tasks.saveIfRevision(
|
||||
task: scheduledTask.copyWith(title: 'Scheduled task updated'),
|
||||
ownerId: ownerId,
|
||||
expectedRevision: taskInsert.revision!,
|
||||
);
|
||||
await repositories.tasks.saveAll([backlogTask, childTask]);
|
||||
await repositories.tasks.save(
|
||||
backlogTask.copyWith(title: 'Backlog task updated'),
|
||||
);
|
||||
|
||||
final blockInsert = await repositories.lockedBlocks.insertBlock(
|
||||
block: block,
|
||||
ownerId: ownerId,
|
||||
);
|
||||
final blockDuplicate = await repositories.lockedBlocks.insertBlock(
|
||||
block: block,
|
||||
ownerId: ownerId,
|
||||
);
|
||||
final blockUpdate = await repositories.lockedBlocks.saveBlockIfRevision(
|
||||
block: block.copyWith(name: 'Work block renamed'),
|
||||
ownerId: ownerId,
|
||||
expectedRevision: blockInsert.revision!,
|
||||
);
|
||||
final blockArchive = await repositories.lockedBlocks.archiveBlock(
|
||||
blockId: block.id,
|
||||
ownerId: ownerId,
|
||||
expectedRevision: blockUpdate.revision!,
|
||||
archivedAt: _instant(11, 10),
|
||||
);
|
||||
|
||||
final overrideInsert = await repositories.lockedBlocks.insertOverride(
|
||||
override: override,
|
||||
ownerId: ownerId,
|
||||
);
|
||||
final overrideDuplicate =
|
||||
await repositories.lockedBlocks.insertOverride(
|
||||
override: override,
|
||||
ownerId: ownerId,
|
||||
);
|
||||
final overrideUpdate =
|
||||
await repositories.lockedBlocks.saveOverrideIfRevision(
|
||||
override: LockedBlockOverride.add(
|
||||
id: override.id,
|
||||
date: _date,
|
||||
name: 'Errand moved',
|
||||
startTime: WallTime(hour: 14, minute: 0),
|
||||
endTime: WallTime(hour: 14, minute: 30),
|
||||
projectId: project.id,
|
||||
createdAt: now,
|
||||
updatedAt: _instant(11, 15),
|
||||
),
|
||||
ownerId: ownerId,
|
||||
expectedRevision: overrideInsert.revision!,
|
||||
);
|
||||
|
||||
await repositories.schedulingSnapshots.save(snapshot);
|
||||
await repositories.schedulingSnapshots.save(
|
||||
SchedulingStateSnapshot(
|
||||
id: snapshot.id,
|
||||
capturedAt: _instant(11, 20),
|
||||
window: snapshot.window,
|
||||
tasks: [scheduledTask, backlogTask],
|
||||
operationName: 'coverage-snapshot-updated',
|
||||
),
|
||||
);
|
||||
|
||||
final activityAppend = await repositories.taskActivities.append(
|
||||
activity: activity,
|
||||
ownerId: ownerId,
|
||||
);
|
||||
final activityDuplicate = await repositories.taskActivities.append(
|
||||
activity: activity,
|
||||
ownerId: ownerId,
|
||||
);
|
||||
await repositories.taskActivities.save(
|
||||
TaskActivity(
|
||||
id: activity.id,
|
||||
operationId: activity.operationId,
|
||||
code: activity.code,
|
||||
taskId: activity.taskId,
|
||||
projectId: activity.projectId,
|
||||
occurredAt: _instant(11, 30),
|
||||
metadata: {'updated': true},
|
||||
),
|
||||
);
|
||||
|
||||
await repositories.projectStatistics.save(statistics);
|
||||
final statisticsUpdate =
|
||||
await repositories.projectStatistics.saveIfRevision(
|
||||
statistics: statistics.copyWith(completedTaskCount: 3),
|
||||
ownerId: ownerId,
|
||||
expectedRevision: 1,
|
||||
);
|
||||
final statisticsOwnerMismatch =
|
||||
await repositories.projectStatistics.saveIfRevision(
|
||||
statistics: statistics,
|
||||
ownerId: 'other-owner',
|
||||
expectedRevision: statisticsUpdate.revision!,
|
||||
);
|
||||
|
||||
final noticeInsert =
|
||||
await repositories.noticeAcknowledgements.insert(notice);
|
||||
final noticeDuplicate =
|
||||
await repositories.noticeAcknowledgements.insert(notice);
|
||||
await repositories.noticeAcknowledgements.save(
|
||||
NoticeAcknowledgementRecord(
|
||||
ownerId: ownerId,
|
||||
noticeId: notice.noticeId,
|
||||
acknowledgedAt: _instant(11, 40),
|
||||
),
|
||||
);
|
||||
|
||||
return ApplicationResult.success({
|
||||
'settingsRevision': settingsUpdate.revision,
|
||||
'settingsStale': settingsStale.failure?.code,
|
||||
'projectRevision': projectArchive.revision,
|
||||
'projectDuplicate': projectDuplicate.failure?.code,
|
||||
'projectMissingArchive': projectMissingArchive.failure?.code,
|
||||
'taskRevision': taskUpdate.revision,
|
||||
'taskDuplicate': taskDuplicate.failure?.code,
|
||||
'blockRevision': blockArchive.revision,
|
||||
'blockDuplicate': blockDuplicate.failure?.code,
|
||||
'overrideRevision': overrideUpdate.revision,
|
||||
'overrideDuplicate': overrideDuplicate.failure?.code,
|
||||
'activityRevision': activityAppend.revision,
|
||||
'activityDuplicate': activityDuplicate.failure?.code,
|
||||
'statisticsRevision': statisticsUpdate.revision,
|
||||
'statisticsOwnerMismatch': statisticsOwnerMismatch.failure?.code,
|
||||
'noticeRevision': noticeInsert.revision,
|
||||
'noticeDuplicate': noticeDuplicate.failure?.code,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
final writeSummary = write.requireValue;
|
||||
expect(writeSummary['settingsRevision'], 2);
|
||||
expect(writeSummary['settingsStale'], RepositoryFailureCode.staleRevision);
|
||||
expect(writeSummary['projectRevision'], 3);
|
||||
expect(
|
||||
writeSummary['projectDuplicate'],
|
||||
RepositoryFailureCode.duplicateId,
|
||||
);
|
||||
expect(
|
||||
writeSummary['projectMissingArchive'],
|
||||
RepositoryFailureCode.notFound,
|
||||
);
|
||||
expect(writeSummary['taskRevision'], 2);
|
||||
expect(writeSummary['taskDuplicate'], RepositoryFailureCode.duplicateId);
|
||||
expect(writeSummary['blockRevision'], 3);
|
||||
expect(writeSummary['blockDuplicate'], RepositoryFailureCode.duplicateId);
|
||||
expect(writeSummary['overrideRevision'], 2);
|
||||
expect(
|
||||
writeSummary['overrideDuplicate'],
|
||||
RepositoryFailureCode.duplicateId,
|
||||
);
|
||||
expect(writeSummary['activityRevision'], 1);
|
||||
expect(
|
||||
writeSummary['activityDuplicate'],
|
||||
RepositoryFailureCode.duplicateId,
|
||||
);
|
||||
expect(writeSummary['statisticsRevision'], 2);
|
||||
expect(
|
||||
writeSummary['statisticsOwnerMismatch'],
|
||||
RepositoryFailureCode.ownerMismatch,
|
||||
);
|
||||
expect(writeSummary['noticeRevision'], 1);
|
||||
expect(writeSummary['noticeDuplicate'], RepositoryFailureCode.duplicateId);
|
||||
|
||||
final read = await runtime.applicationStore.read<Map<String, Object?>>(
|
||||
action: (repositories) async {
|
||||
final taskPage = await repositories.tasks.findByOwner(
|
||||
ownerId: ownerId,
|
||||
page: const RepositoryPageRequest(limit: 2),
|
||||
);
|
||||
final taskPage2 = await repositories.tasks.findByOwner(
|
||||
ownerId: ownerId,
|
||||
page: RepositoryPageRequest(cursor: taskPage.nextCursor, limit: 2),
|
||||
);
|
||||
final backlogCandidates =
|
||||
await repositories.tasks.findBacklogCandidates(
|
||||
const BacklogCandidateQuery(
|
||||
ownerId: ownerId,
|
||||
projectId: 'project-alpha',
|
||||
tags: {BacklogTag.wishlist},
|
||||
),
|
||||
);
|
||||
final projectActive = await repositories.projects.findByOwner(
|
||||
ownerId: ownerId,
|
||||
);
|
||||
final projectArchived = await repositories.projects.findByOwner(
|
||||
ownerId: ownerId,
|
||||
includeArchived: true,
|
||||
);
|
||||
final blocksActive = await repositories.lockedBlocks.findBlocksByOwner(
|
||||
ownerId: ownerId,
|
||||
);
|
||||
final blocksArchived =
|
||||
await repositories.lockedBlocks.findBlocksByOwner(
|
||||
ownerId: ownerId,
|
||||
includeArchived: true,
|
||||
);
|
||||
final overridesForDate =
|
||||
await repositories.lockedBlocks.findOverridesForDate(
|
||||
ownerId: ownerId,
|
||||
date: _date,
|
||||
);
|
||||
final scheduled = await repositories.tasks.findScheduledInWindow(
|
||||
_window(7, 9),
|
||||
);
|
||||
final scheduledForOwner =
|
||||
await repositories.tasks.findScheduledInWindowForOwner(
|
||||
ownerId: ownerId,
|
||||
window: _window(7, 9),
|
||||
page: const RepositoryPageRequest(cursor: 'bad-cursor', limit: 1),
|
||||
);
|
||||
final snapshotsInWindow =
|
||||
await repositories.schedulingSnapshots.findInWindow(_window(7, 9));
|
||||
final snapshotsOutside = await repositories.schedulingSnapshots
|
||||
.findInWindow(_window(16, 17));
|
||||
final activitiesByOwner = await repositories.taskActivities.findByOwner(
|
||||
ownerId: ownerId,
|
||||
);
|
||||
final noticePage =
|
||||
await repositories.noticeAcknowledgements.findByOwner(
|
||||
ownerId: ownerId,
|
||||
page: const RepositoryPageRequest(limit: 1),
|
||||
);
|
||||
final operation = await repositories.operations.findById(
|
||||
'repository-surface',
|
||||
);
|
||||
final idempotency = await repositories.operations.findByIdempotencyKey(
|
||||
ownerId: ownerId,
|
||||
operationId: 'repository-surface',
|
||||
);
|
||||
|
||||
return ApplicationResult.success({
|
||||
'taskPageCount': taskPage.items.length,
|
||||
'taskPage2Count': taskPage2.items.length,
|
||||
'taskNextCursor': taskPage.nextCursor,
|
||||
'taskByIdTitle':
|
||||
(await repositories.tasks.findById(scheduledTask.id))?.title,
|
||||
'taskRecordRevision': (await repositories.tasks.findRecordById(
|
||||
scheduledTask.id,
|
||||
))
|
||||
?.revision,
|
||||
'plannedCount':
|
||||
(await repositories.tasks.findByStatus(TaskStatus.planned))
|
||||
.length,
|
||||
'projectTaskCount': (await repositories.tasks.findByProjectId(
|
||||
ownerId: ownerId,
|
||||
projectId: project.id,
|
||||
))
|
||||
.items
|
||||
.length,
|
||||
'childTaskCount': (await repositories.tasks.findByParentTaskId(
|
||||
ownerId: ownerId,
|
||||
parentTaskId: backlogTask.id,
|
||||
))
|
||||
.items
|
||||
.length,
|
||||
'backlogCandidateTitle': backlogCandidates.items.single.title,
|
||||
'scheduledCount': scheduled.length,
|
||||
'scheduledForOwnerCount': scheduledForOwner.items.length,
|
||||
'activeProjectCount': projectActive.items.length,
|
||||
'archivedProjectCount': projectArchived.items.length,
|
||||
'projectRecordRevision': (await repositories.projects.findRecordById(
|
||||
project.id,
|
||||
))
|
||||
?.revision,
|
||||
'allProjectCount': (await repositories.projects.findAll()).length,
|
||||
'activeBlockCount': blocksActive.items.length,
|
||||
'archivedBlockCount': blocksArchived.items.length,
|
||||
'blockRecordRevision':
|
||||
(await repositories.lockedBlocks.findBlockRecordById(block.id))
|
||||
?.revision,
|
||||
'allBlockCount':
|
||||
(await repositories.lockedBlocks.findAllBlocks()).length,
|
||||
'overrideRecordRevision': (await repositories.lockedBlocks
|
||||
.findOverrideRecordById(override.id))
|
||||
?.revision,
|
||||
'overrideCount': overridesForDate.items.length,
|
||||
'allOverrideCount':
|
||||
(await repositories.lockedBlocks.findAllOverrides()).length,
|
||||
'snapshotTaskCount':
|
||||
(await repositories.schedulingSnapshots.findById(snapshot.id))
|
||||
?.tasks
|
||||
.length,
|
||||
'snapshotInWindowCount': snapshotsInWindow.length,
|
||||
'snapshotOutsideCount': snapshotsOutside.length,
|
||||
'activityMetadataUpdated':
|
||||
(await repositories.taskActivities.findById(activity.id))
|
||||
?.metadata['updated'],
|
||||
'activityByOperationCount': (await repositories.taskActivities
|
||||
.findByOperationId(activity.operationId))
|
||||
.length,
|
||||
'activityByTaskCount':
|
||||
(await repositories.taskActivities.findByTaskId(activity.taskId))
|
||||
.length,
|
||||
'activityByProjectCount': (await repositories.taskActivities
|
||||
.findByProjectId(activity.projectId))
|
||||
.length,
|
||||
'activityByOwnerCount': activitiesByOwner.items.length,
|
||||
'activityAllCount':
|
||||
(await repositories.taskActivities.findAll()).length,
|
||||
'statisticsCompleted':
|
||||
(await repositories.projectStatistics.findByProjectId(project.id))
|
||||
?.completedTaskCount,
|
||||
'statisticsRecordRevision': (await repositories.projectStatistics
|
||||
.findRecordByProjectId(project.id))
|
||||
?.revision,
|
||||
'statisticsAllCount':
|
||||
(await repositories.projectStatistics.findAll()).length,
|
||||
'settingsCompact':
|
||||
(await repositories.ownerSettings.findByOwnerId(ownerId))
|
||||
?.compactModeEnabled,
|
||||
'settingsRevision':
|
||||
(await repositories.ownerSettings.findRecordByOwnerId(ownerId))
|
||||
?.revision,
|
||||
'noticeAcknowledgedAt':
|
||||
(await repositories.noticeAcknowledgements.findById(
|
||||
ownerId: ownerId,
|
||||
noticeId: notice.noticeId,
|
||||
))
|
||||
?.acknowledgedAt,
|
||||
'noticeOwnerCount':
|
||||
(await repositories.noticeAcknowledgements.findByOwnerId(ownerId))
|
||||
.length,
|
||||
'noticePageCount': noticePage.items.length,
|
||||
'operationName': operation?.operationName,
|
||||
'idempotencyName': idempotency?.operationName,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
final readSummary = read.requireValue;
|
||||
expect(readSummary['taskPageCount'], 2);
|
||||
expect(readSummary['taskPage2Count'], 1);
|
||||
expect(readSummary['taskNextCursor'], '2');
|
||||
expect(readSummary['taskByIdTitle'], 'Scheduled task updated');
|
||||
expect(readSummary['taskRecordRevision'], 2);
|
||||
expect(readSummary['plannedCount'], 1);
|
||||
expect(readSummary['projectTaskCount'], 3);
|
||||
expect(readSummary['childTaskCount'], 1);
|
||||
expect(readSummary['backlogCandidateTitle'], 'Backlog task updated');
|
||||
expect(readSummary['scheduledCount'], 1);
|
||||
expect(readSummary['scheduledForOwnerCount'], 1);
|
||||
expect(readSummary['activeProjectCount'], 0);
|
||||
expect(readSummary['archivedProjectCount'], 1);
|
||||
expect(readSummary['projectRecordRevision'], 3);
|
||||
expect(readSummary['allProjectCount'], 1);
|
||||
expect(readSummary['activeBlockCount'], 0);
|
||||
expect(readSummary['archivedBlockCount'], 1);
|
||||
expect(readSummary['blockRecordRevision'], 3);
|
||||
expect(readSummary['allBlockCount'], 1);
|
||||
expect(readSummary['overrideRecordRevision'], 2);
|
||||
expect(readSummary['overrideCount'], 1);
|
||||
expect(readSummary['allOverrideCount'], 1);
|
||||
expect(readSummary['snapshotTaskCount'], 2);
|
||||
expect(readSummary['snapshotInWindowCount'], 1);
|
||||
expect(readSummary['snapshotOutsideCount'], 0);
|
||||
expect(readSummary['activityMetadataUpdated'], isTrue);
|
||||
expect(readSummary['activityByOperationCount'], 1);
|
||||
expect(readSummary['activityByTaskCount'], 1);
|
||||
expect(readSummary['activityByProjectCount'], 1);
|
||||
expect(readSummary['activityByOwnerCount'], 1);
|
||||
expect(readSummary['activityAllCount'], 1);
|
||||
expect(readSummary['statisticsCompleted'], 3);
|
||||
expect(readSummary['statisticsRecordRevision'], 2);
|
||||
expect(readSummary['statisticsAllCount'], 1);
|
||||
expect(readSummary['settingsCompact'], isFalse);
|
||||
expect(readSummary['settingsRevision'], 2);
|
||||
expect(readSummary['noticeAcknowledgedAt'], _instant(11, 40));
|
||||
expect(readSummary['noticeOwnerCount'], 1);
|
||||
expect(readSummary['noticePageCount'], 1);
|
||||
expect(readSummary['operationName'], 'repository-surface');
|
||||
expect(readSummary['idempotencyName'], 'repository-surface');
|
||||
});
|
||||
}
|
||||
|
||||
/// Fixed date used by lifecycle tests.
|
||||
final _date = CivilDate(2026, 1, 2);
|
||||
|
||||
/// Returns an instant on the fixed lifecycle date.
|
||||
DateTime _instant(int hour, [int minute = 0]) {
|
||||
return DateTime.utc(_date.year, _date.month, _date.day, hour, minute);
|
||||
}
|
||||
|
||||
/// Opens and bootstraps a runtime at [path].
|
||||
Future<SqliteApplicationRuntime> _openBootstrappedRuntime(String path) async {
|
||||
final runtime = await SqliteApplicationRuntime.open(path: path);
|
||||
final bootstrap = await runtime.bootstrap();
|
||||
expect(bootstrap.isSuccess, isTrue);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/// Creates command use cases for [runtime].
|
||||
V1ApplicationCommandUseCases _commands(SqliteApplicationRuntime runtime) {
|
||||
return V1ApplicationCommandUseCases(
|
||||
applicationStore: runtime.applicationStore,
|
||||
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates management use cases for [runtime].
|
||||
V1ApplicationManagementUseCases _management(SqliteApplicationRuntime runtime) {
|
||||
return V1ApplicationManagementUseCases(
|
||||
applicationStore: runtime.applicationStore,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates the Today read query for [runtime].
|
||||
GetTodayStateQuery _todayQuery(SqliteApplicationRuntime runtime) {
|
||||
return GetTodayStateQuery(
|
||||
applicationStore: runtime.applicationStore,
|
||||
timeZoneResolver: const FixedOffsetTimeZoneResolver.utc(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates an operation context for [operationId].
|
||||
ApplicationOperationContext _context(String operationId, DateTime now) {
|
||||
return ApplicationOperationContext.start(
|
||||
operationId: operationId,
|
||||
ownerTimeZone: OwnerTimeZoneContext(
|
||||
ownerId: SqliteApplicationRuntime.defaultV1OwnerId,
|
||||
timeZoneId: SqliteApplicationRuntime.defaultV1TimeZoneId,
|
||||
),
|
||||
clock: FixedClock(now),
|
||||
idGenerator: SequentialIdGenerator(prefix: operationId),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a fresh temporary SQLite path.
|
||||
Future<String> _tempDatabasePath() async {
|
||||
final directory = await Directory.systemTemp.createTemp(
|
||||
'focus-flow-sqlite-app-',
|
||||
);
|
||||
addTearDown(() => directory.delete(recursive: true));
|
||||
return '${directory.path}/scheduler.sqlite';
|
||||
}
|
||||
|
||||
/// Creates a project profile fixture.
|
||||
ProjectProfile _project(String id, String name) {
|
||||
return ProjectProfile(
|
||||
id: id,
|
||||
name: name,
|
||||
colorKey: 'project-$id',
|
||||
defaultPriority: PriorityLevel.high,
|
||||
defaultReward: RewardLevel.high,
|
||||
defaultDifficulty: DifficultyLevel.easy,
|
||||
defaultReminderProfile: ReminderProfile.gentle,
|
||||
defaultDurationMinutes: 30,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a task fixture.
|
||||
Task _task({
|
||||
required String id,
|
||||
required String title,
|
||||
required TaskStatus status,
|
||||
DateTime? scheduledStart,
|
||||
DateTime? scheduledEnd,
|
||||
String? parentTaskId,
|
||||
Set<BacklogTag> backlogTags = const <BacklogTag>{},
|
||||
}) {
|
||||
return Task(
|
||||
id: id,
|
||||
title: title,
|
||||
projectId: 'project-alpha',
|
||||
type: TaskType.flexible,
|
||||
status: status,
|
||||
priority: PriorityLevel.medium,
|
||||
reward: RewardLevel.high,
|
||||
difficulty: DifficultyLevel.easy,
|
||||
durationMinutes: 30,
|
||||
scheduledStart: scheduledStart,
|
||||
scheduledEnd: scheduledEnd,
|
||||
parentTaskId: parentTaskId,
|
||||
backlogTags: backlogTags,
|
||||
reminderOverride: ReminderProfile.gentle,
|
||||
createdAt: _instant(7),
|
||||
updatedAt: _instant(7),
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a scheduling window on the fixed test date.
|
||||
SchedulingWindow _window(int startHour, int endHour) {
|
||||
return SchedulingWindow(
|
||||
start: _instant(startHour),
|
||||
end: _instant(endHour),
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/// Aggregates Scheduler Core workspace tests.
|
||||
library;
|
||||
|
||||
|
|
@ -69,6 +72,10 @@ import '../packages/scheduler_persistence_memory/test/memory_repository_conforma
|
|||
as memory_repository_conformance_test;
|
||||
import '../packages/scheduler_persistence_sqlite/test/sqlite_repository_conformance_test.dart'
|
||||
as sqlite_repository_conformance_test;
|
||||
import '../packages/scheduler_persistence_sqlite/test/scheduler_db_test.dart'
|
||||
as sqlite_scheduler_db_test;
|
||||
import '../packages/scheduler_persistence_sqlite/test/sqlite_application_unit_of_work_test.dart'
|
||||
as sqlite_application_unit_of_work_test;
|
||||
|
||||
/// Entry point for this executable Dart file.
|
||||
/// It wires the local command, test, or application behavior needed by this repository without exposing additional public API.
|
||||
|
|
@ -107,4 +114,6 @@ void main() {
|
|||
repo_contract_smoke_test.main();
|
||||
memory_repository_conformance_test.main();
|
||||
sqlite_repository_conformance_test.main();
|
||||
sqlite_scheduler_db_test.main();
|
||||
sqlite_application_unit_of_work_test.main();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue