Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,20 @@ jobs:
- name: Check formatting
run: dart format --output=none --set-exit-if-changed .

# drift 產生的 *.g.dart 有提交(app/AGENTS.md § 資料層)。重跑 codegen 後
# app/ 有任何變動或新檔,就是改了 table 卻沒重跑 build_runner。
# 路徑用 `.`:這個 job 的工作目錄已經是 app/。
- name: Check generated code is up to date
run: |
dart run build_runner build
changes="$(git status --porcelain -- .)"
if [ -n "$changes" ]; then
echo "$changes"
git diff -- .
echo "Generated code is stale: run 'dart run build_runner build' in app/ and commit the result."
exit 1
fi

# fmp_lints 與 riverpod_lint 是 analyzer 插件,只有 dart analyze 看得到
# 它們的診斷(flutter/flutter#187999)。第一次會解析並編譯插件,要連 pub。
- name: Plugin lints (dart analyze)
Expand Down
101 changes: 101 additions & 0 deletions .trellis/spec/app/data/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# 資料層(`app/lib/data/`)

加表、改 schema、寫 repository 時適用。規則(只有 `lib/data/` 碰資料庫、產生檔提交、
持久化格式)與閘門見 `app/AGENTS.md` § 資料層;為什麼選 drift、schema 原則,見
ADR 0010。這裡只寫怎麼做。

## 目錄

```
lib/data/
database/
app_database.dart # @DriftDatabase、schemaVersion、MigrationStrategy
app_database.g.dart # build_runner 產生,提交
tables.dart # 所有 table
converters.dart # 列舉與時間的 TypeConverter(持久化格式)
open_app_database.dart # 開資料目錄裡的 fmp.db
repositories/
<名稱>_repository.dart # 值型別+repository,上層只看得到這一層
drift_schemas/app_database/ # 每版的 schema 快照(drift_schema_v<N>.json)
test/drift/app_database/ # 快照測試;generated/ 是 drift_dev 產生的輔助碼
```

`build.yaml` 的 `databases:` 讓 `drift_dev make-migrations` 找到資料庫類別,上面兩個
`drift_schemas/`、`test/drift/` 的位置是它的預設值。

## 寫一張表

- 類別名 `<名稱>Table`,`tableName` 寫 SQL 名稱(snake_case、複數);
`@DataClassName('<名稱>Row')`。`*Row` 只在 `lib/data/` 內用。
- 欄位用 `late final`(drift 文件現行寫法)。自我參照的 `check()` 要寫明型別:
`late final IntColumn id = integer().check(id.equals(1))();`,否則推不出型別。
- 列舉:Dart 端是 `lib/domain/` 的 enum,資料庫存字串,轉換寫在 `converters.dart`,
字串逐一寫死、讀到不認得的值拋 `FormatException`。不用 drift 的 `textEnum`:它存
enum 的 `name`,改名就改了資料。
- 時間:`integer().map(const EpochMillisecondsConverter())()`(UTC epoch 毫秒)。不用
`dateTime()`,它預設存秒。
- 關係交給資料庫:`references(..., onDelete: KeyAction.cascade)` 這類外鍵與
`primaryKey` 的複合鍵。
- 單列設定表:`id` 加 `CHECK (id = 1)`,欄位全部 `nullable()`,空=沒設定過
(ADR 0011 §決定 7)。

## 寫一個 repository

- `final class <名稱>Repository`,建構子收 `AppDatabase`;本身就是上層注入的單位,
不另外抽介面(要換掉就換一個接記憶體資料庫的實例)。
- 回傳自己的 `@immutable` 值型別(含 `==`/`hashCode`),不回傳 `*Row`、不讓 drift 型別
(`Value`、companion)出現在公開方法的參數或回傳值。
- 只加有人呼叫的方法。
- 更新用 `insertOnConflictUpdate`(`ON CONFLICT DO UPDATE`),不要用
`InsertMode.insertOrReplace`:REPLACE 會先刪列,觸發外鍵的 cascade,把子表一起清掉
(`plugin_repository_test.dart` 的 `updating a plugin keeps its storage` 守著)。

## 改 schema(第二版起)

官方文件:https://drift.simonbinder.eu/migrations/(make-migrations)、
https://drift.simonbinder.eu/migrations/step_by_step/、
https://drift.simonbinder.eu/migrations/tests/。

1. 改 `tables.dart`,`AppDatabase.schemaVersion` 加一。
2. `dart run build_runner build`。
3. `dart run drift_dev make-migrations`。它會:
- 存 `drift_schemas/app_database/drift_schema_v<N>.json`;
- 產生 `lib/data/database/app_database.steps.dart`(`stepByStep`);
- 重產 `test/drift/app_database/generated/`,第一次還會產生
`test/drift/app_database/migration_test.dart`(每對版本的空資料升級測試)。
4. 在 `AppDatabase.migration` 加 `onUpgrade`。ADR 0010 §決定 3 要求失敗整個回滾,而
drift 不會自己把 `onUpgrade` 包進交易,所以以 `Migrator.runMigrationSteps` 的
dartdoc(drift 2.35.0)為底,改兩處:
- 順序:關外鍵(`PRAGMA foreign_keys` 在交易內無效,要在交易外)→
`transaction(...)`,裡面依序跑 `m.runMigrationSteps(...)`、
`PRAGMA foreign_key_check`(有結果就拋錯)、`PRAGMA user_version = <to>` →
開外鍵。
- 外鍵檢查放進交易、不只 debug:dartdoc 把它放在交易之後、只在 debug 斷言,那時
已經提交,拋錯也回滾不了。
- `user_version` 在交易內寫:drift 在 `onUpgrade` 回傳之後、交易外才寫版本
(`engines.dart` 的 `_runMigrations`),中間中斷的話,下次開啟會在已升級的
資料上再跑一次 migration。
5. 測試,每個 migration 都要有:
- `migration_test.dart` 的空資料升級(make-migrations 產生,跑得過就好);
- 資料完整性:用 `verifier.schemaAt(N-1)` 與 `generated/schema_v<N-1>.dart` 的舊版
資料類別寫入資料,升級後讀回;
- **不改使用者設定過的值**(ADR 0010 §決定 3):舊版先寫入使用者值,跑 migration,
斷言值不變;只有沒設定過(空)的列可以被 migration 影響。
6. `schema_test.dart` 不用改:它比對的是「程式碼建出的 schema」與「最新快照」。

開發中改 schema 還沒發版時,一樣走上面的步驟;不要改已提交的快照。

## 測試

- 資料庫一律用 `test/support/memory_database.dart` 的 `memoryDatabase()`:和 App 同一個
`AppDatabase`(外鍵、migration 策略都會跑),執行器是 `NativeDatabase.memory()`,
測試結束自動關閉。不在 `lib/` 留測試用的建構子或開關。
- `watch` 的測試用 `StreamIterator` 先拿到第一個值再寫入;先寫再訂閱會漏掉初始值。
- 持久化格式用 `customSelect` 直接查表,斷言寫死的字面值(`stored format` 群組)。
- 真的開檔案的只有 `open_app_database_test.dart`,在 `Directory.systemTemp` 下跑。

## Quality Check

- `dart run build_runner build` 後 `git status` 沒有變動(CI 同一步)。
- `schema_test.dart` 綠;改過 schema 就有新快照與第 5 步的三種測試。
- `lib/data/` 以外沒有 drift 型別出現在 import 或公開 API。
2 changes: 2 additions & 0 deletions .trellis/spec/app/testing/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
| 建置設定 | 原生身分:能執行就執行(`cmake -P`),不能就解析設定檔並附變異案例 | `test/identity/` |
| 插件契約、整合、golden | M1 PR 9、PR 13 與設計系統元件加入時再寫 | — |

- 碰資料庫的測試用 `test/support/memory_database.dart`,寫法見
`.trellis/spec/app/data/index.md` § 測試。
- 正式程式碼不留測試掛鉤(`*ForTesting`、`@visibleForTesting` 的後門);要替換的東西
經建構子或 provider 注入。
- 不設覆蓋率門檻。
Expand Down
2 changes: 1 addition & 1 deletion .trellis/tasks/09-28-m1-skeleton-tracer/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
- v1 schema 快照;
- repository。
- [ ] `TrackKey`(`domain/`)照舊版格式,加測試。
- [ ] 探針分支:`isar_community`+`sqlite3` 共存、16KB 對齊,結論寫進本任務 `research/`。
- [x] 探針分支:`isar_community`+`sqlite3` 共存、16KB 對齊,結論寫進本任務 `research/`(2026-09-29:兩平台共存、全部對齊,`research/isar-sqlite3-coexistence.md`;ADR 0010 已補)。
- 測試:快照一致;設定「使用者值不被新預設蓋掉」(ADR 0011)。

### 6. 設定與 log
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# isar_community 與 sqlite3 共存探針(design.md §3 第 3.11 列)

日期:2026-09-29。探針在一次性 worktree(`agent-ac8d1954b63a5bfb0`,基於 `50399003`)裡跑,
沒有 commit、不合併。要回答的問題來自 ADR 0010 §後果:

- `isar_community` 與 `sqlite3` 兩套原生庫能不能在同一個 Flutter App 裡共存(Android、Windows);
- Android 原生 `.so` 是否 16KB page size 對齊。

## 結論

| 問題 | Android | Windows |
|------|---------|---------|
| 兩庫同一 App 共存、各自開庫讀寫 | **可以**(debug、release 都跑通) | **可以**(release 跑通) |
| 建置衝突(重複符號/native asset/Gradle packaging) | 無 | 無 |
| 16KB 對齊 | **全部對齊**(見下表) | 不適用 |

ADR 0010 的「若衝突,legacy import 改成獨立一次性小程式」**不需要啟用**。

另有一個 ADR 沒預料到的限制:**`isar_community_generator` 3.3.2 無法進 `app/` 的 pub workspace**
(見「附帶發現」),M5 的 `legacy_import/` 需要另想 codegen 路徑。

## 使用的版本

| 項目 | 版本 |
|------|------|
| Flutter/Dart | 3.47.5(stable)/3.13.4 |
| `sqlite3` | 3.6.0(pub.dev 最新 3.x,2026-09-13 發佈) |
| └ 傳遞依賴 | `hooks` 2.2.0(由 2.0.2 升上來)、`code_assets` 1.2.1、`native_toolchain_c` 0.19.3、`record_use` 1.1.1(由 0.6.0 升上來) |
| └ 實際載入的 SQLite | 3.53.4(hook 預設走 `PrecompiledBinary`:下載預編譯檔並驗 hash,不在本機編譯) |
| `isar_community`/`isar_community_flutter_libs` | 3.3.2/3.3.2(pub.dev 最新,2026-03-23) |
| └ Isar core | `IsarCore using libmdbx: v0.13.8-temp-upstream-fix` |
| `isar_community_generator`(只在 workspace 外用) | 3.3.2,搭 `build_runner` 2.15.1、`analyzer` 10.2.0、`source_gen` 4.2.4 |
| Android NDK/build-tools | 28.2.13676358/36.0.0 |
| 模擬器 | `emulator-5554`,Android 17,x86_64,`getconf PAGE_SIZE` = **16384**(16KB 頁環境) |

## 探針內容

- `app/pubspec.yaml`:`flutter pub add sqlite3:^3.6.0 isar_community:3.3.2 isar_community_flutter_libs:3.3.2`。
- `app/lib/probe/probe_item.dart`:一個 `@collection class ProbeItem { Id id; late String name; }`;
`probe_item.g.dart` 在 workspace 外的暫存 package 產生後複製進來。
- `app/lib/main_probe.dart`:啟動時
1. `sqlite3.openInMemory()` → `select sqlite_version()`,建表、寫一列、讀回;
2. `Isar.open([ProbeItemSchema], directory: <getTemporaryDirectory()>/isar_probe)`,寫一筆、`findAll()` 讀回;
3. 兩個結果 `print('PROBE: ...')`、寫進 `Directory.systemTemp/fmp_probe_result.txt`,並顯示在畫面上(`package:material_ui`)。

## Android

### 建置

```
flutter build apk --flavor dev --debug -t lib/main_probe.dart # 71.6s √ app-dev-debug.apk
flutter build apk --flavor dev --release -t lib/main_probe.dart # 124.1s √ app-dev-release.apk (50.2MB)
```

release 用 `build.gradle.kts` 現有的 debug 簽名設定,不需額外處理。`-v` 重跑 release 後 grep
`duplicate|conflict|More than one file|pickFirst`:**零命中**。只看到下列警告,都不是這兩個套件造成的衝突:

- `WARNING: The option setting 'android.builtInKotlin=false' is deprecated.`、`'android.newDsl=false' is deprecated.`(`:app` 既有)
- `w: Deprecated 'org.jetbrains.kotlin.android' plugin usage`:`:app`、`:isar_community_flutter_libs`、`:jni`、`:jni_flutter` 各一次(AGP 9 內建 Kotlin 的遷移提示)
- `CMake project not found, skipping support Android 15 16k page size migration.`(Flutter 工具的資訊行,App 沒有自己的 CMake)

`isar_community_flutter_libs` 的 `android/build.gradle` 仍寫 `classpath 'com.android.tools.build:gradle:8.6.0'`、
`compileSdkVersion 35`,在本專案的 AGP 下照樣能建置,沒有報錯。

### APK 內的原生庫(`unzip -l`,release)

```
lib/arm64-v8a/ libapp.so 3146632 libdartjni.so 131400 libflutter.so 11747864 libisar.so 1125656 libsqlite3.so 1732360
lib/armeabi-v7a/ libapp.so 3506760 libdartjni.so 81600 libflutter.so 8615900 libisar.so 914460 libsqlite3.so 1713736
lib/x86_64/ libapp.so 3277704 libdartjni.so 116792 libflutter.so 13051424 libisar.so 1258072 libsqlite3.so 1709544
```

debug APK 沒有 `libapp.so`(JIT),另多一個 `arm64-v8a/libVkLayer_khronos_validation.so`(Flutter debug 自帶)。
`libsqlite3.so` 由 `sqlite3` 的 build hook 產出、經 Flutter 的 native assets 流程打包;`libisar.so` 由
`isar_community_flutter_libs` 帶入。`libdartjni.so` 來自既有依賴鏈上的 `jni` 1.0.3,不是本探針新增的。

兩個庫匯出符號互不重疊(`llvm-nm -D --defined-only`:`libisar.so` 93 個匯出、其中含 `sqlite` 的 0 個;
`libsqlite3.so` 含 `mdbx` 的 0 個),各自是獨立 `.so`,沒有符號搶用。

### 執行(模擬器,16KB 頁)

```
adb -s emulator-5554 install -r build/app/outputs/flutter-apk/app-dev-debug.apk
adb -s emulator-5554 shell monkey -p com.personal.fmp.dev -c android.intent.category.LAUNCHER 1
adb -s emulator-5554 logcat -d | grep PROBE:
```

debug:

```
I flutter : PROBE: sqlite OK version=3.53.4 row=hello-sqlite
I flutter : PROBE: isar OK version=3.3.2 count=1 row=hello-isar
I flutter : PROBE: wrote /data/user/0/com.personal.fmp.dev/code_cache/fmp_probe_result.txt
```

release(同樣安裝後 `am start -n com.personal.fmp.dev/com.personal.fmp.MainActivity`):

```
I flutter : IsarCore using libmdbx: v0.13.8-temp-upstream-fix
I flutter : PROBE: sqlite OK version=3.53.4 row=hello-sqlite
I flutter : PROBE: isar OK version=3.3.2 count=1 row=hello-isar
```

沒有 `UnsatisfiedLinkError`、`dlopen` 失敗或 FATAL。兩張截圖都顯示兩行 OK(留在探針 worktree 的
`probe_android_debug.png`、`probe_android_release.png`,不合併)。模擬器頁大小是 16384,所以這同時是
16KB 頁上的實際載入測試。

### 16KB 對齊

ZIP 對齊(`build-tools/36.0.0/zipalign -c -P 16 -v 4 <apk>`):debug、release 兩個 APK 的所有 `.so` 都是
`(OK)`,結尾 `Verification successful`。

ELF LOAD segment 對齊(`ndk/28.2.13676358/.../llvm-readelf -lW`,取每個 `LOAD` 的 `Align`;
≥ 0x4000 即符合 16KB):

| 庫 | 來源 | arm64-v8a | x86_64 | armeabi-v7a | 16KB 對齊 |
|----|------|-----------|--------|-------------|-----------|
| `libsqlite3.so` | `sqlite3` build hook(預編譯下載) | 0x4000 | 0x4000 | 0x4000 | 是 |
| `libisar.so` | `isar_community_flutter_libs` 3.3.2 | 0x4000 | 0x4000 | 0x4000 | 是 |
| `libflutter.so` | Flutter engine | 0x10000 | 0x10000 | 0x10000 | 是 |
| `libapp.so`(僅 release) | Dart AOT | 0x10000 | 0x10000 | 0x4000 | 是 |
| `libdartjni.so` | `jni` 1.0.3 | 0x4000 | 0x4000 | 0x4000 | 是 |
| `libVkLayer_khronos_validation.so`(僅 debug) | Flutter debug | 0x10000 | — | — | 是 |

debug 與 release 同一個庫的數值相同。16KB 要求只針對 64 位元 ABI(arm64-v8a、x86_64),32 位元欄僅供參考。

## Windows

```
flutter build windows --flavor dev --release -t lib/main_probe.dart # 228.2s √ build\windows\x64\dev\runner\Release\fmp.exe
```

建置輸出沒有 warning/`LNK`/duplicate。產物目錄:

```
fmp.exe 91648 flutter_windows.dll 21274112 libisar.dll 996352
isar_community_flutter_libs_plugin.dll 86528 sqlite3.dll 1709056 dartjni.dll 58368
```

執行 `fmp.exe`(視窗標題 `FMP Dev`),8 秒後讀 `%TEMP%\fmp_probe_result.txt`:

```
PROBE: sqlite OK version=3.53.4 row=hello-sqlite
PROBE: isar OK version=3.3.2 count=1 row=hello-isar
written=2026-09-29T14:17:32.571851
```

之後 `Stop-Process` 關掉。`sqlite3.dll` 與 `libisar.dll` 各自獨立、檔名不撞。

## 附帶發現:Isar codegen 進不了 workspace

```
flutter pub add --dev isar_community_generator:3.3.2 build_runner --dry-run
Because fmp_lints depends on analyzer 13.3.0 and isar_community_generator >=3.3.2 depends on
analyzer >=8.0.0 <11.0.0, isar_community_generator >=3.3.2 is forbidden.
```

`isar_community_generator` 3.3.2 是 pub.dev 最新版,上界 `analyzer <11`;`app/` 的 workspace 因
`fmp_lints` 釘 `analyzer` 13.3.0(ADR 0015)。本探針改在 workspace 外的獨立 package 跑
`dart run build_runner build`(analyzer 10.2.0,會警告 `SDK language version 3.13.0 is newer than
analyzer language version 3.12.0`,但仍成功產出),再把 `.g.dart` 複製進 `app/lib/probe/`,執行期完全正常。
舊 App 的 `.g.dart` 沒有進版控(`git ls-files 'lib/**/*.g.dart'` 為空),所以 M5 做 `legacy_import/`
時要選一條路:

- 在 workspace 外產生舊 schema 的 `.g.dart` 後提交進 `app/lib/legacy_import/`(舊 schema 已凍結,
產生一次即可;本探針證實此路可行);或
- 等 generator 放寬 analyzer 上界。

這不影響 ADR 0010 的共存結論,但 M5 開工前應在 ADR 0010 或 M5 設計裡記一筆。

## 若日後出現衝突

本次沒有衝突,備援不用啟動。若未來某次升級(例如 `sqlite3` hook 改變打包方式、Isar fork 換新 core)
讓兩庫在同一行程衝突,ADR 0010 的備援是把 legacy import 拆成由新 App 啟動的獨立一次性小程式:
主 App 不再依賴 `isar_community`,由另一個只含 Isar 與舊 secure storage 的程式讀舊資料、輸出中介格式給
主 App 匯入。代價是多一份中介格式與其驗證;在 Android 上,同一行程內的另一個 isolate 共用已載入的原生庫、
隔離不了衝突,所以小程式至少要跑在另一個行程。
3 changes: 2 additions & 1 deletion .trellis/tasks/09-28-m1-skeleton-tracer/task.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
"09-29-split-agent-instructions",
"09-29-app-skeleton",
"09-29-fmp-lints",
"09-29-platform-layer"
"09-29-platform-layer",
"09-29-drift-schema"
],
"parent": "09-26-fmp-rewrite",
"relatedFiles": [],
Expand Down
5 changes: 5 additions & 0 deletions .trellis/tasks/archive/2026-09/09-29-drift-schema/check.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/design.md", "reason": "M1 layout, drift table list and technical choices"}
{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/research/m1-tooling-facts.md", "reason": "drift and sqlite3 versions, sqlite3_flutter_libs EOL"}
{"file": "docs/adr/0010-drift-data-layer-and-legacy-import.md", "reason": "Data layer rules"}
{"file": ".trellis/spec/app/platform/index.md", "reason": "How to get the app data directory"}
{"file": ".trellis/spec/app/lints/index.md", "reason": "Lint rules the data layer must satisfy"}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/design.md", "reason": "M1 layout, drift table list and technical choices"}
{"file": ".trellis/tasks/09-28-m1-skeleton-tracer/research/m1-tooling-facts.md", "reason": "drift and sqlite3 versions, sqlite3_flutter_libs EOL"}
{"file": "docs/adr/0010-drift-data-layer-and-legacy-import.md", "reason": "Data layer rules"}
{"file": ".trellis/spec/app/platform/index.md", "reason": "How to get the app data directory"}
{"file": ".trellis/spec/app/lints/index.md", "reason": "Lint rules the data layer must satisfy"}
Loading
Loading