From 5472e57163ed19840d5cedf2c1520a05caeaad87 Mon Sep 17 00:00:00 2001 From: 1morr Date: Tue, 29 Sep 2026 14:44:19 +0800 Subject: [PATCH 1/3] feat(app): add the drift database with schema v1 Generated code is committed and CI checks it is current, so a fresh checkout builds without a codegen step. TrackKey is ported unchanged because legacy import keys on it. --- .github/workflows/ci.yml | 14 + .trellis/spec/app/data/index.md | 101 + .trellis/spec/app/testing/index.md | 2 + app/.gitignore | 4 + app/AGENTS.md | 30 +- app/build.yaml | 11 + .../app_database/drift_schema_v1.json | 246 +++ app/lib/app/database_error_app.dart | 40 + app/lib/data/database/app_database.dart | 31 + app/lib/data/database/app_database.g.dart | 1781 +++++++++++++++++ app/lib/data/database/converters.dart | 60 + app/lib/data/database/open_app_database.dart | 32 + app/lib/data/database/tables.dart | 62 + .../appearance_settings_repository.dart | 69 + .../data/repositories/plugin_repository.dart | 96 + .../plugin_storage_repository.dart | 38 + app/lib/domain/appearance.dart | 11 + app/lib/domain/track_key.dart | 74 + app/lib/main.dart | 11 + app/pubspec.lock | 180 +- app/pubspec.yaml | 6 + app/test/app/database_error_app_test.dart | 22 + app/test/data/database/app_database_test.dart | 31 + .../data/database/open_app_database_test.dart | 51 + .../appearance_settings_repository_test.dart | 104 + .../repositories/plugin_repository_test.dart | 89 + .../plugin_storage_repository_test.dart | 60 + app/test/domain/track_key_test.dart | 69 + .../drift/app_database/generated/schema.dart | 22 + .../app_database/generated/schema_v1.dart | 218 ++ app/test/drift/app_database/schema_test.dart | 42 + app/test/support/memory_database.dart | 20 + 32 files changed, 3620 insertions(+), 7 deletions(-) create mode 100644 .trellis/spec/app/data/index.md create mode 100644 app/build.yaml create mode 100644 app/drift_schemas/app_database/drift_schema_v1.json create mode 100644 app/lib/app/database_error_app.dart create mode 100644 app/lib/data/database/app_database.dart create mode 100644 app/lib/data/database/app_database.g.dart create mode 100644 app/lib/data/database/converters.dart create mode 100644 app/lib/data/database/open_app_database.dart create mode 100644 app/lib/data/database/tables.dart create mode 100644 app/lib/data/repositories/appearance_settings_repository.dart create mode 100644 app/lib/data/repositories/plugin_repository.dart create mode 100644 app/lib/data/repositories/plugin_storage_repository.dart create mode 100644 app/lib/domain/appearance.dart create mode 100644 app/lib/domain/track_key.dart create mode 100644 app/test/app/database_error_app_test.dart create mode 100644 app/test/data/database/app_database_test.dart create mode 100644 app/test/data/database/open_app_database_test.dart create mode 100644 app/test/data/repositories/appearance_settings_repository_test.dart create mode 100644 app/test/data/repositories/plugin_repository_test.dart create mode 100644 app/test/data/repositories/plugin_storage_repository_test.dart create mode 100644 app/test/domain/track_key_test.dart create mode 100644 app/test/drift/app_database/generated/schema.dart create mode 100644 app/test/drift/app_database/generated/schema_v1.dart create mode 100644 app/test/drift/app_database/schema_test.dart create mode 100644 app/test/support/memory_database.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3e13aed..edc60372 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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) diff --git a/.trellis/spec/app/data/index.md b/.trellis/spec/app/data/index.md new file mode 100644 index 00000000..9f5de7a5 --- /dev/null +++ b/.trellis/spec/app/data/index.md @@ -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.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.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 = ` → + 開外鍵。 + - 外鍵檢查放進交易、不只 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.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。 diff --git a/.trellis/spec/app/testing/index.md b/.trellis/spec/app/testing/index.md index 9c22765b..099df946 100644 --- a/.trellis/spec/app/testing/index.md +++ b/.trellis/spec/app/testing/index.md @@ -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 注入。 - 不設覆蓋率門檻。 diff --git a/app/.gitignore b/app/.gitignore index 79f7ecab..d77f79ca 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -46,3 +46,7 @@ app.*.map.json # Widget Preview related .widget_preview/ + +# 根目錄的 .gitignore 忽略 *.g.dart(舊專案不提交產生檔);app/ 提交 drift +# 產生的程式碼,CI 檢查它與原始碼一致(app/AGENTS.md § 資料層)。 +!*.g.dart diff --git a/app/AGENTS.md b/app/AGENTS.md index 498063b0..ab9b7c6f 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -12,8 +12,10 @@ | 任何改動 | `dart format --output=none --set-exit-if-changed .`、`dart analyze --fatal-infos`、`flutter analyze`、`flutter test` | | `packages/fmp_lints/`、`analysis_options.yaml` 的 `plugins:` | 上一列,加 `packages/fmp_lints/` 內的 `dart test` 與 `dart run tool/lint_sentinel.dart` | | 原生身分(`android/app/`、`windows/runner/`) | 第一列,加 `flutter build apk --flavor dev --debug`/`--flavor prod --debug` 與 `flutter build windows --flavor dev`/`--flavor prod` | +| drift 的 table 或資料庫類別(`lib/data/database/`) | 先 `dart run build_runner build`,再跑第一列;改了 schema 另照 § 資料層 存新快照 | -- `flutter test` 不加參數:`live` 預設跳過(見「零聯網」)。CI 的 `app` job 跑上表前兩列; +- `flutter test` 不加參數:`live` 預設跳過(見「零聯網」)。CI 的 `app` job 跑上表前兩列 + 與產生檔檢查(見「資料層」); `fmp_lints` 的測試另外以 `TEST_ANALYZER_WINDOWS_PATHS=true` 再跑一次(Windows 路徑)。 - `flutter analyze` 看不到 analyzer 插件的診斷、照樣回 No issues(flutter/flutter#187999), 所以兩個都要跑:插件規則看 `dart analyze`,Flutter 專屬的診斷看 `flutter analyze`。 @@ -83,6 +85,32 @@ lint 的範圍是整個 `lib/platform/`,組裝點以外的平台層檔案、 `%APPDATA%\com.personal\fmp`、Android 舊版沙盒 `com.personal.fmp`)或其下,`main()` 在 `runApp` 之前就拋 `LegacyDataLocationException`:Windows 的原生視窗仍會開,但內容空白。 +## 資料層 + +`lib/data/`(ADR 0010)。怎麼加表、改 schema:`.trellis/spec/app/data/index.md`。 + +- 只有 `lib/data/` import `drift`/`sqlite3`。閘門:lint `fmp_layer_imports`。上層拿到的是 + repository 自己的值型別(`AppearanceSettings`、`InstalledPlugin`),不是 drift 產生的 + `*Row` 類別;這半條沒有閘門,review 時看。 +- 資料庫是資料目錄下的 `fmp.db`,`main()` 在 `runApp` 之前開啟並跑一次查詢;開不起來只 + 顯示 `DatabaseErrorApp`,不在半開的資料庫上啟動(ADR 0010 §決定 3)。閘門: + `test/data/database/open_app_database_test.dart`(損壞的檔案在開啟時就拋)。`main()` + 的分支本身沒有測試。 +- 外鍵每次開啟都在 `beforeOpen` 打開(SQLite 預設關、只對當前連線有效;ADR 0019 §決定 1)。 + 閘門:`test/data/database/app_database_test.dart` 與 repository 測試的 cascade 案例。 +- drift 產生的 `*.g.dart` 提交進 repo(`app/.gitignore` 覆寫根目錄對 `*.g.dart` 的忽略), + 拉下來不用先跑 codegen。改了 table 或 `@DriftDatabase` 就重跑 + `dart run build_runner build` 並提交產生檔。閘門:CI `app` job 的 + 「Check generated code is up to date」;本機沒有東西擋。 +- schema 快照在 `drift_schemas/app_database/`。閘門:`test/drift/app_database/schema_test.dart` + ——程式碼建出的 schema 必須等於最新快照,`schemaVersion` 必須等於最新快照的版本。 +- 持久化格式:列舉存 `lib/data/database/converters.dart` 寫死的字串(不是 enum 的 + `name`)、時間存 UTC epoch 毫秒、`TrackKey`(`lib/domain/track_key.dart`)的字面輸出 + 與舊版逐字相同(M5 匯入要對得上)。改任何一個就是改資料格式。閘門:repository 測試的 + `stored format` 案例、`test/domain/track_key_test.dart`。 +- `sqlite3` 3.x 以 build hooks 在建置時從它的 GitHub releases 下載預先編譯的 SQLite; + 第一次建置或 `flutter test` 要能連 GitHub。 + ## 零聯網 ADR 0015 §決定 3 的兩道防線: diff --git a/app/build.yaml b/app/build.yaml new file mode 100644 index 00000000..14a92aa8 --- /dev/null +++ b/app/build.yaml @@ -0,0 +1,11 @@ +# drift 的程式碼產生器設定(https://drift.simonbinder.eu/migrations/)。 +# `databases:` 給 `dart run drift_dev make-migrations` 找資料庫類別;快照存在 +# drift_schemas/app_database/,測試輔助碼在 test/drift/app_database/generated/ +# (兩者都是 make-migrations 的預設位置)。步驟見 .trellis/spec/app/data/index.md。 +targets: + $default: + builders: + drift_dev: + options: + databases: + app_database: lib/data/database/app_database.dart diff --git a/app/drift_schemas/app_database/drift_schema_v1.json b/app/drift_schemas/app_database/drift_schema_v1.json new file mode 100644 index 00000000..3e3f100f --- /dev/null +++ b/app/drift_schemas/app_database/drift_schema_v1.json @@ -0,0 +1,246 @@ +{ + "_meta": { + "description": "This file contains a serialized version of schema entities for drift.", + "version": "1.3.0" + }, + "options": { + "store_date_time_values_as_text": false + }, + "entities": [ + { + "id": 0, + "references": [], + "type": "table", + "data": { + "name": "appearance_settings", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "check": { + "dart_expression": { + "elements": [ + { + "lexeme": "id", + "tag": "id" + }, + ".equals(1)" + ] + } + } + } + ] + }, + { + "name": "theme_mode", + "getter_name": "themeMode", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const ThemeModeSettingConverter()", + "dart_type_name": "ThemeModeSetting" + } + }, + { + "name": "locale", + "getter_name": "locale", + "moor_type": "string", + "nullable": true, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const LocaleSettingConverter()", + "dart_type_name": "LocaleSetting" + } + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 1, + "references": [], + "type": "table", + "data": { + "name": "installed_plugins", + "was_declared_in_moor": false, + "columns": [ + { + "name": "id", + "getter_name": "id", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "version", + "getter_name": "version", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "manifest_json", + "getter_name": "manifestJson", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "script", + "getter_name": "script", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "installed_at", + "getter_name": "installedAt", + "moor_type": "int", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [], + "type_converter": { + "dart_expr": "const EpochMillisecondsConverter()", + "dart_type_name": "DateTime" + } + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "id" + ] + } + }, + { + "id": 2, + "references": [ + 1 + ], + "type": "table", + "data": { + "name": "plugin_storage", + "was_declared_in_moor": false, + "columns": [ + { + "name": "plugin_id", + "getter_name": "pluginId", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "defaultConstraints": "REFERENCES installed_plugins (id) ON DELETE CASCADE", + "dialectAwareDefaultConstraints": { + "sqlite": "REFERENCES installed_plugins (id) ON DELETE CASCADE" + }, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [ + { + "foreign_key": { + "to": { + "table": "installed_plugins", + "column": "id" + }, + "initially_deferred": false, + "on_update": null, + "on_delete": "cascade" + } + } + ] + }, + { + "name": "key", + "getter_name": "key", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + }, + { + "name": "value", + "getter_name": "value", + "moor_type": "string", + "nullable": false, + "customConstraints": null, + "default_dart": null, + "default_client_dart": null, + "dsl_features": [] + } + ], + "is_virtual": false, + "without_rowid": false, + "constraints": [], + "explicit_pk": [ + "plugin_id", + "key" + ] + } + } + ], + "fixed_sql": [ + { + "name": "appearance_settings", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"appearance_settings\" (\"id\" INTEGER NOT NULL CHECK(\"id\" = 1), \"theme_mode\" TEXT NULL, \"locale\" TEXT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "installed_plugins", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"installed_plugins\" (\"id\" TEXT NOT NULL, \"version\" TEXT NOT NULL, \"manifest_json\" TEXT NOT NULL, \"script\" TEXT NOT NULL, \"installed_at\" INTEGER NOT NULL, PRIMARY KEY (\"id\"));" + } + ] + }, + { + "name": "plugin_storage", + "sql": [ + { + "dialect": "sqlite", + "sql": "CREATE TABLE IF NOT EXISTS \"plugin_storage\" (\"plugin_id\" TEXT NOT NULL REFERENCES installed_plugins (id) ON DELETE CASCADE, \"key\" TEXT NOT NULL, \"value\" TEXT NOT NULL, PRIMARY KEY (\"plugin_id\", \"key\"));" + } + ] + } + ] +} \ No newline at end of file diff --git a/app/lib/app/database_error_app.dart b/app/lib/app/database_error_app.dart new file mode 100644 index 00000000..cc1a9757 --- /dev/null +++ b/app/lib/app/database_error_app.dart @@ -0,0 +1,40 @@ +import 'package:material_ui/material_ui.dart'; + +import 'package:fmp/core/app_flavor.dart'; + +/// 資料庫開不起來時的畫面(ADR 0010 §決定 3):App 不在半開的資料庫上啟動, +/// 只顯示錯誤。重試、匯出診斷等選項隨舊資料匯入(M5)與 log(ADR 0025)再加。 +class DatabaseErrorApp extends StatelessWidget { + const DatabaseErrorApp({ + super.key, + required this.flavor, + required this.error, + }); + + final AppFlavor flavor; + + /// 開啟時拋出的錯誤,原樣顯示供回報問題。 + final Object error; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: flavor.displayName, + // 先寫死繁中;slang 在 M1 PR 12 接上後改用翻譯字串。 + home: Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '無法開啟資料庫', + style: Theme.of(context).textTheme.headlineMedium, + ), + SelectableText('$error'), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/data/database/app_database.dart b/app/lib/data/database/app_database.dart new file mode 100644 index 00000000..2533c68a --- /dev/null +++ b/app/lib/data/database/app_database.dart @@ -0,0 +1,31 @@ +import 'package:drift/drift.dart'; + +import 'package:fmp/data/database/converters.dart'; +import 'package:fmp/data/database/tables.dart'; +import 'package:fmp/domain/appearance.dart'; + +part 'app_database.g.dart'; + +/// App 唯一的資料庫(ADR 0010)。只有 `lib/data/` 使用它(lint +/// `fmp_layer_imports` 擋其他目錄 import drift);上層透過 +/// `lib/data/repositories/` 存取。 +/// +/// 執行器從建構子注入:App 用 `openAppDatabase` 開資料目錄裡的檔案,測試用 +/// `NativeDatabase.memory()`。 +@DriftDatabase( + tables: [AppearanceSettingsTable, InstalledPluginsTable, PluginStorageTable], +) +class AppDatabase extends _$AppDatabase { + AppDatabase(super.executor); + + /// 改了 `tables.dart` 就要加一,並存新快照(drift_schemas/)。 + @override + int get schemaVersion => 1; + + @override + MigrationStrategy get migration => MigrationStrategy( + // SQLite 預設不檢查外鍵,而且這個設定只對目前連線有效,每次開啟都要設 + // (ADR 0019 §決定 1)。放在 beforeOpen:migration 跑完之後、第一個查詢之前。 + beforeOpen: (_) => customStatement('PRAGMA foreign_keys = ON'), + ); +} diff --git a/app/lib/data/database/app_database.g.dart b/app/lib/data/database/app_database.g.dart new file mode 100644 index 00000000..6cdacc58 --- /dev/null +++ b/app/lib/data/database/app_database.g.dart @@ -0,0 +1,1781 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_database.dart'; + +// ignore_for_file: type=lint +class $AppearanceSettingsTableTable extends AppearanceSettingsTable + with TableInfo<$AppearanceSettingsTableTable, AppearanceSettingsRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $AppearanceSettingsTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + check: () => id.equals(1), + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter + themeMode = + GeneratedColumn( + 'theme_mode', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter( + $AppearanceSettingsTableTable.$converterthemeModen, + ); + @override + late final GeneratedColumnWithTypeConverter locale = + GeneratedColumn( + 'locale', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter( + $AppearanceSettingsTableTable.$converterlocalen, + ); + @override + List get $columns => [id, themeMode, locale]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'appearance_settings'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + AppearanceSettingsRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AppearanceSettingsRow( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + themeMode: $AppearanceSettingsTableTable.$converterthemeModen.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_mode'], + ), + ), + locale: $AppearanceSettingsTableTable.$converterlocalen.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}locale'], + ), + ), + ); + } + + @override + $AppearanceSettingsTableTable createAlias(String alias) { + return $AppearanceSettingsTableTable(attachedDatabase, alias); + } + + static TypeConverter $converterthemeMode = + const ThemeModeSettingConverter(); + static TypeConverter $converterthemeModen = + NullAwareTypeConverter.wrap($converterthemeMode); + static TypeConverter $converterlocale = + const LocaleSettingConverter(); + static TypeConverter $converterlocalen = + NullAwareTypeConverter.wrap($converterlocale); +} + +class AppearanceSettingsRow extends DataClass + implements Insertable { + /// 固定為 1;CHECK 讓第二列插不進去。 + final int id; + final ThemeModeSetting? themeMode; + final LocaleSetting? locale; + const AppearanceSettingsRow({required this.id, this.themeMode, this.locale}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || themeMode != null) { + map['theme_mode'] = Variable( + $AppearanceSettingsTableTable.$converterthemeModen.toSql(themeMode), + ); + } + if (!nullToAbsent || locale != null) { + map['locale'] = Variable( + $AppearanceSettingsTableTable.$converterlocalen.toSql(locale), + ); + } + return map; + } + + AppearanceSettingsTableCompanion toCompanion(bool nullToAbsent) { + return AppearanceSettingsTableCompanion( + id: Value(id), + themeMode: themeMode == null && nullToAbsent + ? const Value.absent() + : Value(themeMode), + locale: locale == null && nullToAbsent + ? const Value.absent() + : Value(locale), + ); + } + + factory AppearanceSettingsRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AppearanceSettingsRow( + id: serializer.fromJson(json['id']), + themeMode: serializer.fromJson(json['themeMode']), + locale: serializer.fromJson(json['locale']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'themeMode': serializer.toJson(themeMode), + 'locale': serializer.toJson(locale), + }; + } + + AppearanceSettingsRow copyWith({ + int? id, + Value themeMode = const Value.absent(), + Value locale = const Value.absent(), + }) => AppearanceSettingsRow( + id: id ?? this.id, + themeMode: themeMode.present ? themeMode.value : this.themeMode, + locale: locale.present ? locale.value : this.locale, + ); + AppearanceSettingsRow copyWithCompanion( + AppearanceSettingsTableCompanion data, + ) { + return AppearanceSettingsRow( + id: data.id.present ? data.id.value : this.id, + themeMode: data.themeMode.present ? data.themeMode.value : this.themeMode, + locale: data.locale.present ? data.locale.value : this.locale, + ); + } + + @override + String toString() { + return (StringBuffer('AppearanceSettingsRow(') + ..write('id: $id, ') + ..write('themeMode: $themeMode, ') + ..write('locale: $locale') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, themeMode, locale); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AppearanceSettingsRow && + other.id == this.id && + other.themeMode == this.themeMode && + other.locale == this.locale); +} + +class AppearanceSettingsTableCompanion + extends UpdateCompanion { + final Value id; + final Value themeMode; + final Value locale; + const AppearanceSettingsTableCompanion({ + this.id = const Value.absent(), + this.themeMode = const Value.absent(), + this.locale = const Value.absent(), + }); + AppearanceSettingsTableCompanion.insert({ + this.id = const Value.absent(), + this.themeMode = const Value.absent(), + this.locale = const Value.absent(), + }); + static Insertable custom({ + Expression? id, + Expression? themeMode, + Expression? locale, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (themeMode != null) 'theme_mode': themeMode, + if (locale != null) 'locale': locale, + }); + } + + AppearanceSettingsTableCompanion copyWith({ + Value? id, + Value? themeMode, + Value? locale, + }) { + return AppearanceSettingsTableCompanion( + id: id ?? this.id, + themeMode: themeMode ?? this.themeMode, + locale: locale ?? this.locale, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (themeMode.present) { + map['theme_mode'] = Variable( + $AppearanceSettingsTableTable.$converterthemeModen.toSql( + themeMode.value, + ), + ); + } + if (locale.present) { + map['locale'] = Variable( + $AppearanceSettingsTableTable.$converterlocalen.toSql(locale.value), + ); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AppearanceSettingsTableCompanion(') + ..write('id: $id, ') + ..write('themeMode: $themeMode, ') + ..write('locale: $locale') + ..write(')')) + .toString(); + } +} + +class $InstalledPluginsTableTable extends InstalledPluginsTable + with TableInfo<$InstalledPluginsTableTable, InstalledPluginRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $InstalledPluginsTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _versionMeta = const VerificationMeta( + 'version', + ); + @override + late final GeneratedColumn version = GeneratedColumn( + 'version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _manifestJsonMeta = const VerificationMeta( + 'manifestJson', + ); + @override + late final GeneratedColumn manifestJson = GeneratedColumn( + 'manifest_json', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _scriptMeta = const VerificationMeta('script'); + @override + late final GeneratedColumn script = GeneratedColumn( + 'script', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + late final GeneratedColumnWithTypeConverter installedAt = + GeneratedColumn( + 'installed_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ).withConverter( + $InstalledPluginsTableTable.$converterinstalledAt, + ); + @override + List get $columns => [ + id, + version, + manifestJson, + script, + installedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'installed_plugins'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('version')) { + context.handle( + _versionMeta, + version.isAcceptableOrUnknown(data['version']!, _versionMeta), + ); + } else if (isInserting) { + context.missing(_versionMeta); + } + if (data.containsKey('manifest_json')) { + context.handle( + _manifestJsonMeta, + manifestJson.isAcceptableOrUnknown( + data['manifest_json']!, + _manifestJsonMeta, + ), + ); + } else if (isInserting) { + context.missing(_manifestJsonMeta); + } + if (data.containsKey('script')) { + context.handle( + _scriptMeta, + script.isAcceptableOrUnknown(data['script']!, _scriptMeta), + ); + } else if (isInserting) { + context.missing(_scriptMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + InstalledPluginRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return InstalledPluginRow( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + version: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}version'], + )!, + manifestJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}manifest_json'], + )!, + script: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}script'], + )!, + installedAt: $InstalledPluginsTableTable.$converterinstalledAt.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}installed_at'], + )!, + ), + ); + } + + @override + $InstalledPluginsTableTable createAlias(String alias) { + return $InstalledPluginsTableTable(attachedDatabase, alias); + } + + static TypeConverter $converterinstalledAt = + const EpochMillisecondsConverter(); +} + +class InstalledPluginRow extends DataClass + implements Insertable { + /// 音源 id,例如 B 站插件的 id。 + final String id; + final String version; + final String manifestJson; + final String script; + + /// UTC epoch 毫秒。 + final DateTime installedAt; + const InstalledPluginRow({ + required this.id, + required this.version, + required this.manifestJson, + required this.script, + required this.installedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['version'] = Variable(version); + map['manifest_json'] = Variable(manifestJson); + map['script'] = Variable(script); + { + map['installed_at'] = Variable( + $InstalledPluginsTableTable.$converterinstalledAt.toSql(installedAt), + ); + } + return map; + } + + InstalledPluginsTableCompanion toCompanion(bool nullToAbsent) { + return InstalledPluginsTableCompanion( + id: Value(id), + version: Value(version), + manifestJson: Value(manifestJson), + script: Value(script), + installedAt: Value(installedAt), + ); + } + + factory InstalledPluginRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return InstalledPluginRow( + id: serializer.fromJson(json['id']), + version: serializer.fromJson(json['version']), + manifestJson: serializer.fromJson(json['manifestJson']), + script: serializer.fromJson(json['script']), + installedAt: serializer.fromJson(json['installedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'version': serializer.toJson(version), + 'manifestJson': serializer.toJson(manifestJson), + 'script': serializer.toJson(script), + 'installedAt': serializer.toJson(installedAt), + }; + } + + InstalledPluginRow copyWith({ + String? id, + String? version, + String? manifestJson, + String? script, + DateTime? installedAt, + }) => InstalledPluginRow( + id: id ?? this.id, + version: version ?? this.version, + manifestJson: manifestJson ?? this.manifestJson, + script: script ?? this.script, + installedAt: installedAt ?? this.installedAt, + ); + InstalledPluginRow copyWithCompanion(InstalledPluginsTableCompanion data) { + return InstalledPluginRow( + id: data.id.present ? data.id.value : this.id, + version: data.version.present ? data.version.value : this.version, + manifestJson: data.manifestJson.present + ? data.manifestJson.value + : this.manifestJson, + script: data.script.present ? data.script.value : this.script, + installedAt: data.installedAt.present + ? data.installedAt.value + : this.installedAt, + ); + } + + @override + String toString() { + return (StringBuffer('InstalledPluginRow(') + ..write('id: $id, ') + ..write('version: $version, ') + ..write('manifestJson: $manifestJson, ') + ..write('script: $script, ') + ..write('installedAt: $installedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, version, manifestJson, script, installedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is InstalledPluginRow && + other.id == this.id && + other.version == this.version && + other.manifestJson == this.manifestJson && + other.script == this.script && + other.installedAt == this.installedAt); +} + +class InstalledPluginsTableCompanion + extends UpdateCompanion { + final Value id; + final Value version; + final Value manifestJson; + final Value script; + final Value installedAt; + final Value rowid; + const InstalledPluginsTableCompanion({ + this.id = const Value.absent(), + this.version = const Value.absent(), + this.manifestJson = const Value.absent(), + this.script = const Value.absent(), + this.installedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + InstalledPluginsTableCompanion.insert({ + required String id, + required String version, + required String manifestJson, + required String script, + required DateTime installedAt, + this.rowid = const Value.absent(), + }) : id = Value(id), + version = Value(version), + manifestJson = Value(manifestJson), + script = Value(script), + installedAt = Value(installedAt); + static Insertable custom({ + Expression? id, + Expression? version, + Expression? manifestJson, + Expression? script, + Expression? installedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (version != null) 'version': version, + if (manifestJson != null) 'manifest_json': manifestJson, + if (script != null) 'script': script, + if (installedAt != null) 'installed_at': installedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + InstalledPluginsTableCompanion copyWith({ + Value? id, + Value? version, + Value? manifestJson, + Value? script, + Value? installedAt, + Value? rowid, + }) { + return InstalledPluginsTableCompanion( + id: id ?? this.id, + version: version ?? this.version, + manifestJson: manifestJson ?? this.manifestJson, + script: script ?? this.script, + installedAt: installedAt ?? this.installedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (version.present) { + map['version'] = Variable(version.value); + } + if (manifestJson.present) { + map['manifest_json'] = Variable(manifestJson.value); + } + if (script.present) { + map['script'] = Variable(script.value); + } + if (installedAt.present) { + map['installed_at'] = Variable( + $InstalledPluginsTableTable.$converterinstalledAt.toSql( + installedAt.value, + ), + ); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('InstalledPluginsTableCompanion(') + ..write('id: $id, ') + ..write('version: $version, ') + ..write('manifestJson: $manifestJson, ') + ..write('script: $script, ') + ..write('installedAt: $installedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $PluginStorageTableTable extends PluginStorageTable + with TableInfo<$PluginStorageTableTable, PluginStorageRow> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $PluginStorageTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _pluginIdMeta = const VerificationMeta( + 'pluginId', + ); + @override + late final GeneratedColumn pluginId = GeneratedColumn( + 'plugin_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES installed_plugins (id) ON DELETE CASCADE', + ), + ); + static const VerificationMeta _keyMeta = const VerificationMeta('key'); + @override + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _valueMeta = const VerificationMeta('value'); + @override + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [pluginId, key, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'plugin_storage'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('plugin_id')) { + context.handle( + _pluginIdMeta, + pluginId.isAcceptableOrUnknown(data['plugin_id']!, _pluginIdMeta), + ); + } else if (isInserting) { + context.missing(_pluginIdMeta); + } + if (data.containsKey('key')) { + context.handle( + _keyMeta, + key.isAcceptableOrUnknown(data['key']!, _keyMeta), + ); + } else if (isInserting) { + context.missing(_keyMeta); + } + if (data.containsKey('value')) { + context.handle( + _valueMeta, + value.isAcceptableOrUnknown(data['value']!, _valueMeta), + ); + } else if (isInserting) { + context.missing(_valueMeta); + } + return context; + } + + @override + Set get $primaryKey => {pluginId, key}; + @override + PluginStorageRow map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PluginStorageRow( + pluginId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}plugin_id'], + )!, + key: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}key'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + $PluginStorageTableTable createAlias(String alias) { + return $PluginStorageTableTable(attachedDatabase, alias); + } +} + +class PluginStorageRow extends DataClass + implements Insertable { + final String pluginId; + final String key; + final String value; + const PluginStorageRow({ + required this.pluginId, + required this.key, + required this.value, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['plugin_id'] = Variable(pluginId); + map['key'] = Variable(key); + map['value'] = Variable(value); + return map; + } + + PluginStorageTableCompanion toCompanion(bool nullToAbsent) { + return PluginStorageTableCompanion( + pluginId: Value(pluginId), + key: Value(key), + value: Value(value), + ); + } + + factory PluginStorageRow.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PluginStorageRow( + pluginId: serializer.fromJson(json['pluginId']), + key: serializer.fromJson(json['key']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'pluginId': serializer.toJson(pluginId), + 'key': serializer.toJson(key), + 'value': serializer.toJson(value), + }; + } + + PluginStorageRow copyWith({String? pluginId, String? key, String? value}) => + PluginStorageRow( + pluginId: pluginId ?? this.pluginId, + key: key ?? this.key, + value: value ?? this.value, + ); + PluginStorageRow copyWithCompanion(PluginStorageTableCompanion data) { + return PluginStorageRow( + pluginId: data.pluginId.present ? data.pluginId.value : this.pluginId, + key: data.key.present ? data.key.value : this.key, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('PluginStorageRow(') + ..write('pluginId: $pluginId, ') + ..write('key: $key, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(pluginId, key, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PluginStorageRow && + other.pluginId == this.pluginId && + other.key == this.key && + other.value == this.value); +} + +class PluginStorageTableCompanion extends UpdateCompanion { + final Value pluginId; + final Value key; + final Value value; + final Value rowid; + const PluginStorageTableCompanion({ + this.pluginId = const Value.absent(), + this.key = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + PluginStorageTableCompanion.insert({ + required String pluginId, + required String key, + required String value, + this.rowid = const Value.absent(), + }) : pluginId = Value(pluginId), + key = Value(key), + value = Value(value); + static Insertable custom({ + Expression? pluginId, + Expression? key, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (pluginId != null) 'plugin_id': pluginId, + if (key != null) 'key': key, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + PluginStorageTableCompanion copyWith({ + Value? pluginId, + Value? key, + Value? value, + Value? rowid, + }) { + return PluginStorageTableCompanion( + pluginId: pluginId ?? this.pluginId, + key: key ?? this.key, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (pluginId.present) { + map['plugin_id'] = Variable(pluginId.value); + } + if (key.present) { + map['key'] = Variable(key.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PluginStorageTableCompanion(') + ..write('pluginId: $pluginId, ') + ..write('key: $key, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + $AppDatabaseManager get managers => $AppDatabaseManager(this); + late final $AppearanceSettingsTableTable appearanceSettingsTable = + $AppearanceSettingsTableTable(this); + late final $InstalledPluginsTableTable installedPluginsTable = + $InstalledPluginsTableTable(this); + late final $PluginStorageTableTable pluginStorageTable = + $PluginStorageTableTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + appearanceSettingsTable, + installedPluginsTable, + pluginStorageTable, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'installed_plugins', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('plugin_storage', kind: UpdateKind.delete)], + ), + ]); +} + +typedef $$AppearanceSettingsTableTableCreateCompanionBuilder = + AppearanceSettingsTableCompanion Function({ + Value id, + Value themeMode, + Value locale, + }); +typedef $$AppearanceSettingsTableTableUpdateCompanionBuilder = + AppearanceSettingsTableCompanion Function({ + Value id, + Value themeMode, + Value locale, + }); + +class $$AppearanceSettingsTableTableFilterComposer + extends Composer<_$AppDatabase, $AppearanceSettingsTableTable> { + $$AppearanceSettingsTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters + get themeMode => $composableBuilder( + column: $table.themeMode, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters + get locale => $composableBuilder( + column: $table.locale, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); +} + +class $$AppearanceSettingsTableTableOrderingComposer + extends Composer<_$AppDatabase, $AppearanceSettingsTableTable> { + $$AppearanceSettingsTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get themeMode => $composableBuilder( + column: $table.themeMode, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get locale => $composableBuilder( + column: $table.locale, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$AppearanceSettingsTableTableAnnotationComposer + extends Composer<_$AppDatabase, $AppearanceSettingsTableTable> { + $$AppearanceSettingsTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumnWithTypeConverter get themeMode => + $composableBuilder(column: $table.themeMode, builder: (column) => column); + + GeneratedColumnWithTypeConverter get locale => + $composableBuilder(column: $table.locale, builder: (column) => column); +} + +class $$AppearanceSettingsTableTableTableManager + extends + RootTableManager< + _$AppDatabase, + $AppearanceSettingsTableTable, + AppearanceSettingsRow, + $$AppearanceSettingsTableTableFilterComposer, + $$AppearanceSettingsTableTableOrderingComposer, + $$AppearanceSettingsTableTableAnnotationComposer, + $$AppearanceSettingsTableTableCreateCompanionBuilder, + $$AppearanceSettingsTableTableUpdateCompanionBuilder, + ( + AppearanceSettingsRow, + BaseReferences< + _$AppDatabase, + $AppearanceSettingsTableTable, + AppearanceSettingsRow + >, + ), + AppearanceSettingsRow, + PrefetchHooks Function() + > { + $$AppearanceSettingsTableTableTableManager( + _$AppDatabase db, + $AppearanceSettingsTableTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$AppearanceSettingsTableTableFilterComposer( + $db: db, + $table: table, + ), + createOrderingComposer: () => + $$AppearanceSettingsTableTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$AppearanceSettingsTableTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value themeMode = const Value.absent(), + Value locale = const Value.absent(), + }) => AppearanceSettingsTableCompanion( + id: id, + themeMode: themeMode, + locale: locale, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + Value themeMode = const Value.absent(), + Value locale = const Value.absent(), + }) => AppearanceSettingsTableCompanion.insert( + id: id, + themeMode: themeMode, + locale: locale, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable< + $AppearanceSettingsTableTable, + AppearanceSettingsRow + >(table), + BaseReferences< + _$AppDatabase, + $AppearanceSettingsTableTable, + AppearanceSettingsRow + >(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$AppearanceSettingsTableTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $AppearanceSettingsTableTable, + AppearanceSettingsRow, + $$AppearanceSettingsTableTableFilterComposer, + $$AppearanceSettingsTableTableOrderingComposer, + $$AppearanceSettingsTableTableAnnotationComposer, + $$AppearanceSettingsTableTableCreateCompanionBuilder, + $$AppearanceSettingsTableTableUpdateCompanionBuilder, + ( + AppearanceSettingsRow, + BaseReferences< + _$AppDatabase, + $AppearanceSettingsTableTable, + AppearanceSettingsRow + >, + ), + AppearanceSettingsRow, + PrefetchHooks Function() + >; +typedef $$InstalledPluginsTableTableCreateCompanionBuilder = + InstalledPluginsTableCompanion Function({ + required String id, + required String version, + required String manifestJson, + required String script, + required DateTime installedAt, + Value rowid, + }); +typedef $$InstalledPluginsTableTableUpdateCompanionBuilder = + InstalledPluginsTableCompanion Function({ + Value id, + Value version, + Value manifestJson, + Value script, + Value installedAt, + Value rowid, + }); + +final class $$InstalledPluginsTableTableReferences + extends + BaseReferences< + _$AppDatabase, + $InstalledPluginsTableTable, + InstalledPluginRow + > { + $$InstalledPluginsTableTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static MultiTypedResultKey<$PluginStorageTableTable, List> + _pluginStorageTableRefsTable(_$AppDatabase db) => + MultiTypedResultKey.fromTable( + db.pluginStorageTable, + aliasName: 'installed_plugins__id__plugin_storage__plugin_id', + ); + + $$PluginStorageTableTableProcessedTableManager get pluginStorageTableRefs { + final manager = $$PluginStorageTableTableTableManager( + $_db, + $_db.pluginStorageTable, + ).filter((f) => f.pluginId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull( + _pluginStorageTableRefsTable($_db), + ); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache), + ); + } +} + +class $$InstalledPluginsTableTableFilterComposer + extends Composer<_$AppDatabase, $InstalledPluginsTableTable> { + $$InstalledPluginsTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get version => $composableBuilder( + column: $table.version, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get manifestJson => $composableBuilder( + column: $table.manifestJson, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get script => $composableBuilder( + column: $table.script, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters get installedAt => + $composableBuilder( + column: $table.installedAt, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + Expression pluginStorageTableRefs( + Expression Function($$PluginStorageTableTableFilterComposer f) f, + ) { + final $$PluginStorageTableTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.pluginStorageTable, + getReferencedColumn: (t) => t.pluginId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$PluginStorageTableTableFilterComposer( + $db: $db, + $table: $db.pluginStorageTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$InstalledPluginsTableTableOrderingComposer + extends Composer<_$AppDatabase, $InstalledPluginsTableTable> { + $$InstalledPluginsTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get version => $composableBuilder( + column: $table.version, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get manifestJson => $composableBuilder( + column: $table.manifestJson, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get script => $composableBuilder( + column: $table.script, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get installedAt => $composableBuilder( + column: $table.installedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$InstalledPluginsTableTableAnnotationComposer + extends Composer<_$AppDatabase, $InstalledPluginsTableTable> { + $$InstalledPluginsTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get version => + $composableBuilder(column: $table.version, builder: (column) => column); + + GeneratedColumn get manifestJson => $composableBuilder( + column: $table.manifestJson, + builder: (column) => column, + ); + + GeneratedColumn get script => + $composableBuilder(column: $table.script, builder: (column) => column); + + GeneratedColumnWithTypeConverter get installedAt => + $composableBuilder( + column: $table.installedAt, + builder: (column) => column, + ); + + Expression pluginStorageTableRefs( + Expression Function($$PluginStorageTableTableAnnotationComposer a) f, + ) { + final $$PluginStorageTableTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.pluginStorageTable, + getReferencedColumn: (t) => t.pluginId, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$PluginStorageTableTableAnnotationComposer( + $db: $db, + $table: $db.pluginStorageTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$InstalledPluginsTableTableTableManager + extends + RootTableManager< + _$AppDatabase, + $InstalledPluginsTableTable, + InstalledPluginRow, + $$InstalledPluginsTableTableFilterComposer, + $$InstalledPluginsTableTableOrderingComposer, + $$InstalledPluginsTableTableAnnotationComposer, + $$InstalledPluginsTableTableCreateCompanionBuilder, + $$InstalledPluginsTableTableUpdateCompanionBuilder, + (InstalledPluginRow, $$InstalledPluginsTableTableReferences), + InstalledPluginRow, + PrefetchHooks Function({bool pluginStorageTableRefs}) + > { + $$InstalledPluginsTableTableTableManager( + _$AppDatabase db, + $InstalledPluginsTableTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$InstalledPluginsTableTableFilterComposer( + $db: db, + $table: table, + ), + createOrderingComposer: () => + $$InstalledPluginsTableTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$InstalledPluginsTableTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value version = const Value.absent(), + Value manifestJson = const Value.absent(), + Value script = const Value.absent(), + Value installedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => InstalledPluginsTableCompanion( + id: id, + version: version, + manifestJson: manifestJson, + script: script, + installedAt: installedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String version, + required String manifestJson, + required String script, + required DateTime installedAt, + Value rowid = const Value.absent(), + }) => InstalledPluginsTableCompanion.insert( + id: id, + version: version, + manifestJson: manifestJson, + script: script, + installedAt: installedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable<$InstalledPluginsTableTable, InstalledPluginRow>( + table, + ), + $$InstalledPluginsTableTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({pluginStorageTableRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (pluginStorageTableRefs) db.pluginStorageTable, + ], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (pluginStorageTableRefs) + await $_getPrefetchedData< + InstalledPluginRow, + $InstalledPluginsTableTable, + PluginStorageRow + >( + currentTable: table, + referencedTable: $$InstalledPluginsTableTableReferences + ._pluginStorageTableRefsTable(db), + managerFromTypedResult: (p0) => + $$InstalledPluginsTableTableReferences( + db, + table, + p0, + ).pluginStorageTableRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.pluginId == item.id), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $$InstalledPluginsTableTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $InstalledPluginsTableTable, + InstalledPluginRow, + $$InstalledPluginsTableTableFilterComposer, + $$InstalledPluginsTableTableOrderingComposer, + $$InstalledPluginsTableTableAnnotationComposer, + $$InstalledPluginsTableTableCreateCompanionBuilder, + $$InstalledPluginsTableTableUpdateCompanionBuilder, + (InstalledPluginRow, $$InstalledPluginsTableTableReferences), + InstalledPluginRow, + PrefetchHooks Function({bool pluginStorageTableRefs}) + >; +typedef $$PluginStorageTableTableCreateCompanionBuilder = + PluginStorageTableCompanion Function({ + required String pluginId, + required String key, + required String value, + Value rowid, + }); +typedef $$PluginStorageTableTableUpdateCompanionBuilder = + PluginStorageTableCompanion Function({ + Value pluginId, + Value key, + Value value, + Value rowid, + }); + +final class $$PluginStorageTableTableReferences + extends + BaseReferences< + _$AppDatabase, + $PluginStorageTableTable, + PluginStorageRow + > { + $$PluginStorageTableTableReferences( + super.$_db, + super.$_table, + super.$_typedResult, + ); + + static $InstalledPluginsTableTable _pluginIdTable(_$AppDatabase db) => db + .installedPluginsTable + .createAlias('plugin_storage__plugin_id__installed_plugins__id'); + + $$InstalledPluginsTableTableProcessedTableManager get pluginId { + final $_column = $_itemColumn('plugin_id')!; + + final manager = $$InstalledPluginsTableTableTableManager( + $_db, + $_db.installedPluginsTable, + ).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_pluginIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item]), + ); + } +} + +class $$PluginStorageTableTableFilterComposer + extends Composer<_$AppDatabase, $PluginStorageTableTable> { + $$PluginStorageTableTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get key => $composableBuilder( + column: $table.key, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get value => $composableBuilder( + column: $table.value, + builder: (column) => ColumnFilters(column), + ); + + $$InstalledPluginsTableTableFilterComposer get pluginId { + final $$InstalledPluginsTableTableFilterComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.pluginId, + referencedTable: $db.installedPluginsTable, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$InstalledPluginsTableTableFilterComposer( + $db: $db, + $table: $db.installedPluginsTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$PluginStorageTableTableOrderingComposer + extends Composer<_$AppDatabase, $PluginStorageTableTable> { + $$PluginStorageTableTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get key => $composableBuilder( + column: $table.key, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get value => $composableBuilder( + column: $table.value, + builder: (column) => ColumnOrderings(column), + ); + + $$InstalledPluginsTableTableOrderingComposer get pluginId { + final $$InstalledPluginsTableTableOrderingComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.pluginId, + referencedTable: $db.installedPluginsTable, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$InstalledPluginsTableTableOrderingComposer( + $db: $db, + $table: $db.installedPluginsTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$PluginStorageTableTableAnnotationComposer + extends Composer<_$AppDatabase, $PluginStorageTableTable> { + $$PluginStorageTableTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get key => + $composableBuilder(column: $table.key, builder: (column) => column); + + GeneratedColumn get value => + $composableBuilder(column: $table.value, builder: (column) => column); + + $$InstalledPluginsTableTableAnnotationComposer get pluginId { + final $$InstalledPluginsTableTableAnnotationComposer composer = + $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.pluginId, + referencedTable: $db.installedPluginsTable, + getReferencedColumn: (t) => t.id, + builder: + ( + joinBuilder, { + $addJoinBuilderToRootComposer, + $removeJoinBuilderFromRootComposer, + }) => $$InstalledPluginsTableTableAnnotationComposer( + $db: $db, + $table: $db.installedPluginsTable, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: + $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$PluginStorageTableTableTableManager + extends + RootTableManager< + _$AppDatabase, + $PluginStorageTableTable, + PluginStorageRow, + $$PluginStorageTableTableFilterComposer, + $$PluginStorageTableTableOrderingComposer, + $$PluginStorageTableTableAnnotationComposer, + $$PluginStorageTableTableCreateCompanionBuilder, + $$PluginStorageTableTableUpdateCompanionBuilder, + (PluginStorageRow, $$PluginStorageTableTableReferences), + PluginStorageRow, + PrefetchHooks Function({bool pluginId}) + > { + $$PluginStorageTableTableTableManager( + _$AppDatabase db, + $PluginStorageTableTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$PluginStorageTableTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$PluginStorageTableTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$PluginStorageTableTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value pluginId = const Value.absent(), + Value key = const Value.absent(), + Value value = const Value.absent(), + Value rowid = const Value.absent(), + }) => PluginStorageTableCompanion( + pluginId: pluginId, + key: key, + value: value, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String pluginId, + required String key, + required String value, + Value rowid = const Value.absent(), + }) => PluginStorageTableCompanion.insert( + pluginId: pluginId, + key: key, + value: value, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable<$PluginStorageTableTable, PluginStorageRow>( + table, + ), + $$PluginStorageTableTableReferences(db, table, e), + ), + ) + .toList(), + prefetchHooksCallback: ({pluginId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (pluginId) { + state = state.withJoin( + currentTable: table, + currentColumn: table.pluginId, + referencedTable: $$PluginStorageTableTableReferences + ._pluginIdTable(db), + referencedColumn: $$PluginStorageTableTableReferences + ._pluginIdTable(db) + .id, + ) as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + ), + ); +} + +typedef $$PluginStorageTableTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $PluginStorageTableTable, + PluginStorageRow, + $$PluginStorageTableTableFilterComposer, + $$PluginStorageTableTableOrderingComposer, + $$PluginStorageTableTableAnnotationComposer, + $$PluginStorageTableTableCreateCompanionBuilder, + $$PluginStorageTableTableUpdateCompanionBuilder, + (PluginStorageRow, $$PluginStorageTableTableReferences), + PluginStorageRow, + PrefetchHooks Function({bool pluginId}) + >; + +class $AppDatabaseManager { + final _$AppDatabase _db; + $AppDatabaseManager(this._db); + $$AppearanceSettingsTableTableTableManager get appearanceSettingsTable => + $$AppearanceSettingsTableTableTableManager( + _db, + _db.appearanceSettingsTable, + ); + $$InstalledPluginsTableTableTableManager get installedPluginsTable => + $$InstalledPluginsTableTableTableManager(_db, _db.installedPluginsTable); + $$PluginStorageTableTableTableManager get pluginStorageTable => + $$PluginStorageTableTableTableManager(_db, _db.pluginStorageTable); +} diff --git a/app/lib/data/database/converters.dart b/app/lib/data/database/converters.dart new file mode 100644 index 00000000..34ab8449 --- /dev/null +++ b/app/lib/data/database/converters.dart @@ -0,0 +1,60 @@ +import 'package:drift/drift.dart'; + +import 'package:fmp/domain/appearance.dart'; + +// 列舉與時間存進資料庫的格式(ADR 0010 §決定 2)。字串逐一寫死,不用 enum 的 +// `name`:Dart 端改名不該悄悄改掉已存的資料。讀到不認得的值就拋錯,不猜。 + +/// [ThemeModeSetting] ↔ `system`/`light`/`dark`。 +final class ThemeModeSettingConverter + extends TypeConverter { + const ThemeModeSettingConverter(); + + @override + ThemeModeSetting fromSql(String fromDb) => switch (fromDb) { + 'system' => ThemeModeSetting.system, + 'light' => ThemeModeSetting.light, + 'dark' => ThemeModeSetting.dark, + _ => throw FormatException('Unknown theme mode in the database', fromDb), + }; + + @override + String toSql(ThemeModeSetting value) => switch (value) { + ThemeModeSetting.system => 'system', + ThemeModeSetting.light => 'light', + ThemeModeSetting.dark => 'dark', + }; +} + +/// [LocaleSetting] ↔ `zh-TW`/`zh-CN`/`en`。 +final class LocaleSettingConverter + extends TypeConverter { + const LocaleSettingConverter(); + + @override + LocaleSetting fromSql(String fromDb) => switch (fromDb) { + 'zh-TW' => LocaleSetting.zhTw, + 'zh-CN' => LocaleSetting.zhCn, + 'en' => LocaleSetting.en, + _ => throw FormatException('Unknown locale in the database', fromDb), + }; + + @override + String toSql(LocaleSetting value) => switch (value) { + LocaleSetting.zhTw => 'zh-TW', + LocaleSetting.zhCn => 'zh-CN', + LocaleSetting.en => 'en', + }; +} + +/// [DateTime] ↔ UTC epoch 毫秒。drift 內建的 `dateTime()` 存的是秒,所以不用。 +final class EpochMillisecondsConverter extends TypeConverter { + const EpochMillisecondsConverter(); + + @override + DateTime fromSql(int fromDb) => + DateTime.fromMillisecondsSinceEpoch(fromDb, isUtc: true); + + @override + int toSql(DateTime value) => value.millisecondsSinceEpoch; +} diff --git a/app/lib/data/database/open_app_database.dart b/app/lib/data/database/open_app_database.dart new file mode 100644 index 00000000..fb719043 --- /dev/null +++ b/app/lib/data/database/open_app_database.dart @@ -0,0 +1,32 @@ +import 'dart:io'; + +import 'package:drift/native.dart'; +import 'package:path/path.dart' as p; + +import 'package:fmp/data/database/app_database.dart'; + +/// 資料庫檔名,放在 App 資料目錄(`AppPlatform.dataDirectory`)底下。 +const databaseFileName = 'fmp.db'; + +/// 開啟 [dataDirectory] 裡的 [databaseFileName],沒有就建立。 +/// +/// drift 在第一個查詢時才真正開檔、建表或跑 migration;這裡先跑一個查詢,讓 +/// 開不起來(檔案損壞、migration 失敗)在啟動時就拋出,呼叫端才能改開錯誤頁, +/// 不在半開的資料庫上啟動 App(ADR 0010 §決定 3)。失敗時先關閉再拋。 +/// +/// SQLite 跑在 drift 管理的背景 isolate(`createInBackground`),不佔 UI +/// isolate。 +Future openAppDatabase(Directory dataDirectory) async { + final database = AppDatabase( + NativeDatabase.createInBackground( + File(p.join(dataDirectory.path, databaseFileName)), + ), + ); + try { + await database.customSelect('SELECT 1').get(); + } on Object { + await database.close(); + rethrow; + } + return database; +} diff --git a/app/lib/data/database/tables.dart b/app/lib/data/database/tables.dart new file mode 100644 index 00000000..d53d05ef --- /dev/null +++ b/app/lib/data/database/tables.dart @@ -0,0 +1,62 @@ +import 'package:drift/drift.dart'; + +import 'package:fmp/data/database/converters.dart'; + +// Schema v1(M1)。改這個檔案就是改 schema:bump `AppDatabase.schemaVersion`、 +// 存新快照、寫 migration 與升級測試(.trellis/spec/app/data/index.md)。 +// SQL 表名以 `tableName` 寫死,Dart 類別改名不會改到資料庫。 + +/// 外觀設定,單列(ADR 0011 §決定 7)。欄位為空=使用者沒設定過。 +@DataClassName('AppearanceSettingsRow') +class AppearanceSettingsTable extends Table { + @override + String get tableName => 'appearance_settings'; + + /// 固定為 1;CHECK 讓第二列插不進去。 + late final IntColumn id = integer().check(id.equals(1))(); + late final themeMode = text().nullable().map( + const ThemeModeSettingConverter(), + )(); + late final locale = text().nullable().map(const LocaleSettingConverter())(); + + @override + Set> get primaryKey => {id}; +} + +/// 從檔案安裝的插件(ADR 0014)。 +@DataClassName('InstalledPluginRow') +class InstalledPluginsTable extends Table { + @override + String get tableName => 'installed_plugins'; + + /// 音源 id,例如 B 站插件的 id。 + late final id = text()(); + late final version = text()(); + late final manifestJson = text()(); + late final script = text()(); + + /// UTC epoch 毫秒。 + late final installedAt = integer().map(const EpochMillisecondsConverter())(); + + @override + Set> get primaryKey => {id}; +} + +/// 每個插件自己的 key/value(ADR 0014 §決定 5)。移除插件時由外鍵的 +/// `ON DELETE CASCADE` 一起清掉(ADR 0014 §決定 8)。 +@DataClassName('PluginStorageRow') +class PluginStorageTable extends Table { + @override + String get tableName => 'plugin_storage'; + + late final pluginId = text().references( + InstalledPluginsTable, + #id, + onDelete: KeyAction.cascade, + )(); + late final key = text()(); + late final value = text()(); + + @override + Set> get primaryKey => {pluginId, key}; +} diff --git a/app/lib/data/repositories/appearance_settings_repository.dart b/app/lib/data/repositories/appearance_settings_repository.dart new file mode 100644 index 00000000..0f8d44f7 --- /dev/null +++ b/app/lib/data/repositories/appearance_settings_repository.dart @@ -0,0 +1,69 @@ +import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; + +import 'package:fmp/data/database/app_database.dart'; +import 'package:fmp/domain/appearance.dart'; + +/// 外觀設定的值。欄位為 `null` 表示使用者沒設定過(ADR 0011 §決定 7),由上層 +/// 套用預設值。 +@immutable +final class AppearanceSettings { + const AppearanceSettings({this.themeMode, this.locale}); + + /// 全部沒設定過。 + static const empty = AppearanceSettings(); + + final ThemeModeSetting? themeMode; + final LocaleSetting? locale; + + @override + bool operator ==(Object other) => + other is AppearanceSettings && + other.themeMode == themeMode && + other.locale == locale; + + @override + int get hashCode => Object.hash(themeMode, locale); + + @override + String toString() => + 'AppearanceSettings(themeMode: $themeMode, locale: $locale)'; +} + +/// `appearance_settings` 單列表的存取。 +final class AppearanceSettingsRepository { + AppearanceSettingsRepository(this._database); + + final AppDatabase _database; + + /// 單列的主鍵;表上的 CHECK 只允許這個值。 + static const _rowId = 1; + + SimpleSelectStatement<$AppearanceSettingsTableTable, AppearanceSettingsRow> + get _row => + _database.select(_database.appearanceSettingsTable) + ..where((t) => t.id.equals(_rowId)); + + /// 讀目前的設定;還沒有列時回傳 [AppearanceSettings.empty]。 + Future read() async => + _fromRow(await _row.getSingleOrNull()); + + /// 目前的設定,之後每次寫入再發一次。 + Stream watch() => _row.watchSingleOrNull().map(_fromRow); + + /// 只寫入有給的欄位;沒給的(`null`)維持原值。 + Future write({ThemeModeSetting? themeMode, LocaleSetting? locale}) => + _database + .into(_database.appearanceSettingsTable) + .insertOnConflictUpdate( + AppearanceSettingsTableCompanion( + id: const Value(_rowId), + themeMode: Value.absentIfNull(themeMode), + locale: Value.absentIfNull(locale), + ), + ); + + static AppearanceSettings _fromRow(AppearanceSettingsRow? row) => row == null + ? AppearanceSettings.empty + : AppearanceSettings(themeMode: row.themeMode, locale: row.locale); +} diff --git a/app/lib/data/repositories/plugin_repository.dart b/app/lib/data/repositories/plugin_repository.dart new file mode 100644 index 00000000..dea8ad16 --- /dev/null +++ b/app/lib/data/repositories/plugin_repository.dart @@ -0,0 +1,96 @@ +import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; + +import 'package:fmp/data/database/app_database.dart'; + +/// 一個從檔案安裝的插件(ADR 0014)。 +@immutable +final class InstalledPlugin { + const InstalledPlugin({ + required this.id, + required this.version, + required this.manifestJson, + required this.script, + required this.installedAt, + }); + + /// 音源 id。 + final String id; + final String version; + + /// manifest 原文;解析是插件層的事。 + final String manifestJson; + final String script; + + /// 存成 UTC epoch 毫秒;讀回來是 UTC、精度到毫秒。 + final DateTime installedAt; + + @override + bool operator ==(Object other) => + other is InstalledPlugin && + other.id == id && + other.version == version && + other.manifestJson == manifestJson && + other.script == script && + other.installedAt == installedAt; + + @override + int get hashCode => + Object.hash(id, version, manifestJson, script, installedAt); + + @override + String toString() => + 'InstalledPlugin(id: $id, version: $version, installedAt: $installedAt)'; +} + +/// `installed_plugins` 的存取。 +final class PluginRepository { + PluginRepository(this._database); + + final AppDatabase _database; + + /// 安裝;同 id 已存在就覆蓋成 [plugin](更新)。 + /// + /// 用 upsert(`ON CONFLICT DO UPDATE`)而不是先刪再插:更新插件不能經由 + /// 外鍵的 cascade 清掉它的 storage。 + Future install(InstalledPlugin plugin) => _database + .into(_database.installedPluginsTable) + .insertOnConflictUpdate( + InstalledPluginsTableCompanion.insert( + id: plugin.id, + version: plugin.version, + manifestJson: plugin.manifestJson, + script: plugin.script, + installedAt: plugin.installedAt, + ), + ); + + /// 依 id 讀;沒有安裝時回傳 `null`。 + Future byId(String id) async { + final row = await (_database.select( + _database.installedPluginsTable, + )..where((t) => t.id.equals(id))).getSingleOrNull(); + return row == null ? null : _fromRow(row); + } + + /// 所有已安裝的插件,依 id 排序。 + Future> list() async { + final rows = await (_database.select( + _database.installedPluginsTable, + )..orderBy([(t) => OrderingTerm.asc(t.id)])).get(); + return [for (final row in rows) _fromRow(row)]; + } + + /// 移除;它的 storage 由外鍵的 `ON DELETE CASCADE` 一起清掉。 + Future remove(String id) => (_database.delete( + _database.installedPluginsTable, + )..where((t) => t.id.equals(id))).go(); + + static InstalledPlugin _fromRow(InstalledPluginRow row) => InstalledPlugin( + id: row.id, + version: row.version, + manifestJson: row.manifestJson, + script: row.script, + installedAt: row.installedAt, + ); +} diff --git a/app/lib/data/repositories/plugin_storage_repository.dart b/app/lib/data/repositories/plugin_storage_repository.dart new file mode 100644 index 00000000..5bfc3209 --- /dev/null +++ b/app/lib/data/repositories/plugin_storage_repository.dart @@ -0,0 +1,38 @@ +import 'package:drift/drift.dart'; + +import 'package:fmp/data/database/app_database.dart'; + +/// `plugin_storage` 的存取:每個插件自己的 key/value(ADR 0014 §決定 5)。 +/// +/// 插件必須已經安裝(外鍵);對沒安裝的 id 寫入會拋錯。移除插件時它的資料由 +/// `PluginRepository.remove` 經外鍵一起清掉。 +final class PluginStorageRepository { + PluginStorageRepository(this._database); + + final AppDatabase _database; + + /// 讀 [key];沒有時回傳 `null`。 + Future read(String pluginId, String key) async { + final row = + await (_database.select(_database.pluginStorageTable) + ..where((t) => t.pluginId.equals(pluginId) & t.key.equals(key))) + .getSingleOrNull(); + return row?.value; + } + + /// 寫入 [key];已存在就覆蓋。 + Future write(String pluginId, String key, String value) => _database + .into(_database.pluginStorageTable) + .insertOnConflictUpdate( + PluginStorageTableCompanion.insert( + pluginId: pluginId, + key: key, + value: value, + ), + ); + + /// 刪除 [key];本來就沒有也不算錯。 + Future delete(String pluginId, String key) => (_database.delete( + _database.pluginStorageTable, + )..where((t) => t.pluginId.equals(pluginId) & t.key.equals(key))).go(); +} diff --git a/app/lib/domain/appearance.dart b/app/lib/domain/appearance.dart new file mode 100644 index 00000000..8f4eacd2 --- /dev/null +++ b/app/lib/domain/appearance.dart @@ -0,0 +1,11 @@ +/// 使用者選的主題模式(ADR 0011 §決定 7)。沒選過時由上層套用預設值。 +/// +/// 存進資料庫的字串在 `lib/data/database/converters.dart`,不是 [name]:改名 +/// 不會改到資料格式。 +enum ThemeModeSetting { system, light, dark } + +/// 使用者選的介面語言(ADR 0011 §決定 7)。沒選過時由上層套用預設值。 +/// +/// 存進資料庫的是 BCP 47 語言標籤(`zh-TW`、`zh-CN`、`en`),對照在 +/// `lib/data/database/converters.dart`。 +enum LocaleSetting { zhTw, zhCn, en } diff --git a/app/lib/domain/track_key.dart b/app/lib/domain/track_key.dart new file mode 100644 index 00000000..ceff1bf0 --- /dev/null +++ b/app/lib/domain/track_key.dart @@ -0,0 +1,74 @@ +/// 曲目識別鍵的唯一實作(ADR 0005)。 +/// +/// 這個字串**是持久化格式的一部分**,不只是個內部識別碼: +/// +/// - 新資料庫以它當曲目的唯一鍵(ADR 0010 §決定 2)。 +/// - 舊版把同一個字串存在 Isar 的複合索引與備份 JSON 的外鍵裡;M5 匯入舊資料 +/// (`lib/legacy_import/`)靠逐字相同的鍵把歌單、播放歷史接回曲目。 +/// +/// 格式與行為照舊版 `lib/data/models/track_key.dart` 原樣搬來(ADR 0008 檔頭 +/// 補充:葉節點照搬),`test/domain/track_key_test.dart` 用寫死的字面值把輸出 +/// 釘住。改動字面輸出等於改資料格式,要另立 ADR 並寫 migration。 +/// +/// **它以 `cid` 區分分 P,不是 `pageNum`。** 需要用 pageNum 區分的地方(例如 +/// 串流解析的行程內快取)自己在後面接 pageNum。 +class TrackKey { + const TrackKey._(); + + /// 完整識別鍵:有 cid 就是三段式,否則兩段式。 + static String format(String sourceTypeId, String sourceId, {int? cid}) => + cid != null ? '$sourceTypeId:$sourceId:$cid' : '$sourceTypeId:$sourceId'; + + /// 分組鍵:同一支影片的所有分 P 共用它。 + static String formatGroup(String sourceTypeId, String sourceId) => + '$sourceTypeId:$sourceId'; + + /// 把 [format] 產生的鍵拆回來;格式不符時回傳 null。 + /// + /// 匯入舊備份是需要反向解析的路徑,而它同時也讓「format/parse 來回一致」 + /// 這件事可以被測試。 + static TrackKeyParts? tryParse(String raw) { + final parts = raw.split(':'); + if (parts.length == 2) { + if (parts[0].isEmpty || parts[1].isEmpty) return null; + return TrackKeyParts(sourceTypeId: parts[0], sourceId: parts[1]); + } + if (parts.length == 3) { + final cid = int.tryParse(parts[2]); + if (cid == null || parts[0].isEmpty || parts[1].isEmpty) return null; + return TrackKeyParts( + sourceTypeId: parts[0], + sourceId: parts[1], + cid: cid, + ); + } + return null; + } +} + +/// [TrackKey.tryParse] 的結果。 +class TrackKeyParts { + const TrackKeyParts({ + required this.sourceTypeId, + required this.sourceId, + this.cid, + }); + + final String sourceTypeId; + final String sourceId; + final int? cid; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TrackKeyParts && + other.sourceTypeId == sourceTypeId && + other.sourceId == sourceId && + other.cid == cid; + + @override + int get hashCode => Object.hash(sourceTypeId, sourceId, cid); + + @override + String toString() => TrackKey.format(sourceTypeId, sourceId, cid: cid); +} diff --git a/app/lib/main.dart b/app/lib/main.dart index a6908df8..1bffe713 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,9 +1,11 @@ import 'package:flutter/services.dart' show appFlavor; import 'package:flutter/widgets.dart'; +import 'package:fmp/app/database_error_app.dart'; import 'package:fmp/app/fmp_app.dart'; import 'package:fmp/app/unsupported_platform_app.dart'; import 'package:fmp/core/app_flavor.dart'; +import 'package:fmp/data/database/open_app_database.dart'; import 'package:fmp/platform/platform.dart'; Future main() async { @@ -16,6 +18,15 @@ Future main() async { runApp(UnsupportedPlatformApp(flavor: flavor)); case final dataDirectory: final directory = await dataDirectory.resolve(); + // 開不起來就只顯示錯誤頁,不在半開的資料庫上啟動(ADR 0010 §決定 3)。 + // 還沒有東西讀寫資料庫:接上 Riverpod 的 PR 會把這裡開好的資料庫交給 + // provider。在那之前開啟只為了建立 fmp.db 並確認它可用。 + try { + await openAppDatabase(directory); + } on Object catch (error) { + runApp(DatabaseErrorApp(flavor: flavor, error: error)); + return; + } runApp(FmpApp(flavor: flavor, dataDirectoryPath: directory.path)); } } diff --git a/app/pubspec.lock b/app/pubspec.lock index 52c273fc..f95d01ff 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -65,6 +65,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "8a5c5761af8e31748bba3c82f68925ace40f3225c3eea25be9beb57eca7cd7a8" + url: "https://pub.dev" + source: hosted + version: "4.0.11" + build_config: + dependency: transitive + description: + name: build_config + sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d + url: "https://pub.dev" + source: hosted + version: "1.3.3" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33 + url: "https://pub.dev" + source: hosted + version: "4.1.6" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "894c243f6bc32015fec466ce30a6925bd537a77a426ee5bf481120477eb3de67" + url: "https://pub.dev" + source: hosted + version: "2.16.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "44922ecebd8e3d0cef743dadd3b54df4e87e2a4c52a29465a1872aa9e192caa8" + url: "https://pub.dev" + source: hosted + version: "5.1.2" + built_value: + dependency: transitive + description: + name: built_value + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 + url: "https://pub.dev" + source: hosted + version: "8.13.0" characters: dependency: transitive description: @@ -73,6 +121,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" cli_config: dependency: transitive description: @@ -81,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: "71e43f1976c3eae2d9468a5b4d6600bf8cae61508b87f27fa1799228935d48ab" + url: "https://pub.dev" + source: hosted + version: "0.5.2" clock: dependency: transitive description: @@ -145,6 +217,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.13" + drift: + dependency: "direct main" + description: + name: drift + sha256: e3b6352aa02e60bed6ffc3cc9894c421578dad5fbef21b1f7f57cdc0e4edabcb + url: "https://pub.dev" + source: hosted + version: "2.35.0" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: fbad5c1e3a73820cdb1f32161eb2f40bb5e5f03c0c1a3d236595725aec7efc6d + url: "https://pub.dev" + source: hosted + version: "2.35.0" fake_async: dependency: transitive description: @@ -169,6 +257,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -208,14 +304,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" hooks: dependency: transitive description: name: hooks - sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.2.0" http_multi_server: dependency: transitive description: @@ -272,6 +376,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" leak_tracker: dependency: transitive description: @@ -340,10 +452,10 @@ packages: dependency: transitive description: name: meta - sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.3" + version: "1.19.0" mime: dependency: transitive description: @@ -352,6 +464,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: a1c26117c48cebe5677b0cf0e33a980a79a7c5577effc86f52e5a0d309cdcb60 + url: "https://pub.dev" + source: hosted + version: "0.19.3" node_preamble: dependency: transitive description: @@ -464,14 +584,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.1" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 + url: "https://pub.dev" + source: hosted + version: "1.6.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" record_use: dependency: transitive description: name: record_use - sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "1.1.1" shelf: dependency: transitive description: @@ -509,6 +645,14 @@ packages: description: flutter source: sdk version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 + url: "https://pub.dev" + source: hosted + version: "4.2.4" source_map_stack_trace: dependency: transitive description: @@ -533,6 +677,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqlite3: + dependency: "direct main" + description: + name: sqlite3 + sha256: fa3fd961e83b2dca8890a7b1014ab1226831d2b5908067c0ab7f385ffb655caf + url: "https://pub.dev" + source: hosted + version: "3.6.0" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: "772bb2f6f5bce0631a60f26b57d6e8882e1105c22345b05c163ee6ee5d7ba32d" + url: "https://pub.dev" + source: hosted + version: "0.45.0" stack_trace: dependency: transitive description: @@ -549,6 +709,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a + url: "https://pub.dev" + source: hosted + version: "2.1.2" string_scanner: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 0d363feb..3354852b 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -14,6 +14,9 @@ workspace: - packages/fmp_lints dependencies: + # 資料層(ADR 0010)。sqlite3 3.x 以 build hooks 打包 SQLite 原生庫,不用已 + # EOL 的 sqlite3_flutter_libs;兩者只准在 lib/data/ import(fmp_layer_imports)。 + drift: ^2.35.0 flutter: sdk: flutter # Flutter 3.47 起 Material 以獨立套件發佈,app/ 直接用它,不 import @@ -21,8 +24,11 @@ dependencies: material_ui: ^1.5.0 path: ^1.9.1 path_provider: ^2.1.6 + sqlite3: ^3.6.0 dev_dependencies: + build_runner: ^2.16.1 + drift_dev: ^2.35.0 flutter_test: sdk: flutter flutter_lints: ^6.0.0 diff --git a/app/test/app/database_error_app_test.dart b/app/test/app/database_error_app_test.dart new file mode 100644 index 00000000..8ddeb7a9 --- /dev/null +++ b/app/test/app/database_error_app_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/app/database_error_app.dart'; +import 'package:fmp/core/app_flavor.dart'; + +void main() { + testWidgets('says the database cannot be opened and shows the error', ( + tester, + ) async { + await tester.pumpWidget( + DatabaseErrorApp( + flavor: AppFlavor.dev, + error: const FormatException('file is not a database'), + ), + ); + + expect(find.text('無法開啟資料庫'), findsOneWidget); + expect( + find.text('FormatException: file is not a database'), + findsOneWidget, + ); + }); +} diff --git a/app/test/data/database/app_database_test.dart b/app/test/data/database/app_database_test.dart new file mode 100644 index 00000000..c17a34f3 --- /dev/null +++ b/app/test/data/database/app_database_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/memory_database.dart'; + +void main() { + test('foreign keys are enforced on every connection', () async { + final database = memoryDatabase(); + + final row = await database.customSelect('PRAGMA foreign_keys').getSingle(); + + expect(row.data.values.single, 1); + }); + + test('appearance_settings only accepts the single row with id 1', () async { + final database = memoryDatabase(); + + await database.customStatement( + 'INSERT INTO appearance_settings (id) VALUES (1)', + ); + await expectLater( + database.customStatement( + 'INSERT INTO appearance_settings (id) VALUES (2)', + ), + throwsA(anything), + ); + final count = await database + .customSelect('SELECT COUNT(*) AS c FROM appearance_settings') + .getSingle(); + expect(count.read('c'), 1); + }); +} diff --git a/app/test/data/database/open_app_database_test.dart b/app/test/data/database/open_app_database_test.dart new file mode 100644 index 00000000..913ed02b --- /dev/null +++ b/app/test/data/database/open_app_database_test.dart @@ -0,0 +1,51 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/data/database/open_app_database.dart'; +import 'package:fmp/data/repositories/appearance_settings_repository.dart'; +import 'package:fmp/domain/appearance.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory dataDirectory; + + setUp(() { + dataDirectory = Directory.systemTemp.createTempSync('fmp_open_db_'); + // 刪得掉也證明資料庫關閉後檔案沒被佔住(Windows 上開著的檔案刪不掉)。 + addTearDown(() => dataDirectory.deleteSync(recursive: true)); + }); + + File databaseFile() => File(p.join(dataDirectory.path, 'fmp.db')); + + test('creates fmp.db in the data directory', () async { + final database = await openAppDatabase(dataDirectory); + addTearDown(database.close); + + expect(databaseFile().existsSync(), isTrue); + final foreignKeys = await database + .customSelect('PRAGMA foreign_keys') + .getSingle(); + expect(foreignKeys.data.values.single, 1); + }); + + test('keeps data across reopening', () async { + final first = await openAppDatabase(dataDirectory); + await AppearanceSettingsRepository(first) + .write(themeMode: ThemeModeSetting.dark); + await first.close(); + + final second = await openAppDatabase(dataDirectory); + addTearDown(second.close); + + expect( + await AppearanceSettingsRepository(second).read(), + const AppearanceSettings(themeMode: ThemeModeSetting.dark), + ); + }); + + test('throws while opening when the file is not a database', () async { + databaseFile().writeAsStringSync('not a database, just some text ' * 100); + + await expectLater(openAppDatabase(dataDirectory), throwsA(anything)); + }); +} diff --git a/app/test/data/repositories/appearance_settings_repository_test.dart b/app/test/data/repositories/appearance_settings_repository_test.dart new file mode 100644 index 00000000..1cfda793 --- /dev/null +++ b/app/test/data/repositories/appearance_settings_repository_test.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/data/repositories/appearance_settings_repository.dart'; +import 'package:fmp/domain/appearance.dart'; + +import '../../support/memory_database.dart'; + +void main() { + test('reads all fields unset before anything is written', () async { + final repository = AppearanceSettingsRepository(memoryDatabase()); + + expect(await repository.read(), AppearanceSettings.empty); + }); + + test('reads back what was written', () async { + final repository = AppearanceSettingsRepository(memoryDatabase()); + + await repository.write( + themeMode: ThemeModeSetting.light, + locale: LocaleSetting.zhCn, + ); + + expect( + await repository.read(), + const AppearanceSettings( + themeMode: ThemeModeSetting.light, + locale: LocaleSetting.zhCn, + ), + ); + }); + + test('a partial write keeps the other field', () async { + final repository = AppearanceSettingsRepository(memoryDatabase()); + + await repository.write(locale: LocaleSetting.en); + await repository.write(themeMode: ThemeModeSetting.dark); + + expect( + await repository.read(), + const AppearanceSettings( + themeMode: ThemeModeSetting.dark, + locale: LocaleSetting.en, + ), + ); + }); + + test('watch emits the current value and every write', () async { + final repository = AppearanceSettingsRepository(memoryDatabase()); + final events = StreamIterator(repository.watch()); + addTearDown(events.cancel); + + expect(await events.moveNext(), isTrue); + expect(events.current, AppearanceSettings.empty); + + await repository.write(themeMode: ThemeModeSetting.system); + expect(await events.moveNext(), isTrue); + expect( + events.current, + const AppearanceSettings(themeMode: ThemeModeSetting.system), + ); + }); + + group('stored format', () { + // 資料庫裡的字串是持久化格式(ADR 0010 §決定 2),不是 enum 的名字。 + test('is pinned for every value', () async { + final database = memoryDatabase(); + final repository = AppearanceSettingsRepository(database); + Future> stored() async => + (await database + .customSelect( + 'SELECT theme_mode, locale FROM appearance_settings', + ) + .getSingle()) + .data + .values + .toList(); + + for (final (themeMode, locale, expected) in [ + (ThemeModeSetting.system, LocaleSetting.zhTw, ['system', 'zh-TW']), + (ThemeModeSetting.light, LocaleSetting.zhCn, ['light', 'zh-CN']), + (ThemeModeSetting.dark, LocaleSetting.en, ['dark', 'en']), + ]) { + await repository.write(themeMode: themeMode, locale: locale); + expect(await stored(), expected); + } + }); + + for (final (column, value) in [('theme_mode', 'sepia'), ('locale', 'ja')]) { + test('an unknown stored $column is an error, not a guess', () async { + final database = memoryDatabase(); + await database.customStatement( + 'INSERT INTO appearance_settings (id, $column) VALUES (1, ?)', + [value], + ); + + await expectLater( + AppearanceSettingsRepository(database).read(), + throwsFormatException, + ); + }); + } + }); +} diff --git a/app/test/data/repositories/plugin_repository_test.dart b/app/test/data/repositories/plugin_repository_test.dart new file mode 100644 index 00000000..dabbca29 --- /dev/null +++ b/app/test/data/repositories/plugin_repository_test.dart @@ -0,0 +1,89 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/data/repositories/plugin_repository.dart'; +import 'package:fmp/data/repositories/plugin_storage_repository.dart'; + +import '../../support/memory_database.dart'; + +InstalledPlugin plugin(String id, {String version = '1.0.0'}) => + InstalledPlugin( + id: id, + version: version, + manifestJson: '{"id":"$id"}', + script: 'export default {};', + installedAt: DateTime.utc(2026, 9, 29, 12, 30, 15, 250), + ); + +void main() { + test('installs, reads by id and lists in id order', () async { + final repository = PluginRepository(memoryDatabase()); + + await repository.install(plugin('b')); + await repository.install(plugin('a')); + + expect(await repository.byId('a'), plugin('a')); + expect(await repository.byId('missing'), isNull); + expect(await repository.list(), [plugin('a'), plugin('b')]); + }); + + test('installing the same id again updates it', () async { + final repository = PluginRepository(memoryDatabase()); + + await repository.install(plugin('a')); + await repository.install(plugin('a', version: '2.0.0')); + + expect(await repository.list(), [plugin('a', version: '2.0.0')]); + }); + + group('stored format', () { + test('installedAt is UTC epoch milliseconds', () async { + final database = memoryDatabase(); + // plugin() 的 installedAt 是 2026-09-29T12:30:15.250Z。 + await PluginRepository(database).install(plugin('a')); + + final row = await database + .customSelect('SELECT installed_at FROM installed_plugins') + .getSingle(); + + expect(row.read('installed_at'), 1790685015250); + expect( + (await PluginRepository(database).byId('a'))!.installedAt.isUtc, + isTrue, + ); + }); + }); + + group('plugin storage lifetime', () { + test('removing a plugin removes its storage', () async { + final database = memoryDatabase(); + final plugins = PluginRepository(database); + final storage = PluginStorageRepository(database); + await plugins.install(plugin('a')); + await plugins.install(plugin('b')); + await storage.write('a', 'buvid', 'x'); + await storage.write('b', 'buvid', 'y'); + + await plugins.remove('a'); + + expect(await plugins.byId('a'), isNull); + expect(await storage.read('a', 'buvid'), isNull); + expect(await storage.read('b', 'buvid'), 'y'); + // 直接查表,確認不是只有讀不到而是真的刪掉。 + final rows = await database + .customSelect("SELECT * FROM plugin_storage WHERE plugin_id = 'a'") + .get(); + expect(rows, isEmpty); + }); + + test('updating a plugin keeps its storage', () async { + final database = memoryDatabase(); + final plugins = PluginRepository(database); + final storage = PluginStorageRepository(database); + await plugins.install(plugin('a')); + await storage.write('a', 'buvid', 'x'); + + await plugins.install(plugin('a', version: '2.0.0')); + + expect(await storage.read('a', 'buvid'), 'x'); + }); + }); +} diff --git a/app/test/data/repositories/plugin_storage_repository_test.dart b/app/test/data/repositories/plugin_storage_repository_test.dart new file mode 100644 index 00000000..8e0ed7f6 --- /dev/null +++ b/app/test/data/repositories/plugin_storage_repository_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/data/repositories/plugin_repository.dart'; +import 'package:fmp/data/repositories/plugin_storage_repository.dart'; + +import '../../support/memory_database.dart'; + +void main() { + late PluginStorageRepository storage; + + setUp(() async { + final database = memoryDatabase(); + storage = PluginStorageRepository(database); + for (final id in ['a', 'b']) { + await PluginRepository(database).install( + InstalledPlugin( + id: id, + version: '1.0.0', + manifestJson: '{}', + script: '', + installedAt: DateTime.utc(2026), + ), + ); + } + }); + + test('reads null for a key that was never written', () async { + expect(await storage.read('a', 'buvid'), isNull); + }); + + test('writes, overwrites and deletes a key', () async { + await storage.write('a', 'buvid', 'first'); + expect(await storage.read('a', 'buvid'), 'first'); + + await storage.write('a', 'buvid', 'second'); + expect(await storage.read('a', 'buvid'), 'second'); + + await storage.delete('a', 'buvid'); + expect(await storage.read('a', 'buvid'), isNull); + + // 刪除不存在的 key 不算錯。 + await storage.delete('a', 'buvid'); + }); + + test('keys are separate per plugin', () async { + await storage.write('a', 'buvid', 'x'); + await storage.write('b', 'buvid', 'y'); + await storage.delete('a', 'buvid'); + + expect(await storage.read('a', 'buvid'), isNull); + expect(await storage.read('b', 'buvid'), 'y'); + }); + + test('writing for a plugin that is not installed fails', () async { + await expectLater( + storage.write('missing', 'buvid', 'x'), + throwsA(anything), + ); + expect(await storage.read('missing', 'buvid'), isNull); + }); +} diff --git a/app/test/domain/track_key_test.dart b/app/test/domain/track_key_test.dart new file mode 100644 index 00000000..346f7c32 --- /dev/null +++ b/app/test/domain/track_key_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/domain/track_key.dart'; + +/// 識別鍵的字面輸出是持久化格式的一部分:新資料庫的曲目唯一鍵,也是 M5 匯入 +/// 舊資料時對上舊版 Isar 索引與備份 JSON 的鍵。所以這裡用寫死的字串釘住輸出, +/// 字面值與舊版 `test/data/models/track_key_test.dart` 相同。 +void main() { + group('TrackKey literal output', () { + test('is pinned for both the cid and the cid-less branch', () { + expect(TrackKey.format('bilibili', 'BV123456'), 'bilibili:BV123456'); + expect( + TrackKey.format('bilibili', 'BV123456', cid: 42), + 'bilibili:BV123456:42', + ); + expect(TrackKey.formatGroup('bilibili', 'BV123456'), 'bilibili:BV123456'); + expect(TrackKey.format('youtube', 's466YCiHfKw'), 'youtube:s466YCiHfKw'); + expect(TrackKey.format('netease', '139774'), 'netease:139774'); + }); + + test('the group key leaves out the cid', () { + // 同一支影片的分 P 要落在同一組。 + expect( + TrackKey.formatGroup('bilibili', 'BV123456'), + TrackKey.format('bilibili', 'BV123456'), + ); + expect( + TrackKey.formatGroup('bilibili', 'BV123456'), + isNot(TrackKey.format('bilibili', 'BV123456', cid: 42)), + ); + }); + + test('toString of parsed parts is the key itself', () { + expect( + const TrackKeyParts( + sourceTypeId: 'bilibili', + sourceId: 'BV123456', + cid: 42, + ).toString(), + 'bilibili:BV123456:42', + ); + }); + }); + + group('tryParse', () { + test('round-trips everything format produces', () { + for (final parts in [ + const TrackKeyParts(sourceTypeId: 'bilibili', sourceId: 'BV1'), + const TrackKeyParts(sourceTypeId: 'bilibili', sourceId: 'BV1', cid: 42), + const TrackKeyParts(sourceTypeId: 'youtube', sourceId: 's466YCiHfKw'), + const TrackKeyParts(sourceTypeId: 'netease', sourceId: '139774'), + ]) { + final raw = TrackKey.format( + parts.sourceTypeId, + parts.sourceId, + cid: parts.cid, + ); + expect(TrackKey.tryParse(raw), parts, reason: raw); + } + }); + + test('rejects what it cannot round-trip', () { + expect(TrackKey.tryParse('bilibili'), isNull); + expect(TrackKey.tryParse('bilibili:'), isNull); + expect(TrackKey.tryParse(':BV1'), isNull); + expect(TrackKey.tryParse('bilibili:BV1:notacid'), isNull); + expect(TrackKey.tryParse('a:b:1:2'), isNull); + }); + }); +} diff --git a/app/test/drift/app_database/generated/schema.dart b/app/test/drift/app_database/generated/schema.dart new file mode 100644 index 00000000..f780cf8d --- /dev/null +++ b/app/test/drift/app_database/generated/schema.dart @@ -0,0 +1,22 @@ +// dart format width=80 +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; +import 'package:drift/internal/migrations.dart'; + +import 'schema_v1.dart' as v1; + +class GeneratedHelper implements SchemaInstantiationHelper { + @override + GeneratedDatabase databaseForVersion(QueryExecutor db, int version) { + switch (version) { + case 1: + return v1.DatabaseAtV1(db); + default: + throw MissingSchemaException(version, versions); + } + } + + static const versions = const [1]; +} diff --git a/app/test/drift/app_database/generated/schema_v1.dart b/app/test/drift/app_database/generated/schema_v1.dart new file mode 100644 index 00000000..4b68d4d5 --- /dev/null +++ b/app/test/drift/app_database/generated/schema_v1.dart @@ -0,0 +1,218 @@ +// dart format width=80 +// GENERATED BY drift_dev, DO NOT MODIFY. +// ignore_for_file: type=lint,unused_import +// +import 'package:drift/drift.dart'; + +class AppearanceSettings extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + AppearanceSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + $customConstraints: 'NOT NULL CHECK (id = 1)', + ); + late final GeneratedColumn themeMode = GeneratedColumn( + 'theme_mode', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + late final GeneratedColumn locale = GeneratedColumn( + 'locale', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'NULL', + ); + @override + List get $columns => [id, themeMode, locale]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'appearance_settings'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + AppearanceSettings createAlias(String alias) { + return AppearanceSettings(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class InstalledPlugins extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + InstalledPlugins(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn version = GeneratedColumn( + 'version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn manifestJson = GeneratedColumn( + 'manifest_json', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn script = GeneratedColumn( + 'script', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn installedAt = GeneratedColumn( + 'installed_at', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [ + id, + version, + manifestJson, + script, + installedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'installed_plugins'; + @override + Set get $primaryKey => {id}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + InstalledPlugins createAlias(String alias) { + return InstalledPlugins(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(id)']; + @override + bool get dontWriteConstraints => true; +} + +class PluginStorage extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + PluginStorage(this.attachedDatabase, [this._alias]); + late final GeneratedColumn pluginId = GeneratedColumn( + 'plugin_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: + 'NOT NULL REFERENCES installed_plugins(id)ON DELETE CASCADE', + ); + late final GeneratedColumn key = GeneratedColumn( + 'key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL', + ); + @override + List get $columns => [pluginId, key, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'plugin_storage'; + @override + Set get $primaryKey => {pluginId, key}; + @override + Never map(Map data, {String? tablePrefix}) { + throw UnsupportedError('TableInfo.map in schema verification code'); + } + + @override + PluginStorage createAlias(String alias) { + return PluginStorage(attachedDatabase, alias); + } + + @override + List get customConstraints => const ['PRIMARY KEY(plugin_id, "key")']; + @override + bool get dontWriteConstraints => true; +} + +class DatabaseAtV1 extends GeneratedDatabase { + DatabaseAtV1(QueryExecutor e) : super(e); + late final AppearanceSettings appearanceSettings = AppearanceSettings(this); + late final InstalledPlugins installedPlugins = InstalledPlugins(this); + late final PluginStorage pluginStorage = PluginStorage(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + appearanceSettings, + installedPlugins, + pluginStorage, + ]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules([ + WritePropagation( + on: TableUpdateQuery.onTableName( + 'installed_plugins', + limitUpdateKind: UpdateKind.delete, + ), + result: [TableUpdate('plugin_storage', kind: UpdateKind.delete)], + ), + ]); + @override + int get schemaVersion => 1; +} diff --git a/app/test/drift/app_database/schema_test.dart b/app/test/drift/app_database/schema_test.dart new file mode 100644 index 00000000..0cac9cbe --- /dev/null +++ b/app/test/drift/app_database/schema_test.dart @@ -0,0 +1,42 @@ +import 'package:drift_dev/api/migrations_native.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/memory_database.dart'; +import 'generated/schema.dart'; + +/// 程式碼建出來的 schema 必須等於最新的快照(drift_schemas/app_database/)。 +/// +/// 改了 `lib/data/database/tables.dart` 卻沒 bump 版本、存新快照,這裡會紅。 +/// 各版之間的升級測試由 `drift_dev make-migrations` 產生在同一個目錄 +/// (`.trellis/spec/app/data/index.md`)。 +void main() { + late SchemaVerifier verifier; + + setUpAll(() { + verifier = SchemaVerifier(GeneratedHelper()); + }); + + test('schemaVersion is the latest snapshot', () { + expect(memoryDatabase().schemaVersion, GeneratedHelper.versions.last); + }); + + test('a new database has exactly the snapshot schema', () async { + // 記憶體資料庫是空的,開啟時由程式碼的 onCreate 建表;再拿它和快照比。 + final database = memoryDatabase(); + + await verifier.migrateAndValidate(database, database.schemaVersion); + }); + + test('a schema that differs from the snapshot fails the check', () async { + // 證明上一個測試不是空轉:多一個欄位就要比對失敗。 + final database = memoryDatabase(); + await database.customStatement( + 'ALTER TABLE plugin_storage ADD COLUMN extra TEXT', + ); + + await expectLater( + verifier.migrateAndValidate(database, database.schemaVersion), + throwsA(isA()), + ); + }); +} diff --git a/app/test/support/memory_database.dart b/app/test/support/memory_database.dart new file mode 100644 index 00000000..ddae312f --- /dev/null +++ b/app/test/support/memory_database.dart @@ -0,0 +1,20 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fmp/data/database/app_database.dart'; + +/// 記憶體內的 [AppDatabase],測試結束時關閉。 +/// +/// 和 App 用同一個資料庫類別,所以 migration 策略(含外鍵)一樣會跑;只有 +/// 執行器換成記憶體。`closeStreamsSynchronously` 照 drift 的測試指南,避免 +/// `watch` 的串流在測試結束後還留著計時器。 +AppDatabase memoryDatabase() { + final database = AppDatabase( + DatabaseConnection( + NativeDatabase.memory(), + closeStreamsSynchronously: true, + ), + ); + addTearDown(database.close); + return database; +} From c3d58ffb0bd16832f99fc3d15dc905dfb526c004 Mon Sep 17 00:00:00 2001 From: 1morr Date: Tue, 29 Sep 2026 14:44:19 +0800 Subject: [PATCH 2/3] docs(adr): record the isar and sqlite3 coexistence probe --- .../09-28-m1-skeleton-tracer/implement.md | 2 +- .../research/isar-sqlite3-coexistence.md | 177 ++++++++++++++++++ .../tasks/09-28-m1-skeleton-tracer/task.json | 3 +- ...0010-drift-data-layer-and-legacy-import.md | 2 + 4 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 .trellis/tasks/09-28-m1-skeleton-tracer/research/isar-sqlite3-coexistence.md diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md index d4e14352..23af6187 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/implement.md @@ -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 diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/research/isar-sqlite3-coexistence.md b/.trellis/tasks/09-28-m1-skeleton-tracer/research/isar-sqlite3-coexistence.md new file mode 100644 index 00000000..27776b30 --- /dev/null +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/research/isar-sqlite3-coexistence.md @@ -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: /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 `):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 共用已載入的原生庫、 +隔離不了衝突,所以小程式至少要跑在另一個行程。 diff --git a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json index af380ce1..1a6c708c 100644 --- a/.trellis/tasks/09-28-m1-skeleton-tracer/task.json +++ b/.trellis/tasks/09-28-m1-skeleton-tracer/task.json @@ -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": [], diff --git a/docs/adr/0010-drift-data-layer-and-legacy-import.md b/docs/adr/0010-drift-data-layer-and-legacy-import.md index 2b1d69df..e80b46b5 100644 --- a/docs/adr/0010-drift-data-layer-and-legacy-import.md +++ b/docs/adr/0010-drift-data-layer-and-legacy-import.md @@ -70,6 +70,8 @@ - 之後要注意: - `isar_community` 與 `sqlite3` 兩套原生庫共存沒有直接先例,第一個里程碑在 Android、Windows 實測;若衝突,legacy import 改成由新 App 啟動的獨立一次性小程式。 - `sqlite3` 原生庫的 Android 16KB page size 對齊,第一個里程碑用官方對齊檢查確認。 + - 實測結果(2026-09-29,M1 PR 5 期間):`isar_community` 3.3.2 與 `sqlite3` 3.6.0 在 Android(debug、release,16KB 頁的模擬器)與 Windows 共存、各自讀寫成功;Android 所有 `.so` 的 LOAD 段 p_align ≥ 0x4000,`zipalign -c -P 16` 通過。不需要獨立小程式。紀錄在 `.trellis/tasks/09-28-m1-skeleton-tracer/research/isar-sqlite3-coexistence.md`。 + - `isar_community_generator` 3.3.2 要求 `analyzer <11`,與 `app/` workspace 的 analyzer 13 衝突,無法在 workspace 內跑 Isar codegen;M5 在 workspace 外產生 `*.g.dart` 後提交(上述實測已證實可行),或等 generator 放寬。 - 新憑證存放處在網路與帳號的 ADR 決定;`flutter_secure_storage` 升到 11.x 的條件同樣在那裡處理。 ## 如何確認 From f7f7cf20b249247b87693a0fef2a51c4d49970b6 Mon Sep 17 00:00:00 2001 From: 1morr Date: Tue, 29 Sep 2026 14:44:20 +0800 Subject: [PATCH 3/3] chore(task): archive drift-schema --- .../2026-09/09-29-drift-schema/check.jsonl | 5 + .../09-29-drift-schema/implement.jsonl | 5 + .../archive/2026-09/09-29-drift-schema/prd.md | 74 ++++++++++++ .../09-29-drift-schema/research/notes.md | 106 ++++++++++++++++++ .../2026-09/09-29-drift-schema/task.json | 26 +++++ 5 files changed, 216 insertions(+) create mode 100644 .trellis/tasks/archive/2026-09/09-29-drift-schema/check.jsonl create mode 100644 .trellis/tasks/archive/2026-09/09-29-drift-schema/implement.jsonl create mode 100644 .trellis/tasks/archive/2026-09/09-29-drift-schema/prd.md create mode 100644 .trellis/tasks/archive/2026-09/09-29-drift-schema/research/notes.md create mode 100644 .trellis/tasks/archive/2026-09/09-29-drift-schema/task.json diff --git a/.trellis/tasks/archive/2026-09/09-29-drift-schema/check.jsonl b/.trellis/tasks/archive/2026-09/09-29-drift-schema/check.jsonl new file mode 100644 index 00000000..982b6cca --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-drift-schema/check.jsonl @@ -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"} diff --git a/.trellis/tasks/archive/2026-09/09-29-drift-schema/implement.jsonl b/.trellis/tasks/archive/2026-09/09-29-drift-schema/implement.jsonl new file mode 100644 index 00000000..982b6cca --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-drift-schema/implement.jsonl @@ -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"} diff --git a/.trellis/tasks/archive/2026-09/09-29-drift-schema/prd.md b/.trellis/tasks/archive/2026-09/09-29-drift-schema/prd.md new file mode 100644 index 00000000..d3bce31e --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-drift-schema/prd.md @@ -0,0 +1,74 @@ +# drift 最小 schema 與 TrackKey(M1 PR 5) + +父任務:`../09-28-m1-skeleton-tracer`(design §3 的「M1 的 drift 表」;implement「5.」)。 + +依據: +- ADR 0010 §決定 1–3:drift+sqlite3、只有資料層存取、schema 原則與演進; +- ADR 0019 §決定 1:`PRAGMA foreign_keys = ON`; +- ADR 0011 §決定 7:設定每組一張單列表,欄位為空表示沒設定過; +- ADR 0014 §決定 5、8:每插件 storage,移除插件時清除; +- ADR 0005:`TrackKey` 格式。 + +## 做什麼 + +1. **依賴**:`drift`、`sqlite3` 3.x(內建 build hooks,不用已 EOL 的 `sqlite3_flutter_libs`)、`drift_dev`、`build_runner`。 + - 版本以 pub.dev 為準; + - 開庫方式(`NativeDatabase` 或 `drift_flutter`)照 drift 官方文件對 sqlite3 3.x 的建議,來源記在 `research/notes.md`。 +2. **資料庫**:`lib/data/database/`。 + - 檔案放在平台層的 App 資料目錄(PR 4 的 `AppPlatform`)底下,檔名 `fmp.db`。 + - 開啟時執行 `PRAGMA foreign_keys = ON`。 + - 只有 `lib/data/` 能 import drift,`fmp_layer_imports` 已守。 +3. **Schema v1**: + + | 表 | 欄位 | 備註 | + |---|---|---| + | `appearance_settings` | `id`(固定 1)、`theme_mode`(可空文字:`system`/`light`/`dark`)、`locale`(可空文字:`zh-TW`/`zh-CN`/`en`) | 單列;空=沒設定過(ADR 0011) | + | `installed_plugins` | `id`(音源 id,主鍵)、`version`、`manifest_json`、`script`、`installed_at`(UTC epoch 毫秒) | 從檔案安裝(ADR 0014) | + | `plugin_storage` | `plugin_id`(外鍵 → `installed_plugins.id`,`ON DELETE CASCADE`)、`key`、`value`;主鍵 (`plugin_id`, `key`) | 每插件 key/value(ADR 0014 §決定 5);B 站匿名 `buvid` 放這裡(ADR 0012) | + + - 列舉值在 Dart 端用 enum 轉換,資料庫存字串。 + - 沒有 `tracks` 等音樂庫表,那些在 M4。 +4. **Repository**(`lib/data/repositories/`),只做 M1 用得到的操作: + - `AppearanceSettingsRepository`:讀單列(沒有就回全空)、寫入部分欄位、`watch`; + - `PluginRepository`:安裝或更新、依 id 讀、列出、移除(連帶清掉 storage); + - `PluginStorageRepository`:依插件 id 讀、寫、刪 key。 + + 介面讓上層以 provider 注入,本 PR 還不接 Riverpod。 +5. **Schema 演進工具**: + - 以 drift 官方工具存 v1 快照:`drift_schemas/` 與 `drift_dev schema dump`/`generate`。 + - 測試:用 drift 官方的 schema 驗證 API,確認目前程式碼的 schema 等於 v1 快照。 + - 在 `.trellis/spec/app/data/index.md` 寫下之後每加一版要做的事:bump `schemaVersion`、dump、寫 step-by-step migration、升級測試,並附官方文件連結。 + - 「migration 不改使用者設定過的值」的測試要等第一個 migration 出現才有意義。spec 寫明它是每個 migration 的必備測試。 +6. **產生的程式碼**:drift 產生的 `*.g.dart` 提交進 repo。 + - CI 的 `app` job 加一步:跑 `dart run build_runner build`,然後 `git diff --exit-code`,確認產生的程式碼是最新的。 + - `app/AGENTS.md` 寫明改了 table 要重跑 build_runner。 + - 理由:舊專案把產生檔 gitignore,結果新 worktree 常因為沒跑 codegen,出現像原始碼錯誤的「missing getter」(根目錄 `lib/AGENTS.md` 有記)。提交產生檔並檢查它是最新的,拉下來就能用。 + - 產生檔若違反 `fmp_lints` 或 analyzer 規則,在 `analysis_options.yaml` 排除 `**/*.g.dart`,並寫明原因。 +7. **`TrackKey`**:`lib/domain/track_key.dart`。 + - 照舊版 `lib/data/models/track_key.dart` 的格式與行為原樣搬(ADR 0008 檔頭補充:葉節點照搬): + - `format`、`formatGroup`、`tryParse`; + - 以 cid 區分分 P。 + - 測試用寫死的字面值釘住輸出,照舊版 `test/data/models/track_key_test.dart`。 + - 在 dartdoc 說明為什麼釘死:它是持久化格式,M5 匯入舊資料要對得上。 +8. **`main()`**:啟動時開資料庫。開啟失敗時照 ADR 0010 §決定 3 顯示錯誤頁,不在半開的資料庫上啟動。M1 只要一個最小的錯誤畫面(字串先寫死繁中,PR 12 接 slang)。 +9. **文件**: + - `app/AGENTS.md` 資料段: + - 只有 `lib/data/` 碰資料庫(lint 守); + - 改 table 的步驟與閘門(快照測試、產生檔檢查); + - `TrackKey` 是持久化格式。 + - `.trellis/spec/app/data/index.md`(繁中)。 + +## 驗收 + +- [ ] `app/`: + - format 通過; + - `dart analyze --fatal-infos`、`flutter analyze` 零問題; + - `flutter test` 全綠; + - 哨兵通過。 +- [ ] `dart run build_runner build` 之後 `git diff --exit-code` 乾淨。 +- [ ] 測試: + - schema 等於 v1 快照; + - 外鍵生效:刪除插件時 storage 一起消失,插入不存在的 `plugin_id` 會失敗; + - 外觀設定沒有列時讀到全空,寫入後讀回; + - `TrackKey` 字面值與來回一致。 +- [ ] Windows、Android 的 dev 版開啟後,資料目錄出現 `fmp.db`(主對話實機)。 diff --git a/.trellis/tasks/archive/2026-09/09-29-drift-schema/research/notes.md b/.trellis/tasks/archive/2026-09/09-29-drift-schema/research/notes.md new file mode 100644 index 00000000..99bc049c --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-drift-schema/research/notes.md @@ -0,0 +1,106 @@ +# drift 最小 schema:查證紀錄 + +- 查證日期:2026-09-29。 +- 本次子代理環境沒有 context7/WebFetch 工具,改以 `curl` 直接抓官方頁面與 pub.dev + JSON API,並讀 pub cache 裡 drift 2.35.0/drift_dev 2.35.0 的原始碼。 + +## 1. 版本(pub.dev JSON API,`latest`) + +| 套件 | 版本 | 發佈 | 備註 | +|---|---|---|---| +| `drift` | 2.35.0 | 2026-09-09 | 依賴 `sqlite3 ^3.4.0` | +| `drift_dev` | 2.35.0 | 2026-09-09 | dev | +| `sqlite3` | 3.6.0 | 2026-09-13 | 依賴 `hooks`、`code_assets`、`native_toolchain_c` | +| `build_runner` | 2.16.1 | 2026-09-02 | `analyzer >=13.3.0 <15.0.0`,與 `fmp_lints` 釘的 13.3.0 相容 | +| `drift_flutter` | 0.3.1 | 2026-07-11 | 依賴 `path_provider`、`sqlite3_flutter_libs ^0.6.0+eol`、`sqlcipher_flutter_libs ^0.7.0+eol` | +| `sqlite3_flutter_libs` | 0.6.0+eol | 2026-02-15 | 空殼,沒有依賴 | + +## 2. 開庫方式:`NativeDatabase.createInBackground`,不用 `drift_flutter` + +- drift Setup(https://drift.simonbinder.eu/setup/)的「Dart (sqlite3)」依賴組合就是 + `drift` + `sqlite3` + `drift_dev` + `build_runner`;Flutter 版另列 `drift_flutter` + + `path_provider`,並給了「Manual database setup」用 `NativeDatabase.createInBackground(file)`。 +- Platforms 總覽(https://drift.simonbinder.eu/platforms/):「Starting from drift version + 2.32.0 depending on versions 3.x of the sqlite3 package, no further setup is required and + an up-to-date copy of SQLite will automatically be bundled with your app」;「It is no + longer necessary to depend on sqlite3_flutter_libs or sqlcipher_flutter_libs」。 +- Native 頁(https://drift.simonbinder.eu/platforms/vm/):`NativeDatabase.createInBackground` + 由 drift 開背景 isolate 跑 SQLite,建議行動裝置使用。 +- sqlite3 `UPGRADING_TO_V3.md`(https://github.com/simolus3/sqlite3.dart/blob/main/UPGRADING_TO_V3.md): + 不再依賴 `sqlite3_flutter_libs`;`applyWorkaroundToOpenSqlite3OnOldAndroidVersions` 可以 + 移除;原生庫改由 hooks 打包,預設從 GitHub releases 下載預先編譯的版本。 +- sqlite3 `doc/hook.md`:預設編譯選項含 `SQLITE_TEMP_STORE=2`(暫存放記憶體)。drift 文件提到 + Android 要設 `sqlite3.tempDirectory` 避免複雜查詢 OOM,那是針對暫存寫檔的情況;M1 的查詢 + 很小,且預設二進位把暫存放記憶體,所以不設(也就不必在 `lib/data/` 碰 `path_provider`)。 + +**決定**:`drift` + `sqlite3` 3.x,`NativeDatabase.createInBackground(File(<資料目錄>/fmp.db))`。 +不用 `drift_flutter`:它的價值是用 `path_provider` 決定目錄,而目錄已由平台層 +(`AppPlatform.dataDirectory`)決定;它還會把 EOL 的 `sqlite3_flutter_libs` 空殼帶進依賴樹, +PRD 要求不用。 + +## 3. 外鍵 + +- drift 文件的 Migrations 頁 `beforeOpen` 範例與 `Migrator.runMigrationSteps` 的 dartdoc + (drift 2.35.0,`lib/src/runtime/query_builder/migration.dart`)都在 `beforeOpen` 或 + migration 後執行 `PRAGMA foreign_keys = ON`。 +- `GeneratedDatabase.beforeOpen`(`lib/src/runtime/api/db_base.dart`)的順序:onCreate/ + onUpgrade → `MigrationStrategy.beforeOpen`。所以放在 `beforeOpen`,每次開啟都設, + migration 期間維持關閉(drift 建議 migration 時關外鍵)。 +- drift **不會**自己把 `onUpgrade` 包進交易(同檔)。ADR 0010 §決定 3 的「失敗整個回滾」要 + 照 `runMigrationSteps` dartdoc 的寫法自己包:關外鍵 → `transaction(runMigrationSteps)` → + `PRAGMA foreign_key_check` → 開外鍵。寫進 `.trellis/spec/app/data/index.md`,第一個 + migration 時做。 +- 檢查時修正:dartdoc 的 `foreign_key_check` 在交易提交之後、只在 debug 斷言,拋錯已回滾 + 不了;而 drift 寫 `user_version` 在 `onUpgrade` 回傳之後、交易外 + (`lib/src/runtime/executor/helpers/engines.dart` 的 `_runMigrations`)。所以 spec 改成 + 外鍵檢查與 `PRAGMA user_version = ` 都放進同一個交易。 + +## 4. schema 快照與驗證 + +- Schema exports(https://drift.simonbinder.eu/migrations/exports/):`drift_dev schema dump` + 存 `drift_schema_vN.json`;「We recommend exporting the initial schema once」。 +- Migrations 總覽(https://drift.simonbinder.eu/migrations/):推薦 `make-migrations`; + `build.yaml` 的 `databases:` 設資料庫位置,預設 `schema_dir: drift_schemas/`、 + `test_dir: test/drift/`。 +- 讀 `drift_dev` 2.35.0 `lib/src/cli/commands/make_migrations.dart`:只有一個版本時它只寫 + 快照(`writer.schemas.length == 1` 就 `continue`),不產生測試輔助碼;第二版起才產生 + steps 檔、`generated/schema_v*.dart` 與 `migration_test.dart`。 +- 所以 v1:`dart run drift_dev make-migrations`(存到 `drift_schemas/app_database/`)+ + `dart run drift_dev schema generate drift_schemas/app_database/ test/drift/app_database/generated/` + (產生 `GeneratedHelper`)。之後每版只要 `make-migrations`,目錄已對齊它的預設。 +- `make-migrations` 產生的 `migration_test.dart` 只測「舊快照升級後等於新快照」,不比對 + **程式碼**與最新快照:程式碼改了、卻沒 bump 或沒存快照時它照樣綠。所以另寫 + `test/drift/app_database/schema_test.dart`:用記憶體資料庫讓程式碼的 onCreate 建表, + `SchemaVerifier.migrateAndValidate(db, schemaVersion)` 與快照比對 + (`drift_dev/lib/src/services/schema/verifier_common.dart`:reference 取自 + `startAt(expectedVersion)`,actual 取自 db 本身);再加一個多一欄就必須拋 + `SchemaMismatch` 的反例,證明比對不是空轉。 +- 測試寫法(https://drift.simonbinder.eu/testing/):`DatabaseConnection(NativeDatabase.memory(), + closeStreamsSynchronously: true)`。 + +## 5. 表的寫法 + +- Tables 頁(https://drift.simonbinder.eu/dart_api/tables/):「In table classes, columns are + defined as late final fields」。`check()` 自我參照時 `late final` 要寫明型別 + (實測:不寫會 `top_level_cycle`;用 getter 寫法則觸發 `recursive_getters` info, + `--fatal-infos` 會紅)。 +- nullable 欄位接非 nullable 的 `TypeConverter`,drift 產生 `NullAwareTypeConverter.wrap` + (見產生的 `app_database.g.dart`),converter 只處理非空值。 +- `dateTime()` 預設存 unix 秒;ADR 0010 要 UTC epoch 毫秒,所以用 `integer()` + converter。 + +## 6. 產生檔 + +- drift 產生的 `app_database.g.dart` 開頭有 `// ignore_for_file: type=lint`,`fmp_lints` + 對它也沒有診斷;`dart analyze --fatal-infos` 乾淨,所以 `analysis_options.yaml` 不排除。 +- 根目錄 `.gitignore` 有 `*.g.dart`(舊專案);`app/.gitignore` 以 `!*.g.dart` 覆寫。 +- 刪掉 `.dart_tool/build` 後在已有產生檔的狀態下跑 `dart run build_runner build`,不需要 + `--delete-conflicting-outputs`,也不產生差異(CI 的情況)。 + +## 7. 自我檢查(變異) + +手動做過、沒有編碼成測試的兩個變異,確認測試抓得到: + +- 拿掉 `beforeOpen` 的 `PRAGMA foreign_keys = ON`:四個測試紅(pragma、開檔、cascade、 + 外鍵拒絕)。 +- `PluginRepository.install` 改成 `InsertMode.insertOrReplace`:`updating a plugin keeps its + storage` 紅(REPLACE 觸發 cascade)。 diff --git a/.trellis/tasks/archive/2026-09/09-29-drift-schema/task.json b/.trellis/tasks/archive/2026-09/09-29-drift-schema/task.json new file mode 100644 index 00000000..7a563bef --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-29-drift-schema/task.json @@ -0,0 +1,26 @@ +{ + "id": "drift-schema", + "name": "drift-schema", + "title": "drift 最小 schema 與 TrackKey", + "description": "M1 PR 5: drift + sqlite3 database in the app data directory, schema v1 (appearance_settings, installed_plugins, plugin_storage) with snapshot, repositories, TrackKey", + "status": "completed", + "dev_type": null, + "scope": null, + "package": "app", + "priority": "P2", + "creator": "1morr", + "assignee": "1morr", + "createdAt": "2026-09-29", + "completedAt": "2026-09-29", + "branch": "feat/drift-schema", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "09-28-m1-skeleton-tracer", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file