feat: complete sqlite runtime persistence plan

This commit is contained in:
Ashley Venn 2026-07-01 19:40:56 -07:00
parent f29fb3779e
commit ce8ebe1c1e
53 changed files with 8718 additions and 60 deletions

View file

@ -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.

View file

@ -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
```

View file

@ -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.

View file

@ -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
```

View file

@ -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
```

View file

@ -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
```

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -12,6 +12,8 @@ Included documents cover:
ADRs, public API baseline, and requirements traceability matrix. ADRs, public API baseline, and requirements traceability matrix.
- UI Plan 1, the implemented compact Today timeline mockup plan, including its - UI Plan 1, the implemented compact Today timeline mockup plan, including its
source mockups and completed block handoff notes. 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 Excluded documents include superseded originals, MongoDB-targeted persistence or
runtime plans, planned/blocked UI handoff plans, old archive indexes, and review runtime plans, planned/blocked UI handoff plans, old archive indexes, and review

View file

@ -1,7 +1,17 @@
<!-- SPDX-FileCopyrightText: 2026 FocusFlow contributors -->
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
# Current Software Plan # Current Software Plan
No active implementation plans are queued here after UI Plan 1 completed on Ordered implementation queue:
2026-06-30.
Completed implementation plans live in `../Archived plans/`. Curated copies 1. `Date Selection 01 - Day Navigation and Date Picker/`
that still match the app's current state live in `../Completed Plans/`. 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/`.

View file

@ -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 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. 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: Prerequisites:

View file

@ -7,18 +7,28 @@ Flutter desktop app target for the FocusFlow UI work.
## Current Scope ## Current Scope
UI Plan 1 implements a compact Today timeline mockup and selected-task modal. The app launches into the compact Today timeline. Normal startup now opens an
The app target already existed before UI Plan 1 Block 1, so this package keeps on-disk SQLite database and bootstraps owner settings plus the default Home
the existing Flutter desktop scaffolding and local scheduler dependency. project when they are missing.
The app launches into the compact Today screen. All visible controls are The compact Today screen can toggle completion for currently rendered task
intentionally no-op except selecting a task card and closing the modal. 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 Normal runtime uses `lib/app/persistent_scheduler_composition.dart`, which reads
domain `ProjectProfile`, `OwnerSettings`, and `Task` records in an in-memory `SCHEDULER_SQLITE_PATH` from a Dart define. If no define is present, it uses the
application store, then reads `TodayState` through `GetTodayStateQuery`. 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`. Widgets consume `TodayScreenData` from `lib/models/today_screen_models.dart`.
Scheduler core remains the source of truth for task state and Today ordering. 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'; import 'package:scheduler_core/scheduler_core.dart';
``` ```
The app must not import scheduler `src/` files, SQLite/Drift adapters, The persistent composition root may import
desktop notification implementations, backup/export packages, or direct OS APIs `package:scheduler_persistence_sqlite/sqlite.dart` and `dart:io` for runtime
for this UI slice. `test/forbidden_imports_test.dart` enforces that boundary. 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 ## Intentionally No-op
@ -43,7 +56,28 @@ for this UI slice. `test/forbidden_imports_test.dart` enforces that boundary.
- Settings button. - Settings button.
- Show upcoming. - Show upcoming.
- Timeline card action icons. - 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 ## Commands
@ -57,8 +91,7 @@ Run these from `apps/focus_flow_flutter/`.
## Next Plans ## Next Plans
- Wire functional Done, Push, Move to Backlog, and Break up actions. - Wire visible Backlog navigation and quick-capture UI into the main shell.
- Add Backlog and quick-capture UI. - Wire functional Push, Move to Backlog, and Break up actions.
- Add persistent SQLite-backed runtime composition.
- Add real navigation/settings. - Add real navigation/settings.
- Add Shield/Recovery only in a later scope. - Add Shield/Recovery only in a later scope.

View file

@ -4,7 +4,7 @@
part of '../demo_scheduler_composition.dart'; part of '../demo_scheduler_composition.dart';
/// Formats a civil date as a long month/day/year label. /// Formats a civil date as a long month/day/year label.
String _formatDateLabel(CivilDate date) { String formatSchedulerDateLabel(CivilDate date) {
const months = [ const months = [
'January', 'January',
'February', 'February',

View file

@ -9,12 +9,13 @@ import 'package:scheduler_core/scheduler_core.dart';
import '../controllers/scheduler_command_controller.dart'; import '../controllers/scheduler_command_controller.dart';
import '../controllers/scheduler_read_controller.dart'; import '../controllers/scheduler_read_controller.dart';
import '../controllers/today_screen_controller.dart'; import '../controllers/today_screen_controller.dart';
import 'scheduler_app_composition.dart';
part 'demo/demo_date_formatter.dart'; part 'demo/demo_date_formatter.dart';
part 'demo/demo_seed_tasks.dart'; part 'demo/demo_seed_tasks.dart';
/// Wires the Flutter demo UI to in-memory scheduler application use cases. /// 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. /// 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. /// Callers should pass already-normalized scheduler data because validation and defaulting belong at the model or service boundary, not in arbitrary UI code.
DemoSchedulerComposition._({ DemoSchedulerComposition._({
@ -54,7 +55,12 @@ class DemoSchedulerComposition {
final DateTime readAt; final DateTime readAt;
/// Human-readable label for [date]. /// 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. /// Creates a deterministic seeded composition for the demo app and tests.
factory DemoSchedulerComposition.seeded({ factory DemoSchedulerComposition.seeded({
@ -115,6 +121,7 @@ class DemoSchedulerComposition {
} }
/// Creates the controller used by the compact Today screen mockup. /// Creates the controller used by the compact Today screen mockup.
@override
TodayScreenController createTodayScreenController() { TodayScreenController createTodayScreenController() {
return TodayScreenController( return TodayScreenController(
read: () { read: () {
@ -163,6 +170,7 @@ class DemoSchedulerComposition {
} }
/// Creates the command controller used by interactive scheduler controls. /// Creates the command controller used by interactive scheduler controls.
@override
SchedulerCommandController createCommandController({ SchedulerCommandController createCommandController({
required ReadRefresh refreshReads, required ReadRefresh refreshReads,
}) { }) {
@ -171,6 +179,7 @@ class DemoSchedulerComposition {
contextFor: context, contextFor: context,
localDate: date, localDate: date,
refreshReads: refreshReads, refreshReads: refreshReads,
quickCaptureProjectId: defaultProjectId,
); );
} }
@ -186,4 +195,8 @@ class DemoSchedulerComposition {
idGenerator: SequentialIdGenerator(prefix: operationId), idGenerator: SequentialIdGenerator(prefix: operationId),
); );
} }
/// Releases demo composition resources.
@override
Future<void> dispose() async {}
} }

View file

@ -4,6 +4,8 @@
/// Composes the FocusFlow Flutter application around Focus Flow App. /// Composes the FocusFlow Flutter application around Focus Flow App.
library; library;
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../controllers/scheduler_command_controller.dart'; import '../controllers/scheduler_command_controller.dart';
@ -17,7 +19,7 @@ import '../widgets/sidebar.dart';
import '../widgets/task_selection_modal.dart'; import '../widgets/task_selection_modal.dart';
import '../widgets/timeline/timeline_view.dart'; import '../widgets/timeline/timeline_view.dart';
import '../widgets/top_bar.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.dart';
part 'home/focus_flow_home_state.dart'; part 'home/focus_flow_home_state.dart';
@ -31,7 +33,7 @@ class FocusFlowApp extends StatelessWidget {
const FocusFlowApp({required this.composition, super.key}); const FocusFlowApp({required this.composition, super.key});
/// Factory and controller bundle used by the app tree. /// Factory and controller bundle used by the app tree.
final DemoSchedulerComposition composition; final SchedulerAppComposition composition;
/// Builds the widget subtree for this component. /// Builds the widget subtree for this component.
/// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data. /// The method translates the current widget configuration and state into Flutter UI without mutating scheduler domain data.

View file

@ -9,7 +9,7 @@ class FocusFlowHome extends StatefulWidget {
const FocusFlowHome({required this.composition, super.key}); const FocusFlowHome({required this.composition, super.key});
/// Factory and controller bundle used by the home screen. /// 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. /// 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. /// Flutter calls this once for each mounted widget instance before lifecycle callbacks and builds begin.

View file

@ -32,6 +32,7 @@ class _FocusFlowHomeState extends State<FocusFlowHome> {
void dispose() { void dispose() {
commandController.dispose(); commandController.dispose();
controller.dispose(); controller.dispose();
unawaited(widget.composition.dispose());
super.dispose(); super.dispose();
} }

View file

@ -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();
}
}

View file

@ -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();
}

View file

@ -11,6 +11,7 @@ class SchedulerCommandController extends ChangeNotifier {
required this.contextFor, required this.contextFor,
required this.localDate, required this.localDate,
required this.refreshReads, required this.refreshReads,
required this.quickCaptureProjectId,
DateTime Function()? now, DateTime Function()? now,
}) : now = now ?? _utcNow; }) : now = now ?? _utcNow;
@ -26,6 +27,9 @@ class SchedulerCommandController extends ChangeNotifier {
/// Refresh callback invoked after commands that mutate read state. /// Refresh callback invoked after commands that mutate read state.
final ReadRefresh refreshReads; 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. /// Clock used by direct UI commands such as marking a task complete.
final DateTime Function() now; final DateTime Function() now;
@ -50,7 +54,7 @@ class SchedulerCommandController extends ChangeNotifier {
return commands.quickCaptureToBacklog( return commands.quickCaptureToBacklog(
context: contextFor(operationId), context: contextFor(operationId),
title: title, title: title,
projectId: 'home', projectId: quickCaptureProjectId,
); );
}, },
); );

View file

@ -6,13 +6,47 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'app/demo_scheduler_composition.dart';
import 'app/focus_flow_app.dart'; import 'app/focus_flow_app.dart';
import 'app/persistent_scheduler_composition.dart';
export 'app/demo_scheduler_composition.dart'; export 'app/demo_scheduler_composition.dart';
export 'app/focus_flow_app.dart'; export 'app/focus_flow_app.dart';
export 'app/persistent_scheduler_composition.dart';
/// Starts the FocusFlow app with the seeded demo scheduler composition. /// Starts the FocusFlow app with the persistent SQLite scheduler composition.
void main() { Future<void> main() async {
runApp(FocusFlowApp(composition: DemoSchedulerComposition.seeded())); 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'),
),
),
),
);
}
} }

View file

@ -33,6 +33,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" 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: collection:
dependency: transitive dependency: transitive
description: description:
@ -41,6 +49,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" 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: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@ -49,6 +73,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.9" version: "1.0.9"
drift:
dependency: transitive
description:
name: drift
sha256: "6cc0b623c0e83f7080524d8396e9301b1d78b9c66a4fdceeb0f798211303254c"
url: "https://pub.dev"
source: hosted
version: "2.34.0"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -57,6 +89,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" 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: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -75,6 +123,22 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@ -107,6 +171,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@ -131,6 +203,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" 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: path:
dependency: transitive dependency: transitive
description: description:
@ -139,6 +219,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" 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: scheduler_core:
dependency: "direct main" dependency: "direct main"
description: description:
@ -146,6 +242,20 @@ packages:
relative: true relative: true
source: path source: path
version: "0.1.0" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -159,6 +269,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.2" 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: stack_trace:
dependency: transitive dependency: transitive
description: description:
@ -199,6 +325,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" 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: vector_math:
dependency: transitive dependency: transitive
description: description:
@ -215,6 +349,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.2.0" 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: sdks:
dart: ">=3.12.2 <4.0.0" dart: ">=3.12.2 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.18.0-18.0.pre.54"

View file

@ -14,6 +14,9 @@ dependencies:
sdk: flutter sdk: flutter
scheduler_core: scheduler_core:
path: ../../packages/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 cupertino_icons: ^1.0.8
dev_dependencies: dev_dependencies:
@ -21,5 +24,11 @@ dev_dependencies:
sdk: flutter sdk: flutter
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
dependency_overrides:
scheduler_core:
path: ../../packages/scheduler_core
scheduler_persistence:
path: ../../packages/scheduler_persistence
flutter: flutter:
uses-material-design: true uses-material-design: true

View file

@ -10,7 +10,9 @@ import 'package:flutter_test/flutter_test.dart';
/// Runs app package-boundary tests. /// Runs app package-boundary tests.
void main() { 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 appRoot = Directory.current;
final dartFiles = <File>[ final dartFiles = <File>[
..._dartFiles(Directory('${appRoot.path}/lib')), ..._dartFiles(Directory('${appRoot.path}/lib')),
@ -33,7 +35,8 @@ void main() {
isEmpty, isEmpty,
reason: 'Forbidden imports found:\n${violations.join('\n')}', reason: 'Forbidden imports found:\n${violations.join('\n')}',
); );
}); },
);
} }
/// Top-level helper that performs the `_dartFiles` operation for this file. /// Top-level helper that performs the `_dartFiles` operation for this file.
@ -56,6 +59,7 @@ List<_ForbiddenImport> _forbiddenImportsFor(String relativePath) {
"package:scheduler_core/src/", "package:scheduler_core/src/",
'scheduler_core internals', 'scheduler_core internals',
), ),
if (!_isPersistentComposition(relativePath))
const _ForbiddenImport( const _ForbiddenImport(
"package:scheduler_persistence_sqlite/", "package:scheduler_persistence_sqlite/",
'SQLite persistence adapter', 'SQLite persistence adapter',
@ -71,11 +75,18 @@ List<_ForbiddenImport> _forbiddenImportsFor(String relativePath) {
"package:scheduler_export_json/", "package:scheduler_export_json/",
'JSON export package', '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'), 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. /// 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. /// 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 { class _ForbiddenImport {

View 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;
}

View file

@ -6,6 +6,9 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
} }

View file

@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
sqlite3_flutter_libs
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST

View file

@ -11,11 +11,15 @@ import 'package:drift/drift.dart';
part 'scheduler_db.g.dart'; part 'scheduler_db.g.dart';
part 'scheduler_db/tables/tasks/tasks.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/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_blocks.dart';
part 'scheduler_db/tables/locked_time/locked_overrides.dart'; part 'scheduler_db/tables/locked_time/locked_overrides.dart';
part 'scheduler_db/tables/settings/settings_table.dart'; part 'scheduler_db/tables/settings/settings_table.dart';
part 'scheduler_db/tables/snapshots/snapshots.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/task_dao.dart';
part 'scheduler_db/daos/project_dao.dart'; part 'scheduler_db/daos/project_dao.dart';
part 'scheduler_db/daos/locked_block_dao.dart'; part 'scheduler_db/daos/locked_block_dao.dart';

View file

@ -7,11 +7,15 @@ part of '../../scheduler_db.dart';
@DriftDatabase( @DriftDatabase(
tables: [ tables: [
Tasks, Tasks,
TaskActivities,
Projects, Projects,
ProjectStatisticsTable,
LockedBlocks, LockedBlocks,
LockedOverrides, LockedOverrides,
SettingsTable, SettingsTable,
Snapshots, Snapshots,
ApplicationOperations,
NoticeAcknowledgements,
], ],
daos: [ daos: [
TaskDao, TaskDao,
@ -29,7 +33,7 @@ class SchedulerDb extends _$SchedulerDb {
/// Returns the derived `schemaVersion` value for this object. /// 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. /// Keeping this as an accessor gives callers a stable read surface while the owning type controls how the value is calculated.
@override @override
int get schemaVersion => 1; int get schemaVersion => 2;
/// Runs the `migration` operation and completes after its asynchronous work finishes. /// 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. /// 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( MigrationStrategy get migration => MigrationStrategy(
onCreate: (Migrator migrator) async { onCreate: (Migrator migrator) async {
await migrator.createAll(); 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)',
);
}
} }

View file

@ -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};
}

View file

@ -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};
}

View file

@ -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};
}

View file

@ -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};
}

View file

@ -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),
);
}

View file

@ -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();
}
}

View file

@ -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);
}

View file

@ -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(),
);
}

View file

@ -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(),
);
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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,
),
);
}

View file

@ -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;
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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),
);
}
}

View file

@ -5,10 +5,13 @@
library; library;
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart' as drift; import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:scheduler_core/scheduler_core.dart' as core; import 'package:scheduler_core/scheduler_core.dart' as core;
import 'package:scheduler_persistence/persistence.dart'; import 'package:scheduler_persistence/persistence.dart';
import 'package:sqlite3/sqlite3.dart' as sqlite;
import 'scheduler_db.dart'; import 'scheduler_db.dart';
part 'sqlite_repositories/sqlite_task_repository.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_locked_block_repository.dart';
part 'sqlite_repositories/sqlite_settings_repository.dart'; part 'sqlite_repositories/sqlite_settings_repository.dart';
part 'sqlite_repositories/sqlite_schedule_snapshot_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`. /// Private file-level value for `_snapshotRetention`.
/// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals. /// Keeping this value near the helpers that use it avoids hidden global configuration while still preventing duplicate literals.

View file

@ -14,6 +14,7 @@ dependencies:
drift: ^2.20.0 drift: ^2.20.0
scheduler_core: any scheduler_core: any
scheduler_persistence: any scheduler_persistence: any
sqlite3: ^3.3.3
dev_dependencies: dev_dependencies:
build_runner: ^2.4.13 build_runner: ^2.4.13

View file

@ -4,18 +4,21 @@
/// Tests Scheduler Db behavior for the Scheduler Persistence Sqlite package. /// Tests Scheduler Db behavior for the Scheduler Persistence Sqlite package.
library; library;
import 'dart:io';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:scheduler_persistence_sqlite/sqlite.dart'; import 'package:scheduler_persistence_sqlite/sqlite.dart';
import 'package:sqlite3/sqlite3.dart' as sqlite;
import 'package:test/test.dart'; import 'package:test/test.dart';
/// Entry point for this executable Dart file. /// 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. /// It wires the local command, test, or application behavior needed by this repository without exposing additional public API.
void main() { 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()); final db = SchedulerDb(NativeDatabase.memory());
addTearDown(db.close); addTearDown(db.close);
expect(db.schemaVersion, 1); expect(db.schemaVersion, 2);
final rows = await db final rows = await db
.customSelect( .customSelect(
@ -29,13 +32,207 @@ void main() {
expect( expect(
tableNames, tableNames,
containsAll(<String>[ containsAll(<String>[
'application_operations',
'locked_blocks', 'locked_blocks',
'locked_overrides', 'locked_overrides',
'notice_acknowledgements',
'projects', 'projects',
'project_statistics',
'settings', 'settings',
'snapshots', 'snapshots',
'task_activities',
'tasks', '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();
}
} }

View file

@ -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),
);
}

View file

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 FocusFlow contributors
// SPDX-License-Identifier: AGPL-3.0-only
/// Aggregates Scheduler Core workspace tests. /// Aggregates Scheduler Core workspace tests.
library; library;
@ -69,6 +72,10 @@ import '../packages/scheduler_persistence_memory/test/memory_repository_conforma
as memory_repository_conformance_test; as memory_repository_conformance_test;
import '../packages/scheduler_persistence_sqlite/test/sqlite_repository_conformance_test.dart' import '../packages/scheduler_persistence_sqlite/test/sqlite_repository_conformance_test.dart'
as sqlite_repository_conformance_test; 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. /// 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. /// 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(); repo_contract_smoke_test.main();
memory_repository_conformance_test.main(); memory_repository_conformance_test.main();
sqlite_repository_conformance_test.main(); sqlite_repository_conformance_test.main();
sqlite_scheduler_db_test.main();
sqlite_application_unit_of_work_test.main();
} }