Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
7fda8d5
Adds tests for screens
Devasy Jul 23, 2026
a3d61b9
Adds tests
Devasy Jul 23, 2026
71659af
Adds comprehensive tests
Devasy Jul 23, 2026
9732b9a
Adds new tests
Devasy Jul 23, 2026
87ce588
Updates test.yml to run on release branches
Devasy Jul 23, 2026
bb98883
Adds test and resolved the warnings and issues
Devasy Jul 23, 2026
8e745cc
Updates tests and minor bug fixes
Devasy Jul 23, 2026
f0fc3c2
Adds fixes for failing testsm and adds connection timeout safety for …
Devasy Jul 24, 2026
43e4dd3
Adds missing lines patch
Devasy Jul 24, 2026
3abd451
Updates the tests with analyse failures
Devasy Jul 24, 2026
9a55007
Updates tests and routine creator to use the common component
Devasy Jul 24, 2026
3b787a0
Updates flutter version and adds tests
Devasy Jul 24, 2026
4b85588
Adds major genui Feature and renderer
Devasy Jul 27, 2026
2a17656
chore: remove patch_so script
Devasy Jul 28, 2026
a790d6e
build: add --build-id=none for jni package in F-Droid metadata
Devasy Jul 28, 2026
e89e2ca
ci: add jni build-id sed step for future reproducible releases
Devasy Jul 28, 2026
85a48fd
feat: assisted pullups, deload-aware ML, handle-scoped PRs, sleeping …
Devasy Aug 5, 2026
62beb92
feat(genui): add A2UiProps alias-aware coercing property reader
Devasy Aug 5, 2026
36253fb
feat(genui): add A2UiSpec contract, A2UiNode and A2UiRegistry
Devasy Aug 5, 2026
85b472d
fix(genui): make A2UiRegistry throw on name/alias collisions
Devasy Aug 5, 2026
6a67623
feat(genui): add A2UiParser with fence, envelope and alias repair
Devasy Aug 5, 2026
116ef77
fix(genui): balanced-bracket JSON extraction and envelope singleton fix
Devasy Aug 5, 2026
c846369
feat(genui): inject A2UiTheme and extract shared panel chrome
Devasy Aug 6, 2026
ffa451e
test(genui): strengthen theme-injection and add A2UiPanel coverage
Devasy Aug 6, 2026
cb60d5f
feat(genui): add A2UiSeries as the shared categorical data shape
Devasy Aug 6, 2026
a426fa2
fix(genui): cover fallback path in A2UiSeries.extract, fix negative-m…
Devasy Aug 6, 2026
a0c3ffc
feat(genui): add StatCardSpec with typed props and trend synonyms
Devasy Aug 6, 2026
1ce4ae1
feat(genui): add MetricGaugeSpec with safe progress and null value
Devasy Aug 6, 2026
58a77f2
feat(genui): add DynamicChartSpec for line, bar and pie
Devasy Aug 6, 2026
243d907
feat(genui): add ScatterPlotSpec with point repair and safe bounds
Devasy Aug 6, 2026
43cae7f
feat(genui): add RadarChartSpec sharing the labels/series shape
Devasy Aug 6, 2026
d3c18b3
feat(genui): add DataListGroupSpec with row repair and optional title
Devasy Aug 6, 2026
5e2e2f1
feat(genui): add FilterChipsSpec with nullable active option
Devasy Aug 6, 2026
359ffd3
feat(genui): add GridContainerSpec, default registry and renderer
Devasy Aug 6, 2026
8d32d0d
feat(genui): generate the A2UI prompt section from the registry
Devasy Aug 6, 2026
fc7e843
refactor(genui): wire coach screen to the A2UI package, drop legacy r…
Devasy Aug 7, 2026
1b0b8cc
fix(genui): bracket negative-value ranges in DynamicChart line/bar axes
Devasy Aug 7, 2026
2287ff0
test(genui): cover all-negative bounds and malformed point entries
Devasy Aug 7, 2026
939349a
fix(genui): widen per-node children lookup back to components/element…
Devasy Aug 7, 2026
298126e
fix(genui): widen looksLikeUi to catch prose-prefixed fences, fix vac…
Devasy Aug 7, 2026
6561e95
refactor(genui): drop presentation payload from tools, add purity and…
Devasy Aug 7, 2026
5735d98
fix(genui): depth-agnostic purity regex, pin two silent-visual regres…
Devasy Aug 7, 2026
c0913bb
fix(genui): propagate registry through recursion, pin prompt drift, c…
Devasy Aug 7, 2026
8024efa
Merge branch 'r2.1.0' of https://github.com/Devasy/RepForge into feat…
Devasy Aug 7, 2026
1f327a5
docs: add design spec for Hive->SQLite migration + coach SQL query tool
Devasy Aug 7, 2026
7a0c18c
fix: persist assisted-load volume correctly, tighten exercise-handle …
Devasy Aug 7, 2026
f363a21
fix: bound sleep-analytics window, drop fabricated data, resolve musc…
Devasy Aug 7, 2026
8862e9d
fix(genui): pie negative-value filtering, overflow guard, stat-card u…
Devasy Aug 7, 2026
0ff6c2d
test: close vacuous-test gaps and pin already-fixed regressions
Devasy Aug 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,19 @@ jobs:

- name: Install dependencies
working-directory: ./workout-logger
run: flutter pub get
run: |
flutter pub get
: "${PUB_CACHE:?PUB_CACHE is not set}"
mapfile -t targets < <(find "$PUB_CACHE" -type f -path '*/jni-*/src/CMakeLists.txt')
if [ "${#targets[@]}" -eq 0 ]; then
echo "Error: no jni-*/src/CMakeLists.txt files found under \$PUB_CACHE" >&2
exit 1
fi
for f in "${targets[@]}"; do
if ! grep -q -- '-Wl,--build-id=none' "$f"; then
sed -i -e 's/-Wl,/-Wl,--build-id=none,/' "$f"
fi
done

- name: Bump version
if: github.event_name == 'push'
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,6 @@ repforge_backup_*.json
tmp_hive_*/
**/tmp_hive_*/


# Subagent-driven-development scratch workspace
.superpowers/
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# Hive → SQLite Migration + Coach SQL Query Tool — Design Spec

**Date:** 2026-08-08
**Status:** Approved
**Feature area:** Storage layer (`lib/services/`) + AI Coach tools (`lib/services/ai/`)

---

## 1. Problem

The AI Coach (`CoachToolService`) currently exposes ~15 narrow, purpose-built tools (`get_exercise_performance`, `get_workouts_in_range`, etc.), each hand-wrapping a specific `WorkoutProvider`/`PRManager` query. This is fine for known question shapes but can't answer arbitrary analytical questions the model wasn't given a preset tool for (e.g. ad-hoc joins, unusual aggregations, novel filters).

The fix — a generic SQL query tool — is a poor fit for the current storage layer: RepForge persists to **Hive**, a key-value store with no query language. Any SQL tool would need a translation layer.

Two paths were considered:
- **Ephemeral snapshot**: build a throwaway in-memory SQLite mirror on every coach tool call, rebuilt from Hive-backed in-memory lists each time.
- **Real migration**: replace Hive with SQLite as the actual persistence backend, so the coach's SQL tool queries live data directly with no translation step.

This spec chooses the second path. `IStorageService` (`lib/services/interfaces/storage_service_interface.dart`) is already a clean DIP boundary — every method takes/returns plain Dart models, no Hive types leak through — so a `SqliteStorageService implements IStorageService` swap is architecturally sound without touching any manager, `WorkoutProvider`, or screen. `MockStorageService` already fulfills the same interface, so the existing test suite is unaffected by the backend swap.

This is two dependent efforts: (A) migrate the storage backend, (B) add the coach's SQL tool on top of it. (A) is materially riskier — it touches real user data — and is the majority of this spec.

---

## 2. Goal

1. Replace Hive with SQLite (`sqflite`) as RepForge's persistence backend, via a new `SqliteStorageService implements IStorageService`, with a safe, reversible, one-time migration for existing installs.
2. Add `run_sql_query` to `CoachToolService`: the model submits a read-only SQL `SELECT`, executed against a dedicated read-only connection to the live database, results returned as JSON rows.

Non-goals: no UI changes, no new user-facing features, no change to any existing `IStorageService` method signature or manager/provider code.

---

## 3. Package Choice: `sqflite`

Considered `sqlite3` (FFI, synchronous) vs `sqflite` (platform channel, async). Chose **`sqflite`**:

- `IStorageService` is entirely `Future`-based already. `sqflite` runs DB work on a native background thread and returns via `Future` naturally — no extra isolate-management code. `sqlite3` is synchronous on the calling isolate; matching the same non-blocking behavior would require hand-rolling a background isolate, which is unjustified complexity at this app's data scale.
- `sqflite` supports `rawQuery(sql, args)` / `rawInsert` / `rawUpdate`, so the coach's arbitrary-SQL tool works identically to how it would under `sqlite3`. No capability is lost.
- No native binary bundling (`sqlite3_flutter_libs`) needed; uses the OS-provided SQLite.

**Known tradeoff:** `sqflite` uses the Android-bundled SQLite version rather than a pinned one, so very old devices could lack newer SQL features (e.g. window functions, SQLite 3.25+/Android 9+). Accepted as low risk for this app's scale and audience.

**Test dependency:** add `sqflite_common_ffi` (dev dependency) — required to run `sqflite`-backed code under `flutter test`, since plain `sqflite` needs a real platform binding unavailable off-device.

---

## 4. Schema

All tables live in one SQLite database file, created in `onCreate`.

```sql
CREATE TABLE exercises (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL, -- 'compound' | 'isolation'
is_custom INTEGER NOT NULL DEFAULT 0,
available_handles TEXT -- JSON array or NULL
);

CREATE TABLE muscle_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
growth_rate REAL NOT NULL DEFAULT 0,
last_updated TEXT NOT NULL
);

CREATE TABLE exercise_muscle_activations (
exercise_id TEXT NOT NULL REFERENCES exercises(id),
muscle_group_id TEXT NOT NULL,
activation_percentage INTEGER NOT NULL
);
Comment on lines +68 to +72

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files matching SQLite migration/coach SQL spec:"
fd -i '2026-08-08-sqlite-migration-and-coach-sql-tool-design.md|sqflite|sqlite' . | sed -n '1,120p'

echo
echo "Target file excerpt:"
if [ -f "docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md" ]; then
  nl -ba docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md | sed -n '55,130p'
fi

echo
echo "Search for foreign key declarations/pragmas/onConfigure:"
rg -n "FOREIGN KEY|PRAGMA foreign_keys|foreign_keys|onConfigure|REFERENCES\s*\(" -S . \
  --glob '!**/.git/**' \
  --glob '!**/build/**' \
  --glob '!**/dist/**' \
  | sed -n '1,240p'

Repository: Devasy/RepForge

Length of output: 346


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Target file excerpt:"
if [ -f "docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md" ]; then
  awk 'NR>=55 && NR<=130 {printf "%6d\t%s\n", NR, $0}' docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md
fi

echo
echo "Search for foreign key declarations/pragmas/onConfigure:"
rg -n --fixed-strings "FOREIGN KEY" "PRAGMA foreign_keys" "foreign_keys" "onConfigure" "REFERENCES (" "REFERENCES(" . \
  --glob '!**/.git/**' \
  --glob '!**/build/**' \
  --glob '!**/dist/**' \
  || true

echo
echo "Candidate database files:"
fd -i 'database|db|sqlite|sqflite' . \
  --glob '!**/.git/**' \
  --glob '!**/build/**' \
  --glob '!**/dist/**' \
  | sed -n '1,80p'

Repository: Devasy/RepForge

Length of output: 3203


🌐 Web query:

SQLite foreign key constraints enabled per database connection PRAGMA foreign_keys ON documentation

💡 Result:

In SQLite, foreign key constraints must be enabled for each database connection using the PRAGMA foreign_keys = ON; command [1][2]. Key details regarding foreign key enforcement include: 1. Default Setting: Foreign key enforcement is disabled by default for backward compatibility [1][2]. Applications should explicitly enable or disable this setting as needed rather than relying on the default, as future releases may change this behavior [3][1]. 2. Connection-Specific: The setting applies only to the current database connection [1][4]. 3. Constraints on Use: The PRAGMA foreign_keys command is a no-op if executed within a transaction (i.e., when a BEGIN or SAVEPOINT is pending) [3][5]. 4. Compilation Requirements: For foreign key constraints to function, the SQLite library must be compiled without the SQLITE_OMIT_FOREIGN_KEY or SQLITE_OMIT_TRIGGER preprocessor macros [1][2]. 5. Alternative Configuration: Developers can also use the sqlite3_db_config C interface with the SQLITE_DBCONFIG_ENABLE_FKEY option to enable or disable foreign key enforcement programmatically [6]. To verify the current status of foreign key enforcement, you can execute the command PRAGMA foreign_keys;, which returns 1 if enabled or 0 if disabled [1][2]. If the command returns no data, it indicates that the version of SQLite being used does not support foreign key enforcement [1][2].

Citations:


Add the missing foreign keys and enable enforcement.

Add foreign keys for muscle_group_id, routine_exercises.exercise_id, sessions.routine_id, exercise_logs.exercise_id, and targets.exercise_id.

Also execute PRAGMA foreign_keys = ON in onConfigure for every writable connection. SQLite disables foreign-key enforcement by default and requires per-connection enablement.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md`
around lines 68 - 72, Update the schema definitions for
exercise_muscle_activations.muscle_group_id, routine_exercises.exercise_id,
sessions.routine_id, exercise_logs.exercise_id, and targets.exercise_id to
include references to their corresponding tables. Also update onConfigure for
every writable connection to execute PRAGMA foreign_keys = ON.

Source: MCP tools


CREATE TABLE routines (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);

CREATE TABLE routine_exercises (
routine_id TEXT NOT NULL REFERENCES routines(id),
exercise_id TEXT NOT NULL,
position INTEGER NOT NULL
);

CREATE TABLE sessions (
id TEXT PRIMARY KEY,
date TEXT NOT NULL,
routine_id TEXT,
duration_min INTEGER NOT NULL,
notes TEXT,
hc_synced_at TEXT
);

CREATE TABLE exercise_logs (
id TEXT PRIMARY KEY, -- synthetic: '${session_id}_${index}'
session_id TEXT NOT NULL REFERENCES sessions(id),
exercise_id TEXT NOT NULL,
notes TEXT,
handle TEXT
);

CREATE TABLE sets (
id TEXT PRIMARY KEY, -- synthetic: '${exercise_log_id}_${index}'
exercise_log_id TEXT NOT NULL REFERENCES exercise_logs(id),
weight REAL NOT NULL,
reps INTEGER NOT NULL,
is_dropset INTEGER NOT NULL DEFAULT 0,
drops_json TEXT, -- JSON array of {id, weight, reps} or NULL
time_taken INTEGER,
timestamp TEXT NOT NULL,
assist_weight REAL,
extra_weight REAL,
handle TEXT
);

CREATE TABLE targets (
id TEXT PRIMARY KEY,
exercise_id TEXT NOT NULL,
target_type TEXT NOT NULL,
target_value REAL NOT NULL,
current_value REAL NOT NULL DEFAULT 0,
estimated_completion_date TEXT,
created_at TEXT NOT NULL,
is_completed INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE personal_records (
exercise_id TEXT PRIMARY KEY,
best_weight REAL NOT NULL,
best_reps INTEGER NOT NULL,
best_volume REAL NOT NULL,
achieved_at TEXT NOT NULL
Comment on lines +128 to +133

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve the handle dimension in personal records.

personal_records.exercise_id permits only one record per exercise. The current handle-aware workout flow requires separate records for different handles. Migration will therefore collide, overwrite, or discard data when one exercise has multiple handles.

Add handle to the key or unique constraint. Define the legacy-record mapping and test migration with multiple handles.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md`
around lines 128 - 133, Update the personal_records schema so records are keyed
by both exercise_id and handle, and define how existing legacy records without
handles are mapped during migration. Ensure migration preserves separate records
for multiple handles, and add coverage validating that no records collide,
overwrite, or get discarded.

);

CREATE TABLE training_programs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
total_weeks INTEGER NOT NULL,
author TEXT,
is_imported INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
phases_json TEXT NOT NULL, -- List<TrainingPhase>.toJson()
weeks_json TEXT NOT NULL -- List<ProgramWeek>.toJson()
);

CREATE TABLE conversations (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'coach',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
messages_json TEXT NOT NULL -- List<ChatMessage>.toJson()
);

CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT
);

CREATE INDEX idx_sets_exercise_log ON sets(exercise_log_id);
CREATE INDEX idx_exercise_logs_session ON exercise_logs(session_id);
CREATE INDEX idx_exercise_logs_exercise ON exercise_logs(exercise_id);
CREATE INDEX idx_sessions_date ON sessions(date);
```

**Deliberately not fully normalized:** `training_programs` (phases/weeks/days/exercises) and `conversations` (messages) are stored as JSON-blob columns rather than exploded into child tables. Both are always read/written as a whole object via existing `toJson()`/`fromJson()` methods, never queried piecemeal by any manager or by the coach's SQL tool. Normalizing them would add several more tables for no query benefit — YAGNI.

---

## 5. `SqliteStorageService`

New file: `lib/services/sqlite_storage_service.dart`, `class SqliteStorageService implements IStorageService`.

- `init()`: opens the database (`openDatabase`), runs `onCreate` (schema above) on first creation.
- Every `IStorageService` method gets a real implementation: entity writes that touch multiple tables (e.g. `saveWorkoutSession` → `sessions` + `exercise_logs` + `sets`) run inside a single `db.transaction()` — delete-then-reinsert child rows for the given parent id, so updates and inserts share one code path.
- `exportAllData()` / `importData()` keep their existing JSON contract (used by the migration below and by the user-facing export/import feature) — implemented by reading/writing through the same model `toJson()`/`fromJson()` methods already used elsewhere.

No changes to `IStorageService`'s method signatures.
Comment on lines +172 to +180

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the reviewed file and nearby sqlite storage related files.
fd -a '2026-08-08-sqlite-migration-and-coach-sql-tool-design.md|sqlite_storage_service|IStorageService|storage_service' . | sed 's#^\./##' | sort

echo '--- reviewed file excerpts ---'
file='docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md'
if [ -f "$file" ]; then
  sed -n '130,210p' "$file"
fi

echo '--- git diff stat ---'
git diff --stat || true

echo '--- search openDatabase versions in tracked files ---'
rg -n "openDatabase|onCreate|onUpgrade|version:\s*[0-9]+|version:" --glob '*.dart' . || true

Repository: Devasy/RepForge

Length of output: 6778


Add an explicit SQLite schema version and upgrade path.

SqliteStorageService.init() only describes onCreate; later app versions must also get schema changes. Add version: 1 and define onUpgrade migrations, including downgrade behavior if sqflite supports it or an explicit data-preserving down-revision path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md`
around lines 172 - 180, Add an explicit schema version to
SqliteStorageService.init() by opening the database with version 1 and an
onUpgrade migration callback. Define the migration structure for future schema
changes, and include supported downgrade handling or an explicit data-preserving
down-revision path if sqflite provides downgrade support.

Source: MCP tools


---

## 6. Migration & Cutover

**Goal:** existing installs upgrade from Hive to SQLite exactly once, safely, with no possibility of a half-migrated state.

1. On app start, `AppInitializer` (in `main.dart`) checks `settings['storage_migrated_v1']` **in the existing Hive settings box** (the migration hasn't happened yet at this point, so Hive is still authoritative for this check).
2. If unset: instantiate both the existing `StorageService` (Hive) and a fresh `SqliteStorageService`. For every entity type, read via the existing, already-correct Hive read methods (`getAllWorkoutSessions()`, `getAllRoutines()`, `getAllTargets()`, `getAllMuscleGroups()`, `getCustomExercises()`, `getAllTrainingPrograms()`, `getAllPersonalRecords()`, `getAllConversations()`, plus raw settings keys) and write each into `SqliteStorageService` through its normal write methods. This trusts only the new write path — reads reuse logic that already works.
3. Only if every entity type migrates without throwing: write `storage_migrated_v1 = true` into the Hive settings box.
4. From that point on (this launch and all future launches), `AppInitializer` hands `WorkoutProvider` a `SqliteStorageService` instead of `StorageService`.
Comment on lines +189 to +191

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Validate migrated data before setting storage_migrated_v1.

A migration that completes without throwing can still lose fields during model-to-table conversion. After the flag is set, SQLite becomes authoritative.

Read back the migrated data and compare a normalized exportAllData() result, entity identifiers, counts, nested sets, JSON blobs, and settings. Set the Hive flag only after validation succeeds.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md`
around lines 189 - 191, Extend the migration flow in AppInitializer to validate
SQLite data before setting storage_migrated_v1: read back the migrated records
through exportAllData() and compare normalized data, identifiers, counts, nested
sets, JSON blobs, and settings against the Hive source. Set the Hive migration
flag only when validation succeeds; otherwise leave it unset and preserve the
existing migration retry behavior.

5. If migration throws partway through anything, the flag is never set. The app falls back to `StorageService` (Hive) for that launch, and retries the full migration on the next app start. There is no partial-migration state a user can get stuck in.
Comment on lines +186 to +192

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make migration retries atomic and idempotent.

A failure after some entity writes leaves partial SQLite state. The next attempt then writes against that state. If Hive changed during the fallback launch, stale rows and primary-key conflicts can remain.

Wrap the complete migration in one SQLite transaction, or migrate into a fresh staging database and replace it only after success. Add a failure-injection test that retries after each entity boundary.

Also applies to: 214-214

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md`
around lines 186 - 192, The migration flow in AppInitializer must be atomic and
safely retryable: execute all Hive reads and SQLite writes within one SQLite
transaction, or stage them in a fresh database and replace the active database
only after complete success. Ensure any failure discards all partial writes so
the next launch can rerun from current Hive data without stale rows or
conflicts, and add failure-injection coverage for retries after each entity
boundary.

6. **Hive boxes are never deleted.** They remain on disk indefinitely as a passive backup — the data volume for a personal fitness log is small, so the disk cost is negligible next to the safety value.

Comment on lines +193 to +194

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Define retention and deletion behavior for the Hive backup.

Keeping Hive indefinitely retains stale sessions, conversations, and settings after SQLite deletes or changes them. A user reset or data-deletion flow can therefore leave sensitive data on disk.

Either remove Hive after verified migration with a documented recovery window, or encrypt it and include it in deletion, reset, and export lifecycle handling.

Also applies to: 220-224

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md`
around lines 193 - 194, Update the Hive backup policy in the migration design to
define explicit retention and deletion behavior. Choose either removal after
verified migration with a documented recovery window, or encryption with
inclusion in deletion, reset, and export flows; replace the indefinite-retention
statement and apply the same policy to the related backup lifecycle section.

This keeps the app in exactly one of two well-defined states at all times: fully on Hive, or fully on SQLite.

---

## 7. Coach SQL Tool: `run_sql_query`

Added to `CoachToolService.buildTools()` / `handleCall()`, alongside (not replacing) the existing curated tools.

- **Connection:** a dedicated **read-only** `sqflite` connection (`openReadOnlyDatabase`) to the same database file used by `SqliteStorageService`. This is the real safety boundary — the OS/SQLite layer itself refuses writes on this connection, regardless of what SQL text is submitted.
- **Text validation (defense-in-depth, not the primary guard):** trim the query, strip a single trailing `;`, reject if a second `;` remains (multi-statement), reject case-insensitively if it doesn't start with `SELECT` or `WITH`, reject if it contains `insert|update|delete|drop|alter|create|attach|detach|pragma|vacuum|replace|trigger` as a keyword.
- **Row cap:** wrap the model's query as `SELECT * FROM (<query>) LIMIT ?` with a default of 200, model-adjustable up to 500 — never trusts a `LIMIT` the model wrote itself.
Comment on lines +203 to +205

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail
rg -n 'openReadOnlyDatabase|singleInstance|readOnly' workout-logger/lib workout-logger/test

Repository: Devasy/RepForge

Length of output: 153


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== repo files matching sqlite/spec docs =="
git ls-files | rg '(^|/)(specs|.*sqlite.*|sql.*|storage|.*storage.*|.*Sql|.*sql.*)' | head -200

echo
echo "== locate target file =="
fd -a '2026-08-08-sqlite-migration-and-coach-sql-tool-design\.md|sqflite|SqliteStorageService|storage_service|storage' . | head -200

echo
echo "== target context if present =="
if [ -f "docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md" ]; then
  nl -ba docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md | sed -n '170,230p'
fi

Repository: Devasy/RepForge

Length of output: 1270


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== search any openReadOnlyDatabase in repo (broad) =="
rg -n 'openReadOnlyDatabase|openDatabase.*readOnly|readOnlyDatabase|singleInstance|readOnly' . --glob '!**/.git/**' || true

echo
echo "== search SqliteStorageService =="
rg -n 'SqliteStorageService|sqliteStorage|sqlite' workout-logger/lib workout-logger/test workout-logger/flutter_runner workout-logger/ios workout-logger/android 2>/dev/null || true

Repository: Devasy/RepForge

Length of output: 615


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md')
lines = p.read_text(encoding='utf-8').splitlines()
for i in range(202, 217):
    print(f"{i+1}: {lines[i]}")
PY

echo
echo "== current sqflite declarations in pubspecs =="
git ls-files '*pubspec.yaml' | xargs -r sh -c 'echo "--- $0"; grep -n -i "sqflite\|sqlite" "$0" || true'

Repository: Devasy/RepForge

Length of output: 2525


🌐 Web query:

sqflite openReadOnlyDatabase singleInstance readOnly openDatabase documentation

💡 Result:

In the sqflite package, there are two primary ways to open a database in read-only mode: using the dedicated openReadOnlyDatabase function or the standard openDatabase function with the readOnly parameter [1][2][3]. openReadOnlyDatabase The openReadOnlyDatabase function is a convenience method specifically designed to open a database at a given path in read-only mode [1][4]. Its implementation internally calls openDatabase with readOnly set to true [1]. Signature: Future openReadOnlyDatabase(String path, {bool? singleInstance = true}) openDatabase Alternatively, you can use the standard openDatabase function by setting the readOnly parameter to true [3][5]. Signature: Future openDatabase(String path, {bool? readOnly = false, bool? singleInstance = true,...}) When readOnly is set to true, the database is opened as-is, and all other configuration parameters (such as version, onCreate, or onUpgrade callbacks) are ignored [3][5]. The singleInstance Parameter The singleInstance parameter, which defaults to true, determines how the database instance is managed [3][5]. When singleInstance is true, sqflite returns a shared database instance for a given path [3][5]. If you call openDatabase or openReadOnlyDatabase multiple times with the same path, the library returns this same existing instance [3][5]. If singleInstance is set to false, a new connection/instance is created each time, which can be useful in specific scenarios like multi-isolate access, though developers are generally advised to manage their own global reference to avoid lock issues [6]. Summary of Behavior When you set readOnly: true (or use the helper function), you cannot perform write operations, and database initialization callbacks like onCreate or onUpgrade will not be executed [3][7][5]. This is commonly used for checking if a file is a valid database file by attempting to open it and read its version [8].

Citations:


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md')
text = p.read_text(encoding='utf-8')
checks = {
    'contains_openReadOnlyDatabase': 'openReadOnlyDatabase' in text,
    'claims_primary_safety_boundary': 'real safety boundary' in text,
    'mentions_single_instance_arg': 'singleInstance' in text,
    'uses_same_file': 'same database file' in text,
}
for name, ok in checks.items():
    print(f"{name}: {ok}")
PY

echo
echo "== inspect current sqflite declaration in pubspec =="
python3 - <<'PY'
from pathlib import Path
p = Path('workout-logger/pubspec.yaml')
print(p.read_text(encoding='utf-8'))
PY

Repository: Devasy/RepForge

Length of output: 4424


Do not present openReadOnlyDatabase on the same file as an independent safety boundary.

Because openReadOnlyDatabase forwards singleInstance: true, a later same-path open can return the existing writable database instance, so the SQL tool’s readonly option can be discarded. Open with singleInstance: false and require an Android integration test that write operations fail, or use an immutable snapshot/read-only-only handle that is enforced by the native SQLite layer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md`
around lines 203 - 205, The connection design must not rely on
openReadOnlyDatabase as an independent safety boundary when it can reuse a
writable same-path instance. Update the read-only connection design to open with
singleInstance: false and require an Android integration test confirming writes
fail, or instead use an immutable snapshot/read-only-only handle enforced by
native SQLite.

Source: MCP tools

- **Error handling:** any exception (syntax error, cap violation, etc.) returns `{'error': message}`, matching every other tool's contract — a bad query is a recoverable turn, not a crash.
Comment on lines +204 to +206

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file excerpt =="
sed -n '160,240p' docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md || true

echo
echo "== related mentions =="
rg -n "Row cap|LIMIT|validate|timeout|query length|500|200" docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md . --glob '!.git/**' | head -120

echo
echo "== local sqlite behavior probe: outer LIMIT does not limit work and negative LIMIT has no upper bound =="
python3 - <<'PY'
import sqlite3
stmt = [
    """CREATE TABLE IF NOT EXISTS t(i INTEGER PRIMARY KEY, v TEXT);""",
    """INSERT INTO t(i, v) SELECT i, printf('%d', i) FROM (WITH RECURSIVE c(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM c WHERE x < :n) SELECT x FROM c)""",
    """SELECT * FROM (SELECT v FROM t ORDER BY v, i DESC) LIMIT ?;""",
]
conn = sqlite3.connect(":memory:")
conn.execute("""CREATE TABLE t(i INTEGER PRIMARY KEY, v TEXT);""")
n = 8000
queries = []
for i, q in enumerate(stmt, start=1):
    s = q[:2000]
    print(f"q{i}: {s[:160].replace(chr(10), ' ')}...")
PY

Repository: Devasy/RepForge

Length of output: 20188


🌐 Web query:

SQLite LIMIT clause negative value -1 no limit behavior documentation

💡 Result:

In SQLite, providing a negative value to the LIMIT clause is interpreted as "no limit," meaning there is no upper bound on the number of rows returned or affected [1][2][3]. This behavior applies consistently across SELECT statements [1][2], as well as UPDATE and DELETE statements (when those statements are used in configurations that support LIMIT clauses, such as when SQLite is compiled with SQLITE_ENABLE_UPDATE_DELETE_LIMIT) [3]. If a negative value is provided, the query proceeds as if the LIMIT clause were absent, returning or processing the entire result set [2][3].

Citations:


Bound accepted row caps and SQL execution budgets.

The outer LIMIT only limits rows returned, not the work SQLite does for scans, sorts, cross joins, or recursive CTEs. SQLite also treats negative LIMIT values as no upper bound. Require row_limit in 1..500 before wrapping the query, keep query length/shape/execution time tight, or snapshot the database for read-heavy tool output.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@docs/superpowers/specs/2026-08-08-sqlite-migration-and-coach-sql-tool-design.md`
around lines 204 - 206, Update the model query execution design to validate
row_limit within 1..500 before applying the outer LIMIT, and add tight bounds
for query length/shape and SQLite execution time to limit unbounded work from
scans, sorts, joins, or recursive CTEs. Preserve the existing error contract by
returning {'error': message} for cap or execution violations.

Source: MCP tools

- **Function description** embeds the full schema (table + column names, one line each) so the model always has it in context without a separate schema-discovery round trip.

---

## 8. Testing

- **`SqliteStorageService`**: new test file, run against an in-memory database via `sqflite_common_ffi` (`databaseFactory = databaseFactoryFfi`, `inMemoryDatabasePath`). Covers every `IStorageService` method, mirroring the existing `MockStorageService`-based test patterns for shape.
- **Migration**: seed a `StorageService` (Hive, using the existing test Hive setup) with representative data across every entity type, run the migration routine against a fresh in-memory `SqliteStorageService`, assert the data matches, assert the flag is set, assert re-running the migration is a no-op (skips already-migrated).
- **Existing test suite** (managers, `WorkoutProvider`, screens): unaffected — all depend on `IStorageService`/`MockStorageService`, never the concrete backend.
- **`run_sql_query`**: valid `SELECT` → correct JSON rows; non-`SELECT` → rejected with error; multi-statement → rejected; row cap enforced; schema-referencing query (e.g. a join across `sessions`/`exercise_logs`/`sets`) returns expected shape.

---

## 9. Rollout Notes

- `pubspec.yaml` additions: `sqflite` (runtime), `sqflite_common_ffi` (dev, for tests).
- `hive`/`hive_flutter` dependencies and `StorageService` (Hive) are **kept**, not removed — they remain the migration source and the pre-migration fallback path indefinitely (or until a future spec decides it's safe to drop them, informed by real-world migration success rates).
- No changes to `CLAUDE.md`'s documented Hive box list are needed for this spec beyond noting the SQLite migration exists; a follow-up doc update once this ships is reasonable but out of scope here.
6 changes: 6 additions & 0 deletions fdroid/metadata/com.devasy.repforge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ Builds:
- git -C $$flutter$$ checkout -f $FLUTTER_VERSION
- $$flutter$$/bin/flutter config --no-analytics
- $$flutter$$/bin/flutter pub get --enforce-lockfile
- sed -i -e 's/-Wl,/-Wl,--build-id=none,/' $PUB_CACHE/hosted/pub.dev/jni-*/src/CMakeLists.txt
scandelete:
- workout-logger/.pub-cache
build:
- export PUB_CACHE=$(pwd)/.pub-cache
- export LDFLAGS="-Wl,--build-id=none"
- $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform="android-arm"

- versionName: 2.0.6
Expand All @@ -50,10 +52,12 @@ Builds:
- git -C $$flutter$$ checkout -f $FLUTTER_VERSION
- $$flutter$$/bin/flutter config --no-analytics
- $$flutter$$/bin/flutter pub get --enforce-lockfile
- sed -i -e 's/-Wl,/-Wl,--build-id=none,/' $PUB_CACHE/hosted/pub.dev/jni-*/src/CMakeLists.txt
scandelete:
- workout-logger/.pub-cache
build:
- export PUB_CACHE=$(pwd)/.pub-cache
- export LDFLAGS="-Wl,--build-id=none"
- $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform="android-arm64"

- versionName: 2.0.6
Expand All @@ -70,10 +74,12 @@ Builds:
- git -C $$flutter$$ checkout -f $FLUTTER_VERSION
- $$flutter$$/bin/flutter config --no-analytics
- $$flutter$$/bin/flutter pub get --enforce-lockfile
- sed -i -e 's/-Wl,/-Wl,--build-id=none,/' $PUB_CACHE/hosted/pub.dev/jni-*/src/CMakeLists.txt
scandelete:
- workout-logger/.pub-cache
build:
- export PUB_CACHE=$(pwd)/.pub-cache
- export LDFLAGS="-Wl,--build-id=none"
- $$flutter$$/bin/flutter build apk --release --split-per-abi --target-platform="android-x64"

AutoUpdateMode: Version
Expand Down
Loading
Loading